# Changelog

## 13.0.1 — 2026-11-13

### Fixed

- The docs of `createToken`'s `currency` and of the create intent's `xrplCurrency` said the code is required only for a fungible XRPL token. The cluster requires it for every XRPL token whose rule can mint, including one that only ever mints NFTs. An app's marketplace subscription issuer, created under `Rules.template.subscriptionNft()`, is one of these, and the template's docs now say so. Only a soulbound rule, or a rule that cannot mint, may omit it. No code changed.

## 13.0.0 — 2026-11-13

**BREAKING.** `MarketplaceClient.buy`, the platform-mediated buy, is deleted. A subscription purchase is one atomic XRPL Batch: `quotePurchase`, then the buyer's wallet signs and submits the Batch, then `confirmPurchase`. Use `@hsuite/smart-engines-cli` 3.3.0 with it (no CLI command uses `MarketplaceClient`; the CLI's own commands and flags are unchanged).

### Added

- Buying a subscription as one atomic XRPL Batch (#3006), on `MarketplaceClient` with the buyer's validator session:
  - `quotePurchase(appId, buyer)` returns a `PurchaseQuote`: the Batch to sign as is and submit, with the app issuer's co-signature included.
  - `checkPurchaseQuote(quote, buyer)` checks the Batch before you sign it. `quotePurchase` runs it on every quote and throws `ValidationError` when the Batch is not the purchase the buyer asked for. It checks that the Batch only pays the quoted developer share and platform cut in the quoted asset, with a `SendMax` of at most a 1% transfer fee, and accepts an NFT minted to the buyer for nothing.
  - `confirmPurchase(appId, txHash)` returns a `PurchaseReceipt` once the Batch and all four inner transactions settled.
- `MarketplaceClient.linkSubscriptionIssuer(appId, link)` links the app's subscription issuer, using the developer's session. The issuer is the XRPL `token` entity created for the app under an app-bound create intent. Pass `{ creationIntent }`, or `{ issuerEntityId, requestNonce }`.

### Removed

- `MarketplaceClient.buy` and its types `MarketplaceBuyEscrow`, `MarketplaceBundleBuyResult` and `MarketplaceBuyResult`, and the `'marketplace-sale'` deposit type (`DepositWalletType`, `signDepositWalletIntent`): the platform-mediated buy (P10) is deleted. A purchase is one atomic XRPL Batch; see #3006.

## 12.0.0 — 2026-11-10

**BREAKING.** The create methods wait for the operation's outcome by default wherever the smart-host admits the create asynchronously, and their return types are now unions that include the `202` admission. Use `@hsuite/smart-engines-cli` 3.2.0 with it (no CLI command wraps a changed method; the CLI's own commands and flags are unchanged).

### Breaking — return types are unions with `AdmittedOperation` (#4216 S5)

Every create-family method on `baas.entities` can now resolve the `202 { operationId, status }` admission as well as its product, so the declared return types widen:

- `createToken`, `createAccount`, `createTopic`, `executeCreateToken`, `executeCreateAccount`, `executeCreateTopic`, `executeCreateAgent`: `Promise<EntityCreationResult | AdmittedOperation>` (`createAccount` and `createTopic` were `Promise<EntityCreationResult>`).
- `prepareCreateToken`, `prepareCreateAccount`, `prepareCreateTopic`, `prepareCreateAgent`: `Promise<PreparedEntityCreation | AdmittedOperation>`.

Narrow with the new `isAdmittedOperation(result)` (exported from `baas`, `baas/entities` and as `baas.isAdmittedOperation`). Code that reads `result.entityId` / `result.chainAccounts` / `prep.prepared` straight off the return value no longer type-checks until it narrows or passes no `{ wait: false }` and casts. At runtime the admission only appears when you pass `{ wait: false }` (or when a key-driven duplicate of such a call answers it); the default resolves the product.

`createAgent` is unchanged: its host route has no async admission, and `{ wait: false }` on it is refused client-side.

### Breaking — waiting is the default where the host admits async (#4216)

With no `wait` option, these arms are admitted with `wait: false` and an idempotency key, then the SDK waits for the journal's outcome (`GET /baas/operations/:id`, SSE wake when you pass `wakeChannel`) and resolves **the same product the synchronous call returned**. An explicit `{ wait: false }` returns the `202` admission instead. There is no synchronous escape hatch on these arms.

| Method | Arm | 11.x default | 12.0.0 default |
|---|---|---|---|
| `createToken` | hedera, `platformPaid` | waited (since S5) | waited (unchanged) |
| `createAccount` | hedera, `platformPaid` single-shot | synchronous | **waits** (`finalized`) |
| `createTopic` | hedera, `platformPaid` single-shot | synchronous | **waits** (`finalized`) |
| `prepareCreateToken` | xrpl | synchronous | **waits** (`prepared`) |
| `prepareCreateAccount` | xrpl | synchronous | **waits** (`prepared`) |
| `executeCreateToken` | xrpl | synchronous | **waits** (`finalized`) |
| `executeCreateAccount` | xrpl | synchronous | **waits** (`finalized`) |
| `createToken` / `createAccount` | xrpl, `fundWith` | synchronous prepare, fund, execute | async prepare, then `fundWith`, then async execute; both waited |
| `prepareCreateAgent` / `executeCreateAgent` | xrpl | waited (since S5) | waited (unchanged) |

Still synchronous, because the host does not admit them: the hedera `fundWith` two-phase (`createAccount`, `createTopic`, and their `prepare*` / `execute*` halves), every solana / polkadot / stellar / bitcoin create, every non-hedera single-shot, `createAgent`, and `transfer` and the other value-movement methods. An explicit `{ wait: false }` there is refused client-side (single-shot routes, the orchestrated `fundWith` flows) or forwarded for the host to refuse (the `prepare*` / `execute*` halves on a chain with no admission). The orchestrated `createToken` / `createAccount` `fundWith` flows have no single admission to return, so they refuse `{ wait: false }`; call the `prepare*` / `execute*` halves with `{ wait: false }` to hold the admissions yourself.

An xrpl `execute*` needs a journaled draft: the host answers `400` to an async execute whose draft was prepared synchronously. The default `prepareCreateToken` / `prepareCreateAccount` on xrpl always produces a journaled draft, and `createToken` / `createAccount` run prepare then execute for you.

What a waiting call can throw that a synchronous one could not: `OperationPendingError` when the wait budget (`waitTimeoutMs`, default 120 s) expires with the operation still pending (the ceremony keeps running; carries `operationId` and the last snapshot), and `OperationAbortedError` when the journal records an abort. Neither is a plain failure. Each wait has its own budget, so an xrpl `createToken({ fundWith })` can take up to two.

### Breaking — idempotency keys are minted per logical call (#4216 S5)

Each admitted create call mints one key (`crypto.randomUUID`) and reuses it on the SDK's single retry of a retryable admission failure; the host's `(appId, kind, key)` seal answers the first admission's operation, so a retry never starts a second ceremony. Pass `idempotencyKey` to make retries across process restarts idempotent too. The execute halves have no key slot (the journal's execution lease is their retry safety), so their admission is not retried. A synchronous `prepare*` call with a caller-supplied `idempotencyKey` still forwards it.

### Added — `baas.operations` (#4216 S5)

`baas.operations.get(operationId)` returns one status snapshot (`GET /api/v3/baas/operations/:id`). `baas.operations.wait(operationId, { until, timeoutMs, pollIntervalMs, wakeChannel, signal })` follows an operation to `finalized` (or `prepared` with `until: 'prepared'`); polling is the authority and the SSE terminal `operation` event is the wake signal. New exports: `OperationsClient`, `OperationPendingError`, `OperationAbortedError`, `isAdmittedOperation`, `generateIdempotencyKey`, `OPERATION_WAIT_TIMEOUT_MS_DEFAULT`, `OPERATION_POLL_INTERVAL_MS_DEFAULT`, and the types `AdmittedOperation`, `CreateCallOptions`, `OperationStatus`, `OperationStatusName`, `OperationGetOptions`, `OperationWaitOptions`. `CreateCallOptions` adds `idempotencyKey`, `wait`, `waitTimeoutMs`, `pollIntervalMs`, `wakeChannel` and `signal` to the usual call options.

### Breaking — `DomainTransferRequest` is `{ newOwner }` (#3338)

`DomainsClient.transfer` took `{ authCode, newRegistrar? }`; the gateway never validated `authCode`, so it was removed. The request is now `{ newOwner: string }`.

### Added — `entities.submitMessage` / `entities.prepareSubmitMessage` (HCS topic submit)

A topic created with `createTopic` had no write path: its `submit_key` is the topic entity's per-entity DKG KeyList, so an app's own Hedera operator cannot submit to it. `prepareSubmitMessage(entityId, { message, payerAccountId? })` calls the new `POST /api/v3/baas/entities/:entityId/message` (the validator runs the sign-gate and attaches the topic submit-key signatures; the topic id comes from the entity record). `submitMessage(entityId, { message, payerAccountId?, fundWith })` prepares, then hands the prepared bytes to your `fundWith`, which signs + submits as the payer (the platform never pays), and returns `{ transactionId, payerAccountId, topicEntityId }`. Hedera only; one HCS chunk: an empty message or one over 1024 UTF-8 bytes is a `ValidationError` before any call. The topic's rule must set `operations.submit.enabled`, else the sign-gate answers `403`. New types: `SubmitTopicMessageRequest`, `SubmitTopicMessageWithFundRequest`, `SubmitTopicMessageResult`.

## 11.2.0 — 2026-11-09

### Changed (consumer-visible) — zod peer range narrowed to `^3.23.0` (#3391)

The peer range said `^3.23.0 || ^4.0.0`, but the shipped declarations are zod-3-shaped: a zod-4 consumer that type-checks the SDK's own `.d.ts` (`skipLibCheck: false`) got 1,246 errors (TS2344/TS2707/TS2694/TS2724 — `ZodEnum` tuple-vs-record, `ZodObject` arity). Runtime was always fine. Rather than rewrite every schema's inferred type for zod 4, the range now admits **zod 3 only**. If you are on zod 4: stay on SDK 11.1.x until a zod-4-clean declaration set ships, or add a resolution forcing zod 3 for this package. A new CI gate (`sdk-zod-consumer`, `scripts/ci/check-sdk-zod-consumer.cjs`) type-checks a strict `--module nodenext` consumer against the packed tarball with `skipLibCheck: false` for every zod major the peer range admits, so the range and the declarations cannot drift apart silently again.

### Added — `./package.json` export

`require('@hsuite/smart-engines-sdk/package.json')` now resolves (it used to throw `ERR_PACKAGE_PATH_NOT_EXPORTED`), for tools that read a dependency's version that way.

## 11.1.0 — 2026-11-09

### Added — `customerSession.login` takes `publicKey` / `caipChainId` from the signer

`signFn` may now return `{ signature, publicKey?, caipChainId? }` as well as a bare signature string (new type `CustomerSessionSignResult`). Wallets that expose their public key only after signing can therefore log in with no `publicKey` up front. A value the signer returns takes precedence over the one passed to `login`. The host's `verify` already treats both fields as optional and verifies the signature as before; nothing about verification changed.

### Added — `statusCode` on `CustomerSessionClient` errors

`CustomerSessionClient` now throws `CustomerSessionError` (exported), which exposes `statusCode` like `BaasError` and the other clients. `.status` is kept as a `@deprecated` alias of `statusCode`.

### Fixed — `ChainType` under zod 4

`ChainType` was `z.infer<typeof ChainTypeSchema>`. The bundled `.d.ts` spells a `z.enum` as `z.ZodEnum<[...]>`, a tuple argument zod 4 rejects, so the inferred type was wrong for consumers on zod 4 (the peer range is `^3.23 || ^4`). `ChainType` is now a declared literal union with the same eight members. The set of chains is unchanged: `cardano` is not added, because the platform removed it deliberately (#1691).

### Added — `@hsuite/smart-engines-sdk/customer-session`

A subpath with `CustomerSessionClient`, `CustomerSessionError` and their types, so a frontend can sign customers in without loading the root bundle (449 KB). The entry is 7.4 KB (CJS `index.js` and ESM `index.mjs`) and imports none of xrpl, zod, ripple-keypairs or @noble/post-quantum; `dist-exports.spec.ts` fails if one is added. It is the only ESM output; every other entry stays CJS. `CustomerSessionClient` now imports the error classes from their own module instead of the `_vendor` barrel to keep it that way.

### Fixed — `xrplWalletFundWith` under Jest's CommonJS VM

`xrplWalletFundWith` loaded `xrpl` with `await import('xrpl')`, which the CJS bundle kept as a native dynamic import. Without `--experimental-vm-modules`, Jest throws "A dynamic import callback was invoked without --experimental-vm-modules", so a consumer test that drove it got a 500. `xrpl` is a required peer and the root entry already imports it statically (`auth/xrpl-signer`), so it is now a static import. No API change.

### Not changed

`messaging.subscribeUrl(channel)` and `messaging.subscribe(channel, handlers)` already mint a stream ticket and open an `EventSource` with it, reconnecting with a fresh ticket each time; nothing was added for the live-feed opener.

## 11.0.1 — 2026-11-08

### Fixed — the package root loads under webpack (Angular / Ionic)

11.0.0 claimed the root entry bundles for the browser. That held for esbuild only. Under webpack the page threw `Cannot find module 'ripple-keypairs'` at load. The two modules that use `ripple-keypairs` (`auth/xrpl-signer`, `build-attestation`) loaded it with `require(...)`, which esbuild emitted as its `__require("ripple-keypairs")` shim, and webpack cannot resolve that shim. Both now use static imports, which compile to a plain `require("ripple-keypairs")` that every bundler resolves to the package's browser build. No API change.

A webpack 5 `target: 'web'` bundle of the root entry now loads; the same bundle of 11.0.0 throws. `dist-exports.spec.ts` now fails any published entry that contains a `__require(` call.

## 11.0.0 — 2026-11-08

**BREAKING.** Every BaaS client now sends exactly what the smart-host serves, proven by a route contract that drives every public method against the host's own route table (`apps/smart-host/src/sdk-contract/sdk-route-contract.spec.ts`). Use `@hsuite/smart-engines-cli` 3.1.0 with it.

### Added — `customerSession.login` re-requests a cross-cluster challenge miss

`customerSession.login({ appId, chain, address, publicKey?, caipChainId?, signFn }, attempts = 3)` runs challenge → sign → verify in one call. Behind the multi-cluster gateway the challenge and the verify can reach different clusters. A peer keeps the first live challenge it holds and drops a replicated one (#2282), and spending a challenge clears it only where it was verified. So a re-login within the 5-minute window could answer `401 Invalid challenge` even though the signature was good. `login` re-requests on exactly that 401 and on `Challenge not found or expired`, signing each new challenge. Every other refusal is thrown at once. Pass `attempts: 1` where each signature is a wallet prompt.

### Security — `xrplWalletFundWith` signs only a plain reserve Payment

Before this release, `xrplWalletFundWith` checked the Payment's `Account`, `Destination` and `Amount`, but signed any other field it was handed. It now refuses, before anything is signed:

- any field the validator's funding prepare never sets, including `SendMax`, `Paths`, `DeliverMin`, `Memos`, `TicketSequence`, and a pre-set `TxnSignature` or `Signers`;
- any non-zero `Flags`, including partial payment;
- a `SigningPubKey` that is not the wallet's own;
- a `Fee` above `maxFeeDrops`. That is a new option, `xrplWalletFundWith(wallet, { maxFeeDrops })`, defaulting to `XRPL_FUND_WITH_DEFAULT_MAX_FEE_DROPS` (5000 drops).

Also new: the `XrplWalletFundWithOptions` type.

### Fixed — the package root bundles for the browser

The root entry used to bundle `ripple-keypairs` together with the Node build of `@xrplf/isomorphic`, which left four `require("crypto")` calls. It also imported Node `crypto` at the top level (for the retire-intent nonce) and called a literal `import('crypto')` in the function-signing helpers. Browser bundlers (esbuild, Angular, Vite) failed the whole build on these.

- `ripple-keypairs` is now an external runtime **dependency** (`^2.0.0`, the range `xrpl` uses), so the app's bundler resolves its browser variants.
- The retire-intent nonce uses `@noble/hashes`.
- The Node-only signing helpers load `crypto` only when they are called.

`esbuild --platform=browser` now bundles the root entry with no error or warning. (webpack still failed at load; fixed in 11.0.1.) `new CustomerSessionClient(hostUrl).login(...)` needs no app credentials, so a frontend can sign customers in directly.

### Fixed — `agents` sends what the host reads and types what it answers

**BREAKING.** The host's agent DTOs are plain types and `register` spreads the body into the lifecycle service, so a wrong field name was not refused: it was dropped or stored unread. Every request below now carries exactly the fields the host reads, under its names, and nothing else.

- `register` / `registerWithIntent` send `rulesConfig` (was `rules`, which the host never read: every SDK-registered agent lost its `trade`, `offchain.allowedUrls`, `functions` and `triggers`), `type` (was never sent; the agent record requires it) and `additionalChains`. `description`, `fundingConfig` and `securityMode` are gone: the host has no reader for them (the security mode comes from `validatorRules`).
- `primaryChain` is required and must be one of `AGENT_SYNC_CHAINS` (`polkadot`, `bitcoin`, `solana`). The host has no default and refuses every other chain, an omitted one included, with a 400. It is now checked before any call, before the network discovery and the intent signing. Create an XRPL agent with `entities.prepareCreateAgent`.
- `register` takes `creationIntent` and `validatorRules` as required: the host refuses a registration without an intent. It answers `AgentRegisterResult` (`{ success, agentId, status, ownerWallet, primaryChain, createdAt, entityId }`), not `AgentInfo`.
- `registerWithIntent` with a pre-signed intent kept only six of its fields and derived the v1 entity id, so a genuine app-bound (v2) creation failed the post-create check after the host had created it. It now requires the v2 fields (`appId`, `network`) and derives the v2 id. A v1 intent, which the host refuses, is refused before sending.
- `AgentRules` is now the agent's real rules config: `trade` (`currency`, `issuer`, `trustLimit?`), `offchain.allowedUrls`, `functions.allowedFunctionIds`, `triggers` (`schedule`, `events`). `maxTradeAmount`, `allowedPairs`, `allowedChains`, `dailyLimit`, `requireApprovalAbove` and `customRules` had no reader; spending limits are the agent's `validatorRules`. `validateAgentRules` checks the new shape (unknown keys, the XRPL market, the strict trigger shapes, app-scoped event subjects, keys the bus will not replicate), and `register`, `registerWithIntent` and `updateRules` run it before sending.
- `updateRules(agentId, rulesConfig)` sends `{ newRulesConfig }` (`ModifyRulesDto`); the flat body arrived as `newRulesConfig: undefined` and the host answered 400. It takes the full config, since the host replaces it, and returns `{ success, agentId, rulesHash }`.
- `trade` sends `baseToken` / `quoteToken` (was `pair` / `price`) and may answer a pending operation or a skip (`AgentTradeSkippedResponse`, new guard `isAgentTradeSkipped`). `fund` and `withdraw` take `tokenId?`; `fund` drops `source`, which the host ignored. `withdraw` may answer a pending operation. `isAgentFundPending` accepts any of these unions.
- Response types follow the host: `AgentInfo` carries `type`, `rulesConfig`, `hostId`, `rulesHash`, `agentWallet`, `treasuryAccount`, `additionalChains`, `updatedAt` and the lifecycle dates (no `rules`, no `description`); `AgentStatus` adds `stopped` and `awaiting_approval`. `pause` / `resume` / `revoke` return `{ success, agentId, status }`; `approve` the approved prepared transaction with `agentId`, `opId` and `resumedSteps?`; `reject` `{ success, agentId, opId, status }`; `getEvents` `{ agentId, events }` (no `total`) with `AgentEvent` the stored audit entry, and an optional `{ limit }`; `getBalances` `{ agentId, balances }` keyed by chain. `AgentCertification.recentEvents` is `AgentEvent[]`.
- New: `AGENT_SYNC_CHAINS`, `AGENT_MAX_ADDITIONAL_CHAINS`, `AgentType`, `AgentChain`, `AgentSyncChain`, `AgentRegisterResult`, `AgentTradeMarket`, `AgentTriggers`, `AgentScheduleTrigger`, `AgentEventTrigger`, `AgentTradeSkippedResponse`, `AgentStatusChangeResult`, `AgentUpdateRulesResult`, `AgentApproveResult`, `AgentRejectResult`, `AgentReclaimReserveResult`, `AgentEventsResponse`, `AgentBalancesResponse`, `isAgentTradeSkipped`, all re-exported from the package root.

### Fixed — deployment, rules, db, storage and messaging now match the routes the host serves

**BREAKING.** Each change below follows the host controller as the source of truth.

- `deployment.init` now sends `appId`. The host requires it (`InitAppDto`), so before this fix every call got a 400. `BaasService` gains `'customer-session'`, which the host enum accepts.
- `deployment.update` resolves the `{ appId, status, url }` envelope the host returns, not `DeployedAppInfo`. `deployment.delete` resolves `{ success, deleted }`.
- `rules.listByOwner` now always sends `owner`. Without it the host answers an empty list. `filter.owner` defaults to the session's wallet (`GET /api/v3/baas/auth/session`), the wallet `publish` records as the owner.
- `db.listCollections` returns `BaasCollectionInfo[]` (`name`, `documentCount`, `createdAt`, `shared`), not names. `BaasQueryOptions.projection` is removed: the host never read it.
- `storage.upload(file, filename, options?, opts?)` takes `{ shared?: boolean }` in place of free-form `metadata`. The host read only `shared`, and it refuses (403) a customer-scoped upload that names it. When the host answers unshared, which happens when it forwards an upload to the app's home cluster, the SDK shares the file explicitly. `storage.getMetadata` returns `BaasFileInfo`. `BaasStorageUsage` gains `quotaUsed` and `quotaLimit`.
- Messaging: `getHistory` resolves `{ messages }` and `listChannels` resolves `{ channels }`, which is what the host sends. `BaasHistoryOptions` loses `startTime` and `endTime`, which the host never read. Channels are typed as `BaasChannelInfo` and addressed by name. The host has no `channelId`. `BaasChannelConfig` gains `shared`. `setPresence(channel, update?)` sends only `{ clientId?, metadata? }`, and the host stamps the timestamps itself. `BaasPresenceInfo` loses `channel`, which the host never sent.
- `CustomerSessionVerifyRequest` gains `caipChainId`. The verify bearer is documented as what it is: an opaque 43-character base64url token, not a JWT.

### Removed — SDK methods whose host routes are gone

**BREAKING.** These are removed: `rules.deprecate` (the host has no deprecate route), `deployment.rotateKek` / `revokeKek` and their response types (the host removed both routes in #1217), and `gateway.routing.proxyRequest` with `ProxyRequest` / `ProxyResponse` (the gateway removed the route in HSUITEPT-20). Against a current host, each of them returned 404.

### Added — receipts plane, sharing toggles, single-document reads, listing and app-host routes

- `baas.receipts` (also on `forCustomer`): `getLatestEpoch()`, `getEpoch(n)` and `getDocumentReceipt(collection, documentId)` over `/api/v3/baas/receipts/:appId/...`.
- Sharing toggles: `db.setCollectionShared`, `db.createCollection(name, { shared })`, `storage.setShared`, `messaging.setChannelShared`.
- `db.getDocument(collection, id)` resolves `null` on a 404. `db.findOne(collection, query?, options?)` is `find` with `limit: 1`.
- `deployment.setListing`, `deployment.setAgentCreation` and `deployment.listMarketplace`. The last one is public.
- `gateway.routing.setAppHost` / `removeAppHost` / `listAppHosts` / `getAppHost`. These are cluster-internal and need `X-API-Key`.

### Added — `platformPaid: true` on `entities.createAccount` / `createToken` / `createTopic`

These three were documented as the platform-paid single-shot creates. On every live chain (XRPL, Stellar, Hedera) they nevertheless ran only the payer-funded prepare, fund and execute flow, and required a `fundWith`. The platform-paid route the host serves (`@PlatformPaidWrite()`) was reachable only by hand-written HTTP. `platformPaid: true` now posts that route: the quorum signs and the platform pays. It covers the app's own create, or a customer the app sponsors per call (`{ sponsorCustomerWrite: true }` on a `forCustomer` view). `fundWith` still selects the payer-funded flow. Passing both is refused.

### Fixed — a create without `fundWith` no longer runs the DKG first

On a payer-funded chain, `createAccount`, `createToken` and `createTopic` called `prepare-create` before checking for `fundWith`. A call without one therefore ran the DKG and reserved an entity before throwing, which left an orphan prepared entity behind. It now refuses before any HTTP call, and the error names both ways to pay.

### Changed — BREAKING: `entities.withdraw` is now `prepareWithdraw` + `executeWithdraw` (#4116)

`withdraw` posted `/entities/:id/withdraw`, which the host does not serve. It now posts `/entities/:id/withdraw/prepare`, and is renamed `prepareWithdraw` to match `prepareCreate*` / `executeCreate*`. There is no alias. `executeWithdraw` is new: it posts the signed bytes to `/withdraw/execute`. The request now carries `appOwnerPublicKey` and `idempotencyKey`; echo the prepare's `idempotencyKey` on the execute, because Hedera refuses an execute without it. The body cannot name a source account (`source` / `from`). The host reads neither of those fields; it withdraws from the entity's own chain account. `PreparedEntityTransactionResult` is now fully typed (`requiredSignatures`, `readyToSubmit`, `validatorSignatures`, `transactionJson`, `broadcast`, `status`, `idempotencyKey`). The execute returns a new type, `ExecutedEntityTransactionResult`.

### Added — `entities.delete(id, { force? }, opts)` and `entities.restore(id, opts)` (#4116)

`delete` tombstones the entity. The host refuses a funded entity with 409 ("withdraw first"), unless you pass `{ force: true }`, which sends `?force=true`. Call options go in the third argument. Any other key in the second argument (for example `onBehalfOf`) is refused, so an identity cannot sit in a slot the host ignores. `restore` clears the tombstone.

### Removed — BREAKING: `entities.launchpad` and its types (#4116)

The host route was gone (HSUITEPT-41), so the method could only 404. `LaunchpadEntityRequest` / `LaunchpadEntityResult` are gone too.

### Changed — BREAKING: a Hedera token is platform-paid only; token fields go in `metadata` (#4116)

The validator refuses every Hedera token `prepare-create`, because the treasury must be the entity's own account. `createToken({ chain: 'hedera' })` now requires `platformPaid: true`, and `fundWith` on Hedera is refused before any call. `prepareCreateToken` sends `name` / `symbol` / `decimals` / `initialSupply` / `memo` in `metadata`, which is the only place the host forwards them from; it used to send them top-level, where they were dropped. `prepareCreateTopic` likewise sends its `memo` in `metadata`. With `platformPaid`, the XRPL issuance fields (`currency`, `compliant`, `wipeable`, `creationIntent`) are refused instead of silently dropped: they ride the payer-funded create only. `executeCreateToken` no longer takes `createTxId`.

### Added — `xrplWalletFundWith(wallet)`: fund an XRPL create from an app wallet (#4116)

The helper returns a `fundWith` that decodes the prepared funding `Payment` and checks the payer (`Account`), the entity address (`Destination`) and the stated reserve (`Amount`). Only then does it sign with an xrpl.js `Wallet` and hand back `signedFundingBlob` for the validator to relay. It refuses on any mismatch, and it never submits.

### Fixed — Hedera account / topic creates check the prepared `transactionId` before `fundWith` pays (#4116)

Without that id, execute-create cannot resolve the network-assigned `0.0.X` id. Before this fix the payer paid for a create the SDK could never finalize. `createAccount` and `createTopic` on Hedera now refuse before `fundWith` runs.

### Added — `appOwnerPublicKey` on `CreateAccountRequest` (#4116)

It is forwarded on `prepareCreateAccount` and on the platform-paid `createAccount`, which are the two routes whose DTO reads it.

### Changed — agent creates are gated by chain before anything is published (#4116)

`createAgent` / `createAgentWithIntent` serve `polkadot` / `bitcoin` / `solana`, which is the host's `SYNC_AGENT_CHAINS`. `prepareCreateAgent` / `prepareCreateAgentWithIntent` serve `xrpl`. Any other `primaryChain` is refused before the rule is published or a create is sent. `prepareCreateAgent` also sends the name as `metadata.name`, which the pending registry row reads. A pre-signed v2 intent keeps its `appId` / `network`, so the expected entity id is derived over them; before, the parser dropped them and every v2 intent read as a mismatch.

### Changed — BREAKING: the customer rides each entities / agents route in the header that route reads (#4116, #3422)

The host validates customer identity per route tier. `scope` routes read `X-Customer-Session-Token`. `claim` routes read `X-Customer-Session-Claim` only, and under `customerScope: strict` they refuse a scope token with 403. `principal` routes read neither header and act as the session wallet. The transport now follows those tiers:

- **`forCustomer(token)`** sends the scope token on every entities / agents GET (and on every other plane, unchanged).
- It sends the claim only (no token) on every entity create, on both halves of every two-phase create whatever the entity type, and on every agents write.
- It refuses the principal-tier routes (`transfer`, `prepareWithdraw`, `executeWithdraw`, `mint`, `burn`, `compliance`, `trustline`, `delete`, `restore`) unless the call passes `{ onBehalfOf }`.
- **Ambient forwarding** (HSUITEPT-6) now attaches the served customer's token only on entities / agents GETs. It never attaches it on the durable-records `template-bootstrap` namespace or on the records purge `DELETE`: the host refuses both (403) when a customer scope is present.
- **`prepare-create` is platform-paid for every entity type**, matching the host's `@PlatformPaidWrite('operation')`, not only for an agent draft. Served to a customer, it needs `{ sponsorCustomerWrite: true }` like the single-shot creates.
- A sponsored relay on entities / agents now names the customer as the claim; `rules.publish` keeps the token.
- On every platform-paid route, the SDK now refuses before sending the two shapes the host's `AppAuthorityGuard` refuses: `onBehalfOf`, and a customer identity without `sponsorCustomerWrite`.

### Fixed — docs: `functions.deploy` example sets `appOwned: true`, the agents example names a supported chain, and a missing `appId` points to `APP_ID` / `fromEnv` (#4116)

### Changed — `engines.node` admits only Node lines patched for the 2026-10-21 security release

`engines.node` was `>=20`. It is now `^22.23.4 || ^24.22.1 || >=26.12.1`: the first releases on each supported line with the 2026-10-21 fixes (CVE-2026-111497, a Brotli decoder out-of-bounds write, plus TLS hostname-verification bypasses). Node 20 is EOL and has no fix.

### Changed — validator discovery admits only registry entries signed under keys you pin (Hacken HSUITEPT-17, #3238)

**BREAKING.** Registry entries are self-declared, and discovery admitted any of them.
`ValidatorDiscoveryConfig` gains `expectedValidatorKeys`: each validator's Ed25519 identity
public key (hex), keyed by full node ID (`validator-sn1`, …). Take them from the cluster
operator (the validators' `GENESIS_EXPECTED_VALIDATOR_KEYS`), never from the registry itself.
`TrustAnchorConfig` inherits the field and `ResolveClusterTrustAnchor` passes it on, so
`BaasClient.connectToCluster({ trustAnchor: { ..., expectedValidatorKeys } })` takes it too.

- An entry counts only when its complete contents carry a valid signature by its node's pinned
  key, issued within five minutes of its consensus time. A leave is signed by the pinned key of
  the publisher it names in `registeredBy`. Pins must be well formed and name distinct keys.
- A signed leave outranks every join and update, whatever the mirror order. Only a join signed
  more than ten minutes after the leave readmits the node. Two different joins or updates from
  one node signed at the same time quarantine that node.
- **Migration:** pass `expectedValidatorKeys` wherever you read the registry. Without pins, or
  with pins that are malformed or repeat a key, discovery finds no validator, so a
  `trustAnchor` fails closed (`TrustAnchorError`, `empty-registry`) unless
  `onFailure: 'passthrough'`.
- **Release order:** publish only after every active validator's registry entry has been
  re-signed by the signing publisher (#3836). Against unsigned entries this reader finds no
  validator.

### Added — `SmartEngineModule` guard opt-out, the app's own public key, and a configurable BaaS host (#3530)

Additive (minor). Any NestJS app with its own authentication could not import the module: both global guards were
always on, and they honoured only the SDK's `@Public()` key, so the app's own public routes (health, login challenges,
webhooks) answered `401`.

- `forRootAsync({ …, guards?, publicMetadataKeys? })` and `forRoot(config, isGlobal, { guards?, publicMetadataKeys? })`.
  New types `SmartEngineGuardWiring` and `SmartEngineGuardOptions`, exported from `/nestjs`.
- `guards: { customerSession?: boolean; appOwner?: boolean }`. Both stay **on** unless explicitly `false`; any other
  value keeps the guard. A `false` is logged at boot. `appOwner: false` **refuses to boot** (module init throws
  `APP_OWNER_GUARD_DISABLED_MESSAGE`, naming the routes) while any controller or handler carries `@AppOwnerOnly()`:
  the decorator is only metadata, so without the guard those routes would be served to any caller.
  `SdkRequestContextInterceptor` stays registered either way.
- `publicMetadataKeys: string[]`: extra metadata keys `CustomerSessionGuard` treats as public, alongside
  `IS_PUBLIC_KEY` (`'hsuite:isPublic'`). For a consumer decorator `SetMetadata('isPublic', true)`, pass
  `['isPublic']`. A blank or non-string key throws at wiring time. It opts a route out of authentication only:
  `AppOwnerGuard` ignores it, so an `@AppOwnerOnly()` route reached anonymously is still `401`. New export
  `SDK_PUBLIC_METADATA_KEYS` (the injection token).
- `SmartEngineServiceConfig.baas?: { hostUrl?: string; pathPrefix?: string }`. `SDK_BAAS_CLIENT`, and so
  `CustomerSessionGuard`'s session validation, targets `baas.hostUrl` (default `validatorBaseUrl`) plus
  `baas.pathPrefix` (default none). An app whose smart-host sits behind the gateway's `/host` rewrite sets
  `baas: { pathPrefix: '/host' }`.
- `DaoClient.vote()`'s JSDoc said the server records the vote "on the caller's behalf". It does not:
  `CastVoteRequest.signature` and `voterPublicKey` are required and verified, exactly as for `submitVote()`.

### Documented — breaking changes that shipped without a note (#3530)

Found upgrading a consumer from 3.2.0 to 7.0.1 (HSuiteNetwork/human-potential-protocol#538).

- **7.0.0, `SmartEngineModule` registers `CustomerSessionGuard` and `AppOwnerGuard` as `APP_GUARD`s.** The 7.0.0
  entry describes the change; the migration it did not spell out for an app with its own authentication:
  - mark anonymous routes with the SDK's `@Public()`, set `IS_PUBLIC_KEY` from your own decorator
    (`applyDecorators(SetMetadata('isPublic', true), SetMetadata(IS_PUBLIC_KEY, true))`), or, from this release,
    pass `publicMetadataKeys: ['isPublic']`;
  - or, from this release, keep your own guard in charge with `guards: { customerSession: false }`. Keep
    `appOwner` on unless the app has no `@AppOwnerOnly()` route; the module refuses to boot otherwise.
- **`BaasService` is no longer exported from the package root.** It is the service-name union in
  `baas/types.ts` and is still reachable as `baas.BaasService` (`import type { baas } from '@hsuite/smart-engines-sdk'`).
- **DAO writes need the caller's signature (#1743, #1784).** `PrepareVoteRequest.voterPublicKey`,
  `CastVoteRequest.signature` (used by `vote()` and `submitVote()`), and `SubmitSignedProposalRequest`
  (`proposerPublicKey`, `signature`) are required; the validator verifies each before recording the vote or
  publishing the proposal, and a missing one is a `400`.
- **`DaoClient.createProposal` is removed (#1784).** It published an unsigned `proposal-created` message. Create a
  proposal with `prepareProposal()`, sign `preparedMessage.payload` with the proposer's key, then `signProposal()`.

## 10.0.0 — 2026-10-29

**Major.** `verifyPqcAttestation` binds every cert signer to the HCS-anchored validator registry (HSUITEPT-14, #4152,
below): its third argument is now `RegistryEvidence` (`fetchRegistrySnapshotFromMirror`), its policy must name a
`network` or an `anchor`, and a served `{snapshotTs, validators}` snapshot is refused. The snapshot fetchers
(`fetchRegistrySnapshot`, `fetchRegistrySnapshotQuorum`, `fetchRegistrySnapshotFromClusters`, `computeSnapshotHash`,
`ValidatorRegistrySnapshotSchema` and their types) are removed, and `reconstructListingSet` takes `registry` plus
`network` / `anchor` instead of `snapshot`. The rule-module API that has been refused since 7.1.0 is removed: the
module builders, `Rules.module`, the seven module templates, `ModuleEntrySchema` and `ModuleEntry` (#4141). Other
changes in this release, each detailed below: `build()`, `Rules.validate` and the platform refuse an agent rule that
grants no action type (#4142); a same-name function deploy replaces the function, each customer gets a share of the
function cap, and `functions.list` reports `usage` (#4146); a BaaS call made while serving a customer carries that
customer (HSUITEPT-6); subscription tiers and marketplace sub-offers are priced in US dollars, settled in HST at the
live rate (`depositAmount`, `priceAmount` and `priceToken` are removed, #2974).

### Changed — subscription tiers and marketplace sub-offers are priced in US dollars, settled in HST at the live rate (#2974, #3699, #3006)

**BREAKING.** A price is a US-dollar figure everywhere; HST is only what it converts to at the live HSUITE/USD rate.

- `subscription.getTiers()` reads `GET /subscription/tiers` only (the `/config` fallback is removed). `SubscriptionTierInfo.depositAmount` is removed; `priceUsd` is the monthly price in US dollars, and the new optional `quote: SubscriptionTierQuote` = `{ asset: 'HSUITE', chain: 'xrpl', amount, rate: { hsuiteUsd, source, sampledAt }, indicative: true }` is that price in whole HST, `ceil(priceUsd / rate)`. `quote` is absent when the validator has no rate. It is indicative: the subscription order locks the price paid when it is requested. `SubscriptionTierQuote` is exported from the package root.
- `SubscriptionUsageStatusResponse.nextTier` is typed with `monthlyPriceUsd` (US dollars), the field the validator sends; the `monthlyPriceHsuite` it was typed with was never sent.
- Marketplace: `MarketplaceListing.subOffer` is `MarketplaceSubOffer` = `{ tier, priceUsd, periodDays }`; `priceToken` and `priceAmount` are removed. `setSubOffer` takes `SubOfferInput` = `{ tier, priceUsd, periodDays }`, where `priceUsd` is a positive decimal string with at most 2 decimals, at most 1,000,000; anything else, or any other key, is a `400`.
- The subscription-status `offer` carries `quote?: MarketplaceOfferQuote` = `{ amountHst, rateHsuiteUsd, rateSource }`. A mediated `buy` returns `priceUsd`, the locked `priceHst`, `rateHsuiteUsd`, `rateSource` and `rateSampledAt`; `amountHst` is `priceHst` plus any reserve surcharge. New types `MarketplaceSubOffer`, `MarketplaceRateSource`, `MarketplaceOfferQuote`, exported from the package root and under `marketplace`.
- **What this breaks.** Code that read `depositAmount` or `monthlyPriceHsuite`, or that wrote or read `priceAmount` / `priceToken`, must move to `priceUsd` (and `quote` for the HST figure). A listing stored with the old sub-offer shape loses its offer on the public listing, smart-host's `offer` and replication, and the buy refuses it with `400`; the owner's `getListingStatus()` still reports `hasSubOffer: true` until the owner sets the offer again.

### Changed — `verifyPqcAttestation` binds every signer to the HCS-anchored validator registry (Hacken HSUITEPT-14 residual, #4152)

**BREAKING.** A `{snapshotTs, validators}` snapshot chose the keys a cert was checked against, and nothing authenticated it, so whoever forged a cert could serve the snapshot that verified it. The verifier now takes the validators' own Ed25519-signed HCS registry rows and authenticates every row under an anchor the caller names.

- `verifyPqcAttestation(cert, payload, registry, policy)`: `registry` is `RegistryEvidence` = `{ kind: 'hcs-validator-registry/v1', registryTopicId, rows, readFrom }` (`REGISTRY_EVIDENCE_KIND`). Read it with `await fetchRegistrySnapshotFromMirror({ network })`, or pass that JSON saved earlier. Anything else, a `{snapshotTs, validators}` snapshot included, returns `registry-snapshot-unanchored`. `registry-snapshot-invalid` is no longer returned.
- The policy names the anchor: `network` (the anchor the SDK ships, `KNOWN_NETWORKS[network].validatorRegistry`) or `anchor` (a `ValidatorRegistryAnchor` you trust: `{ registryTopicId, expectedValidatorKeys, expectedValidatorPqKeySha256?, mirrorUrls }`; an unknown field is refused).
  - Neither returns `registry-anchor-required`. Both, an unknown network or a malformed anchor return `invalid-policy`.
  - A network that ships no anchor returns `registry-anchor-unknown-network`.
  - Evidence read from another topic than the anchor's returns `registry-evidence-topic-mismatch`. Evidence with more than 2000 rows, or that fails its strict parse, returns `registry-evidence-invalid`.
- Shipped anchors (`NetworkPreset.validatorRegistry?`): testnet pins topic `0.0.9143793`, the Ed25519 registry keys of `validator-sn1`, `validator-sn2` and `validator-sn3`, and the mirror `https://testnet.mirrornode.hedera.com`. It ships no ML-DSA pins. Mainnet and devnet ship none. Moving the registry topic or rotating a pinned key needs an SDK release.
- Each signer is judged against its anchored entry:
  - Refuses the cert: a signer no pin names (`registry-unknown-signer`), or one whose key's commitment (sha256 of the raw key bytes) is not the commitment its entry binds (`registry-pubkey-mismatch`).
  - Not counted: a signer whose newest entry is a leave (`registry-inactive-signer`, which used to refuse the cert); one whose own newest binding is older than `policy.maxRegistryAgeMs` (default 72 h, `DEFAULT_MAX_REGISTRY_AGE_MS`, judged at `policy.now` or the current time, also under `maxAgeMs: Infinity`), reported as `registry-evidence-stale:<node> issuedAt=… max=…`; one with no usable key binding, reported as `registry-unkeyed-signer:<node>:<no-entry|no-commitment|duplicate-commitment|equivocation|pq-pin-mismatch>`.
- The commitment is the `dilithium5PublicKeySha256` field of the validator's signed registry entry (validators write it since #4149; `ValidatorInfo.dilithium5PublicKeySha256?` types it), or the anchor's `expectedValidatorPqKeySha256` for that node, which must equal the HCS value when both are present. A validator with neither is `no-commitment`: until a network's validators republish their entry with the field, their signers are not counted under the SDK preset and that network's certs do not verify.
- Quorum: `max(cert.threshold, attestationQuorumThreshold(N), policy.minSigners)`, where N is the number of pinned validators, not the active roster of a snapshot, so a leave or a row a Mirror omits does not lower it. A miss reports the reason of the first signer that was not counted, followed by `verified=… required=…`; with every signer counted it is still `threshold-not-met: …`.
- `VerifyResult.anchor` (`VerifyAnchor`): `{ source: 'sdk-preset' | 'caller', network?, registryTopicId, rosterSize }`, set once an anchor is resolved.
- New `fetchRegistrySnapshotFromMirror({ network } | { anchor })` (the options also take `mirrorUrls`, `timeoutMs` and `allowInsecure`): reads the newest 500 registry rows (plus the rows that complete a chunked entry) from each mirror, unions the rows of every mirror that answers, and returns deep-frozen evidence, so verifying many certs against it folds it once. It throws `ValidationError` unless exactly one of `network` / `anchor` is given, or when the anchor or the mirror list is malformed, and `RegistryUnavailableError` when the network ships no anchor or no mirror answered.
- New `resolveAnchoredRoster(evidence, anchor, { now, maxRegistryAgeMs? })`: the same resolution, pure and synchronous. It returns, for every pinned node, `counted: true` with its `pqKeySha256`, or the reason it is not counted, plus `equivocations` (the conflicting rows, as proof).
- New exports (package root and `/pqc-verify`): `fetchRegistrySnapshotFromMirror`, `resolveAnchoredRoster`, `REGISTRY_EVIDENCE_KIND`, `DEFAULT_MAX_REGISTRY_AGE_MS`, types `ValidatorRegistryAnchor`, `RegistryEvidence`, `RegistryEquivocation`, `AnchoredValidator`, `AnchoredRoster`, `AnchoredRosterResult`, `ResolveAnchoredRosterOptions`, `FetchRegistryFromMirrorOptions`, `VerifyAnchor`. `/pqc-verify` (and `pqcVerify.*`) also exports `MAX_REGISTRY_EVIDENCE_ROWS` (2000) and `MAX_REGISTRY_MESSAGE_CHARS` (16384). New `VerifyPolicy` fields: `network`, `anchor`, `maxRegistryAgeMs`.
- `reconstructListingSet` takes `registry` plus `network` or `anchor` instead of `snapshot` (`ReconstructListingSetOptions`). Evidence that is not `hcs-validator-registry/v1`, or neither `network` nor `anchor`, throws `ValidationError` before any read.
- **Removed** (package root and `/pqc-verify`): `fetchRegistrySnapshot`, `fetchRegistrySnapshotQuorum`, `fetchRegistrySnapshotFromClusters`, `computeSnapshotHash`, `ValidatorRegistrySnapshotSchema`, and the types `ValidatorRegistrySnapshot`, `FetchRegistryOptions`, `QuorumSnapshot`, `RegistryBlock`, `ClusterSnapshotConfig`. `RegistryUnavailableError` stays; `fetchRegistrySnapshotFromMirror` throws it.
- **Migration:** `const registry = await fetchRegistrySnapshotFromMirror({ network: 'testnet' });` then `verifyPqcAttestation(cert, payload, registry, { network: 'testnet', maxAgeMs })`, and `reconstructListingSet({ ...opts, registry, network: 'testnet' })`.

### Removed — the rule-module builders, `Rules.module` and the module templates (Hacken HSUITEPT-41, #4141)

**BREAKING.** A rule carrying `modules` has been refused since 7.1.0, because the co-signing gate never evaluated them. The APIs that only built such rules are now gone; the 7.1.0 entry names each one's canonical replacement.

- Builder methods, from every builder (`BaseRuleBuilder`): `withModules`, `addModule`, `withLimits`, `withRateLimiter`, `withSnapshot`, `withSwap`, `withAirdrop`, `withVesting`, `withStreaming`, `withGovernanceModule`. From the token, account and topic builders: `withCooldown` and `withApprovalThreshold` (`forAgent()` keeps its own, which set the canonical agent fields). From `forAgent()`: `withMaxTradesPerWindow` and `withRequireStructuredOutput`. `BaseRulePartial.modules` is gone.
- `Rules.module` and the `module_` export (the `launchpad`, `stakingPool`, `dao` and `dex` factories), with the types `AllocationTier`, `LaunchpadModuleConfig`, `RewardToken`, `StakingPoolModuleConfig`, `TreasuryAllocation`, `DaoModuleConfig`, `LiquidityPool` and `DexModuleConfig`.
- The seven templates that only threw: `fairLaunch`, `tieredIDO`, `simpleStaking`, `tokenDAO`, `multisigDAO`, `simpleAMM` and `vestingSchedule` (also gone from `Rules.template`), with `FairLaunchParams`, `TieredIDOParams`, `SimpleStakingParams`, `TokenDAOParams`, `MultisigDAOParams`, `SimpleAMMParams` and `VestingScheduleParams`.
- `ModuleEntrySchema` and the `ModuleEntry` type. The rule schemas still accept `modules: []`, and refuse any element with `RULE_MODULES_REFUSED` whatever its shape; the inferred `modules` type is `unknown[]`.

### Changed — an agent rule that grants no action type is refused (Hacken HSUITEPT-12, #4142)

The co-signing gate denies every op whose action type `actionTypeRestrictions.allowed` does not list, and every transfer to an address `recipientAllowlist.addresses` does not list. An agent rule without them reads as unrestricted while it authorises nothing.

- `Rules.forAgent()…build()`, `Rules.validate` and `AgentValidatorRulesSchema` (so `ValidatorRulesSchema` too) refuse an agent rule with no, or an empty, `actionTypeRestrictions.allowed` (issue at `actionTypeRestrictions`), and one that allows `transfer` with no, or an empty, `recipientAllowlist.addresses` (issue at `recipientAllowlist`). `Rules.forAgent().withName('x').build()` on its own now throws `RuleBuildError`.
- Still accepted: `{ allowed: ['mint'] }` with no allow-list, the `tradingAgent` template and `buildDefaultAgentValidatorRules()`.
- New exports: `agentAuthorityRefusals(rule)`, `AGENT_NO_ACTION_TYPE_REFUSED`, `AGENT_TRANSFER_NO_RECIPIENT_REFUSED`. `appPublishRefusals` now includes them.
- **BEHAVIOUR CHANGE (platform):** `baas.rules.publish` refuses such a rule (`Rule failed schema validation`), and `baas.rules.simulate` with an inline rule answers `isValid: false`. Binding an agent to one is refused with a `400` by `POST /api/v3/baas/entities/createAgent` (`entities.createAgent`) and by `POST /api/v3/baas/entities/prepare-create` for an agent (`entities.prepareCreateAgent`), before the platform pays. A rule already published (`ruleRef`) is still evaluated as the gate evaluates it: every op is denied.

### Changed — functions: a same-name deploy replaces the function, each customer has a share of the cap, and `list` reports the allowance (Hacken HSUITEPT-36/37, #4146)

- `functions.list()` returns `usage?: BaasFunctionUsage` = `{ used, limit, mine }`: `used` of `limit` function slots are taken in the app by every customer together, `mine` in the caller's own scope, so `used` can exceed the caller's `functions.length`. A host without this change leaves it out. New type `BaasFunctionUsage`.
- **BEHAVIOUR CHANGE (host):** a `deploy` of a name the same owner (the deploying customer, or the app itself) already holds updates that function in place: same `functionId`, `version` + 1, no slot taken. A new name takes a slot. Before, every deploy minted a new `functionId`. One owner's deploy never replaces, or takes its version from, another owner's function of the same name.
- **BEHAVIOUR CHANGE (host):** a customer is capped at its own share of the app's function limit: `DeployedAppLimits.functionsPerCustomer` (new, optional), else a quarter of `limits.functions`, rounded up. Past it a deploy is refused (`400`, `Function limit reached for your account in app <appId>: maximum N functions per customer allowed`), even while the app has slots left.
- `functions.getStats()` (`GET /api/v3/baas/functions/:appId/stats`) now reaches its handler. The host declared that route after `:appId/:functionId`, which matched `stats` as a function id and answered `404`.

### Changed — a data-plane call made while serving a customer is sent as that customer (Hacken HSUITEPT-6, #4116)

**Behaviour change.** Inside a `SmartEngineModule` request served to a customer, a BaaS call on the app-level client that names no customer of its own now carries the served customer's session as `X-Customer-Session-Token`, so a handler that forgets `forCustomer(...)` / `customerToken` can no longer run the call under the app's tenant-wide authority.

- Covers `db`, `storage`, `messaging`, `functions`, `entities`, `agents`, `records` and `receipts` calls, the bare listings `agents.list()` and `entities.listByOwner()` included. Auth, session mint, `deployment` and `rules` are not among them.
- Boot, cron, `@Public()` and `@AppOwnerOnly()` code has no customer in context and is unchanged. An explicit `customerToken`, `onBehalfOf`, a hand-set `X-Customer-Session-Token` header or a `forCustomer(...)` view always wins, and the header is never sent twice.
- **What this breaks.** App-owned reads and writes made inside a customer-serving handler now carry the customer, so the host scopes them to that customer: rows the app wrote unbound answer `404`, and an app-owned `functions` / `entities` / `agents` operation fails the host's owner compare (`404` / `401`; a revoked or expired forwarded token is a `401` on `functions`). Make those calls as the app, from boot / `@AppOwnerOnly()` code or with `{ asApp: true }`.
- The served customer follows the request's async context, so it also reaches work the request started (`setImmediate`, a synchronous event listener) and a value memoised on first use, which then holds the first caller's customer-scoped rows for every later caller. Read what the app caches, and run what it does in the background, as the app.
- The forwarded header is a credential, so it goes through the plaintext-transport policy an explicit `customerToken` already does: an app with no `authToken` / `apiKey` calling an `http://` host outside the cluster (without `allowInsecure`) gets the same `400`.
- Takes effect only for an app that registers `SmartEngineModule` (it installs the interceptor) on an SDK build containing this change.

### Added

- `HttpCallOptions.asApp?: boolean`: make this call as the app itself while serving a customer. `withCallOptions` passes it through. It carries no identity, so a `customerScope: 'strict'` app's host refuses it on every route the policy does not exempt.

## 9.0.0 — 2026-10-23

**Major.** `@hashgraph/sdk` is now an OPTIONAL peer (#3868, below): a Hedera app that imported it without
declaring it got it implicitly from npm ≥ 7 and must now `npm install @hashgraph/sdk` itself. XRPL-only apps
change nothing and install none of the Hedera stack. Other highlights of this release, each detailed below:
the payer verifies a deposit address through an independent validator (`verifyDepositAddress`, #3506),
PQC attestation certs need the roster's BFT supermajority (#3002), subscription status/list carry `tier`,
`pendingTierChange` and `currentOrder` (#4081), the marketplace listing and fee contract (#4093), the widget pin (`thirdPartyWidgets` on `publish`; `uploadWidgetBundle(appId, tar)` pins nothing, #4093 W3), and the
listing content hash excludes the deployment-derived `hostingUrl` (#4093 M3), so verifiers older than 9.0.0
see a mismatch for a listing whose app is serving.

### Changed — a publish pins third-party widgets with the user's consent fields; an upload pins nothing; a widget calls its own backend only (#4093 W3, W4)

**BREAKING.** A listing's pinned widget hash, and the consent the user gave for it, now change only through a publish.

- `uploadWidgetBundle(appId, tar)` takes the tar only (the `meta: { id, name }` argument is gone) and returns `UploadedWidgetBundle` = `{ contentHash }`. It stores the bundle and records that this app uploaded it on the app's owner cluster (an upload that reaches another cluster is forwarded there, like the other app writes, so the publish that names the hash runs where it was recorded); it no longer writes `thirdPartyWidgets` onto the listing, so uploading never changes what a wallet shows or what a user agreed to. A text field sent beside the bundle is ignored.
- `publish` takes `thirdPartyWidgets?: ThirdPartyWidgetPin[]` (`PublishListingInput`), the only way a widget gets onto a listing: `{ id, name, contentHash, permissions, ledgers, networks, accountAccess }`.
  - `contentHash` must be a bundle this app uploaded (or one its listing already pins), else `400 thirdPartyWidgets.N.contentHash: ...`; the same bytes uploaded by another app do not count.
  - `permissions` are `WIDGET_PERMISSIONS` (the wallet's vocabulary, may be empty), `ledgers` 1..8 of `WIDGET_LEDGERS`, `networks` 1..32 entries `<ledger>:<network>` (`isWidgetNetwork`, for example `xrpl:mainnet`), `accountAccess` `'single'` or `'multiple'`; no list repeats an entry and every network belongs to a declared ledger (`hasDistinctEntries`, `firstNetworkOutsideLedgers`); at most 20 pins with unique `id`s (`MARKETPLACE_LISTING_LIMITS.thirdPartyWidgetsMax`, `widgetTextMax`, `widgetPermissionsMax`, `widgetLedgersMax`, `widgetNetworksMax`).
  - A field outside that list is a `400` naming it. There is no `connectOrigins`: a widget may call its own app's backend and nothing else.
  - Leave the key out to keep the pins, `[]` to remove them. A new `contentHash` is a new publish; the wallet compares the hash the user consented to with the listing's and asks again on a mismatch.
- `ThirdPartyWidgetRef` (what `getApp` / `listApps` return) now carries `permissions`, `ledgers`, `networks` and `accountAccess`, exactly the pinned fields plus `kind`. A ref stored before these fields existed is not returned until its listing is published again.
- A widget's CSP `connect-src` is exactly its own app's public smart-app origin (the `url` of its deployment status, `https://<hash>.testnet.smart-app.hsuite.network`), or `'none'` when the app never deployed. Before it was always `'none'`. On the shared widget host the app is the one that pins and uploaded the hash; a hash two apps both pin names none.
- New exports (`marketplace.*`): `WIDGET_PERMISSIONS`, `WIDGET_ACCOUNT_ACCESS`, `WIDGET_LEDGERS`, `WIDGET_NETWORK_NAMES`, `isWidgetNetwork`, `hasDistinctEntries`, `firstNetworkOutsideLedgers`, types `WidgetPermission`, `WidgetAccountAccess`, `WidgetLedger`, `ThirdPartyWidgetPin`, `UploadedWidgetBundle`.
- **Migration:** call `uploadWidgetBundle(appId, tar)`, take `contentHash` from the answer, then `publish(appId, { ...listing, thirdPartyWidgets: [{ id, name, contentHash, permissions, ledgers, networks, accountAccess }] })`.

### Changed — a rule published for a customer is their own signed agent rule (Hacken HSUITEPT-10, #3996)

- `rules.publish(rule, opts)` takes `opts.creationIntent` (new type `PublishRuleOptions`,
  `HttpCallOptions & { creationIntent? }`). With it the rule rides in the `{ rule, creationIntent }`
  envelope; without it the wire is unchanged.
- **BEHAVIOUR CHANGE (host):** `POST /api/v3/baas/rules/publish` refuses (`400`) a publish the app
  relays for a customer (`sponsorCustomerWrite` with `customerToken` / `customerClaim`) unless the
  rule is an `agent` rule and `creationIntent` is that customer's signed agent intent for this app
  over exactly this rule. The host checks it before the platform pays. The app's own publish (no
  customer on the call) is unchanged.
- `entities.createAgentWithIntent` / `prepareCreateAgentWithIntent` with a `signer` refuse a rule that
  is not an `agent` rule before signing or publishing anything, and publish the rule with the signed
  intent, so the host verifies it before it pays.
- **Migration:** an app that publishes a customer's agent rule passes the intent the customer signed
  in the app frontend (`signEntityCreateIntent`):
  `baas.rules.publish(rule, { creationIntent, sponsorCustomerWrite: true })`.

### Added — the wallet's validator session reads its own subscription status (#4093 M6)

`GET /api/v3/baas/marketplace/apps/:appId/subscription` accepts the validator session the wallet already holds for `publish` and `getListingStatus`, for the session's own wallet, and answers `{ active: boolean, offer? }`. Before, smart-host answered `403 Authenticated session has no app identity` to it (a validator session names no app) and the wallet had to open a second, customer, session just for this read.

- `MarketplaceClient.getSubscriptionStatus(appId, { chain?, session?: 'customer' | 'validator' })`: `session: 'validator'` sends the validator token (`setValidatorToken`). The default is still the customer session.
- The wallet is still read from the session, never from a parameter (`?wallet=` does not exist), so a session can only ask about its own wallet. The exception is only for this route and only for a session bound to no app and no principal (what validator authentication mints); every other host route still refuses it, and a session that names a suspended app, or claims a principal without an app, is still refused here.

### Changed — `listApps` validates its paging and every filter is documented (#4093 M5)

An invalid page is a `400` that names the parameter instead of being clamped or ignored: `limit` must be an integer 1..100 (default 20) and `offset` an integer 0..10000 (default 0). `limit=abc`, `0`, `-1`, `1.5`, `101`, `offset=-1`, `abc`, `10001`, and a repeated or bracketed key all answer `limit must be an integer between 1 and 100` / `offset must be an integer between 0 and 10000`. Before, `abc` was dropped, `0` and `-1` were raised to 1, `101` and more were cut to 100 and an `offset` over 10000 was cut to 10000 (returning a page that was not the one asked for).

`ListAppsParams` now documents every parameter: `q` (literal, case-insensitive, at most 64 characters, over name and tags), `sort` (`trust` | `name`, and the default order), `minTrustScore` (a non-number is ignored), `bundles` (only `true` filters), `featured` (see the M1 entry), `category`, `targetAudience`. `total` counts the whole filter, not the page.

### Changed — `publish` explains its amount, and the SDK documents how the fee is paid (#4093 L1)

`PublishResult.quote` (new type `ListingFeeQuote`, whole HST units): `{ pricePerPeriodHst, periodDays, periods, reserveSurchargeHst, carriedHst, totalHst }`. `totalHst` is the same number as `depositInstructions.amountHst` and `deposit.amount`, and the lines always add up: `totalHst = pricePerPeriodHst * periods + reserveSurchargeHst + carriedHst`. For example, a first publish with a 50 HST fee and a 100 HST surcharge quotes 150 (the surcharge is derived from the XRP reserve outlay, so it varies).

- `depositInstructions.reserveSurchargeHst` is now the surcharge that is actually inside `amountHst`. For a developer's second app it used to repeat the wallet's surcharge even after it had settled out of the wallet, so fee + surcharge did not equal the amount.
- Documented on `PublishResult`: each developer has ONE dedicated fee wallet (one line item per app); there is no `DestinationTag`, by design; the quote is a cumulative balance target (payments add up, send only the difference); apps are credited oldest-first; a publish is served by the app's owner cluster whatever cluster it reached, so the address is stable; an overpayment is not credited as extra periods and there is no refund route yet (it is settled with the payment, and until the next settlement pass it is the held deposit, `heldDepositHst`).

### Changed — every listing carries `featured` and `sortPriority`; `featured: false` filters correctly (#4093 M1)

`MarketplaceListing.featured` (default `false`), `sortPriority` (default `0`) and `trustScore` (default `0`) are on every item of `listApps` and `getApp`, whatever the stored listing holds: the platform used to pass them through "as stored", so a listing written without them (an app whose trust score was computed before its first publish) served neither key. `listApps({ featured: false })` returns every live app that is not featured, including those. A first publish now seeds every fee and ranking field of the stored listing, including on a listing that already exists as a bare score.

### Changed — one rule for "listed": the public list and `listing-status` cannot disagree (#4093 L2)

An app is in `listApps` / `getApp` (and a bundle's `components`) if and only if its `getListingStatus()` says `listed: true` AND `listingFeePaidUntil` is after now (epoch ms). Before, the public list asked only for `listed`, so a lapsed fee stayed on the list until the hourly expiry sweep; now it leaves at once, and `getListingStatus()` shows `listed: true, listingFeeDue: true` in that window. Both answers read `trustScore`, `featured` and `sortPriority` through one function (a missing one is `0`, `false`, `0`).

- `ListingStatus.published` is `true` only for an app with published listing metadata. It used to be `true` for an app that only ever had a trust score computed.
- `trustScore` is written only for an app that has a listing, and is recomputed when the listing fee credits. Documented: the listing is cluster-local (see `docs/internal/marketplace.md`), so a public read answered by a cluster other than the app's owner does not carry it.

### Changed — marketplace publish is validated: limits, a closed category list, https-only URLs (#4093 M2)

The marketplace `publish` body (`PublishListingInput`) is now enforced by the platform, and the contract is exported so a form can check before it submits. A violation is a `400` whose message starts with the field path (`icon: ...`, `screenshots.1: ...`, `category: ...`).

- `category` is a closed list, `MARKETPLACE_CATEGORIES`: `defi`, `nft`, `analytics`, `security`, `news`, `tools`, `gaming` (type `MarketplaceCategory`; `PublishListingInput.category` is now that union, not `string`). New `MarketplaceClient.getCategories()` reads the live list (`GET /api/v3/baas/marketplace/categories`, public).
- `MARKETPLACE_LISTING_LIMITS`: `name` 1..100, `description` 1..4000, `shortDescription` at most 280, `tags` at most 10 entries of 1..32, `screenshots` at most 8, every URL at most 2048, `widgets` at most 20.
- `icon`, every `screenshots` entry and every link (`websiteUrl`, `documentationUrl`, `supportUrl`, `ossRepoUrl`) must be `https://` URLs (`isHttpsUrl`): a host, no `user:password@`, no whitespace or backslash. `javascript:`, `http:`, `data:` and relative values are refused. The platform also drops any stored non-https value from `listApps` / `getApp` (an `icon` becomes `''`, a screenshot leaves the array, a link is absent), so a wallet that opens a link with `window.open` never receives a `javascript:` value.
- Exports: `marketplace.MARKETPLACE_CATEGORIES`, `marketplace.MARKETPLACE_LISTING_LIMITS`, `marketplace.isHttpsUrl`, type `MarketplaceCategory`; the first two and the type are also on the package root.

### Removed — `subscriptionDepositAmount` and `hsuiteTokenIds` from `SubscriptionConfig` (#4092)

`GET /api/v3/subscription/config` stopped sending two legacy fields, and `SubscriptionConfig` no longer declares them:

- `subscriptionDepositAmount`: an env default (`SUBSCRIPTION_DEPOSIT_AMOUNT`, 1000000 smallest units) that contradicted
  `availableTiers` and `GET /subscription/tiers` (what a tier costs is its USD price, paid in HSUITE at the rate of the
  order). Read the amount from the order (`depositInstructions.pay` / `currentOrder.pay`).
- `hsuiteTokenIds`: the env alone, empty on a genesis-derived deployment (testnet included). Read
  `depositTokenIds` instead: the deposit token per chain (XRPL: `HST.<issuer>`), from the env override or else the HST
  issuer recorded at genesis.

### Changed — `getBilling` returns the billing history, derived from durable orders (#4092)

`GET /api/v3/subscription/billing/:appId` always answered 503 (its ledger was an in-memory, per-pod service that nothing
wrote to and no module provided). It now answers `200` with the app's deposits, upgrades, top-ups, renewals and float
credits, newest first, read off the durable subscription orders every validator replicates, so every validator answers
the same history and a restart loses none of it.

- **The entry type changed**: `SubscriptionBillingLedgerEntry` (`subscriptionId`, `operation: string`, `amount`,
  `balanceAfter`, `metadata`) is replaced by `SubscriptionBillingEntry`: `id`, `operation`
  (`'subscribe' | 'upgrade' | 'topup' | 'renew' | 'credit'`; `subscribe` is the initial deposit), `status`
  (`fulfilled`, `underpaid`, `paid` / `converting` for an XRP order awaiting its conversion, `credited`), `tier?`,
  `priceUsd?`, `asset` (`HSUITE`, or `XRP` for an XRP order), `amount` (what reached the deposit account, in the asset's
  smallest units), `due?`, `hsuiteCredited?` (an XRP order: the HST its conversion delivered), `txHashes`, `account?`,
  `timestamp` (ISO 8601: when the last payment closed on the ledger). `balanceAfter` and `subscriptionId` are gone: the
  orders record no running balance. New types `SubscriptionBillingOperation`; `SubscriptionBillingResponse` is
  `{ appId, entries, total, hasMore }` as before.
- An entry is an order that **took a payment**; a quote nobody paid is not one, so an unpaid app has an empty history
  (`200`, `entries: []`), not an error. Usage charges (per-call metering) are not billing entries.
- `limit` is 1-200 (default 50), `offset` >= 0, `from` / `to` ISO 8601 on `timestamp`; a malformed value is a `400`
  (`limit=abc` used to become NaN). Owner-only: a subscription you do not own answers the same 404 as a missing one.

### Added — `cancel`: discard an app that was never paid for (#4092)

`SubscriptionClient.cancel({ appId })` (`POST /api/v3/subscription/cancel`, types `CancelSubscriptionRequest` /
`CancelSubscriptionResponse`). Allowed only while the subscription is `pending_deposit`, no order of the app has
counted a payment, and the validator's own read of the deposit account's validated payment history shows nothing paid.
The subscription becomes `cancelled` on every cluster (`getStatus` reads `cancelled`; `list` and
`listByStatus('pending_deposit')` stop showing it as pending) and the app's open quotes are closed (on the other
validators once each quote's own deadline has passed; they are inert meanwhile). Anything else is refused:

- `409`, the message beginning `deposit_present:` (the deposit token, HST, has reached the deposit account),
  `payment_received:` (an order has counted a payment, or a payment tagged for one of its quotes is on the ledger, e.g.
  XRP awaiting conversion) or `not_pending_deposit:` (already paid, or over);
- `503` `ledger_unreadable:` with `Retry-After`: the validator could not read the deposit account to check nothing was
  paid (retry; nothing changed);
- `404`, the same answer for an unknown app and for an app you do not own.

A repeat answers `200` with `alreadyCancelled: true`. Nothing is paid out and no chain operation is made: the deposit
account and the XRP reserve fronted for it stay with the validator's deposit-account reclaim, which treats a cancelled,
never-paid account as abandoned once its never-paid timeout has passed. The same app id can be requested again.

### Changed — deployment status carries the history, an always-present runtime and the public url; host auth `expiresAt` is epoch milliseconds (#4093 H1, H2, H4)

The HSuite Wallet team verifies these from outside. **Breaking** for a client that read `expiresAt` as seconds
or `runtime` as optional for an ACTIVE app.

- `deployment.status(appId)` (`BaasRuntimeStatus`):
  - `runtime.deploymentHistory[]` (new `BaasDeploymentHistoryEntry`: `{ tag, imageDigest?, bundleSha256?,
    deployedAt, status }`, `status` is `'active' | 'superseded' | 'rolled_back' | 'failed'`), newest first,
    at most the last 10 revisions, from the history `rollback` validates against. `imageDigest` is the digest
    the deployer pinned and `bundleSha256` the attached frontend bundle's hash, both absent on entries written
    before they were recorded.
  - `runtime` is always present for an ACTIVE app. Before the first deploy it is
    `{ runtimeState: 'NOT_DEPLOYED', image: null, replicas: 0, deploymentHistory: [] }`; `runtime.image` is
    now `string | null` and `runtime.lastError` `string | null`.
  - `url: string | null`: the app's public https URL, the same value the deploy response returns,
    `https://<hash>.<SMART_APP_DOMAIN>` (`<hash>` is the appId without `smart-app-`; the domain is
    `testnet.smart-app.hsuite.network` on testnet and `mainnet.smart-app.hsuite.network` on mainnet). `null`
    until the app is deployed and while it is not ACTIVE.
- `deployment.list()` (`BaasAppListResponse`) gains `total`; each `DeployedAppInfo` gains `url` and its
  `status` is derived from the runtime (`active` / `deploying` / `error` / `inactive`) instead of a column that
  always read `active`. The list covers apps that have a smart-host row (created when push credentials are first
  issued), so an undeployed app is listed as `inactive` with `url: null`. `deployment.get()` carries the same two
  fields.
- `expiresAt` on `BaasChallengeResponse`, `BaasAuthResult` and `BaasSessionInfo` is epoch **milliseconds**
  (it was epoch seconds with no unit stated): compare it with `Date.now()`. A session clamped to the app's
  subscription end carries that end.

### Added — `hostingUrl` on the public marketplace listing (#4093 M3)

`MarketplaceListing.hostingUrl?: string`: the app's public https URL (`deployment.status(appId).url`), present
only while the app has a deployment that is serving (`RUNNING` / `DEGRADED`) and absent otherwise, never guessed.
Response-only: `computeListingContentHash` ignores it (like `featured`, `sortPriority`, `trustScore`), so a deploy
never invalidates a verified listing. The hash does not cover it, so check it against
`https://<appId without smart-app->.<the network's smart-app domain>` before opening it. An SDK older than 9.0.0
does not ignore it: its `computeListingContentHash` differs for a serving listing, so upgrade before the host that
emits it rolls (or drop `hostingUrl` from the body before hashing).

### Added — `BaasClient.logoutAll()` revokes every session this wallet holds for the app (#4093 H5)

`POST /api/v3/baas/auth/logout-all` (SDK `baas.logoutAll()`) revokes every live session the authenticated wallet
holds for the app its bearer is bound to, the calling one included, and resolves `{ revoked: number }`. The wallet
and the app come from the bearer: other wallets' sessions and this wallet's sessions for other apps are untouched.
Unlike `logout()` it does not swallow a server failure.

### Fixed — the push credential docs said it was single-use; it is derived and stable (#4093 H6)

`registry.password` from `deployment.init`, `pushCredentials` and `reissuePushCredentials` is derived from the
app's DKG quorum and the operator's rotation epoch. Every call returns the SAME username and password; calling
again does not rotate it and the earlier password keeps working; the server keeps it and can derive it again, so it
is not "returned once". It rotates only when the operator bumps the epoch and the app is re-provisioned. The SDK
types and JSDoc (and the README) now say so. No behaviour change.

### Fixed — `deployment.getMetrics` answers a clear 503 or 409 instead of a 502 (#4093 H7, #3383)

The route was a 502 on every chart-deployed cluster (the deployer had no Service and the host no `DEPLOYER_URL`);
that is fixed in the chart. Checked in this order: `403`/`404` (not your app); `503` with `retryable: false`
("metrics are not enabled on this cluster") when the cluster runs with monitoring off, because only then does the
host hold the bearer to read the deployer's `/metrics` (retrying does not help; the operator must enable
monitoring); `409 not deployed` for an app with no runtime, before the deployer is contacted; `502` only when the
deployer is genuinely unreachable; otherwise `200`.

### Added — host: per-app widget origins (#4093 W2 variant A)

- Behind `marketplace.widgetPerAppOrigins` (default off) a widget is also served from the origin of the app that
  uploaded it: `https://<appId>.<widgetSandboxHost>/widgets/<contentHash>/<path>`, with the same CSP and headers as the
  bare host. Only the uploader's origin serves the hash; any other app's origin, an unknown subdomain or a malformed one
  answers the same 404 as an unknown hash. The bare `widgetSandboxHost` keeps serving every stored hash (the opaque
  `sandbox` iframe interim). `MarketplaceClient.widgetUrl` still builds the bare-host URL.
- A bundle uploaded before this shipped has no "uploaded by" record: re-submit it to serve it on its app's origin.

### Added — a revoked-widget list, and the widget upload rules (#4093 W5, W6)

- New `MarketplaceClient.getRevokedWidgets()` (and the types `RevokedWidget`, `RevokedWidgetList`, also under
  `marketplace`): the third-party widget content hashes a moderation takedown has revoked, `{ revoked: [{ contentHash,
  revokedAt }], truncated }`, newest takedown first. Public, no session. A wallet with a frame mounted polls it to unmount a
  revoked frame: the widget host's own 410 cannot reach a browser that already holds the (immutable-cached) asset, so
  the contract is: check this list before you mount a widget and while it stays mounted.
- `MarketplaceClient.uploadWidgetBundle` is documented: the 10 MiB cap, at most 500 entries, the extension allow-list,
  the required root `index.html`, no traversal, symlink or absolute paths, and the status each violation returns. The
  doc no longer calls `contentHash` the "sha256 of the bytes": it is the sha256 over the validated entries, so
  re-packing the same files gives the same hash. Entry names are read literally (`./index.html` is not `index.html`). A
  taken-down listing can no longer upload a bundle (409).

### Changed — host: widget serving (`GET /widgets/:contentHash/*`) (#4093 W1, W6, W7)

- Every response the widget route produces, the 404s and the 410 included, carries the framing and isolation headers: the CSP
  (`default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'
  data:; connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; form-action
  'none'; frame-ancestors <wallet origins>`), `Cross-Origin-Resource-Policy: cross-origin`,
  `Cross-Origin-Embedder-Policy: require-corp`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`; never
  `X-Frame-Options`, `Service-Worker-Allowed` or `Content-Disposition`. The CSP no longer carries `sandbox allow-scripts`
  (the opaque origin is the embedder's iframe `sandbox` attribute) and now carries `worker-src 'none'`.
- A hash that a taken-down listing references answers 410 on every path under it.
- Widget assets have their own per-IP window (6000 per minute), apart from the API's 100.

### Changed — a PQC attestation cert needs the roster's BFT supermajority, not a simple majority (#3002)

`verifyPqcAttestation` used to require `max(2, ⌊N/2⌋+1)` verified signers over a roster of `N` active
validators. At N=5 and from N=7 up that lets a coalition of fewer than two thirds produce a verifying cert
(3 of 5, 4 of 7, 6 of 10). It now requires `max(2, ⌈2N/3⌉)`, the bar the platform already applies to its genesis
signing threshold, and so do the platform's own verifier and the emitter that sizes a quorum request.

- New export `attestationQuorumThreshold(activeValidatorCount)` (root and `@hsuite/smart-engines-sdk/pqc-verify`):
  the number of distinct verified signers a cert needs. It throws an error with `code: 'VALIDATION_ERROR'`
  for a roster size that is not a non-negative safe integer. `VerifyResult.requiredSigners` is
  `max(cert.threshold, attestationQuorumThreshold(active roster size), policy.minSigners)`.
- Rosters of up to four validators need the same count as before (2, 2, 2, 3), so the three-validator
  testnet is unaffected. Above four the bar is never lower and usually higher: N=5 → 4 (was 3), N=6 → 4,
  N=7 → 5 (was 4), N=10 → 7 (was 6). A cert that cleared the old majority but not the supermajority now
  fails with `threshold-not-met`.
- A repeated signer still makes the cert fail (`duplicate-signer`), so a duplicate can never count twice.

### Changed — subscription status, list and tier-change answers carry the tier, the pending change and the open order (#4081)

The HSuite Wallet team verifies these from outside; the server now sends what the types promised.

- `SubscriptionStatusResponse.tier` is sent in every state (`pending_deposit` included), and
  `SubscriptionListResponse.subscriptions[].tier` on every entry (also from `listByStatus`). Absent only for a
  row that records no tier.
- `SubscriptionStatusResponse.pendingTierChange` is present while a tier change is scheduled and gone once it
  is applied or cancelled. `PendingTierChange` is now `{ fromTier, toTier, direction, effectiveAt?, orderId? }`
  with `fromTier`, `toTier` and `direction` required, and `direction` (`'upgrade' | 'downgrade'`) is the ONE
  name for the kind of a change in `getStatus`, `downgrade` and `upgrade` answers (the server's internal
  `kind`, `requestedAt` and `topUpAmount` are no longer sent). A downgrade's `effectiveAt` is the renewal
  boundary (the subscription's `expiresAt`); an upgrade has none (it applies when its top-up is verified;
  `orderId` has the quote's deadline). The nested `PendingTierChange.depositInstructions` is never sent: an
  upgrade's instructions are the answer's top-level `depositInstructions`.
- `SubscriptionStatusResponse.currentOrder` (new type `SubscriptionCurrentOrder`): while `pending_deposit`,
  the order the deposit is waiting on, the newest not superseded. Same id, tag, amount and expiry as
  `request` and `getOrder`; `requoteOrder` replaces it (same tag, new id). What to pay (`pay`) is carried
  only while the order is payable (`quoted` / `awaiting_payment`): a paid, converting, fulfilled, underpaid
  or expired order has none (requote the last two). An order paid through a swap carries `swap` (new type
  `SwapPaymentInstruction`) and no `pay`.
- `DepositInstructions.expired` on the answer to a repeated `request`: whether the quote's `expiresAt` has
  passed on the validator's clock. `request` requotes a lapsed order itself, so `true` means it could not.
- `getStatus` for an app you own, on a node that has not replicated its subscription row yet, throws a
  retryable `SdkHttpError` (503, message `subscription_not_replicated: ...`, `Retry-After`) instead of
  answering `hasSubscription: false`, for as long as that node has held the app's replicated record for less
  than one entity-create intent window (10 minutes, `ENTITY_CREATE_INTENT_TTL_MS`). Past it the row is not
  coming and the answer is `not_found` again (a client may create a fresh entity). `hasSubscription: false` /
  `not_found` still means none the caller may see, and is also what a node answers when it cannot tell the
  caller owns the app.
- Documented: `depositWallet.depositAmount` is the deposit required at creation (the bond). A tier upgrade
  paid through its order does not raise it (the top-up grows the prepaid float);
  `depositWallet.actualDepositAmount` is the source of truth for what the account holds.

### Added — the payer verifies a deposit address through an independent validator (#3506)

A deposit wallet's address reaches its payer in the HTTP response of the validator (the leader) that
created it. The wallet is created only under the payer's signed intent, but a compromised leader can
ignore the intent and return an account of its own. The payer now checks the address on its own reads
before paying it, and the check fails closed.

- `verifyDepositAddress({ intent, claim, answeredBy, discovery, ... })` (and
  `createDepositAddressVerifier(setup)`, the same bound to a discovery and the answering origin):
  1. derives the wallet's entity id (`wallet-<32 hex>`) from the payer's OWN signed intent;
  2. picks a validator that is not the one that answered, from `ClusterDiscoveryClient` (pass its
     `trustAnchor` to bind the cluster list to the HCS registry): another cluster, and never a cluster
     whose gateway shares the `answeredBy` origin. An origin that fronts several clusters is never a
     candidate, entries that share a validator node count as one cluster, and an answer from a gateway
     that names another cluster than the one discovery listed for it counts for nothing. When the host
     that answered is a shared round-robin host that matches no discovered cluster (or `answeredBy` is
     `null`), any cluster may have answered, so TWO distinct clusters must both confirm;
  3. reads that validator's binding for the entity id: its account address must equal the address the
     leader returned;
  4. reads the account's immutable on-chain rule memo through the same validator, and the HCS
     publication the memo points to: it must be a deposit-wallet memo, and the rule it points to must be
     the canonical deposit rule REBUILT from the signed intent (`buildDepositAccountRule`), published
     for that entity id and carrying the payer's signed message;
  5. any mismatch, or an answer that cannot be read, throws `DepositVerificationError` with `check` set
     to the step that refused (`intent`, `independent-validator`, `binding`, `memo`, `rule`) and
     `pending` set when waiting could help (not replicated yet, not readable, unreachable) as opposed to
     a mismatch. Pay only when it resolves. Nothing here signs.
  Every request is bounded as a whole (`timeoutMs` covers the body, which is cut off at 256 KB; `deadlineMs`
  bounds the whole check), and text a validator supplies never reaches a message raw.
- `signDepositWalletIntent` also returns `intent` (`{ message, depositType, tokenId }`), the plain-JSON
  record `verifyDepositAddress` takes. Keep it next to the wallet it created: a later payment (a
  top-up, a tier upgrade) is verified against it too.
- Every client path that returns payable deposit instructions takes an optional `verifyDeposit` and
  verifies before it returns (a refusal throws instead of returning the address):
  `SubscriptionClient.request` / `upgrade` / `getOrder` / `requoteOrder` (`verifyDeposit: { intent,
  verifier }`; the response gains `depositVerification`; an order is verified unless it is settled
  (`fulfilled`, `expired`, `paid`, `converting`), so a status this SDK does not know is checked, not
  waved through), `MarketplaceClient.publish` / `buyFeatured` (same), and `MarketplaceClient.buy`
  (`verifyDeposit: { intents, verifier }`, the signed intents keyed by app id, one per component of a
  bundle; an answer that is not exactly one purchase shape, or a bundle that does not hold exactly one
  escrow per signed intent, is refused). `publish` and `buyFeatured` are served by the app's OWNER
  cluster, which is not necessarily the one dialed, so they verify with `answererUnknown` (two distinct
  clusters must confirm). An XRP `pay` instruction, or a result's `deposit` alias, that names another
  address than the verified one is refused too. Without `verifyDeposit` the instructions come back as
  before, unverified. Not covered: a swap provider's pay-in address (`depositInstructions.swap`).
- `MarketplaceClient.buy` now returns `MarketplaceBuyResult` (a single escrow, or a bundle's
  `components`) instead of `any`.
- Needs a cluster that serves `GET /api/v3/discovery/deposit-accounts/:chain/:entityId` (a new public,
  read-only route under the already-routed `v3/discovery` prefix). A cluster without it answers 404, which
  the check reports as `independent-validator`: upgrade the clusters before relying on it.

### Changed — `@hashgraph/sdk` is an optional peer: an XRPL-only app no longer installs the Hedera SDK (#3868)

`@hashgraph/sdk` was a REQUIRED peer, so npm 7 and later installed it, with its protobufjs, `@grpc/grpc-js`
and React Native peer tree, into every consumer, XRPL-only apps included. The package never loaded it: no
built `.js` or `.d.ts` file imports or requires an `@hashgraph/*` module, and the Hedera signer type
(`HederaSigner`) is structural.

- `peerDependenciesMeta["@hashgraph/sdk"].optional` is now `true`. It stays a peer, `^2.62.0`, so a Hedera-side
  app is still held to that range.
- **Hedera-side apps install it themselves.** An app that signs with a Hedera key (`signChallengeHedera`, a
  `PrivateKey` from `@hashgraph/sdk`) and relied on the SDK pulling the package in must now run
  `npm install @hashgraph/sdk` and list it in its own dependencies. XRPL-only apps change nothing and lose the
  package on their next install. No SDK API changed.
- The README's install section and peer table say so.
- Guarded in CI: the `sdk-nest-boot` gate (`scripts/ci/check-sdk-nest-boot.cjs`) now boots `forRoot` and
  `forRootAsync` from the built bundle with `@hashgraph/*` hidden from it, fails on any `@hashgraph/*` import or
  require in the bundle's JS or in a non-test source file (a type-only import would reach the `.d.ts`), or any
  request for one while an entry point loads, and `npm pack`s the bundle and
  `npm install`s the tarball plus `xrpl` into an empty project against a loopback registry that refuses the
  `@hashgraph` scope. The install must succeed and land nothing of `@hashgraph`; the installed SDK must load
  every non-Nest entry point and sign with an XRPL wallet; with `@hashgraph/sdk` added by hand it signs with a
  Hedera key; and the same install of a tarball WITHOUT the optional flag must fail on `@hashgraph/sdk`.

## 8.2.0 — 2026-10-21

### Removed — `SmartEngineClient.settlement` / `SettlementClient` (#3699 PR4)

- The raw-tier `settlement` sub-client (`initiate`, `getStatus`, `confirmXrpLanded`, `getHistory`) and its
  types (`SettlementInitiateRequest`, `SettlementStatusResponse`, `SettlementHistoryEntry`,
  `SettlementPurpose`, `SettlementAsset`) are deleted. They called `/api/v3/settlement/*`, which the
  validator no longer serves: the legacy multi-asset settlement engine behind it was removed. It was never
  part of the public package surface (the raw tier is retired from the root export, #3496); only workspace
  tooling importing `@hsuite/smart-engines-sdk/client` is affected. Paying a subscription in an asset other
  than HSUITE is planned on the order model (#3699 PR2/PR3), not on these routes.

### Added — paying a subscription in XRP (#3699 PR2)

- `SubscriptionRequest.paymentAsset: 'XRP'` is payable on a validator whose operator switched XRP
  payments on: the order is priced in XRP (the HST/XRP market plus a buffer) and
  `DepositInstructions.pay` (`XrpPaymentInstruction`: `asset: 'XRP'`, `amount` in DROPS, `address`,
  `destinationTag`) says what to send. The tag is how the payment is matched to the order. The HST
  fields of the same instructions describe what the deposit account holds once the platform has converted
  the XRP into it.
- `SubscriptionOrder` gains `xrp` (the locked XRP leg: ratio, buffer, the HST the conversion must deliver)
  and `conversion` (`converted` is the fulfilment evidence; `failed` means the conversion could not be made,
  the order is not fulfilled and the subscription does not activate). For an XRP order `pay.amount` and
  `receivedAmount` are in drops. Type-only.
- `isXrpQuoteUnavailableError` / `XRP_QUOTE_UNAVAILABLE_CODE`: an XRP request is refused with a message
  starting `xrp_quote_unavailable:` when the network has no HST/XRP market (no AMM pool, no order book).
  Not retryable until an operator seeds one; pay in HSUITE instead.
- The deposit-wallet rule the SDK builds (`buildDepositAccountRule`, `DEPOSIT_ACCOUNT_RULE_VERSION`
  `2.1.0`) for a `subscription` deposit over an XRPL asset now declares
  `operations.transfer.permitsXrpConversion`, which lets the deposit account's quorum sign its own
  XRP-to-HST conversion and the sweep of leftover XRP to the Treasury, and nothing else of that shape.
  The validator rebuilds the identical rule from the signed intent, so an SDK older than the validator
  fails closed ("the rule does not hash to the intent ruleHash"): upgrade together.

### Changed — validator: a DAO proposal's id is the hash of its signed content (#3916)

- No change to the flow: `prepareProposal` → sign `preparedMessage.payload` verbatim →
  `signProposal`. The validator now assigns `proposalId = prop-<hex sha256>` of the
  proposal's signed content (DAO, proposer, type, actions, signed window, snapshot pin and a
  new server-drawn `nonce`), and the signed payload is `v: 2` with that `nonce`. A
  `proposal-created` message whose id is not the hash of its content is refused by every
  validator, so a vote for an id counts for exactly one proposal content.
- `DaoProposalConfig` gains the optional response fields `nonce`, `signedVotingStartsAt` and
  `signedVotingEndsAt` (the window the proposer signed; `votingStartsAt`/`votingEndsAt`
  move to the consensus window once the proposal lands). Type-only; no runtime change.
- The validator refuses a DAO `votingRules.votingPeriodMs` below `1000` (`400` at
  `POST /api/v3/dao/create`): the voting window is counted in whole consensus seconds.

### Added — payer-signed deposit-wallet intents (#3506 A2c)

A deposit wallet (the account a developer, buyer or node operator funds) is now created only under
its PAYER's signed create intent: `wallet` joins the entity types every validator withholds without
one. The payer signs the canonical deposit rule, built client-side from a vendored copy of the
validator's builder (so it never signs a rule a possibly-compromised leader handed it); the validator
rebuilds the identical rule from the signed message alone and refuses any difference.

- `signDepositWalletIntent(signer, { network, depositType, chain, tokenId, appId? })` returns
  `{ proof, entityId, ruleHash, rule }`. `depositType` is `subscription` | `listing` | `featured`
  (the developer), `marketplace-sale` (the buyer) or `membership` (the applicant node operator).
  `appId` makes the intent app-bound (v2): the smart-app's own entityId, the listed / featured app,
  the purchased app; omit it for a membership stake (v1).
- `buildDepositAccountRule`, `depositRuleCreatedAt` (the rule's `created` is the intent's `iat`),
  `isDepositIntentRequiredError`, `DEPOSIT_INTENT_REQUIRED_CODE` and the types `DepositWalletType`,
  `BuildDepositAccountRuleParams`, `DepositWalletIntentParams`. Parity with the validator's builder
  is enforced by `deposit-account-rule.parity.spec.ts`: a drift fails closed at the validator.
- `SubscriptionConfig.depositTokenIds` (`GET /api/v3/subscription/config`): the deposit token per
  chain to sign over (XRPL `HST.<issuer>`). Absent on a cluster that predates this change: do not sign.
- `SubscriptionRequest.depositCreationIntent`; `MarketplaceClient.publish(appId, listing, { depositCreationIntent })`,
  `buyFeatured(appId, { depositCreationIntent })`, `buy(appId, { depositCreationIntent | depositCreationIntents })`.
  The intent is needed only when a wallet is actually created (the first publish / feature of a
  developer, the first buy of an app); a request that needs one and has none is a 400 whose message
  starts with `deposit_intent_required:` (`isDepositIntentRequiredError`). A proof is valid for ten
  minutes: sign right before sending, sign afresh for a retry.
- Membership: `POST /api/v3/membership/apply` takes the applicant operator's proof as `creationIntent`
  (`signDepositWalletIntent` with `depositType: 'membership'`, no `appId`, `chain: 'xrpl'`).

### Changed — validator: every smart-app creation needs its signed intent (#3632)

- No SDK code change. The validator now refuses (`400`) a `POST /api/v3/smart-apps` that
  carries no `creationIntent` (or no `validatorRules` for it to sign), and every responder
  withholds an intent-less smart-app or agent creation: the `ENTITY_CREATE_INTENT_ENFORCE`
  lever is gone. The server no longer synthesises a default rule. `hsuite subscribe`
  (CLI 1.9.0) already signs; a raw HTTP client builds the rule
  (`buildDefaultSmartAppValidatorRules()` for the default) and signs it with
  `signEntityCreateIntent`. An intent-less creation under an intent-derived entityId
  (`<type>-<32 hex>`) is withheld whatever its type.

### Added — a lock-wallet bridge's minimum deposit (#3536)

- `BridgeMintOperationConfig` (`BridgeRulesConfig.mint`) with `limits.minDeposit?: string`:
  a lock-wallet bridge's optional minimum deposit, chosen by its creator (whole destination units; absent: no
  minimum). It is anchored with the bridge's other terms at create and is immutable; a
  deposit below it is never minted. The validator refuses it on any other bridge.
- `CreateBridgeRequest.sourceDecimals?: number`: the source token's decimals, which a
  lock-wallet bridge's terms require. The validator already read it; the type now carries it.
  Type-only; no runtime change.

### Changed — `DaoVotingPowerModel` is `'token-balance'` only (#3916)

- `DaoVotingPowerModel` (and so `DaoVotingRules.votingPowerModel`) no longer lists
  `'quadratic'`, `'one-person-one-vote'`, `'nft-based'` or `'delegated'`. The validator's
  tally weighs votes by token balance only, and `POST /api/v3/dao/create` has refused every
  other model since #3937; the type now says so. TypeScript callers that passed another
  model get a compile error instead of a 400. No runtime change.

## 8.1.0

### Note — this release also ships develop's unreleased #3410 tenant-keys primitives

Publishing `8.1.0` from a branch merged with `develop` also ships `src/tenant-keys/` (threshold
tenant-key derivation — JRSS, Pedersen, Shamir, tSeal, AEAD envelope — landed by #3410, merged
here at `0bd6eb7e9`) and its new runtime dependency, `@noble/curves@1.9.7`. Neither is part of
this PR's own scope (#3506 A3) — noted here factually so the publish log doesn't read as
"8.1.0 = signed intents only." Whether `#3410`'s primitives are ready to ship in a published
release is a separate call; not made here.

### Added — signed entity-create intents (#3506 A3)

- New module `entity-create-intent` (exported from the package root): the requestor-signed
  intent primitives that let a customer smart-app / smart-agent create carry proof the
  DEVELOPER'S wallet — not the leader/validator — chose the rule and the entity identity.
  - `signEntityCreateIntent(signer, {network, entityType, chain, rule})` — builds + signs the
    intent (fresh CSPRNG nonce, 10-minute TTL) via the same chain-agnostic
    `{chain, walletAddress, publicKey?, signFn}` signer shape `BaasClient.authenticate` /
    `DeploymentClient.delete` already use. Returns `{ proof, entityId, ruleHash }` — send
    `proof` as `creationIntent` and `rule` as `validatorRules` in the create request body.
  - `buildDefaultSmartAppValidatorRules(created?)` / `buildDefaultAgentValidatorRules(created?)` —
    the exact default `ValidatorRules` the validator would otherwise synthesize server-side,
    now available client-side so a developer who wants "the standard default" can still sign
    over it themselves.
  - `buildEntityCreateIntentMessage`, `hashCanonicalRule`, `canonicalRuleStringify`,
    `deriveIntentEntityId`, `ENTITY_CREATE_INTENT_TYPE`, `ENTITY_CREATE_INTENT_TTL_MS` — the
    lower-level vendored primitives, byte-for-byte parity-tested (monorepo-internal only)
    against `libs/multi-chain-core`'s canonical encoder and `libs/shared`'s default builders.
  - This is the SDK half of #3506 A3 ("every legitimate customer create carries a signed
    intent"), a prerequisite for A2b (server-side enforcement) landing without breaking
    legitimate customer creates. Zero chain authority added: the SDK still never signs on the
    caller's behalf — `signFn` stays the caller's.
  - `assertCreatedEntityMatchesIntent(returnedId, expectedEntityId)` — defence-in-depth
    (crypto review of #3636): throws, naming both ids, when a create response's `appId` does
    not equal the intent-derived entityId. Callers MUST treat a mismatched response as
    untrusted and stop (see `hsuite subscribe`'s usage in `@hsuite/smart-engines-cli`).
  - `fetchIntentNetwork(gatewayUrl)` — fetches the cluster's `GenesisConfig.network` from the
    PUBLIC, ingress-routed `GET /api/v3/discovery/network` (NOT `/api/v3/genesis/status`,
    which is not on the chart's ingress allow-list and 404s through the gateway). Returns a
    discriminated `{ok:true,network} | {ok:false,status}` so a caller can distinguish "this
    cluster predates signed create intents" (404) from any other failure.
  - **Isomorphic (Node + browser):** the entity-create-intent primitives now use
    `@noble/hashes/utils` (`randomBytes`, `utf8ToBytes`, `bytesToHex`) exclusively — no
    `node:crypto`, no `Buffer`. The customer-owned agent-creation path (pending product
    decision) signs from a browser wallet, not Node; this module must work there too.
  - `signEntityCreateIntent` now fails fast, client-side, when an XRPL signer has no
    `publicKey` (the validator has no way to derive a verification key from the address
    alone) — before signing anything, not after a round-trip.

### Added — agent create intents (#3506 A3, part 2)

- `AgentRegisterRequest` and `CreateAgentRequest` (`baas.entities.createAgent` /
  `prepareCreateAgent`) accept an optional `creationIntent` proof; `AgentRegisterRequest` also
  accepts `validatorRules` — required together (a `creationIntent` with no `validatorRules` is
  refused by the host, since the server default rule can never match a signed `ruleHash`).
- `AgentsClient.registerWithIntent(request, authorization, opts?)` and
  `EntitiesClient.{createAgentWithIntent,prepareCreateAgentWithIntent}(request, authorization, opts?)`
  — convenience paths that build the default agent rule (or use a caller-supplied one), fetch
  the cluster's LIVE `GenesisConfig.network` via `fetchIntentNetwork` (`GET
  /api/v3/discovery/network` — public, ingress-routed; never `/api/v3/genesis/status`, which is
  not on the chart's ingress allow-list), sign with a caller-supplied `EntityCreateIntentSigner`,
  and verify the host's returned entity id against the intent-derived one
  (`assertCreatedEntityMatchesIntent`) before returning.
- **Mainnet-only network guard**, mirroring `hsuite subscribe`'s `HSUITE_NETWORK` cross-check
  exactly: a discovered `testnet` is always accepted; a discovered `mainnet` is refused unless
  the caller's `network` is explicitly `'mainnet'` (case-insensitive).
- **Owner rule (Tomachi, 2026-09-30): the agent's OWNER signs.** An app-owned agent is signed by
  the app owner's own wallet — the convenience path above may use it directly. A
  `baas.forCustomer(token)`-owned agent must be signed by the END CUSTOMER's own wallet in the
  app frontend (via `signEntityCreateIntent`, exported from this package) — `AgentsClient` /
  `EntitiesClient` REFUSE the signer-authorization shape client-side (no HTTP request at all)
  when called on a `forCustomer(...)`-scoped view, OR when the app-scoped call itself carries
  `customerClaim`/`onBehalfOf`/`customerToken`; only a pre-signed
  `{ creationIntent, validatorRules | ruleRef }` is accepted for a customer-owned agent,
  forwarded unchanged. The pre-signed path is verified too, not just forwarded blind: the SDK
  parses `creationIntent.message`, checks it describes `entityType:'agent'` (and, on
  `AgentsClient`, that the supplied `validatorRules` hashes to the intent's `ruleHash`), then
  runs the same post-create `assertCreatedEntityMatchesIntent` check the signer path gets.
- **BEHAVIOUR CHANGE:** `BaasClient.forCustomer(token).agents.register(...)` and
  `.entities.prepareCreateAgent(...)` (agent entityType) now send `X-Customer-Session-Claim` —
  previously `PLATFORM_PAID_CREATE_PATHS` omitted both paths, so a `forCustomer` agent
  registration/prepare-create silently fell back to the APP session wallet as owner instead of
  the intended customer. A caller relying on the old (buggy) app-owned fallback will now see the
  customer become the owner, as the API always documented.
- **BEHAVIOUR CHANGE (#3506 A3 re-review MEDIUM-A):** `BaasClient.forCustomer(token)
  .entities.executeCreateAgent(...)` now also sends `X-Customer-Session-Claim` — the EXECUTE
  half of the two-phase agent-create flow was missing from the same body-aware routing rule that
  covers `prepare-create`, so a customer-owned agent could PREPARE successfully (the customer was
  bound as owner) but then 403 at EXECUTE (the host's ownership gate fell back to the app wallet
  with no claim present). Both halves of the flow now carry the claim.
- **`#3506 A3 re-review LOW-A`:** the pre-signed path on both `AgentsClient.registerWithIntent`
  and `EntitiesClient.{createAgentWithIntent,prepareCreateAgentWithIntent}` now refuses
  client-side (no HTTP call) when `creationIntent`'s signed `chain` does not match the request's
  `primaryChain` — on `agents.register` a mismatch previously reached the host and wasted a
  platform-paid rule publish before the validator refused it. `AgentsClient` mirrors the host's
  own default (`primaryChain` omitted → `'hedera'`) so an omitted field can't silently diverge
  from what was signed either.

### Added — registry tier keys (#3673)

- `ValidatorInfo.natsTierKeys`: the NATS public keys of a
  validator cluster's host / gateway / provisioner, each with a signature by that key over
  `hsuite/validator-registry/nats-tier-binding/v1\n<registryTopicId>\n<nodeId>\n<tier>`.
  Self-declared: verify before trusting. `TopicMessage.chunk_info` is now typed.

### Added — agent delete (Hacken HSUITEPT-38)

- **`agents.delete(agentId, opts?)`: an owner can delete an agent (Hacken HSUITEPT-38).** Revoke
  was the only way to retire an agent, and the agent stayed in every read. `agents.delete` calls
  `DELETE /api/v3/baas/agents/:agentId`: the host revokes the agent if it is not revoked yet and
  hides it from every read, and the deletion replicates to the other clusters. The agent keeps its
  `limits.agents` slot, because its funded reserve is not reclaimed. Delete does not touch the
  chain: the wallet entity stays listed under its owner in `baas.entities`, and its funds move only
  as far as the agent's validator rule allows (the default rule allows no transfer). Only the owner
  may delete. A `forCustomer(token)` view sends only the data-plane token, acts as the app on owner
  routes and deletes nothing (404); to delete a customer's agent, pass `onBehalfOf` or
  `customerClaim`. Another customer, another app and an id already deleted all get the 404 an
  unknown id gets. A host without this release answers 404 for every id.

### Changed — who wrote it, on the wire (Hacken HSUITEPT-7)

Types only: every new field is optional and no call site changes. With a smart-host of the same
release:

- **Storage: `appWallet` replaces `owner` on customer-scoped reads.** `listFiles` rows and
  `getMetadata` through a `forCustomer(token)` view (or with `customerToken`) no longer carry
  `owner`. It was the app session wallet, which read like per-user ownership. The new
  `BaasFileInfo.appWallet` carries the same value. The app acting as itself still receives `owner`
  beside `appWallet`. `BaasFileInfo.owner` is deprecated. **Migration:** read `appWallet` where
  you read `owner`; who uploaded a file is `actor`.
- **Messaging: `publish` names its publisher.** `BaasPublishResult` gains `sender` (the publishing
  actor's id, as on `BaasMessage.sender`) and `actor`. They are in the response body, so the PQC
  receipt's output hash covers them.
- **Database: a delete commits who deleted it.** A delete's `newHash` is now the tombstone
  `sha256(JSON.stringify({ _id, previousHash, deletedBy }))` (`_id` is the `documentId`,
  `deletedBy` the transition's `actor`); it was 64 zeros. It is not a Merkle leaf: the document's
  leaf is still removed, so `stateRoot` is the root of the live documents, and `proof` is still the
  removed document's inclusion proof under the previous root. The delete's PQC receipt attests the
  same `actor`. **Migration:** code that recognised a delete by its zero `newHash` reads
  `operation === 'delete'`.

Host only, same release: the PQC receipt of a write on the db, storage, messaging and functions
planes also attests the `actor` the write was attributed to, beside `caller` (the session wallet).
An `X-E2E-Test` header no longer files a write as test traffic (`initiator.via: 'e2e'`) unless the
operator sets `BAAS_E2E_MARKER_ENABLED=true`, and never under `NODE_ENV=production`.
### Added — durable records (#3848)

- **`baas.records`: owner-routed compare-and-set records (`DurableRecordsClient`).** Two
  namespaces: `compose-drafts`, a verified customer's own drafts (call it through
  `forCustomer(token)`), and `template-bootstrap`, the app acting as itself (a customer gets 403).
  `list`, `get` and `compareAndSet(namespace, id, { expectedVersion, mutationId, data })`:
  version 0 creates exactly once, a stale version gets 409, and replaying the same mutation
  returns the stored record. A delete (`{ expectedVersion, mutationId, deleted: true }`) is
  terminal: the author gets the slot and bytes back, and the ID stays a tombstone that refuses
  re-creation, charged to the app's byte budget until the app purges it. Quotas: 32 records and
  256 KiB per customer, 512 records and 4 MiB per app; over a quota is 429, and a payload over
  16 KiB is 400. Every call is served by the app's pinned home cluster: routing off, no pinned
  placement or an unreachable home answers 503, never a local copy. Also on
  `CustomerScopedBaasClient` and the `SCOPED_BAAS_CLIENT` view (`@hsuite/smart-engines-sdk/nestjs`).
- **`records.purge(namespace, { author } | { tombstones: true })`**, app session only (a
  customer, forwarded or direct, gets 403): drops one author's records, live and deleted, or
  every tombstone in the namespace, and returns `{ purged }`. Purged IDs become writable again.
  A 409 means records changed mid-purge: what was purged stays purged, so retry for the rest.
- New types: `DurableRecord`, `DurableRecordMutation`, `DurableRecordNamespace`,
  `DurableRecordPurge`. Needs a smart-host release that carries #3848.

### Added — function pause (Hacken HSUITEPT-37)

- **`functions.pause(functionId, opts?)` and `functions.resume(functionId, opts?)`.** They call
  `POST /api/v3/baas/functions/:appId/:functionId/pause` and `.../resume` and return the
  function's `BaasFunctionInfo`. While a function is paused no invoke runs it: the host answers
  `409` (`code: 'STATE_CONFLICT'`, `message: 'Function is paused'`) until it is resumed. Both
  calls are idempotent, and the owner can still update or delete a paused function.
  Authorization is update's: a customer pauses its own function through `forCustomer(token)` (or
  `{ customerToken }`) and the app pauses its own; a customer cannot pause an app-level shared
  function. A caller that may not edit the function gets the 404 an unknown id gets.
- **The pause is held by the app's pinned home cluster.** Pause, resume and HTTP invokes are
  routed there. An invoke that runs inside another cluster (an agent's function step, an
  automation or an event trigger), or on a host with no pinned placement ring, is refused with
  `503` instead of reading that cluster's stale copy.
- `BaasFunctionInfo.paused?: boolean` is `true` while paused; absent, as from an older host, means
  not paused. Needs a smart-host release that carries the pause routes.

### Changed — agent creation is bound to its app and admitted once (Hacken HSUITEPT-4, HSUITEPT-10)

- **`signEntityCreateIntent` binds an agent intent to its app.** For `entityType: 'agent'` it
  requires `appId` and signs an application-bound `hsuite.entity-create-intent.v2` intent
  (`APP_ENTITY_CREATE_INTENT_TYPE`); without a valid `appId` it throws before asking the wallet
  to sign. The derived `entityId` binds the app, network and chain as well. Token, account and
  topic intents are unchanged. `AgentsClient.registerWithIntent` and
  `EntitiesClient.{createAgentWithIntent,prepareCreateAgentWithIntent}` sign with the
  `BaasClient`'s own `appId` and the cluster's network in lowercase; a client without an `appId`
  throws before signing or sending anything. **Migration:** a customer frontend that signs its
  own agent intent passes `appId`, the app it creates the agent in.
- **Validator, same release:** an agent create without that intent, or with one bound to
  another app than the one the host names, is refused (`400`) before any DKG, and every
  responder withholds an intent-less agent creation whatever `ENTITY_CREATE_INTENT_ENFORCE` says.
- **Host, same release:** `agents.register`, `entities.createAgent` and an agent
  `entities.prepareCreateAgent` refuse (`400`) an agent intent not bound to the calling app.
  Each runs as one durable creation operation at the app's pinned home cluster, so a retry never
  admits a second budget slot, and a sponsored customer's creation counts against that
  customer's ceiling wherever it enters. While the home cluster's validator does not serve the
  creation-operation journal, these calls answer `503` and admit nothing; retry later.
### Added — the registry snapshot a cluster quorum agrees on (issue 3328, Hacken HSUITEPT-14)

- `fetchRegistrySnapshotFromClusters({ network | bootstrap, minAgree?, maxClusters?,
  trustAnchor? })` discovers the network's clusters (`GET /api/v3/discovery/clusters`), reads the
  validator-registry snapshot from the cluster gateways discovery lists, at most `maxClusters` of
  them (the first ones listed; default 5, or `minAgree` if more), recomputes its hash locally
  and returns it only when at least `minAgree` (default 2) distinct origins serve the same one.
  The seed is asked for the list, not for a snapshot; its own origin is read only if it lists
  itself as a cluster. A gateway URL that does not parse to an origin (`https://`, which names
  no host) is never read and counts as a cluster that did not answer, so one origin never votes
  twice (two spellings of one host, another port or a trailing dot, are two origins; bind exact
  origins with `trustAnchor`). Discovery failure or disagreement throws
  `RegistryUnavailableError`; an explicit `maxClusters` below `minAgree` throws `ValidationError`
  before any request. `trustAnchor` binds
  every listed gateway to the on-chain validator registry first, counts one vote per anchored
  validator (one whose record binds two origins votes once) and always fails closed (no
  `onFailure: 'passthrough'`); without it the seed is trusted for the list, so the clusters are
  only as independent as the seed, and `hsuite verify` takes that default path unless given
  `--registry` or `--registry-hosts`.
  Exported from the root and from `@hsuite/smart-engines-sdk/pqc-verify`, which now also exports
  `fetchRegistrySnapshotQuorum` and `computeSnapshotHash`. A single host's
  `fetchRegistrySnapshot(url)` stays available, but its docs now say what it is: that host's
  word, not a trust anchor. The docs and the verifier's `registry-snapshot-required` hint point
  at the new function.

### Added — a live feed that re-mints its stream ticket (#3751 item 5)

- **`messaging.subscribe(channel, { onMessage, onError?, signal?, maxRetries? }, opts?)` keeps a
  browser live feed open across refused and dropped streams.** A stream ticket opens one
  connection, and `EventSource` gives up on any non-200. So a stream the host refuses before
  spending its ticket (a `503` while the clusters share one gateway address, #3259) stayed
  closed, and the browser's own reconnect after a drop replays the spent ticket. `subscribe`
  closes every failed `EventSource` itself and opens the next one on a ticket that
  `subscribeUrl` mints for that connection: every bearer stays in the mint's headers, and no
  URL carries one. It reconnects after 1 s, doubling to at most 30 s; a stream that opens resets
  the delay. After `maxRetries` consecutive failed reconnects (default 8, at most 20; any other
  value throws `ValidationError`) it stops. Each failure reaches `onError`, the last one too.
  `close()`, or aborting `signal`, cancels a queued reconnect, and a ticket still being minted
  opens nothing. `opts` (for example `{ customerToken }`) rides on every mint. It opens the
  global `EventSource` (a browser, or a polyfill in Node) on the same ticket route as
  `subscribeUrl`, so it needs no newer host. New type: `MessagingSubscriptionOptions`.
  `subscribeUrl` is unchanged.

### Added — a function's own egress, and who ran each log line (Hacken HSUITEPT-40, HSUITEPT-2)

- `BaasFunctionDeployRequest.egress?: string[]`: the hosts this function's `fetch()` may reach,
  each one declared by the app (`backend.egress`); the host refuses (400) a host the app does not
  declare. Omitted keeps the app's whole list; `[]` keeps the function on the app's own host.
  `BaasFunctionInfo.egress` reports it. An older host ignores the field, so the function keeps
  the app's whole list there.
- `BaasFunctionLog.actor?: string`: who started the run that wrote the line (a customer's
  `chain:address`, an agent id, or `system` for the app itself).
- **Host, same release:** `functions.getLogs` on a shared (`appOwned`) function, called through
  `forCustomer(token)`, returns only that customer's own runs (Hacken HSUITEPT-2); the app acting
  as itself still reads every run. A function's `fetch()` is now checked against
  the port of each declared destination as well as its host: a host declared on 443 is refused
  on 8443 (Hacken HSUITEPT-40). `functions.deploy`, `update` and `delete` answer 503 when the
  host's function store refuses the write, and leave the function as it was, instead of
  reporting a success the next restart undoes (Hacken HSUITEPT-36).

### Fixed — chunked validator-registry entries (#3673)

- `ValidatorDiscoveryClient.getValidators()` decoded each Mirror Node row on its own, so an
  entry over 1024 bytes (Hedera splits it into chunks; an entry with `natsTierKeys` is over
  that size) was read as two halves of a JSON document and dropped. Every caller saw the node
  as absent, and the trust anchor failed closed (`empty-registry` on a new network). Rows are
  now reassembled by `chunk_info` before the registry is interpreted, the same way the
  validator reads them (`discovery/registry-chunks.ts`, shared by both). A chunked entry's
  consensus position is its newest chunk. An incomplete group yields no entry. A group cut
  by the 500-row read window is completed from at most one more page (`getValidators` now
  asks for up to 600 rows); if it still cannot be completed, it is left out.

### Fixed

- **`encrypted` on storage metadata now says what the host reports (#3625, Hacken HSUITEPT-51).**
  The host encrypts every stored object before pinning it to IPFS and reports `encrypted: true`.
  The `BaasFileMetadata.encrypted` JSDoc read "client-side encrypted", which never happened.
  `BaasFileInfo` gains the optional `encrypted` field that `listFiles` rows carry. Types only;
  no call site changes.
- **Published type declarations now keep their JSDoc.** The `.d.ts` bundles inherited the repo's
  `removeComments`, which stripped every doc comment and `@deprecated` tag. The JavaScript is
  unchanged.

### Deprecated

Refused since 7.1.0's module gate (HSUITEPT-41); the host route is gone.

| Deprecated | Canonical replacement |
|---|---|
| `entities.launchpad`, `LaunchpadEntityRequest`, `LaunchpadEntityResult` | `rules.publish(Rules.template.utilityToken(...))`, then `entities.createToken` with the returned `ruleRef`. The host no longer serves the route: the call answers 404 |
| `gateway.routing.proxyRequest`, `ProxyRequest`, `ProxyResponse` | None. The gateway no longer serves `POST /routing/proxy` (Hacken HSUITEPT-20): the call answers 404. Reach an app through its own hostname |
| `GatewayHealthResponse.{timestamp, uptime, version, gatewayId}`, `GatewayReadinessResponse.{timestamp, verifiedHostsCount, reason}`, `GatewayLivenessResponse.uptime`, `ClusterHealthAggregate.validator.version`, its chains' `latency` and `genesis.multiChain.queueSummary` | None: a gateway that carries HSUITEPT-9 no longer sends them, so the fields are now optional. `gateway.getStatus()` (internal `X-API-Key`) still reports the gateway's id, uptime and version |

Refused since this release (HSUITEPT-41): no vote reads a rule's `governance` block (see Security).

| Deprecated | Canonical replacement |
|---|---|
| `withGovernance` | `controller: 'dao'` / `requiresApproval: true` on each operation the DAO must approve |
| `DaoGovernedTokenParams.daoTokenId` | nothing: it is ignored, and the template's operations keep their DAO gates |

Refused since this release (HSUITEPT-41): no fee is set or charged from a rule (see Security).

| Deprecated | Canonical replacement |
|---|---|
| `forToken().withFees` | nothing: no fee is set or charged from a rule. Bound transfers with `operations.transfer` `limits`, `whitelist`, `blacklist` and `requiresKyc` |

Refused since this release (HSUITEPT-41): no transaction is classified as these operations, and
nothing reads a soulbound slot's `allowedActions` (see Security).

| Deprecated | Canonical replacement |
|---|---|
| `TokenOperationsConfig` `pause`, `associate`, `dissociate`, `grantKyc`, `revokeKyc`; `PauseOperationConfigSchema` | `operations.update` (`controller: 'dao'` / `requiresApproval: true`, `allowedFields`), which decides those transactions where the gate co-signs them |
| `AccountOperationsConfig` `deleteAllowance`, `stake`, `unstake` | `operations.update`, likewise |
| `AccountOperationsConfig` `approveAllowance` | nothing: the gate refuses a delegation (a Solana Approve) and a Hedera allowance as unknown operations |
| `SoulboundNftConfig.allowedActions` | `soulbound.enforced` alone, and `operations.mint` / `burn` / `wipe` |

### Security

- **Public health answers say whether, never what (Hacken HSUITEPT-9).** Gateway `GET /health` and
  `GET /ready` answer `{ status }` alone, `GET /live` `{ status, timestamp }`, and
  `GET /cluster/health` an allow-list of the fields the peer aggregator reads: no uptime, build
  version, gateway id, verified-host count, chain error text or genesis configuration. Types
  only on the SDK side (see Deprecated); `getReadiness()` still resolves a not-ready gateway.
- **Host, same release: no answer tells a caller that someone else's object exists.** Calls
  through `baas.entities` on an entity you do not own (transfer, withdraw, mint, burn,
  compliance, trustline, delete, restore, execute-create) answer `404`, the unknown-id answer,
  where they answered `403`; an agent's entity is also `404` to a session for another app.
  `baas.messaging` channel names are per customer: publishing to or creating a name another
  customer uses gives you your own channel instead of a `404` / "already exists". A channel
  name containing a control character is refused with one `400` by every call that looks the
  channel up. `subscribeUrl` looks nothing up, so it still mints a ticket for such a name; the
  stream that ticket opens answers the `400`.
  `deployment.setWebhook` refuses every unsafe URL with one constant `400` message.
  **Migration:** code that treated a `403` from an entity route as "not the owner" should treat
  `404` the same way.
- **The vouched agent creates above make the agent the customer's (Hacken HSUITEPT-50,
  HSUITEPT-52).** The host takes an agent's owner only from that claim, so an agent created
  through a `forCustomer(token)` view is now in that customer's own reads, under
  `limits.agentsPerCustomer`, and finalizable only on that customer's claim.
  **Release order:** publish only after a smart-host release that carries #3668, and with it #3419.
  A host without #3419 refuses the customer's opaque session bearer as the claim, so every vouched
  call is a `401`. A host without #3668 lets `execute-create` finalize a view's customer-owned
  draft into whichever app the executing session names.
  **Migration:** an agent draft prepared through a view before this release is app-owned, so
  finalizing it through the view is now a `403`. Finalize those drafts on the app-level client.
- **Host, same release: the app acting as itself reads only the agents it owns.** An agent read
  with no customer identity (`agents.get` / `list` / `getEvents` / `getBalances` / `certification`
  on the app-level client) used to see every agent in the app's tenant. It now sees the agents the
  app's own wallet owns, so a customer's agent answers `404`. Read a customer's agents through
  `forCustomer(token)`.
- **Agent rules: `timeRange`, `tokenGates` and `invariants` are enforced (Hacken HSUITEPT-41).**
  `forAgent()` accepts these three through the shared builder (`withTimeRange`,
  `withTokenGates`, `withInvariants`), and the 7.1.0 refusal text names them as the canonical
  replacement for modules, but the co-signing gate's agent path read none of them: an agent op
  outside the window, without the gated holding, or minting above `invariants.maxSupply` was
  co-signed, and `rules.simulate` answered `isValid: true`. The platform now runs the
  token/account/topic checks on the agent arm too, against the agent's own account. No SDK
  code changes. **Migration:** an agent whose published rule declares one of these fields is
  held to it once the validators are upgraded.
- **Token gates: `minBalance` is enforced, and fields the gate cannot check are refused (Hacken
  HSUITEPT-41).** The co-signing gate checked only that the subject held a gated token, so a
  gate on `minBalance: '1000000'` passed on a holding of 1. It now requires the holding to reach
  `minBalance`, on every rule arm. A per-token `timeRange` and NFT `serialNumbers` were accepted
  and never read (the gate reads holdings, not serials, and applies one window): the vendored
  schema now refuses them on their own path with the platform's message (`RULE_CONTROL_REFUSED`,
  exported with `unenforcedRuleControls`), and an unknown key in `fungibles` / `nonFungibles` is
  refused instead of silently dropped. `serialNumbers: []` still parses.
  **Migration:** declare the window once as `tokenGates.timeRange` and gate NFTs on the token id.
  Once the validators are upgraded, an entity whose published rule still carries either field is
  denied every operation (`ruleAtoms: ["UnenforcedControlAtom"]`): publish a corrected rule first.
- **Agent-rule sub-fields are enforced or refused, never skipped (Hacken HSUITEPT-41).** The
  vendored schema accepted values the gate's agent path ignored, so `permissions.mode:
  'blacklist'` let the blacklisted caller through. Now enforced: a permissions entry's
  `expiresAt` (an expired entry no longer grants) and `cooldown.perAction`, keyed by agent action
  type (`transfer`, `mint`, `burn`, `offer`, `payment`) or op kind (`token.transfer`,
  `account.transfer`, `token.mint`, `token.burn`); the entry for the op overrides `cooldownMs`, as
  `CooldownAtom` documents; and an `approvedPairs` pair's `chain`, which was required and never
  compared: a pair now approves its `baseToken` / `quoteToken` on that chain only, as
  `ApprovedPairsAtom` does (`rules.simulate` of such a rule needs the op's `chain` in `context`,
  and denies without it). Now refused with `RULE_CONTROL_REFUSED`, on their own path:
  `permissions.mode` other than `'whitelist'`, `allowWildcards`, `allowSelfAdd`, `maxEntries`, a
  `controller` other than `'owner'`, a `requiredScope` other than `'execute'`, a `'read'` entry, a
  `perAction` key naming no agent action, `tradeLimits.periodMs` other than `86400000`, any
  `decimals` other than `0`, `approvedPairs.strictMode: false`, `approvalThreshold.currency`,
  `operationalWindow.timezone` other than `'UTC'`, `inclusive: false`, `allocationLimits.min`, and
  `allocationLimits.max` without `tokenId`. The defaults `Rules.atom.*` and the docs use
  (`periodMs: 86400000`, `strictMode: true`, `decimals: 0`, `timezone: 'UTC'`, `inclusive: true`,
  `controller: 'owner'`, `requiredScope: 'execute'`) all still parse; the types are unchanged.
  **Migration:** remove the refused values (or, for `allocationLimits.max`, name the `tokenId`)
  and publish the rule again. Once the validators are upgraded, an agent whose published rule
  still carries one is denied every operation (`ruleAtoms: ["UnenforcedControlAtom"]`). Check
  each pair's `chain` too: a pair naming another chain than the agent's ops (or a misspelling
  such as `'XRPL'`) no longer approves them (`ruleAtoms: ["ApprovedPairsAtom"]`).
- **`defaultController: 'dao'` is refused: it gated nothing (Hacken HSUITEPT-41).** The
  co-signing gate reads each operation's own `controller` / `requiresApproval` and never the
  rule-level `defaultController`, so `withController('dao')` with an enabled operation that
  declared no controller of its own was co-signed with no DAO vote, and `rules.simulate` said it
  was permitted. `build()`, `Rules.validate`, smart-host publish, `rules.simulate` of an inline
  rule and smart-app creation on the validator (`POST /api/v3/smart-apps` with `validatorRules`,
  which the CLI's `subscribe` calls) now refuse `defaultController: 'dao'` on `defaultController`
  with `DEFAULT_CONTROLLER_DAO_REFUSED` (exported with `appPublishRefusals`); an agent rule
  refuses it in the platform schema too. The `daoGovernedToken`, `soulboundNft`, `membershipNft`,
  `managedAccount` and `systemTopic` templates now emit `defaultController: 'owner'`: their DAO
  gates were already on the operations, so what they enforce is unchanged. So do the nine rules
  the validator's public `GET /api/v3/validators/templates/:type/:name` served with the label
  (`REGULATED_TOKEN`, the `dao/*` topics and others); the gate never read it, so they too
  enforce what they did, and each now publishes unchanged.
  **Migration:** put `controller: 'dao'` (or `requiresApproval: true`) on each operation the DAO
  must approve, drop `withController('dao')`, and publish the rule again. A rule published
  before this release keeps its label and is gated as before, by its operations only.
- **A rule-level `governance` block is refused: no vote read it (Hacken HSUITEPT-41).** A
  DAO-gated operation (`controller: 'dao'` / `requiresApproval: true`) is decided by the HSUITE
  DAO under that DAO's own voting rules. The gate's governance authorizer never read the rule's
  `daoTokenId`, `quorumPercentage`, `timelockSeconds`, `votingThreshold`, `delegation` or
  `wisdomWeightFn`, so an app that named its own DAO token, quorum or timelock was held to none
  of them. `build()`, `Rules.validate`, smart-host publish, `rules.simulate` of an inline rule
  and smart-app creation now refuse `governance` on every arm, on its path, with
  `GOVERNANCE_REFUSED` (exported with `appPublishRefusals`), and `withGovernance` is deprecated.
  The `daoGovernedToken` template no longer emits the block (its `daoTokenId` is ignored), nor
  do the validator's served `DAO_GOVERNED_TOKEN` and `TREASURY_ACCOUNT`; their operation-level
  DAO gates are unchanged. The platform's genesis rules keep their governance blocks.
  **Migration:** drop `withGovernance` (and any `governance` field) and publish the rule again.
  A rule published before this release keeps its block, which no vote reads.
- **A key's `enabled` / `controller` / `requiresApproval`, token fees, an account `distribution`
  and the invariant governance floors are refused: nothing enforced them (Hacken HSUITEPT-41).**
  Each parsed on publish and in `rules.simulate`, and none of them gated anything: the co-signing
  gate decides an op on the op's own `enabled` / `controller` / `requiresApproval` / `limits`, an
  entity's keys are set when it is created, from its security mode and multisig (a Hedera TSS
  token's from its create request), no fee is set or charged from a rule, and the only reader of
  `distribution` is the platform's settlement resolver, which splits the platform's own payouts,
  checks no transfer against it, and is not an app's to set. So
  `forToken().withOperations({ mint: { enabled: true } }).withKeys({ supply: { controller:
  'dao', requiresApproval: true, threshold: 2 } })` was simulated as `token.mint` permitted, with
  no DAO vote. `invariants.minQuorumPercentage` and `minTimelockSeconds` floor only a governance
  block, which an app may no longer publish. `build()`, `Rules.validate`, smart-host publish
  (agent registration included), `rules.simulate` of an inline rule and smart-app creation now
  refuse a key's `enabled`, `controller` and `requiresApproval` (each on `keys.<key>.<field>`), a
  non-empty `fees` or `distribution` block and either floor, each on its path, with
  `KEYS_REFUSED`, `FEES_REFUSED`, `DISTRIBUTION_REFUSED` and `GOVERNANCE_FLOORS_REFUSED`
  (exported with `appPublishRefusals`). A key's `threshold` and `security` still publish, and
  `withKeys` stays for them: entity creation refuses a key whose threshold differs from the
  entity's multisig threshold, or whose security differs from the rule's `defaultSecurity`. A
  key or block that declares nothing (`keys: {}`, `{ admin: {} }`) still parses, and so do
  `invariants.maxSupply`, `minBalance` and `immutableFields`. The vendored account schema never
  had `distribution`, so the SDK already refused it as an unrecognized key. The eight canonical
  templates no longer emit `keys` (four of their server mirrors declare a per-key `security`
  other than their rule's, which entity creation refuses); the validator's twelve served
  templates drop theirs except `ESCROW_ACCOUNT`'s 2-signature `threshold`, and `NFT_COLLECTION`
  drops the royalty no rule ever charged. Every operation-level gate is unchanged: a DAO approval
  a template put on a key never gated anything (the `daoGovernedToken` burn its supply key signs
  has no DAO gate, before or after). `withFees` is deprecated. A publish refused here is refused
  before the platform-paid validator hop, so it keeps no daily budget slot. Platform rules
  (genesis, deposit accounts) keep these fields, and the settlement resolver now reads a split
  only from a deposit wallet the platform created, so an app entity's `distribution` published
  before this release no longer sets the split of its settlements.
  **Migration:** drop a key's `enabled`, `controller` and `requiresApproval`, `withFees` and the
  floors (and any `fees` or `distribution` field), put each DAO gate on the operation, and
  publish the rule again. A rule published before this release keeps its fields; none of them
  gates an operation.
- **An account's `destinationAllowlist` binds its token transfers too, and `allowedDestinations`
  is enforced (Hacken HSUITEPT-41).** The co-signing gate read only the allowlist's `roles`, and
  only on a native `account.transfer`. So `{ enforced: true, allowedDestinations: ['0.0.7'] }`
  let an account transfer go anywhere, and a `token.transfer` the same `transfer` operation
  authorises (a Solana SPL or Asset Hub token transfer, an XRPL NFT offer) went anywhere under
  `roles` as well; `rules.simulate` answered both with `isValid: true`. Every transfer the
  operation authorises, native or token, must now go to a listed destination or to a resolved
  role address, with or without `enforced`. One whose destination cannot be determined is
  denied, and so is every transfer under an allowlist that names no destination, such as
  `{ allowedDestinations: [] }`, which used to allow any (`ruleAtoms:
  ["DestinationAllowlistAtom"]`). A Solana SPL or Asset Hub token transfer names no
  destination, so an account with an allowlist cannot make one. On Hedera each hbar, token and
  NFT credit of a TransferTransaction is checked on its own (see the per-leg entry below).
  `enforced: false` switches nothing off, so smart-host publish, `rules.simulate`
  of an inline rule and smart-app creation refuse it on `destinationAllowlist.enforced`
  (`DESTINATION_ALLOWLIST_ENFORCED_REFUSED`, exported with `appPublishRefusals`). The vendored
  account schema has no `destinationAllowlist`, so this concerns rules sent to the host
  directly.
  **Migration:** once the validators are upgraded, an account whose published rule declares a
  `destinationAllowlist` is held to it on its token transfers too, and to `allowedDestinations`
  even with `enforced: false`; one whose allowlist names no destination makes no transfer.
  List every destination its transfers need, or publish a rule without the allowlist.
- **An account's `assetRestriction` binds every asset it names, `allowedAssets` included, on its
  token transfers too (Hacken HSUITEPT-41).** The gate read only `tokenId`, or `currency` +
  `issuer` when no `tokenId` was set, and only on a native `account.transfer` and a trust-line
  `token.update`. So `{ tokenId: '0.0.111', enforced: true, allowedAssets: ['0.0.222'] }` moved
  0.0.111 and refused 0.0.222 (the declared list inverted), a list alone refused every
  transfer, and a `token.transfer` moved any asset; `rules.simulate` agreed. Every binding now
  holds at once, whatever `enforced` says: the `tokenId`, the `currency` + `issuer` (read as
  `<CURRENCY>.<ISSUER>`) and `allowedAssets`, on every transfer the `transfer` operation
  authorises and on a trust-line change (`ruleAtoms: ["AssetRestrictionAtom"]`). An op whose
  asset cannot be determined is denied: a native-coin transfer carries no asset id, so naming
  the native symbol in `allowedAssets` does not admit it, and a Solana SPL or Asset Hub token
  transfer or an XRPL NFT offer names no asset either. An XRPL IOU payment or trust-line change
  carries an asset id the gate reads, and so does each token or NFT leg of a Hedera
  TransferTransaction (its `0.0.x`, checked leg by leg); an hbar leg and a Hedera TokenUpdate
  carry none, so they are denied. A restriction that names no complete asset (a `currency`
  without an `issuer`) denies every such op. `enforced: false` switches
  nothing off, so the same app surfaces refuse it on `assetRestriction.enforced`
  (`ASSET_RESTRICTION_ENFORCED_REFUSED`, exported with `appPublishRefusals`). The vendored
  account schema has no `assetRestriction`, so this too concerns rules sent to the host
  directly. Platform deposit accounts name a single `tokenId` or `currency` + `issuer` and
  settle by XRPL Payment, which the gate already held to that asset.
  **Migration:** once the validators are upgraded, list in `allowedAssets` every asset an
  account's transfers need, in the gate's form (`<CURRENCY>.<ISSUER>` on XRPL, `0.0.x` on
  Hedera); keep a `tokenId` or `currency` + `issuer` only for an account that moves that one
  asset; an account that must move native coin (hbar included), make a Hedera token update, or
  move any asset on a chain whose transfers name none, needs a rule without
  `assetRestriction`.
- **`metadata.expiresAt` is refused: no rule expires on it (Hacken HSUITEPT-41).** The
  `ValidatorMetadata` type called it an auto-expiration date, but nothing reads a rule's
  metadata expiry, so `rules.simulate` of a `token.mint` under a rule whose `expiresAt` had
  passed answered `isValid: true`. `build()`, `Rules.validate`, smart-host publish,
  `rules.simulate` of an inline rule and smart-app creation now refuse it on
  `metadata.expiresAt` with `METADATA_EXPIRES_AT_REFUSED` (exported with
  `appPublishRefusals`). The rest of `metadata` still publishes, `deprecatedAt` included.
  **Migration:** bound the rule in time with `timeRange` (its `end` denies every operation
  after it) and drop `metadata.expiresAt`. A rule published before this release keeps the
  field, which nothing reads.
- **A transfer `blacklist` denies a transfer whose destination the gate cannot name (Hacken
  HSUITEPT-41).** `operations.transfer.blacklist` was checked only when the gate could name the
  destination, so a transfer to a blacklisted recipient was co-signed whenever it could not: a
  Solana native or SPL transfer, a Stellar payment, a Bitcoin PSBT, an Asset Hub token transfer
  and an XRPL NFT offer accept. `rules.simulate` of a
  transfer with no destination answered `isValid: true`. Such a transfer is now denied
  (`ruleAtoms: ["BlacklistAtom"]`), as the whitelist and the allowlist deny it, on every op the
  `transfer` operation authorises; `blacklistEnabled: false` still switches the blacklist off.
  On Hedera every credit of a transfer is compared with the list (see the per-leg entry below).
  No SDK code changes. **Migration:** once the validators are
  upgraded, an account whose rule declares a blacklist cannot make those transfers; publish a
  rule without the blacklist (or with `blacklistEnabled: false`) for one that must.
- **`soulbound.enforced` binds a Hedera NFT transfer too (Hacken HSUITEPT-41).** The gate checked
  `soulbound` on a `token.transfer` only, and a Hedera TransferTransaction is an
  `account.transfer` whatever it moves, so a token rule with `transfer` enabled and soulbound
  enforced let the platform co-sign a Hedera NFT transfer; `rules.simulate` agreed. Every
  transfer decided under a token rule with soulbound enforced that may move a token is now
  denied (`ruleAtoms: ["SoulboundNftAtom"]`): a `token.transfer`, a Hedera transfer carrying a
  token or NFT leg, and an `account.transfer` whose legs the gate cannot read (on any other
  chain, or in a dry-run). An hbar-only Hedera transfer still passes. The flag binds the token
  entity's own transfers only: the gate decides a transfer under the rule of the entity it
  co-signs for, so another holder's transfer moves under that holder's own rule, or under none
  outside the platform, and the gate has no token-wide soulbound. The `soulboundNft` and
  `membershipNft` templates disable `transfer`, so they denied these already. No SDK code
  changes.
  **Migration:** an entity whose token rule enforces soulbound moves no token through the gate,
  and outside Hedera it makes no native transfer either, as the gate cannot tell one from a
  token move there: send native coin from an entity whose rule declares no soulbound slot.
- **Operation configs no transaction is classified as, and `soulbound.allowedActions`, are
  refused (Hacken HSUITEPT-41).** A token's `pause`, `associate`, `dissociate`, `grantKyc` and
  `revokeKyc` and an account's `approveAllowance`, `deleteAllowance`, `stake` and `unstake`
  parsed and gated nothing: no op kind maps to them, so the gate denies a Hedera pause, KYC
  change, association or allowance and a Solana delegate approval as unknown ops, and decides an
  XRPL pause (a global freeze), a Stellar trust-line authorisation, a Solana delegate revocation
  or a Hedera staking change by `operations.update`, whatever the config said. `soulbound.allowedActions` gated
  nothing either: the gate reads `enforced` only and decides a mint, burn or wipe by its own
  operation. `rules.simulate` of a `token.mint` beside a DAO-gated `pause` answered
  `isValid: true`. `build()`, `Rules.validate`, smart-host publish (agent registration
  included), `rules.simulate` of an inline rule and smart-app creation now refuse each, enabled
  or not, on its path with `UNREACHED_OPERATION_REFUSED` or `SOULBOUND_ALLOWED_ACTIONS_REFUSED`
  (exported with `appPublishRefusals`), and their schema fields are deprecated. The
  `utilityToken`, `daoGovernedToken`, `soulboundNft`, `subscriptionNft` and `membershipNft`
  templates drop `pause`, `managedAccount` drops its allowance configs, and the soulbound
  templates emit `{ enforced: true }`; the validator's served `SIMPLE_TOKEN`, `REGULATED_TOKEN`,
  `DAO_GOVERNED_TOKEN` and `TREASURY_ACCOUNT` drop theirs. What each enforces is unchanged. The
  platform's genesis rules keep their `allowedActions`.
  **Migration:** drop these configs and `allowedActions`, gate the transactions they named
  with `operations.update` (`controller: 'dao'` / `requiresApproval: true`, `allowedFields`),
  and publish the rule again. A rule published before this release keeps them, inert as before.
- **A platform-paid write relayed for a customer needs the app's explicit sponsorship (Hacken
  HSUITEPT-10).** `rules.publish`, the single-shot `entities.create*` and `agents.register` make
  the quorum sign and the platform pay. Hacken reached them through a smart-app route that
  admitted a self-minted customer session and then called `client.rules.publish(...)` on the
  app's own client — on the wire, the app's own write.
  - New `HttpCallOptions.sponsorCustomerWrite` (sent as `X-Platform-Paid-Sponsor: app`): the app
    states, per call, that it pays for the customer it relays.
  - `SmartEngineModule` now also registers `SdkRequestContextInterceptor` (exported). While a
    request is served to a customer (not `@AppOwnerOnly()`, not `@Public()`), a platform-paid call
    from ANY client in the process — `SDK_BAAS_CLIENT`, `SCOPED_BAAS_CLIENT`, or a `BaasClient`
    the app built itself — is refused with `403` before it is sent unless it passes
    `sponsorCustomerWrite: true`; a sponsored one also carries that customer as
    `X-Customer-Session-Token`. Boot, cron and `@AppOwnerOnly()` writes are unchanged.
  - **Host, same release:** a platform-paid route refuses (`403`) any request that carries a
    customer identity (`X-Customer-Session-Token` / `X-Customer-Session-Claim`) without the
    sponsorship header, and caps sponsored ones per customer as well as per app. **Migration:** a
    vouched `forCustomer(token).agents.register(...)` / `entities.createAgent(...)` (customer-owned
    agents) is refused until the call passes `{ sponsorCustomerWrite: true }`.
  - **Every agent creation, two-phase included (Hacken HSUITEPT-4).** An agent draft's
    `entities.prepareCreateAgent` (`prepare-create` with `entityType: 'agent'`) takes a slot of
    the app's agent allowance, so it follows the same rule, in the SDK and on the host: a free
    self-minted customer spends that allowance only when the app sponsors them, then under the
    per-customer cap. Token / account / topic prepares and every `execute-create` are unchanged.
    **Migration:** `forCustomer(token).entities.prepareCreateAgent(...)` needs
    `{ sponsorCustomerWrite: true }` too.
  - The customer is bound to the request's subscription, not to building its Observable, so the
    refusal also holds behind a route interceptor that calls `next.handle()` only once subscribed
    (`defer`, an awaited lookup).
- **An app-owned function deploy made while serving a customer is refused (Hacken HSUITEPT-1,
  HSUITEPT-2).** The host takes `appOwned: true` on a deploy that names no customer, and an
  app-owned function is readable and invocable by every customer of the app. So an app that
  answered the host's `403` on a customer's deploy with `appOwned: true` would hand that
  customer's function to all of them. While a `SmartEngineModule` request is served to a
  customer, `functions.deploy({ appOwned: true, ... })` from any client in the process is now
  refused with `403` (`SdkHttpError`) before it is sent. **Migration:** deploy the customer's
  function as theirs (`{ customerToken }`), and shared functions from an `@AppOwnerOnly()` route,
  boot or cron, which are unchanged.
- **The PQC freshness bound no longer passes a stale cert on a malformed clock (Hacken
  HSUITEPT-14).** `verifyPqcAttestation(cert, payload, snapshot, { maxAgeMs, now: NaN })` made both
  age comparisons false, so a cert of any age verified. `policy.now` must now be a non-negative
  finite number (`invalid-policy` otherwise), and the comparisons accept only a `ts` inside the
  window, so an unreadable default clock rejects as well.
- **BEHAVIOUR CHANGE: `verifyPqcAttestation` requires a freshness decision (Hacken HSUITEPT-14).**
  Freshness was opt-in, so the documented call `verifyPqcAttestation(cert, payload, snapshot)`
  accepted a quorum cert of any age, a year-old one included. `policy.maxAgeMs` is now required:
  without it the result is `{ valid: false, reason: 'freshness-policy-required' }`. A finite value
  keeps its meaning (`stale-attestation` / `future-dated-attestation`); `maxAgeMs: Infinity` is the
  deliberate archival choice and applies no time bound, exactly what the old no-policy call did.
  `reconstructListingSet` passes `Infinity` itself: a listing stays current until a later record
  supersedes it. The signature is unchanged, so this shows up at runtime, not at compile time.
  **Migration:** add `{ maxAgeMs: <oldest age in ms you accept> }` for a live credential, or
  `{ maxAgeMs: Infinity }` where you verify a historical record on purpose. `hsuite verify` now
  requires `--max-age <ms|infinity>`.
- **BEHAVIOUR CHANGE: a cert that carries `topicMessageId` verifies only when the caller vouches
  for it (Hacken HSUITEPT-14).** The coordinate is outside every signature, so a genuine cert
  re-associated with any other on-chain record (`{ ...cert, topicMessageId: '0.0.999@…' }`) still
  verified `valid: true`. New `policy.expectedTopicMessageId`: the coordinate you read from a
  Mirror node. A carried coordinate without it is `topic-message-id-unauthenticated`; a different
  one is `topic-message-id-mismatch`; a malformed pin is `invalid-policy`. Certs the platform
  emits carry no coordinate (its submit gate refuses one), so they are unaffected. Binding the
  coordinate into the signatures needs `pqcCertVersion: 2`; this does not do that.
- **The gate judges every leg of a Hedera transfer (Hacken HSUITEPT-41).** It read a Hedera
  `TransferTransaction` as its first non-negative hbar entry. A second credit, a real credit listed
  after a zero entry, and every token and NFT leg (which named no recipient) were never compared
  with the transfer `whitelist`, `blacklist` and `destinationAllowlist`, an agent's
  `recipientAllowlist` or the DAO proposal that authorised the transfer. Every credit is now a leg
  with its own recipient and asset, and the transfer is co-signed only when every leg is allowed;
  a deny's `reason` names the leg (`transfer leg 2 of 3: …`) and `ruleAtoms` are unchanged. No
  DAO proposal, of any shape, authorises a transfer with more than one leg: a proposal's transfer
  action names one recipient, and such a transfer names none. A credit back to the signing entity
  is a leg too, so an atomic swap or sale under a destination `whitelist`, `destinationAllowlist`
  or agent `recipientAllowlist` now needs the entity's own account listed; it passed when its first
  hbar credit went to a listed account, and now fails closed. Amount caps bound the hbar the legs
  credit in total; a transfer that also moves a token or NFT has no tinybar amount, so a declared
  cap denies it, as it denied every token transfer before. A one-leg token or NFT transfer now
  names its token and recipient: a `whitelist`, `destinationAllowlist`, `recipientAllowlist`,
  `assetRestriction` (`0.0.x`), an agent's `approvedPairs` or `requiresKyc` that denied it for
  want of one can pass it, and a `blacklist` that passed it now denies a listed recipient. A
  `requiresKyc` reads each leg of a multi-leg transfer on its own: the payer and that leg's
  recipient, for that leg's token. A leg whose read fails is denied, and so is an hbar leg, which
  names no token to read (it denied every multi-leg transfer before). No SDK code changes.
  **Migration:** none for a one-leg hbar transfer; a multi-leg Hedera transfer now needs every
  leg allowed, the entity's own account included when a leg credits it back.
- **A create that moves value faces the transfer controls (Hacken HSUITEPT-41).** The gate
  allowed every create without reading the rule (creation is gated upstream), under the binding's
  cached copy and with no source bound. A Hedera AccountCreate's initial balance, a Solana
  SystemProgram createAccount's lamports and a Stellar createAccount's starting balance move value
  out of the account that funds them, so a create co-signed for an entity moved its funds past
  every transfer control, and a Stellar envelope led by a createAccount carried further ops the
  gate never read. Such a create is now the `account.transfer` it also is, judged under the
  co-signed entity's on-chain rule: `transfer` must be enabled, amount caps and `minBalance` bind
  the value, and a destination control denies it (the gate names no destination for the new
  account). On Hedera the daily, weekly, monthly and total windows count the initial balances an
  account funded as well as its transfers, so a funded create spends from the same cap. A Hedera
  initial balance that decodes negative (a uint64 of 2^63 or more) is denied as unknown. A
  Stellar createAccount must be its envelope's only op and run against the envelope's source, or
  it is denied as unknown. A create that moves nothing is gated upstream as before.
  `rules.simulate` of an `account.create` whose context carries an amount other than `0` judges
  it as that transfer too (`metadata.opKind: "account.transfer"`); it used to answer `isValid:
  true` where the gate denies. No SDK code changes. **Migration:** the Hedera Protocol
  Treasury's 5 hbar cluster-operator funding is now held to the Treasury's transfer rule
  (`monthlyLimit`, `minBalance`). The gate reads the Treasury's balance from the mirror node's
  balance snapshots (about 15 minutes apart), which show a new account only after the next
  snapshot, so a fresh genesis now waits before Phase 0b until that read shows the Treasury:
  every 30 s (`GENESIS_TREASURY_BALANCE_POLL_MS`) for up to 20 minutes
  (`GENESIS_TREASURY_BALANCE_WAIT_MS`), then it stops with an operator error and funds no
  Hedera cluster operator (the XRPL ones are created by then). A funded create that the new entity co-signs too is denied on that
  co-signature, as the new entity has no on-chain rule yet: create it with no initial balance
  and fund it with a transfer.
- **An SPL delegation, account handover or close is no longer decided by `update` alone (Hacken
  HSUITEPT-41).** Every SPL Token instruction the gate did not list was classified `token.update`
  and decided by `operations.update`. So an account whose transfers a `whitelist` (or another
  transfer control) bound, and whose rule enabled `update`, could have the gate co-sign an Approve
  or ApproveChecked delegating its tokens to any key, a SetAuthority handing the account or its
  close authority to one, a CloseAccount paying its lamports anywhere, or a Token-2022 extension
  instruction that moves tokens or lamports. The gate now refuses an Approve, an ApproveChecked, a
  SetAuthority over anything but a mint's MintTokens or FreezeAccount authority, and every
  Token-2022 extension instruction, as unknown operations (`ruleAtoms: ["UnknownOpKindAtom"]`).
  A CloseAccount is a transfer of the account's lamports to its destination, held to the
  `transfer` operation's controls; it carries no amount and the gate cannot tell whether it moves
  a token, so a declared amount cap, a declared `minBalance` and an enforced `soulbound` each deny
  it (`AmountLimitAtom`, `MinBalanceAtom`, `SoulboundNftAtom`). A Revoke, a ThawAccount, the
  account and multisig initialisers and SyncNative stay decided by `update`, and so does a
  SetAuthority over a mint's MintTokens or FreezeAccount authority (the Solana pause and unpause),
  but only one that removes the authority or gives it back to the entity's own vault: one that
  hands it to any other key would let that key mint or freeze outside every mint control and
  `maxSupply`, no rule control authorises it, and the gate refuses it whatever `update` says (it
  co-signed it before). No SDK code changes. **Migration:** a Solana entity that recovers a token
  account's rent (`token/close-account/prepare`) needs `transfer` enabled, the rent destination
  allowed by its transfer controls, and no amount cap, `minBalance` or enforced `soulbound`; an
  unpause must restore the mint authority to the entity's vault (the preparer's default).
- **A Stellar payment must be its envelope's only op (Hacken HSUITEPT-41).** The gate classifies a
  Stellar envelope by its first op, and re-decodes only an admin op (a trust-line change, an
  account setting, a clawback, a merge) to check it is the envelope's only op. A payment or path
  payment is not one, so an envelope led by a payment within the caps could carry a second
  payment of any size, and a payment could run against another source than the envelope's. A
  payment, a path payment or a createAccount that is not the envelope's only op, or that carries
  its own source, is now denied as unknown (`ruleAtoms: ["UnknownOpKindAtom"]`). No SDK code
  changes. **Migration:** send one payment per transaction.
- **An account delete faces the destination controls too (Hacken HSUITEPT-41).** A Hedera
  AccountDelete, an XRPL AccountDelete and a Stellar accountMerge move the whole balance to a
  destination, but `operations.delete` alone decided them: a rule that bound every transfer's
  destination still let a delete sweep the balance anywhere. A delete now also faces what a
  transfer of that balance faces, as a CloseAccount does: the `transfer` operation's `whitelist`
  and `blacklist` and the top-level `destinationAllowlist` bind its destination (the gate now
  reads a Hedera AccountDelete's transfer account and a Stellar accountMerge's destination), and
  a declared `minBalance` or an enforced `soulbound` denies it. A deny's `reason` starts with the
  delete's destination; `ruleAtoms` are those of the control. `operations.delete` and its
  `destinationAllowlist` bind as before. No SDK code changes. **Migration:** a rule that enables
  `delete` needs the delete's destination allowed by its transfer controls, and no `minBalance` or
  enforced `soulbound`. Platform deposit accounts delete only to the Treasury, which their
  allowlist names.
- **No client sends a credential over plaintext `http://` to a public host (Hacken HSUITEPT-16).**
  `createHttpClient` — the transport under `SmartGatewayClient`, `BaasClient` and every
  `HttpClient`-backed sub-client — refuses to attach `Authorization`, `X-API-Key` or
  `X-Customer-Session-*` to a URL the shared transport policy refuses (anything but `https://`
  or an in-cluster service name, `localhost` included): it throws `SdkHttpError` (400) and sends
  nothing. `HttpClientConfig.allowInsecure` opts in, for local development; `BaasClient` and
  `SmartEngineClient` pass theirs, and `MarketplaceClient` keeps its plaintext-for-localhost
  allowance. `SmartGatewayClient`'s `allowInsecure`, previously ignored, now reaches its
  transport. `CustomerSessionClient` takes `allowInsecure` as a third constructor argument
  (`BaasClient` passes its own) and refuses such plaintext for every call (`ValidationError`).
  The standalone `HistoricalBalanceClient` takes `allowInsecure` (`HistoricalBalanceClientError`
  400). **Migration:** a `createHttpClient`, `SmartGatewayClient`, `CustomerSessionClient` or
  standalone `HistoricalBalanceClient` that sent credentials to `http://localhost` or another
  public plaintext host without `allowInsecure` now throws; pass `allowInsecure: true` for local
  development.
- **BEHAVIOUR CHANGE: the validator-registry snapshot is read over HTTPS only (Hacken HSUITEPT-16,
  issue 3328).** `verifyPqcAttestation` binds every signer to the snapshot's keys, and
  `fetchRegistrySnapshot` read it with a bare GET to any URL, `http://` included, so anyone on the
  path chose the keys. It now applies the shared transport policy before any request: a URL the
  policy refuses (anything but `https://` or an in-cluster service name) or a URL it cannot parse
  throws `RegistryUnavailableError` and nothing is sent. A redirect is not followed either: the
  policy vets the URL asked, not wherever a 30x points, so the 3xx throws `RegistryUnavailableError`
  (`HTTP 302`). `fetchRegistrySnapshotQuorum` uses the same rule instead of its own list: an
  in-cluster name is now admitted, and `localhost` is not.
  **Migration:** both take `allowInsecure: true` for local development.
- **BEHAVIOUR CHANGE: cluster discovery does not follow a redirect from its seed (Hacken
  HSUITEPT-16, issue 3328).** `ClusterDiscoveryClient` refuses a non-`https://` seed, then read
  `GET /api/v3/discovery/clusters` following redirects, so a seed answering 302 to `http://` moved
  the read to plaintext after the check. Without a `trustAnchor` that list decides which gateways
  get asked, a `fetchRegistrySnapshotFromClusters` quorum's included. A 3xx now fails the seed
  like any other non-OK answer (`HTTP 302`), and the next seed is tried. Every discovery path
  shares this read: `connectToCluster`, `resolveClusterEndpoint`, `composeApps` and
  `fetchRegistrySnapshotFromClusters`. **Migration:** pass the URL a seed redirects to as the seed.
- **`fetchRegistrySnapshotQuorum` refuses a split vote (issue 3328).** When two different
  snapshots were each served by `minAgree` or more hosts, it returned whichever group came first
  in the host list, so whoever controlled the hosts listed first chose the keys. A split now
  throws `RegistryUnavailableError` ("hosts serve 2 different registry snapshots").
- **`connectToCluster` reports what the trust anchor concluded (Hacken HSUITEPT-17).**
  `resolveClusterEndpoint` kept the anchor's outcome inside discovery and returned only the
  cluster, so with the opt-in `trustAnchor.onFailure: 'passthrough'` a client bound to a host
  nobody verified looked like a verified one. `BaasClient.getTrustAnchorOutcome()` now returns
  it (`'unavailable'` / `'empty-registry'` = not verified; `null` without a `trustAnchor`),
  `resolveClusterEndpoint` returns it as `anchor`, and every client `composeApps` builds carries
  the first client's outcome through the new optional `BaasClientConfig.trustAnchorOutcome`.
- **BEHAVIOUR CHANGE: a cluster the trust anchor verified carries only the endpoint it verified
  (Hacken HSUITEPT-17).** The anchor binds `gatewayUrl` to the on-chain registry and nothing
  else, yet a verified cluster kept the seed's `natsUrl`, `harborUrl`, `publicIp` and `region`:
  `getClusterById('sn1').endpoints.natsUrl` could name an attacker's broker under a `'verified'`
  outcome. A verified cluster's `endpoints` are now `{ clusterId, gatewayUrl }`. The public
  discovery route has served nothing else since #2751, so an honest seed loses nothing. Without
  a `trustAnchor`, or under `onFailure: 'passthrough'` when the registry cannot be read (outcome
  not `'verified'`), the seed's entry is returned as sent, as before.

## Residuals (not fixed here, tracked separately)

- The customer's own CHAIN is not bound on the `forCustomer` agent path: smart-host's
  `resolveForwardedCustomer` (`apps/smart-host/src/guards/jwt-auth.guard.ts:184-214`) resolves
  only `customer.customerAddress`, dropping the `customerChain` the underlying session carries —
  so a customer-owned agent's `ownerChain` cannot be host-verified against the actual customer
  identity today (the validator still binds the SIGNATURE to `(ownerChain, owner)`, so a wrong
  chain can never pass a valid signature check — this is an edge-check completeness gap, not a
  signature-verification hole). Checked whether threading `customerChain` through is cheap
  (#3506 A3 review): `resolveForwardedCustomer` has 4 call sites
  (`apps/smart-host/src/modules/agents/agents.controller.ts:160,304`,
  `apps/smart-host/src/modules/entities/entities.controller.ts:239,524`), and at least one
  (`agents.controller.ts:329`) forwards the SAME resolved value as a plain string into
  `AgentQuotaService.enforceAgentLimit` and the cross-plane attribution helper — widening the
  return type to carry `customerChain` ripples into both of those signatures plus their tests,
  not just the intent check. Not cheap; left as a residual rather than folded into this PR.

## 8.0.0

### BREAKING — raw tier retired from the public SDK (#3496)

- **`SmartEngineClient` (and everything that existed only to serve it) is no longer exported from
  `@hsuite/smart-engines-sdk`.** It wrapped the validator's raw `/api/v3/{transactions,tss,ipfs,
  tokens,settlement,governance,dao,bridge,envelope,operator,snapshots,historical-balance}` tier,
  which has **no public origin**: the chart's `ingressPaths` allow-list deliberately omits it
  (#1236), and the on-chain validator registry has published no `apiEndpoint` for it since
  #2983/#3492 — only the public `gatewayUrl`. `SmartEngineClient.connectToNetwork` therefore could
  never succeed against a real deployed network; it only ever threw `RegistryApiEndpointMissingError`.
  Tomachi's decision on #3496 was to retire the raw tier from the public SDK rather than keep
  shipping a guaranteed-throw. `BaasClient` is now the **only public path** to web3.
  **Migration — before/after:**

  ```typescript
  // Before (7.x) — raw tier, validator-direct, chain-agnostic prepare-sign-submit:
  import { SmartEngineClient } from '@hsuite/smart-engines-sdk';
  const client = new SmartEngineClient({ validatorBaseUrl: 'https://validator.example.com' });
  const prepared = await client.transactions.prepareTransfer({
    chain: 'xrpl', from: 'rFROM', to: 'rTO', amount: '100',
  });
  // caller signs `prepared.transactionBytes` locally and submits it

  // After (8.x) — BaaS entity-scoped tier, ingress-routed, the entity is the source + fee-payer:
  import { BaasClient } from '@hsuite/smart-engines-sdk';
  const baas = await BaasClient.connectToCluster({ network: 'testnet', appId: 'my-app' });
  await baas.authenticate({ chain: 'xrpl', walletAddress: wallet.address, publicKey: wallet.publicKey, signFn: sign });
  const prepared = await baas.entities.transfer('entity-1', {
    chain: 'xrpl', to: 'rTO', amount: '100',
  });
  // caller signs `prepared.transactionBytes` locally and submits it — same sovereignty model,
  // reached through the tier the public gateway actually routes.
  ```

  There is no drop-in replacement for `client.tss`, `client.ipfs`, `client.settlement`,
  `client.governance`, `client.dao`, `client.bridge`, `client.envelope`, `client.tokens` or
  `client.operator` — none of those paths were ever publicly reachable either. The subscription/
  faucet/resources/personhood validator surfaces (which ARE on the ingress allow-list) keep their
  standalone SDK clients (`SubscriptionClient`, `FaucetClient`, `ResourcesClient`,
  `PersonhoodClient`) — those did not depend on `SmartEngineClient` and are unaffected.

## 7.1.0

### Security

- **Rules carrying `modules` are refused: `Rules.validate`, every builder's `build()` and the
  seven module templates now fail fast (Hacken HSUITEPT-41).** The validators' co-signing gate
  evaluates only the canonical rule fields. It never read `modules[]`, so a control declared as a
  module did not constrain its verdict: a transfer above a `withLimits` cap, or an op without the
  approvals an `approval-threshold` module required, passed the gate whenever the canonical
  fields permitted it. The rule schema accepted any lowercase module `type`, so a misspelled type
  such as `limitss` raised no error, and `baas.rules.simulate`, which runs the same evaluator,
  answered `isValid: true` for such ops. The platform now refuses every rule with a non-empty
  `modules` array at publish (400), simulate (`isValid: false`), entity creation and the
  validators' HCS read, and the gate denies an already-published rule that still carries one
  (`ruleAtoms: ["RuleModuleAtom"]`). The vendored schema mirrors the refusal on the `modules`
  path with the platform's message, now exported as `RULE_MODULES_REFUSED`. `modules: []` and an
  absent `modules` still parse. Nothing is removed and the rule types are unchanged, so code that
  compiled against 7.0.x still compiles.
  **Migration:** declare each control with the canonical field below and publish the rule again.
  Once the validators are upgraded, the gate denies every operation of an entity bound to a
  module-bearing rule, transfers and withdrawals included, so the platform can no longer move its
  assets: move them out before the upgrade, then create the entity again under a canonical rule.

### Added

- **`messaging.subscribeUrl(channel)`: the browser live feed without a session bearer in the URL
  (Hacken HSUITEPT-44).** `EventSource` cannot send headers, so apps put the customer's session
  bearer in the stream URL, where every proxy, ingress log and trace records it. The host no
  longer accepts a bearer from a query string on any route. `subscribeUrl` mints a single-use
  stream ticket over `POST /api/v3/baas/messaging/:appId/channels/:channel/subscribe-ticket` with
  every bearer in headers (a `forCustomer` view forwards its customer as
  `X-Customer-Session-Token`) and returns the `.../subscribe?ticket=` URL to open. A ticket opens
  one connection, once, within 30 s: call `subscribeUrl` again whenever the `EventSource` reaches
  `CLOSED`. **Migration:** an app that builds the stream URL with a session bearer itself must
  switch to `subscribeUrl` (or mint the ticket over that POST) before its host is upgraded. A host
  without this release answers the mint with 404.

### Deprecated

Every API that emits `modules` is `@deprecated`, and what it builds is refused as above.

| Deprecated | Canonical replacement |
|---|---|
| `withLimits` | `withOperations({ <op>: { limits: { maxPerTransaction, minPerTransaction, dailyLimit, weeklyLimit, monthlyLimit, totalLimit } } })`, `withInvariants({ maxSupply })` |
| `withCooldown` (token / account / topic) | `limits.cooldownSeconds` on the op (seconds; the atom took `cooldownMs`) |
| `withApprovalThreshold` (token / account / topic) | `requiresApproval: true` or `controller: 'dao'` on the op |
| `withRateLimiter` | a topic's `operations.submit.rateLimit`; elsewhere `limits.cooldownSeconds` or the period caps |
| `withSnapshot` | `withTokenGates` (holdings are read at the pinned consensus time) |
| `withGovernanceModule` | `controller: 'dao'` / `requiresApproval` on the op (8.1.0 refuses `withGovernance` too) |
| `withSwap`, `withAirdrop`, `withVesting`, `withStreaming` | no gate equivalent: bound the op with `limits`, `whitelist` / `blacklist` and `withTimeRange` |
| `withModules`, `addModule`, `ModuleEntry`, `ModuleEntrySchema` | the canonical fields in this table |
| `forAgent().withMaxTradesPerWindow` | `forAgent().withCooldown` and `withTradeLimits` |
| `forAgent().withRequireStructuredOutput` | validate model output in the app before it proposes an op |
| `Rules.atom` | per atom: `cooldown`, `approvalThreshold`, `tradeLimit`, `approvedPairs` → the same plain object on `forAgent().withCooldown` / `withApprovalThreshold` / `withTradeLimits` / `withApprovedPairs` (canonical, unchanged), `timeRange` → `withTimeRange`, `permissionList` → `transfer.whitelist` / `blacklist`, `countApproval` → `requiresApproval` / `controller: 'dao'`, `schemaValidation` → `forTopic().withPayloadSchema` |
| `Rules.molecule`, `Rules.module` | `withTokenGates`, the operation limits and `controller: 'dao'` / `requiresApproval` on the op (8.1.0 refuses `withGovernance` too) |
| `Rules.template.fairLaunch`, `tieredIDO`, `simpleStaking`, `simpleAMM` | `Rules.template.utilityToken`, or `forToken()` with operation limits, `withTokenGates` and `withTimeRange` |
| `Rules.template.tokenDAO`, `multisigDAO` | `Rules.template.daoGovernedToken` |
| `Rules.template.vestingSchedule` | `forAccount()` with `withTimeRange` and `operations.transfer.limits` |

`forAgent().withCooldown` and `forAgent().withApprovalThreshold` set canonical agent fields and
are not deprecated.

### Fixed

- **The README documented `baas.messaging.subscribe('events')`, which never existed.** It now shows
  `new EventSource(await baas.messaging.subscribeUrl('events'))`, re-minted on `CLOSED`.

## 7.0.1

The first fix below hits **every NestJS consumer**: an app that imports `SmartEngineModule`
from the published 7.0.0 package cannot serve a request.

### Fixed

- **`SmartEngineModule` from the published package could not serve a request — every NestJS
  consumer.** The package is bundled by tsup (esbuild), which emits no `design:paramtypes`, and
  `CustomerSessionGuard` / `AppOwnerGuard` took `Reflector` by constructor TYPE, so in the bundle
  nothing supplied it. Both guards are the module's `APP_GUARD`s since 7.0.0, so importing the
  module was enough: on Nest 11 `forRoot` / `forRootAsync` fail at boot (`Nest can't resolve
  dependencies of the CustomerSessionGuard (?, SDK_BAAS_CLIENT)`); on Nest 10 the app boots and
  every route — `@Public()` health probes included — answers 500 (`Cannot read properties of
  undefined (reading 'getAllAndOverride')`). The SDK's own suites stayed green because ts-jest
  compiles the TypeScript source, which does emit that metadata. Every constructor dependency of
  every SDK Nest class is now an explicit `@Inject(token)`: nothing depends on decorator metadata,
  whatever bundler builds the package. The repo's new `sdk-nest-boot` CI gate builds the bundle
  with the package's own tsup config and boots `forRoot` and `forRootAsync` from it, serving a
  `@Public()` route, an `@AppOwnerOnly()` route and a `SCOPED_BAAS_CLIENT` consumer; it fails on
  the 7.0.0 code. Before a publish, `node scripts/ci/check-sdk-nest-boot.cjs --dist
  libs/smartengine-sdk/dist` runs the same check on the exact build being published.
- **`SCOPED_BAAS_CLIENT` could not authenticate to the host — NestJS consumers that inject it.**
  `SmartEngineModule` built `SDK_BAAS_CLIENT` without the app's session (it passed `authToken` to
  the validator client and dropped it for the BaaS client). The host authenticates
  `Authorization` before it reads any customer header, so every scoped call — carrying only
  `X-Customer-Session-Token` — was a 401. The module now gives `SDK_BAAS_CLIENT` the same
  `authToken`. **Set `authToken` to the app's session**: in a deployed app, the `APP_TOKEN` the
  platform injects (the value `BaasClient.fromEnv` reads).
- **`db.getDocumentProof` never returned a proof — every caller.** It requested
  `/api/v3/baas/db/:appId/:documentId/proof`; the host serves
  `GET /api/v3/baas/db/:appId/:collection/:documentId/proof`, so the SDK's path matched the
  single-document read (collection = the document id, id = `proof`) and answered 404.
  **Migration: `getDocumentProof(documentId)` → `getDocumentProof(collection, documentId)`.**
  `DocumentProofResponse` is now the body the host returns, `{ proof }`: `documentId`, `exists`
  and `stateRoot` were never sent (the root is `proof.root`; a missing document is a 404). The old
  call could not succeed, so the compile break lands only on code that was already failing.
- **`db.getStateRoot` / `getStateTransitions` / `getDbStats` declared fields the host never sends —
  every caller reading them.** The host answers `{ stateRoot }`, `{ transitions, count }` and
  `{ collections, totalDocuments, stateRoot, transitionCount }`. The SDK types promised `appId`,
  `blockHeight` and `timestamp`; `total`, `fromBlock` and `toBlock`; and `documents`, `storageSize`
  and `blockHeight` — all `undefined` at runtime, with the compiler vouching for them. The three
  types now match the host bodies, pinned the same way as `DocumentProofResponse`. **Migration:**
  `transitions.total` → `transitions.count`, `stats.documents` → `stats.totalDocuments`, and
  `stats.transitionCount` is new. Drop reads of `appId`, `blockHeight`, `timestamp`,
  `storageSize`, `fromBlock` and `toBlock` — they never carried a value. `getStateTransitions`
  still sends `fromBlock` / `toBlock`, but the host applies only `limit` (the newest N).

## 7.0.0

### BREAKING

- **`AgentInfo.owner` is renamed `AgentInfo.ownerWallet`, and `agents.list()` returns `count`
  instead of `total` (#3364 / HSUITEPT-34).** Both names were fictional. The host answers
  `GET /api/v3/baas/agents[/:agentId]` with its persisted document verbatim — `ISmartAgent`,
  whose owner field is and always was `ownerWallet` — and `AgentsClient` passes the body through
  with no normalizer, so the REQUIRED `owner` this type declared was never populated by anything.
  A consumer writing the filter the type invites, `agents.filter(a => !a.owner || a.owner ===
  caller)`, compared `undefined` on every row and kept the entire agent inventory; the compiler
  endorsed it, and a hand-written unit fixture with an `owner` key kept it green. That is the
  mechanism behind the unauthenticated full-inventory read in HSUITEPT-34. `list()`'s `total` was
  the same defect without the security edge: the envelope field is `count`.
  **Migration: `a.owner` → `a.ownerWallet`, `res.total` → `res.count`.** Both are one-for-one —
  the values are now actually present, so an owner comparison that silently passed everything
  starts filtering. Audit any authorization decision built on the old field before upgrading; it
  was not doing what it read like.
  Chosen over adding a normalizer that mints `owner` alongside `ownerWallet` (the shape
  `baas/entities/client.ts` uses): a normalizer keeps a name the host does not have, and this bug
  is exactly what happens when the next sub-client copies it. `ownerWallet` is now the one owner
  key across the SDK; `EntityInfo.owner` stays populated but is marked `@deprecated` for 8.0.0, so
  no entity consumer breaks twice. `baas/agents/agent-info-contract.spec.ts` pins `AgentInfo`
  against the host's `ISmartAgent` (type-only import, excluded from the published build) and fails
  to compile if the SDK names an owner key the host does not emit, or if any NEW phantom key is
  added. The two pre-existing ones, `rules` (host: `rulesConfig`) and `description` (host: absent),
  are allow-listed there and left for a follow-up — they need a shape decision, not a rename.

- **Importing `SmartEngineModule` now authenticates the whole app (#3228 / HSUITEPT-6, and the
  missing half of external pentest finding HSUITEPT-8).** `forRoot` and `forRootAsync` exported
  `CustomerSessionGuard`, documented it as fail-closed and never registered it: the SDK declared
  **no `APP_GUARD` at all**, so an app that imported the module booted with *zero* global
  authentication and every route not explicitly decorated `@Public()` was anonymously reachable —
  whatever the guard's own documentation said. Both factories now register it
  (`{ provide: APP_GUARD, useClass: CustomerSessionGuard }`), so the module is secure by default:
  a non-`@Public()` route with no bearer is 401, a bearer the host rejects is 401, a bearer minted
  for another app is 401, and a client bound to no `SDK_BAAS_CLIENT` is 401 — still no "open mode"
  switch. **Migration: mark genuinely anonymous routes `@Public()`** — health probes, the customer
  login handshake, static content — on the handler or the controller, or they will start answering
  401. Routes that already sent a customer-session bearer are unaffected, and the guard keeps
  stamping `@CustomerSession()`.

- **The cluster-discovery `trustAnchor` is now a gate, not advice (#3222, external pentest
  finding HSUITEPT-17).** Previously `ClusterDiscoveryClient` kept a discovered cluster when *at
  least one* of its claimed `nodeIds` was on-chain — never looking at the `gatewayUrl` it was
  about to hand to `BaasClient` — and silently returned the seed's list **unverified** whenever
  the mirror-node read failed or the registry was empty. Both `nodeIds` and `gatewayUrl` come
  from the same untrusted seed and the real node ids are published unauthenticated, so an
  attacker copying `validator-sn1` next to `https://attacker.example` passed the anchor.
  Now a cluster passes only if **every** claimed `nodeId` is registered on-chain **and** each
  on-chain record binds the advertised `gatewayUrl` (same http(s) origin as
  `networkEndpoints.gatewayUrl`, falling back to `apiEndpoint`). A registry that cannot be read,
  or is empty, **throws `TrustAnchorError`** from `getClusters()` / `getRandomCluster()` /
  `getRandomGatewayUrl()` / `BaasClient.connectToCluster()`. **A caller with `trustAnchor` set
  against a network whose registry is empty (testnet `0.0.9143793` today — see #2983) flips from
  "connects, unverified" to "throws".** Either remove `trustAnchor` until the registry is
  populated, or opt into the old behaviour explicitly with `trustAnchor.onFailure: 'passthrough'`
  (never in production). `ClusterDiscoveryClient.lastAnchorOutcome` reports which branch ran
  (`'verified'` with the per-cluster rejections, `'unavailable'`, or `'empty-registry'`).
  The JSDoc claim that the anchor compared `publicKey` was false and is removed.

- **`verifyPqcAttestation` requires a validator-registry snapshot for EVERY consumer, and rejects
  duplicate signers (#3230, external pentest finding HSUITEPT-14).** Three defects compounded in the
  cert verifier, and the headline one was that a **forged quorum certificate verified `valid: true`**:

  1. The snapshot was demanded only for consumers on a hardcoded `SECURITY_BEARING_CONSUMERS`
     allowlist, so for every other consumer the cert was verified against **the public keys the cert
     itself carries** — and an ML-DSA keypair is free to mint. A forged cert with two invented
     signers and a self-declared `threshold: 2` passed. The gate was also call-site dependent:
     `fetchRegistrySnapshot` returned `null` on every failure path, so the documented
     `snapshot ?? undefined` pattern silently degraded to verifying against attacker-nominated keys.
     The snapshot is now a **required parameter** (`registry-snapshot-required` guards untyped JS
     callers at runtime) and `fetchRegistrySnapshot` **throws `RegistryUnavailableError`** instead of
     returning `null` — "registry unreachable" and "registry replaced by the attacker" must not be
     the same outcome.
  2. Signers were de-duplicated by `nodeId` while the signature was verified against the embedded
     public key, so ONE keypair listed twice under two nodeIds counted as two signers. A duplicate —
     the same `nodeId`, or the same public key under two nodeIds — is now **rejected**
     (`duplicate-signer`), not silently skipped.
  3. The quorum requirement came from the cert's own `threshold` field, raised only for allowlisted
     consumers. The floor now comes from the trusted snapshot's policy and no caller can lower it.

  Also fixed: `payloadHash`, when present, must agree with `contentHash` (`payload-hash-mismatch`) —
  it was previously parsed and never compared; and an opt-in freshness bound (`policy.maxAgeMs`)
  rejects stale (`stale-attestation`) and future-dated (`future-dated-attestation`) certs, which is
  what bounds the replay window.

  **Migration:** `verifyPqcAttestation(cert, record)` no longer type-checks. Pass a snapshot as the
  third argument — `await fetchRegistrySnapshot(hostUrl)`, or any `{ snapshotTs, validators }` object
  from a source you trust. The CLI does this for you and now exits `2` when no snapshot is reachable,
  instead of exiting `0` with "Cert valid" for a cert it could not actually anchor.

### Changed

- **`ValidatorDiscoveryClient.getValidators()` orders `validator.leave` by consensus timestamp.**
  The registry reduction previously trusted the order the mirror node returned messages in; it
  now keeps the newest message per `nodeId` by `consensus_timestamp`, so a `leave` can only retire
  a registration that precedes it, and a re-join after a leave wins, whatever order the pages
  arrive in.
- **`ValidatorNetworkEndpoints.gatewayUrl?`** — new optional on-chain field: the public cluster
  gateway a validator serves behind. The trust anchor prefers it over `apiEndpoint`.

### Added

- **`HttpCallOptions.customerClaim` — the app vouches for a customer without becoming one
  (#3381).** `onBehalfOf` REPLACES the app's `Authorization` bearer with the customer's JWT, so the
  host sees a `'customer'` principal — and since the platform-paid single-shot creates
  (`createToken` / `createAccount` / `createTopic` / `createAgent` / `launchpad`) went behind
  `AppAuthorityGuard` (#3239) that shape is refused with 403: a customer must not make the app pay.
  `customerClaim` keeps the app's own bearer and forwards the customer's Mode-1 JWT as
  `X-Customer-Session-Claim` only. `BaasClient.forCustomer(token)` now sends that shape on those
  five routes automatically; every other route it serves is unchanged (`X-Customer-Session-Token`
  under the app's bearer, and `onBehalfOf` per call to act AS the customer on customer-paid
  routes). The host binds the vouched claim as the entity OWNER for `agent` only — a token /
  account / topic created this way is app-owned (open decision, #3381 requirement 4).
- **`SCOPED_BAAS_CLIENT` — a request-scoped BaaS view bound to the calling customer, so forwarding
  the end-user's identity is construction, not a call every handler must remember (#3365, external
  pentest finding HSUITEPT-32, the structural half of HSUITEPT-6).** `SDK_BAAS_CLIENT` is one
  app-authority singleton and `CustomerSessionGuard` stamps `request.customerToken` on the same
  request; bridging them was a `forCustomer` call per call site, and one forgotten call was one plane
  wide open. `(SCOPED_BAAS_CLIENT) baas: CustomerScopedBaasClient` is now
  `SDK_BAAS_CLIENT.forCustomer(request.customerToken)` built by Nest once per request (`Scope.REQUEST`,
  registered by both `forRoot` and `forRootAsync`). **Fail closed:** on a request with no customer
  session — a `()` route reaching for it, a guard that did not run — the first use throws 401
  (`SCOPED_BAAS_CLIENT_REQUIRED_MESSAGE`); it never degrades to the app's tenant-wide authority. The
  singleton stays as the explicit escape hatch for tenant administration. Binding is lazy (Nest builds
  request-scoped providers before the guard chain runs) and memoised per request; a class injecting the
  token becomes request-scoped, Nest's usual cost. `CustomerScopedBaasClient` is exported from the
  package root and `/nestjs`. `baas/__tests__/call-options-parity.spec.ts` now drives every public
  method of `db` / `storage` / `functions` / `messaging` through both the per-call `{ customerToken }`
  path and a `withCallOptions` view, read off each prototype, so a method shipped without call options
  fails by name.
- `TrustAnchorVerifier`, `TrustAnchorError`, `httpOrigin` and the `TrustAnchorConfig` /
  `TrustAnchorFailurePolicy` / `TrustAnchorOutcome` / `TrustAnchorRejection` types are exported
  from the package root and `discovery`.
- `ValidatorRegistrySnapshotSchema` / `ValidatorRegistrySnapshot`, `RegistryUnavailableError` and the
  `VerifyPolicy` type are exported from the package root and `pqc-verify` (#3230).
- **`@AppOwnerOnly()` — an authorization primitive, so an app stops hand-rolling "is this caller the
  operator?" (#3368, external pentest finding HSUITEPT-35).** `CustomerSessionInfo` answers who is
  calling and nothing else — no role, no `isAppOwner`, and `subscriptionContext.tier` is a billing
  tier, not a permission — so every app either hand-rolled the owner check or skipped it, as the app
  in that finding did on an analytics route that then served one customer another customer's request
  URLs. Decorate a route (or a controller) `@AppOwnerOnly()` and `AppOwnerGuard` — registered as an
  `APP_GUARD` by both `forRoot` and `forRootAsync`, right after `CustomerSessionGuard` — serves it
  only to the app's developer wallet, using the same predicate the platform already applies in
  `DeploymentService.assertOwnership`. A valid same-app non-owner session is **403**; a session
  minted for another app is **401**; `@Public()` does not make an owner-only route anonymously
  reachable (it opts out of authentication, never out of authorization). Undecorated routes are
  unaffected. **Set `appOwnerWallet` in the module config** — the wallet that owns the app: it is
  the guard's only source of ownership, and while it is absent every `@AppOwnerOnly()` route refuses
  with 403 and logs once per call, deliberately fail-closed (the alternative is the fail-open
  failure mode of #3330). Non-owner and unresolvable-owner share one 403 body, so the guard is not
  an oracle for the app's own misconfiguration. Exported from `/nestjs`: `AppOwnerOnly`,
  `AppOwnerGuard`, `IS_APP_OWNER_ONLY_KEY`, `APP_OWNER_ONLY_MESSAGE`,
  `APP_OWNER_SESSION_REQUIRED_MESSAGE`.

## 6.4.0

`customerToken` is an AUTHORIZATION scope, not a metering label. On a host
carrying smart-host #3171 / #3228 (Hacken HSUITEPT-3 / HSUITEPT-6) the
end-user session an app forwards decides which objects that call may see:
writes are bound to the customer, lists return only their rows, and any
id-addressed call on another customer's object answers 404. This release
makes that token impossible to forget.

### Added

- **`baas.entities.get` / `baas.entities.listByOwner` accept a trailing
  `HttpCallOptions` (smart-host #3273, Hacken HSUITEPT-11).** The two entity
  READ methods now take the same optional per-call options the value-movement
  methods on the same sub-client already do, so an app-as-proxy can scope a
  read to its end user (`customerToken` ⇒ `X-Customer-Session-Token`). On a
  host carrying #3273 that identity is the read scope: `get` on an entity
  owned by anyone else answers 404 — identical to an unknown id, so the route
  is not an existence oracle — and `listByOwner` returns only that owner's
  entities. **Not breaking**: every existing call site compiles and, without
  options, runs on the app's own session as before. Note the paired host
  change: `listByOwner()` now returns the app's own entities, where the host
  previously answered with an empty list unless the caller passed an `owner`
  query parameter (which it never did, and which the host no longer trusts).

- **`HttpCallOptions` on EVERY sub-client method.** `db`, `entities`, `rules`
  and `deployment` gained the trailing optional options bag that `storage`,
  `messaging`, `functions` and `agents` already had, so there is no call left
  that cannot carry the acting customer. Purely additive — every existing
  call site compiles and behaves unchanged.
- **`baas.forCustomer(token)` — a scoped client view.** Returns the
  customer-reachable sub-clients (`db`, `storage`, `functions`, `messaging`,
  `agents`, `rules`, `entities`) over a transport that attaches
  `customerToken` to every request, so a single forgotten call can no longer
  run with the app's tenant-wide authority. The view shares the base client's
  auth token and session refresh; a per-call option still wins over the bound
  token. `deployment` and `customerSession` are deliberately absent — they are
  app-authority / session-mint surfaces with no customer to act for. Exposed
  as the new `CustomerScopedBaasClient` type, and built on the new exported
  `withCallOptions(http, defaults)` transport decorator.
- **`CustomerSessionGuard` (+ `Public()`, `CustomerSession()`,
  `extractBearer`) from `@hsuite/smart-engines-sdk/nestjs`.** The end-user
  session's entry point into an app's own NestJS backend: validates the
  `Authorization: Bearer` against the host through the injected
  `SDK_BAAS_CLIENT`, then stamps `request.customer` (the validated session)
  and `request.customerToken` (the bearer to forward). Fails closed — no
  bearer, a rejected bearer, or no client bound is a 401, and a route that
  is meant to be reachable without a session says so with `@Public()`. It
  exists so no app hand-rolls this guard, and with it an "open mode" that
  lets every request through. Its 401 body is the fixed
  `CUSTOMER_SESSION_REQUIRED_MESSAGE`, which names the FLOW and never the
  host address: a deployed app's resolved `hostUrl` is an in-cluster
  address, and answering every anonymous caller with it discloses the
  namespace, service name, port and API prefix of the central BaaS host
  (Hacken HSUITEPT-9). Shipping it as a constant is what stops every app
  that installs the guard from re-introducing that leak.
- **`BaasDocument.customerId`** — the db-plane customer binding a host
  carrying #3228 returns on a document. Absent means app-owned.
- **Attribution on the wire (smart-host #3227, Hacken HSUITEPT-7).** New
  `BaasActor` (`{ type, id, chain?, sessionId? }`) and
  `BaasDocumentProvenance` (`{ createdBy, updatedBy }`).
  `BaasStateTransition` gains `actor` — who CAUSED the transition, folded
  into the document's Merkle leaf, so it is covered by `proof`/`stateRoot`
  instead of being a rewritable envelope field — plus the previously
  undeclared `appId` and `walletAddress` (the APP session wallet the call was
  authorized under, which is the application's identity, never the actor).
  `BaasDocument` gains `provenance`; `BaasUploadResult` / `BaasFileInfo` gain
  `actor` (and `BaasFileInfo.owner` is now declared and documented as the app
  session wallet); `BaasMessage` gains `actor`, and its `sender` is documented
  as that actor's id. All optional — hosts older than this release do not
  populate them.

### Changed

- Docs no longer describe `customerToken` as "metering-only" — that wording
  predates the host-side scope and reads as though forgetting the token were
  a billing detail rather than a tenant-wide-authority escalation.

### Security

- **Cluster discovery refuses plaintext instead of degrading (HSUITEPT-16, #3229).**
  `ClusterDiscoveryClient` used to drop every non-`https://` registry entry silently, so a
  registry that advertised cleartext gateways looked like "discovery found nothing" — and the
  obvious workaround, `allowInsecure: true`, also switched off the bootstrap-seed and
  validator-URL checks. Now a seed whose cluster list contains **no** HTTPS gateway makes
  `getClusters()` / `resolveClusterEndpoint()` / `BaasClient.connectToCluster()` throw the new
  `InsecureClusterRegistryError` (a `ValidationError` carrying `seed` and the `rejected`
  entries), and it is not retried against the next seed. A mixed list still drops only the
  plaintext entries, so one poisoned entry cannot deny service.
- **`allowInsecure` is split.** `allowInsecure` now governs **caller-supplied** URLs only
  (bootstrap seeds, the trust-anchor mirror node, `ValidatorAuthClient` validator URLs). A new
  `allowInsecureClusters` flag — on `ClusterDiscoveryConfig`, `ResolveClusterConfig`,
  `BaasConnectToClusterConfig` and therefore `composeApps` — is the only thing that admits a
  plaintext `gatewayUrl` **returned by the registry**, and it is the flag that sets the
  resulting `BaasClient`'s `allowInsecure` (the host URL is registry-supplied). The two are never
  implied by each other.

  **Behaviour change:** code that relied on `allowInsecure: true` to accept `http://` registry
  entries (a local k3d / in-cluster registry) must now also pass `allowInsecureClusters: true`.
  Callers against an HTTPS registry are unaffected.
- **One transport policy for every SDK URL validator; no more scheme-derived `allowInsecure`.**
  `BaasClient`, `SmartEngineClient` and `ValidatorAuthClient` now share `transport-policy.ts`:
  plain `http://` is accepted **without any flag** toward a Kubernetes in-cluster service name
  (`*.svc`, `*.svc.cluster.local`, `*.cluster.local` — the deployed smart-app → smart-host path,
  which has no in-cluster TLS), and still requires `allowInsecure: true` toward anything else
  (a public hostname, `localhost`, any IP literal). `BaasClient.fromEnv` therefore no longer sets
  `allowInsecure: hostUrl.startsWith('http://')`: a public plaintext `SMART_HOST_URL` in a
  deployment context is refused at construction (`BaasError` 400) instead of silently accepted.
  Smart-app code that derived the flag from the scheme can delete that line.


## 6.3.0

### Added

- **`devnet` joins `testnet`/`mainnet` as a first-class `NetworkName` (#3040).**
  `KNOWN_NETWORKS.devnet` resolves to `https://gateway.develop.hsuite.network` —
  reserved the same way `mainnet` already is: the name resolves and type-checks
  today, but the `develop.hsuite.network` DNS zone is not live yet, so a real
  discovery call against it fails at runtime with the ordinary "no seed
  reachable" error (not a compile-time surprise) until the devnet cluster infra
  lands separately. Every existing `NetworkName` consumer (`resolveNetwork`,
  `BaasClient.connectToCluster({ network })`, `resolveClusterEndpoint`) handles
  the third case with no code changes — none of them switch exhaustively on the
  union, they all delegate to the `KNOWN_NETWORKS` map lookup.
- **`networkFromEnv(explicit, env)` — the `HSUITE_NETWORK` env convention.**
  A new resolver: an explicit `NetworkName` argument (e.g. a `--network` CLI
  flag) always wins; otherwise `env.HSUITE_NETWORK` is used if
  `isKnownNetwork` accepts it; otherwise `DEFAULT_NETWORK` (`'testnet'` — the
  only live public network today). Never throws — an unrecognized
  `HSUITE_NETWORK` value falls back to the default instead of failing the
  whole process. Takes an explicit `env` object (mirrors `SmartEngineClient
  .fromEnv`'s style) rather than reading `process.env` internally, so it
  stays usable outside Node. **Not breaking:** nothing in the SDK calls this
  automatically — existing call sites are unaffected until a caller opts in.

## 6.2.0 — 2026-08-29

### Added — per-app egress types published (#2914, #2926)

Publish-carry bump. The per-app egress declaration work (#2914, spec #2912) had already changed the SDK's published surface — `src/baas/types.ts` gained the egress types — but the package was still sitting at 6.1.0, exactly what was on the registry, so the egress feature was unreachable to any consumer. 6.2.0 is the additive minor bump that republishes it (CLI bumped alongside to 1.6.0). No other SDK change in this release.

## 6.1.0

### Added

- **`functions.signCode()` can now hold a stable, registerable signing identity (#2271).**
  Previously every `signCode()` call minted a fresh Ed25519 keypair and discarded it —
  proving the payload was internally self-consistent but nothing about who signed it, since
  that "signer" never existed before the call and was never seen again. That made the host's
  `BAAS_FUNCTION_SIGNER_KEYS` trusted-signer allowlist (#2187) impossible to arm: a
  pre-registered public key can never match a keypair regenerated on every call.
  `BaasClientConfig.functionSigningKey` (and the new `FunctionsClient` constructor param)
  lets a caller configure a persistent Ed25519 key once; every `signCode()`/`deploy()`/`eval()`
  call reuses it. `generateBaasFunctionSigningKey()` and `baasFunctionSignerAllowlistHex()` are
  new exports to generate that key and derive the raw-hex form your cluster operator registers.
  **Not breaking:** `signCode()`/`deploy()`/`eval()` keep their existing signatures unchanged;
  the zero-config default (fresh keypair per call) is untouched for backward compatibility —
  configuring `functionSigningKey` is opt-in. See `docs/sdk/README.md` for the migration path
  and the `domainBind` rollout-ordering hazard.

## 6.0.0

### BREAKING

- **`verifyPqcAttestation` now refuses to verify a security-bearing consumer without a
  validator-registry snapshot (#1578).** Previously `registrySnapshot` was optional and
  membership binding was skipped when it was absent. For a load-bearing, censorship-resistant
  trust anchor (currently `app-registry`, the marketplace on-chain registry) signature-only
  verification is meaningless — an attacker can mint ML-DSA keypairs at will — so the verifier
  now returns `{ valid: false, reason: 'registry-snapshot-required' }` when the snapshot is
  omitted. **An existing 2-argument call `verifyPqcAttestation(cert, record)` against an
  `app-registry` cert flips from `valid: true` to `valid: false`.** Pass the snapshot.
- **A minimum quorum floor is enforced for security-bearing consumers.** The cert's own
  self-declared `threshold` is no longer trusted at the trust boundary: a hand-forged
  `threshold: 1` cert carrying one genuine self-signature previously verified `true`. The
  effective threshold is now `max(cert.threshold, 2)`.
- **Signers are counted by DISTINCT `nodeId`.** A cert listing one validator's signature twice
  previously faked a 2-of-N quorum; duplicates now count once.

`SECURITY_BEARING_CONSUMERS` is duplicated here deliberately (this package is standalone and
cannot import monorepo internals) and is pinned to the platform's canonical list by a
cross-surface conformance test.

### Added

- **`composeApps({ network | bootstrap, appIds })` (#1537).** Marketplace "puzzle-up"
  composition: returns a `Record<appId, BaasClient>` so a builder can wire several listed
  smart-apps together. Pure client-side wiring — no runtime composition engine, no shared auth,
  no cross-app execution, no mediated purchase.

## 5.3.0

### Added

- **Server-side transfer/withdraw broadcast (#1649).** `transactions.prepareTransfer`
  (full mode) and the new `transactions.executeTransfer` (partial mode) now
  submit the fully-signed transaction to the chain THEMSELVES on chains that
  have migrated to server-side broadcast (Hedera, XRPL), instead of returning
  bytes for the caller to submit. `PreparedTransaction` gained two new optional
  fields: `broadcast?: boolean` (true when the validator broadcast this tx
  server-side) and `status?: string` (the real on-chain result — e.g. `SUCCESS`
  for Hedera, `tesSUCCESS` for XRPL). When `broadcast` is true, `transactionId`
  is the REAL on-chain transaction id rather than merely a pre-assigned one.
  This eliminates the client-side raw-chain-SDK submission step (and, on
  Hedera, the redundant wallet re-signature the old submit-via-wallet path
  required — full-mode withdraws now need 0 wallet prompts, partial-mode need
  only the owner co-signature). Chains/ops that haven't migrated yet keep
  returning bytes unchanged (`broadcast`/`status` simply absent) — fully
  backward compatible.
- **`transactions.executeTransfer(request: ExecuteTransferRequest)`.** New
  method on the chain-agnostic `TransactionsClient`, previously unexposed by
  the SDK (only reachable via a raw `POST /transfer/execute` call). Submits
  the caller-signed bytes from `prepareTransfer` (`securityMode: 'partial'`)
  back to the validator for threshold co-signing (and, per the above,
  server-side broadcast on migrated chains).

## 5.2.1

### Added

- **`BaasFunctionInfo.createdAt?: string`.** Surfaces the function's original
  creation timestamp, distinct from `deployedAt` (which is overwritten on every
  version bump from an update). Additive/optional — older hosts that don't yet
  send this field remain wire-compatible.

## 5.2.0 — 2026-07-08

### Added

- **`BaasClient` deployment-context auth (trustless `APP_TOKEN`).** `BaasClientConfig`
  now accepts an `authToken` — a pre-minted session bearer that authenticates the
  client FROM CONSTRUCTION (every request carries it; no web3 challenge). New
  `BaasClient.fromEnv(env, overrides?)` reads the platform-injected deployment
  context (`smart-deployer`'s cluster-env: internal `SMART_HOST_URL` + the
  TSS-minted `APP_TOKEN`, accepted by the smart-host's `AppTokenGuard`) and returns
  a ready-authenticated client pointed at the in-cluster host — or `null` when the
  context is absent (running locally), so callers fall back to the
  `authenticate()` challenge path. Lets identical smart-app code reach cluster mode
  BOTH locally (developer wallet + challenge) and deployed (internal host +
  APP_TOKEN); the connection URLs are resolved by the platform, not the developer.

### Removed

- **`client.tss.reshareCluster` / `client.tss.reshareClusterAsync`** and the
  `ReshareRequest` / `ReshareResponse` types they used. The server-side routes
  (`POST /tss/cluster/reshare`, `POST /tss/cluster/reshare/async`) were already
  dead — PR #508 deleted their handler bodies, and #1497 finished that
  deletion by removing the orphaned route decorators. Reshare is
  event-driven (`reshareOnMembershipChange`, triggered by cluster membership
  change + NATS fan-out), never a manual client HTTP call, so there is no
  replacement method. (#1496, follow-up to #1457)

## 5.1.0 — 2026-07-06

### Breaking changes

- **`securityMode` removed from every entity create/execute-create request type** (`CreateTokenRequest`, `CreateAccountRequest`, `CreateTopicRequest`, `CreateAgentRequest`, and the `executeCreateToken` / `executeCreateAccount` / `executeCreateTopic` / `executeCreateAgent` param shapes). Product decision: sovereignty mode ("full" vs "partial") is no longer a caller-supplied param anywhere in the create/withdraw surface — the validator resolves it exclusively from the entity's bound `ValidatorRules` (`canonicalRule.defaultSecurity`, legacy alias `smartNodeSecurity`), the unique source of truth. Passing `securityMode` on the wire (e.g. from an un-migrated caller) is silently ignored by the host/validator — never a 400 — so existing integrations keep working during rollout; only the SDK's compile-time type no longer accepts the field. `WithdrawEntityRequest` never had this field and is unaffected.

### Added

- **`EntityInfo.securityMode?: 'partial' | 'full'`.** `GET /api/v3/baas/entities/:entityId` and `GET /api/v3/baas/entities` now surface the sovereignty mode resolved server-side from the entity's bound rule, so callers can read the mode without re-deriving it. Absent when the entity's rule/binding doesn't resolve one (legacy record).

## 5.0.0 — 2026-06-27

### Breaking changes

- **Root `CreateAccountRequest` / `CreateTokenRequest` now refer to the BaaS shapes.** These exports previously pointed to the legacy validator-direct request types used by `SmartEngineClient.createAccount` / `createToken`. They now point to the BaaS request types consumed by `BaasClient.entities`. If you need the validator-direct request types, import `ValidatorCreateAccountRequest` and `ValidatorCreateTokenRequest` from the SDK root.
- **`EntityCreationResult.agentId` is now required.** The field was optional in 4.2.0; it is now guaranteed on normalized results and is backfilled from `entityId` when the host response omits it. Callers constructing this type directly must now include `agentId`.

### Added

- **Exported client interfaces:** `IBaasClient`, `IEntitiesClient`, and `IRulesClient` are now part of the public surface.
- **Exported `BatchInnerTransaction`** as an alias for `PreparedTransaction`.
- **BaaS and validator-direct request types are re-exported from the root.** This includes `CreateAccountRequest`, `CreateTokenRequest`, `CreateAgentRequest`, `CreateTopicRequest`, `ValidatorCreateAccountRequest`, and `ValidatorCreateTokenRequest`.
- **`EntitiesClient` response normalization.** `getById`, `getByRuleRef`, `lookup`, and create/prepare methods now normalize host responses to the stable `EntityInfo` and `EntityCreationResult` shapes.

## 4.2.0 — 2026-06-26

### Added

- **Two-phase EXECUTABLE-agent surface (additive, non-breaking).** Reflects the host change that promotes a payer-funded XRPL agent into an executable AGENT record during the execute-create/finalize half. Lets the app-as-proxy consumer drive the full prepare → fund → execute agent flow type-safely.
  - **`EntityCreationResult.agentId?: string` + `status?: string`.** The host now merges `{ agentId, status }` additively onto the execute-create envelope once the funded entity is promoted to an agent record (`executeCreateAgent`, and the convenience `createAgent` on payer-funded chains). Present ONLY on the agent execute/finalize path; undefined for non-agent entity types and on the prepare half. Field names + `string` types match the host controller's `executeCreate` return (`apps/smart-host/.../entities.controller.ts`) verbatim.
  - **`CreateAgentRequest.rulesConfig?: Record<string, unknown>` + `capabilities?: string[]`.** Carried through `prepareCreateAgent` (which already forwards the whole request body) so the host can stash them VERBATIM on the PENDING registry row at prepare and recover them at execute to mint the agent record — avoiding a `rulesHash` divergence from re-deriving them from the bound rule. Field names + types mirror the host `PrepareCreateDto` / `entity-registry.schema.ts` verbatim. Optional/additive; ignored by the legacy synchronous `createAgent` and by non-agent entity types.
  - **App-as-payer `fundWith` documented.** `CreateAgentRequest.fundWith` now documents that a smart-app may supply a SERVER-SIDE `fundWith` that signs the prepared payer `Payment` with the app's OWN treasury key and sets `payerAccountId` to that treasury account. No new method — the existing `prepareCreateAgent` → `fundWith` → `executeCreateAgent` sequence already supports an app funding an agent on a customer's behalf.

## 4.1.0 — 2026-06-25

### Added

- **`AgentRegisterRequest.primaryChain?: string`.** Agent registration mints the agent's on-chain wallet ENTITY on the chain named by `primaryChain`; the host requires it and defaults a missing value to `'hedera'` (which has no funded payer in XRPL-only deployments → `PAYER_ACCOUNT_NOT_FOUND`). The field was always accepted at runtime (`AgentsClient.register` forwards the whole body to `POST /api/v3/baas/agents/register`), but the SDK type omitted it, so consumers couldn't set it type-safely. Now declared as a plain optional `string` — valid host values: `'hedera'`, `'xrpl'`, `'polkadot'`, `'solana'`, `'ethereum'`, `'polygon'`, `'bitcoin'`, `'stellar'`. Additive and backward-compatible.

## 4.0.0 — 2026-06-25

**SDK revision — align the surface to the REAL reachable endpoints; remove the un-ingressed-tier footguns.** Two profiles, two tiers: `BaasClient` is the app-as-proxy / external profile (host BaaS tier, `{gateway}/host/api/v3/baas/*`); `SmartEngineClient` is the in-cluster / validator-direct profile (raw `/api/v3/*` tier, un-ingressed at the gateway). The web3 authorization proxy is the HOST, which already exposes the entity value-movement routes — so value-movement now lives on `BaasClient.entities`, and the raw-tier transactions arm is removed from `BaasClient`.

### Breaking changes

- **Removed `BaasClient.transactions` (the `TransactionsClient` sub-client) and `BaasClient`'s internal `txHttp`.** This arm rooted at the RAW `{hostUrl}/api/v3/transactions` origin — a sibling of `/host` that is un-ingressed at the public gateway (RFC #1236), so every call from a gateway-routed `BaasClient` 404'd. That was the footgun. The `TransactionsClient` class itself is unchanged and still ships on `SmartEngineClient` (the validator-direct profile). Migration: external/app-as-proxy callers use `BaasClient.entities.{transfer,withdraw,mint,burn,compliance,trustline}` (host BaaS tier) or, for in-cluster prepare/execute, construct a `SmartEngineClient` with an explicit `validatorBaseUrl`.
- **`SmartEngineClientConfig.baseUrl` → `validatorBaseUrl` (renamed, type-fence).** `SmartEngineClient` is now constructible ONLY with an explicit VALIDATOR origin. The rename makes it impossible to silently pass a gateway URL into the validator-direct client (whose raw `/api/v3/*` tier 404s at the gateway). Migration: `new SmartEngineClient({ baseUrl })` → `new SmartEngineClient({ validatorBaseUrl })`.
- **Removed `SmartEngineClient.connectToCluster()` (and its `ClusterConnectionConfig` / `ClusterConnectionSeed` / `ClusterConnectionResult` types).** It resolved a cluster's `gatewayUrl` and fed it into a gateway-pointed `SmartEngineClient` — the same footgun. To reach web3 through a cluster use `BaasClient.connectToCluster({ network })` (host BaaS tier); for an explicit in-cluster validator origin use `new SmartEngineClient({ validatorBaseUrl })` or `SmartEngineClient.connectToNetwork(...)` (which resolves a real validator `apiEndpoint` from the HCS registry — never a gateway URL). `connectToNetwork` is unchanged.

### Added

- **`BaasClient.entities` value-movement (prepare-bytes only).** Six new methods mirroring the host's entity-scoped routes (`POST /api/v3/baas/entities/:entityId/{transfer,withdraw,mint,burn,compliance,trustline}`), each returning the host's `PreparedEntityTransactionResult` and threading `HttpCallOptions` (`onBehalfOf` / `customerToken` / `headers`) — owner-acting via the end user's Mode-1 customer-session JWT, exactly as `agents.execute`. The entity is the source / treasury / authority + fee-payer (resolved server-side; never named in the body), so a caller can never name a foreign payer or drain another account; a rule-deny surfaces as a 403 `rule_rejected` (use `isRuleRejected`), never as signed bytes. New signatures:
  - `entities.transfer(entityId, body: TransferEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - `entities.withdraw(entityId, body: WithdrawEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - `entities.mint(entityId, body: MintEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - `entities.burn(entityId, body: BurnEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - `entities.compliance(entityId, body: ComplianceEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - `entities.trustline(entityId, body: TrustlineEntityRequest, opts?) → Promise<PreparedEntityTransactionResult>`
  - New exported types: `TransferEntityRequest`, `WithdrawEntityRequest`, `MintEntityRequest`, `BurnEntityRequest`, `ComplianceEntityRequest`, `ComplianceAction`, `TrustlineEntityRequest`, `PreparedEntityTransactionResult`.

## 3.14.0 — 2026-06-25

### Added

- **Agent + BaaS SDK completeness (from the showcase boundary audit).** Lets app-as-proxy consumers drop hand-rolled raw-fetch workarounds.
  - `AgentsClient.certification(agentId, opts?)` → typed `AgentCertification` (entityId, owner/agent/treasury wallets, primaryChain, rulesHash, BLS group key, state-history topic, recent events + tx ids).
  - `AgentsClient.fund()` now returns the discriminated union `AgentPreparedTransactionResponse | AgentPendingApprovalResponse` (the owner approval-threshold path), plus the `isAgentFundPending()` type guard.
  - `AgentInfo` gains the on-chain DKG identity the host already returns: optional `agentWallet`, `treasuryAccount`, `entityId` (additive).
  - `FunctionsClient.getCode(functionId, opts?)` → `BaasFunctionCode` (`{ functionId, code, codeHash }`) for function source reveal.
  - `StorageClient.downloadWithMeta(cid, opts?)` → `{ bytes, contentType?, filename? }` (the bytes-only `download()` discards the headers), backed by a new `HttpClient.getBinaryWithMeta`.
  - `HttpCallOptions.onBehalfOf` — owner-acting pass-through: forwards an end user's Mode-1 customer-session JWT as BOTH `Authorization: Bearer` and `X-Customer-Session-Claim` (no client-side signing; owner-gated agent routes accept only the Mode-1 JWT — the Mode-2 BLS claim is host-minted and rejected there). Distinct from `customerToken` (`X-Customer-Session-Token`), which since 6.4.0 is documented as the customer authorization scope, not a metering label.
  - Error helpers for consumers (e.g. a NestJS exception filter): `SdkHttpError.isRetryable` getter, `isSmartEngineSdkError()` guard, and `toHttpError()` (verbatim mapping with a sub-400 → 502 clamp).
- **`EntitiesClient.prepareCreateAgent(req)` + `executeCreateAgent(req)` — payer-funded two-phase agent create.** Agent creation now mirrors the account create-flow: the synchronous `createAgent` 400s on payer-funded chains (e.g. `chain=xrpl uses prepare-create / execute-create`), so consumers drive the host's generic two-phase create (`POST /api/v3/baas/entities/prepare-create` + `/execute-create`, `entityType: 'agent'`). `prepareCreateAgent` returns `PreparedEntityCreation` (the unsigned payer-funding tx the dapp signs + submits) and `executeCreateAgent` returns `EntityCreationResult` after the validator finalizes (relaying the signed funding blob on XRPL/Stellar, threading the Hedera `createTxId` receipt). `prepareCreateAgent` remaps the agent's `primaryChain` onto the generic prepare/execute DTO's `chain` key. (#117)

## 3.8.0 — 2026-06-17

### Added

- **`BaasClient` now exposes `transactions` (prepare/execute), validator-routed.** The host-routed `BaasClient` gains a `transactions` arm (`TransactionsClient`) so showcase/CLI consumers can drive the sovereignty prepare/execute flow without constructing a separate `SmartEngineClient`. It targets the validator's `/api/transactions/*` routes — a sibling of the `/host` BaaS surface, rooted at the raw gateway/ingress origin (`hostUrl`), NOT under the `pathPrefix`. The prepare endpoints accept an explicit `payerAccountId` (session-less payer) OR carry the web3-auth session token for the JWT-wallet payer path; the token is kept in lock-step with the main client's via `authenticate` / `reauthenticate` / `logout`.

## 3.7.0 — 2026-06-16

### Added — publish Build-Attestation and the anchor-threading fields

Publish-carry bump. The SDK source had drifted past the published 3.6.1 without a version bump, leaving four landed changes unpublished: the Build-Attestation primitive (6cc78a07), the `BaasDeployRequest.attestation` wire field (2e6bc2ab), optional `ruleRef`/`entityType` (d4175f2e), and chain-bitcoin anchor threading (8d467141). The clean deploy CLI (#1126) signs a Build Attestation and sends it via `BaasDeployRequest.attestation`, so it needs those SDK exports on the registry. 3.6.1 → 3.7.0 (additive = minor); the CLI's SDK dependency moved to `^3.7.0` so an external install always resolves the attestation-capable SDK.

## 3.6.1 — 2026-06-15

### Fixed

- **`SmartGatewayClient.getReadiness()` no longer throws when the gateway is not ready.** The gateway's `/api/v3/ready` now returns **HTTP 503** (was 200) when not ready — honest readiness so load balancers / k8s probes can drain a degraded origin. `getReadiness()` now unwraps that 503: the gateway's global exception filter wraps the body as `{statusCode,error,message,context:{status:'not_ready',...}}` (payload under `context`; a non-filtered server's flat top-level body is also accepted), and the method still **resolves** to a `GatewayReadinessResponse` (`{status:'ready'|'not_ready'}`) in both the ready (200) and not-ready (503) cases. Return shape unchanged; genuine errors (non-readiness 503s, 5xx, network) still throw. (#1114)

## 3.5.0 — 2026-06-01

**100% prod-ready arc.** Final cleanup pass after the 3.4.x publication. Closes every finding from two rounds of adversarial verification: legacy/dead-API code removed, BaaS dual-transport collapsed onto the shared HttpClient, path-traversal exposure closed via `encodePathParam` repo-wide, typed signer surfaces narrowed off `any`, test coverage taken from 62% → 90% statements / 49% → 81% branches / 64% → 85% functions / 63% → 91% lines, README + compodoc shipped, and one phantom test spec (615 lines that never imported the class it claimed to test) replaced with 43 real tests.

Shipped as 4 parallel PRs (#1032 docs/compodoc, #1033 legacy cleanup, #1034 arch+security, #1036 test coverage).

### Breaking changes (typed-surface only; runtime preserved)

- **`BaasClientConfig.http` removed.** Field was typed as `ResilientHttpConfig` and accepted "for backward compatibility / future resilience layer integration" but was never wired at runtime — public users passing it got silently ignored. Removing the inert type closes the discrepancy. Callers passing `new BaasClient({ http: {} })` now get a TS2353 excess-property error; runtime behaviour unchanged. (#1033)
- **`SmartEngineClientConfig.http` removed.** Same pattern as above. (#1033)
- **`TokensClient.getDetails(chain, tokenId)` deleted.** Method was shadowed at the Nest route layer (`ValidatorController` won registration order vs `TokenMigrationController`) and never reached its declared handler. JSDoc itself said "prefer `client.getTokenInfo(...)` instead". Shipping a public method for a 404-shadowed endpoint = dead-on-arrival. (#1033)
- **`HederaTransactionsClient` constructor now requires `tssHttp`.** The previous `tssHttp ?? http` fallback was dead in this repo (single call site always passed both args). External callers passing one arg now get "Expected 2 arguments, but got 1". (#1033)
- **`signChallengeXRPL(wallet: XrplSigner)` narrowed**, was `wallet: any`. `XrplSigner.sign(message: string): string | { signedTransaction: string }`. Callers passing a hand-rolled `{ sign: () => Uint8Array }` no longer compile — the runtime never handled Uint8Array. (#1034)
- **`signChallengeHedera(privateKey: HederaSigner)` narrowed**, was `privateKey: any`. Structural type matches `@hashgraph/sdk`'s PrivateKey shape without importing the peer dep. (#1034)

### Security

- **Path-traversal exposure closed**: replaced raw `encodeURIComponent` with the `%2F`-stripping `encodePathParam` helper across ~135 sites (collection IDs, document IDs, agent IDs, function IDs, DAO IDs, etc.). Regression test in `__tests__/path-traversal.spec.ts` feeds `'../../../etc/passwd'` style inputs and asserts the resulting URL stays sandboxed. (#1034)

### Refactor

- **`BaasClient` dual transport collapsed.** Top-level `BaasClient.<method>` calls (e.g., `authenticate`) used a private fetch-based `request/get/post` infra; sub-clients used the shared `HttpClient`. Now everything goes through the shared client. Auth token state moved from `this.authToken` onto `HttpClient` via the new typed `setAuthToken/getAuthToken` accessors. Every `(this.http as any).authToken` peek across the SDK removed. (#1034)
- **`connectToCluster` bootstrap-resolution DRY'd.** ~25 lines of identical scaffolding between `SmartEngineClient.connectToCluster` and `BaasClient.connectToCluster` extracted to `discovery/resolve-cluster.ts`'s `resolveClusterEndpoint`. (#1034)
- `StorageClient.getFile` `@deprecated` JSDoc now pins removal to **4.0.0**. Forwards to `download(cid)` until then. (#1033)
- `SupportedChain` alias inlined to direct `ChainType` use. (#1033)
- `ValidatorDiscoveryClient` JSDoc no longer calls itself "legacy" — it's the intentional HCS-direct discovery fallback. (#1033)

### Tests

- **Phantom `validator-auth.spec.ts` replaced**: the original 615 lines tested `new URL(...)` against the stdlib and never imported `ValidatorAuthClient`. Production was at 2.97% line coverage. Replaced with 43 real tests covering every public method + `ValidatorAuthError` + every failure path. (#1036)
- Added specs for `chains/{hedera,polkadot,solana,xrpl}.ts` (0% → 100%), `discovery/{mirror-node,validator-discovery}` (11-18% → 95%+), `nestjs/smart-engine.service` (30% → 100%), `_vendor/{retry,rate-limiter,errors-and-breaker,network-types}` (9-55% → 100%), gateway sub-clients, residual `client.ts` token/cluster/monitoring branches. (#1036)
- `jest.config.js` `**/index.ts` blanket exclusion replaced with a narrow allowlist of true barrel files — sub-client implementations in `index.ts` now count toward coverage. (#1036)

**Coverage gate (CLAUDE.md ≥80% bar):**

| Axis | 3.4.1 | 3.5.0 |
|------|-------|-------|
| Statements | 62.17% | **89.95%** |
| Branches | 49.51% | **81.33%** |
| Functions | 64.12% | **85.10%** |
| Lines | 62.63% | **91.39%** |

Test count: 637 → **1037** (+400).

### Docs

- **README rewritten** for the 3.4.x+ surface: leads with `BaasClient.connectToCluster({ network: 'testnet' })` as the canonical entrypoint, sub-clients table enumerates every public field on `SmartEngineClient` + `BaasClient`, bundle section refreshed off the actual 3.5.0 figures. Stale 3.0.0 / 3.2.0 references gone. (#1032)
- **Compodoc wired**: `yarn workspace @hsuite/smart-engines-sdk docs` now generates `dist/docs/` against the repo-root `.compodocrc.json`. (#1032)
- **Stale `hsuite-smart-engines-sdk-3.2.0.tgz` deleted** from the package source. (#1032)
- Package-level `@example` in `src/index.ts` rewritten to lead with `connectToCluster`. (#1032)
- `@example` blocks added to `BridgeClient` / `EnvelopeClient` / `OperatorClient` / `ResourcesClient` / `HederaTssClient` / `DiscoveryClient`. (#1032)

### Verified

- `yarn jest`: 67 suites, 1037 passing + 1 skipped = 1038 total, 0 failures.
- `yarn build:tsc`: clean.
- `yarn build`: all 6 subpath dist artifacts produced cleanly.
- `yarn workspace @hsuite/smart-engines-sdk docs`: compodoc generates `dist/docs/` cleanly.

## 3.4.1 — 2026-06-01

**Patch release** restoring 3 subpath exports that the 3.4.0 publish dropped despite being present in source:

- `./pqc-verify-envelope` (landed in #981, 2026-05-29 — before the 3.4.0 release)
- `./ipfs-access-key` (landed in #987, 2026-05-30)
- `./k8s-secret-reader` (landed in #985, 2026-05-30)

Root cause: SDK build config (`tsup.config.mjs` `entry` map + `package.json#scripts.build:dts` invocations) was never updated when the 3 new subpath dirs were added — only the root + `nestjs` + `pqc-verify` entry points were declared in the release commit (`ed4b9bf8`). 3.4.0 shipped with the missing dist artifacts, leaving consumers importing those subpaths with module-not-found errors.

The build config and `package.json#exports` have since been updated on `develop` (merge `d76ef2e4`), but no version bump was published. This 3.4.1 release publishes those fixes.

No source API change. No breaking change. Safe to bump from 3.4.0 to 3.4.1.

### Regression guard

`libs/smartengine-sdk/src/__tests__/dist-exports.spec.ts` asserts that every subpath in `package.json#exports` resolves to files that exist in `dist/` after `yarn build`. Reproduces the 3.4.0 failure mode when a `dist/<name>/` subdir is missing.

## 3.4.0 — 2026-05-31

**Production-readiness arc.** Closes the audit gap identified after 3.3.0 shipped: ~55 of ~110 third-party-facing server endpoints were wrapped; this release brings coverage to ~95/110 (the remaining ~15 are intentionally internal — operator / DKG ceremony / harbor admin). SDK test suite grew from 477 → 637 (+160 tests).

Shipped as 7 stacked PRs (#995, #997, #998, #999, #1000, #1002, #1003).

### Breaking changes

- **`baas.messaging.setPresence` / `getPresence` / `removePresence`** — signatures now take `channel` as the first argument. The previous appId-scoped paths returned 404 server-side, so behaviourally the breakage is `404 → works`.
- **`baas.storage.listFiles`** — pagination option renamed `skip` → `offset` to match the server query-string. The previous `skip` value was silently dropped.
- **`baas.storage.getFile`** — `@deprecated`, now forwards to `download`. The previous endpoint never resolved server-side.
- **Gateway response types realigned** — `HealthResponse`, `GatewayStatusResponse`, `GatewayReadinessResponse`, `GatewayMetricsResponse`, `HostInfo`, `RegisterHostRequest`, `VerificationMethod`, `DnsRecordType` all rewritten to match what `apps/smart-gateway` actually returns. Compile-time errors surface latent caller bugs; previous types were fabricated.
- **`baas.agents.fund` / `trade` / `withdraw`** — return type changed from `{ success; txId? }` to `PreparedTransactionResponse` envelope (sovereignty flow). Callers can now reach the prepared transaction bytes; previously unreachable through the declared type.
- **`tss.signMPC`** — `chain` parameter restricted to literal `'hedera'`. Other chains were never wired server-side and silently 404'd.
- **`Rules.module.agent`** — removed. Server retired `AgentOrganism` in Arc 6 Phase 6.5; SDK module factory was silently no-op'ing on the cluster.

### Fixed — BaaS endpoint paths that 404'd in production (PR #995)

`apps/smart-host` requires `:appId` in every BaaS path; the SDK omitted it on 9 methods plus used the wrong verb on one:

- `storage.download` / `getMetadata` / `exists` / `getUsage` — all now correctly prefixed with `:appId`.
- `functions.invoke` / `get` / `update` / `delete` / `getLogs` / `list` — same fix.
- `messaging.setPresence` / `getPresence` / `removePresence` — channel-scoped per server.
- `agents.updateRules` — verb corrected `PUT` → `PATCH`.
- `db.listCollections` — path corrected to `/collections`.
- `db.createCollection` / `dropCollection` — new wrappers for endpoints that lacked SDK methods.

Regression spec `libs/smartengine-sdk/src/baas/endpoint-paths.spec.ts` pins every fixed URL + verb.

Plumbing: `HttpClient` gained a `patch` method (previously missing) to unblock all PATCH-based endpoints across the SDK.

### Added — `client.dao` (PR #998)

Full typed wrapper over the ~30 endpoints registered by `apps/smart-validator/src/dao/dao.controller.ts`. The largest single missing surface from the audit.

- **Lifecycle** — `create`, `list`, `get`, `getWithProposals`.
- **Proposals** — `listProposals`, `createProposal`, `prepareProposal`, `signProposal`, `prepareVote`, `submitVote`, `vote`, `execute`, plus `getVotes` / `getResults` / `getVoteCounts` / `getVoterRecord`.
- **Treasury** — `getTreasury`, `getTreasuryHistory`.
- **Members** — `listMembers`, `addMember`, `removeMember`, `claimMemberNft`, `getMemberNft`.
- **`client.dao.dashboard`** — per-user rollups (`getStats`, `getActiveProposals`, `getVoteHistory`, `getActivity`, `getPendingActions`, `getImpact`).

### Added — Hedera TSS + Async TSS + NFT prepare paths (PR #999)

- **`client.hedera.tss`** — six TSS-signed immediate-execute endpoints (cluster signs + submits in one round-trip): `createAccount`, `updateMemo`, `createTopic`, `submitMessage`, `createToken`, `mintToken`.
- **`client.tss`** — async workflow: `createEntityAsync`, ~~`reshareClusterAsync`~~ (removed in 5.2.0), `getJob(jobId)`, `signForApp(appId, ...)`.
- **`client.transactions`** — four NFT prepare paths for Polkadot pallet-nfts + Solana Metaplex: `prepareNftCollectionCreate`, `prepareNftSetMetadata`, `prepareNftCollectionSetMetadata`, `prepareNftCollectionLock`.
- **`client.xrpl`** — `prepareAccountSetup` (two-step `SignerListSet` + `DisableMaster` for XRPL sovereignty).

### Added — Six new sub-clients (PR #1000)

- **`client.bridge`** — 8 cross-chain bridge endpoints (`create`, `list`, `get`, `port`, `return`, `getStatus`, `getSupply`, `listClaims`).
- **`client.resources`** — quota / consumption / nodes: 8 endpoints (`getSummary`, `getConsumption`, `getHistory`, `getLedger`, `getStatus`, `getBreakdown`, `listNodes`, `getNode`).
- **`client.envelope`** — `encrypt` / `decrypt` (third-party crypto utility).
- **`client.tokens`** — token-migration: `migrate`, `listMigrations`, `getMigration`, `getMigrationsForToken`, `getDetails`. The last collides with `client.getTokenInfo` at Nest route registration; documented as a known limitation.
- **`client.operator`** — read-only operator-funding: `getBalance`, `getTopup`.
- **`client.subscription`** — extended with `downgrade`, `upgrade`, `cancelTierChange`, `getActiveFor`, `getUsage`, `getUsageStatus`, `getBilling`, `mintCustomer`.

### Added — Rules-engine parity (PR #1002)

- **8 canonical server-mirror templates** field-for-field with `libs/validator-rules-builder/src/templates`: `Rules.template.utilityToken`, `daoGovernedToken`, `soulboundNft`, `subscriptionNft`, `membershipNft`, `managedAccount`, `systemTopic`, `appTopic`. Sits alongside the existing higher-altitude composers (`fairLaunch`, `tradingAgent`, etc.) so devs pick altitude.
- **12 fluent builder methods** on `BaseRuleBuilder`: `withLimits`, `withRateLimiter`, `withSnapshot`, `withCooldown`, `withApprovalThreshold` (canonical atoms now first-class on token/account/topic); molecule shortcuts `withSwap`, `withAirdrop`, `withVesting`, `withStreaming`, `withGovernanceModule`; plus Phase-6.6 AI atoms `withMaxTradesPerWindow` and `withRequireStructuredOutput` on `AgentRulesBuilder`.
- **`baas.rules.simulate(req)`** confirmed present and tested against `POST /api/rules/simulate`.

**Server-side follow-up required**: the 12 new molecule module types (`swap`, `airdrop`, `vesting`, `streaming`, `governance`, `max-trades-per-window`, `require-structured-output`, etc.) will **fail-closed** at the cluster until `libs/rules-engine/src/modules/builtin-modules.ts` registers them. Today only `launchpad / dex / dao / staking-pool` are registered.

### Added — Discovery extras + Deployment KEK / webhook / metrics (PR #1003)

- **`client.discovery`** — `listAllClusters` (full set, not just active), `getClusterByNode(nodeId)`, plus `client.discovery.platformImages` sub-namespace: `list`, `listEnvelopes`, `get`, `getVersion`, `verify`.
- **`baas.deployment`** — `setWebhook(appId, url)` (returns per-tenant HMAC secret), `getMetrics(appId)` (Prometheus text/plain — `HttpClient.getText` plumbing added to bypass JSON.parse), `rotateKek(appId)`, `revokeKek(appId, version)` (ADR-011 Phase 6 KEK rotation surface).

### Verified

- `yarn jest`: 637 passing + 1 skipped = 638 total (was 477 in 3.3.0).
- `yarn build:tsc`: clean (`tsc --skipLibCheck --declaration`).

## 3.3.0 — 2026-05-29

**Smart-App Rules SDK arc.** Adds a complete canonical-rule authoring surface
so smart-app devs can compose, locally validate, publish, and reference
canonical `ValidatorRules` from a single SDK — the prerequisite for entity
creation since the cluster requires a `ruleRef` on every binding.

No breaking changes. Bundle grows ~260 KB (vendored Zod schemas mirroring
`libs/shared/src/dto/validator.dto.ts` + builder/composer/template factories).
`sideEffects: false` added — tree-shaking excludes any sub-namespace a
consumer doesn't import.

### Added — `Rules` namespace (`import { Rules } from '@hsuite/smart-engines-sdk'`)

- **Fluent builders** — `Rules.forToken()` / `.forAccount()` / `.forTopic()` /
  `.forAgent()` with shared `withSecurity` / `withController` / `withOperations` /
  `withTokenGates` / `withGovernance` / `withInvariants` / `withMetadata` /
  `addModule` / `build()` flow. Throws `RuleBuildError` with dot-path messages
  on schema failure.
- **Composer factories** — 100% coverage of `libs/rules-engine`:
  - `Rules.atom.*` — 17 atoms (timeRange, limits, permissionList, rateLimiter,
    cooldown, approvalThreshold, approvedPairs, tradeLimit, snapshot,
    cronSchedule, countApproval, externalEvidence, fieldValues,
    registryReference, schemaValidation, stopLoss, workflowState)
  - `Rules.molecule.*` — 6 molecules (tokenGate, swap, airdrop, vesting,
    streaming, governance)
  - `Rules.module.*` — 5 organism wrappers as canonical `ModuleEntry`
    (launchpad, stakingPool, dao, dex, agent)
- **Templates** — 8 one-call factories producing schema-conformant rules:
  `Rules.template.fairLaunch`, `tieredIDO`, `simpleStaking`, `tokenDAO`,
  `multisigDAO`, `simpleAMM`, `vestingSchedule`, `tradingAgent`.
- **Preflight** — `Rules.validate(rule)` routes by `type` discriminator for
  path-rich error messages (`operations.mint.enabled: Expected boolean`).
- **Vendored Zod schemas** — `ValidatorRulesSchema`, `TokenValidatorRulesSchema`,
  `AccountValidatorRulesSchema`, `TopicValidatorRulesSchema`,
  `AgentValidatorRulesSchema`, plus every sub-schema (governance, invariants,
  modules, payloadSchema, soulbound, etc.). Mirrors `libs/shared` verbatim;
  re-vendor on schema upgrades.

### Added — `BaasClient` sub-clients

- **`baas.rules: RulesClient`** — publish a canonical rule and get back a
  cluster-stamped `ruleRef`:
  - `publish(rule)` → `{ ruleRef: { chain, topicId, consensusTimestamp } }`
  - `get(consensusTimestamp)` → `PublishedRule`
  - `listByOwner({ type? })` → `{ rules, count }`
  - `simulate({ ruleRef? | rule?, action, context })` → `ValidationResult`
  - `getVersionHistory(consensusTimestamp)` → `{ versions }`
  - `deprecate(consensusTimestamp)` → `{ deprecated: true, consensusTimestamp }`
- **`baas.entities: EntitiesClient`** — ruleRef-gated entity creation. The
  cluster's mandatory creation gate enforces that every entity references a
  published rule:
  - `createToken({ chain, name, symbol, decimals?, initialSupply?, ruleRef, memo? })`
  - `createAccount({ chain, ruleRef, initialBalance?, publicKey? })`
  - `createTopic({ chain, ruleRef, memo? })`
  - `createAgent({ primaryChain, ruleRef, name, agentType? })`
  - `launchpad({ chain, name, symbol, launchpad })` — mega-helper: publishes
    a token rule with a `launchpad` ModuleEntry attached + creates the token
    entity referencing the new ruleRef. Single round-trip.
  - `get(entityId)` → `EntityInfo`
  - `listByOwner({ type? })` → `{ entities, count }`

### Added — NestJS providers (subpath: `@hsuite/smart-engines-sdk/nestjs`)

`SmartEngineModule.forRoot` / `.forRootAsync` now expose three injection
tokens consumers can `@Inject(...)`:

- `SDK_BAAS_CLIENT` — shared `BaasClient` instance
- `RULES_CLIENT` — `RulesClient` (alias for `baasClient.rules`)
- `ENTITIES_CLIENT` — `EntitiesClient` (alias for `baasClient.entities`)

```ts
class MyService {
  constructor(
    @Inject(RULES_CLIENT)    private readonly rules:    RulesClient,
    @Inject(ENTITIES_CLIENT) private readonly entities: EntitiesClient,
  ) {}
}
```

`SmartEngineService` provider unchanged.

### Documentation

- Package README extended with a `## Rules — author + publish canonical
  ValidatorRules` section, two new rows in the "Available clients" table,
  and a pointer to the full developer guide.
- Complete developer guide added at
  `docs/internal/developers/libs/rules-engine.md#smart-app-sdk-guide` — covers
  authoring paths, composer reference tables, publish + entity-create
  end-to-end flow, `baas.rules.*` + `baas.entities.*` reference, and 7
  common pitfalls.

### Cluster-side (FYI for SDK consumers — not in this package)

This SDK release lights up server-side endpoints already shipped on the
`smart-engines-multichain` cluster:

- Smart-host: `POST /api/rules/publish`, `GET /api/rules/:ts`,
  `GET /api/rules` (listByOwner), `POST /api/rules/simulate`,
  `GET /api/rules/:ts/versions`, `POST /api/rules/:ts/deprecate`,
  `POST /api/entities/{createToken|createAccount|createTopic|createAgent|launchpad}`,
  `GET /api/entities/:id`, `GET /api/entities` (PRs #949, #950).
- Smart-validator: `POST /api/v3/entities/create` wrapping
  `EntityCreationOrchestrator.createMultisigEntity` for cluster-internal
  service-mesh calls from smart-host (PR #956).

### Build

- tsup CJS bundle: `dist/index.js` 396 KB, `dist/nestjs/index.js` 326 KB,
  `dist/pqc-verify/index.js` 8 KB.
- `dts-bundle-generator` outputs preserved for all three subpaths.
- `sideEffects: false` declared so consumers can tree-shake unused
  sub-namespaces (`baas.rules` excludable if you don't use canonical rules,
  etc.).

## 3.2.1 — 2026-05-16

**Documentation-only fix.** No runtime behavior change.

### Fixed
- `DeploymentClient.init` JSDoc and `BaasInitResponse` type docs no longer
  describe the registry as DOCR. The runtime arc routes pushes through a
  per-tenant Harbor project (`hsuite-customers-<appId>`), and the credentials
  returned in `registry.{server, username, password, repository}` are an
  ephemeral project-scoped robot account whose secret is single-use and not
  persisted server-side. README example aligned with the same wording.

## 3.2.0 — undocumented (PR-A → PR-I, 2026-05-12 → 2026-05-13)

Smart-app runtime arc surface — added `DeploymentClient` with the full
init → push → uploadFrontend → deploy flow plus lifecycle (suspend/
resume/update/rollback/delete) and ownership-gated metrics. See PRs
#516 (PR-A), #523 (PR-C), and #530 (PR-H) for details.

## 3.1.0 — undocumented (2026-05-12)

Added `CustomerSessionClient` for NFT-gated customer→smart-app auth (PR #496).

## 3.0.2 — undocumented (2026-05-12)

Dropped `nestjs` re-exports from the root entry to keep tree-shaking clean for
non-Nest consumers (PR #494).

## 3.0.0 — 2026-05-11

**First publishable release.**

### Breaking changes
- The SDK is now self-contained: workspace-only dependencies (`@hsuite/smart-engines-multi-chain-core`, `@hsuite/smart-engines-shared`) have been vendored under `src/_vendor/` and are no longer required as runtime dependencies. Consumers no longer need access to the HSuite monorepo to install the SDK.
- Heavy peer-style dependencies (`@hashgraph/sdk`, `xrpl`, `@nestjs/common`, `@nestjs/core`, `zod`) are now declared as `peerDependencies` — apps must install them directly.
- Build pipeline switched from raw `tsc` to `tsup` (CJS bundle) + `dts-bundle-generator` (single-file `.d.ts`).

### Bundle
- `dist/index.js` — 134 KB CJS bundle of the validator / gateway / BaaS clients + auth/discovery + chain helpers + resilience primitives.
- `dist/index.d.ts` — single-file TypeScript declarations.
- `dist/nestjs/index.{js,d.ts}` — NestJS module integration.

### Publishing
```
npm publish --access=public
```

## 3.0.1 — 2026-05-11

**Documentation-only fix.** No code changes.

### Fixed
- README rewritten end-to-end. The 3.0.0 README documented a non-existent
  `apiKey` connect flow and Hedera-centric examples that contradicted the
  XRPL-canonical architecture. The new README shows the actual Web3
  challenge-response auth pattern, all BaaS sub-clients, the NestJS
  integration, resilience primitives, and accurate peer-dep guidance.
