From e3df8226670009cf42468080440c7d65bf6dac74 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:56:52 -0400 Subject: [PATCH 1/7] feat(compliance): add lifecycle status types --- src/types/compliance-lifecycle.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/types/compliance-lifecycle.ts diff --git a/src/types/compliance-lifecycle.ts b/src/types/compliance-lifecycle.ts new file mode 100644 index 0000000..3937e90 --- /dev/null +++ b/src/types/compliance-lifecycle.ts @@ -0,0 +1,33 @@ +export type ComplianceLifecycleStatus = + | 'approved' + | 'not-approved' + | 'unavailable'; + +export type ComplianceLifecycleCode = + | 'WHITELIST_APPROVED' + | 'WHITELIST_NOT_APPROVED' + | 'READ_UNAVAILABLE'; + +export interface ComplianceStatusSnapshot { + address: string; + status: ComplianceLifecycleStatus; + code: ComplianceLifecycleCode; + /** True/false only when the current protocol whitelist state was observed. */ + eligible: boolean | null; + observedAt: string; +} + +export interface ComplianceLifecycleDiagnostic { + module: 'available'; + read: { + supported: true; + contractMethod: 'is_whitelisted'; + }; + adminUpdate: { + supported: false; + requiresSigner: true; + reasonCode: 'CONTRACT_WRITE_METHOD_UNAVAILABLE'; + }; + signerConfigured: boolean; + legalStatus: 'not-assessed'; +} From e751b9962d18fd62a143382f4948a8af2da650b2 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:57:00 -0400 Subject: [PATCH 2/7] feat(compliance): add typed lifecycle errors --- src/errors/compliance.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/errors/compliance.ts diff --git a/src/errors/compliance.ts b/src/errors/compliance.ts new file mode 100644 index 0000000..916d170 --- /dev/null +++ b/src/errors/compliance.ts @@ -0,0 +1,23 @@ +export type ComplianceLifecycleErrorCode = + | 'INVALID_ADDRESS' + | 'SIGNER_REQUIRED' + | 'ADMIN_UPDATE_UNSUPPORTED'; + +export type ComplianceLifecycleOperation = 'read' | 'admin-update'; + +export class ComplianceLifecycleError extends Error { + public readonly code: ComplianceLifecycleErrorCode; + public readonly operation: ComplianceLifecycleOperation; + + constructor( + code: ComplianceLifecycleErrorCode, + operation: ComplianceLifecycleOperation, + message: string, + ) { + super(message); + this.name = 'ComplianceLifecycleError'; + this.code = code; + this.operation = operation; + Object.setPrototypeOf(this, ComplianceLifecycleError.prototype); + } +} From c89c2940b076a319dc12491fa5752bda86a2e016 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:57:59 -0400 Subject: [PATCH 3/7] feat(compliance): add typed lifecycle reads and admin gate --- src/compliance.ts | 183 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 165 insertions(+), 18 deletions(-) diff --git a/src/compliance.ts b/src/compliance.ts index 04e0be7..d2534c9 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,37 +1,184 @@ -import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { Contract, nativeToScVal, rpc, StrKey } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; +import { ComplianceLifecycleError } from './errors/compliance'; +import type { + ComplianceLifecycleDiagnostic, + ComplianceStatusSnapshot, +} from './types/compliance-lifecycle'; import { parseSorobanResult } from './utils/xdr-parser'; export class ComplianceModule { -private client: AegisClient; + private client: AegisClient; -constructor(client: AegisClient) { + constructor(client: AegisClient) { this.client = client; } /** - * Queries the contract to check if a user is KYC-approved (whitelisted). - * @param address The Stellar public key to check. - * @returns boolean indicating whitelist status. + * Reads the contract's boolean whitelist value. + * + * `null` means the simulation did not produce a usable boolean. It is kept + * distinct from `false` so lifecycle callers never interpret an unavailable + * read as a confirmed rejection. */ - public async checkWhitelist(address: string): Promise { - const contract = new Contract(this.client.contractId); + private async readWhitelist(address: string): Promise { + this.assertValidAddress(address, 'read'); - // Create the invocation for the read-only 'is_whitelisted' function - const call = contract.call('is_whitelisted', nativeToScVal(address, { type: 'address' })); + const contract = new Contract(this.client.contractId); + const call = contract.call( + 'is_whitelisted', + nativeToScVal(address, { type: 'address' }), + ); const result = await this.client.runNetworkOperation(() => this.client.rpcServer.simulateTransaction({ - // Dummy transaction for simulation purposes - transaction: call as any, // Cast required depending on SDK version wrapper - } as any) + // Preserve the existing SDK adapter for the read-only invocation. + transaction: call as any, + } as any), ); - // rpc.Api.isSimulationSuccess acts as a type guard here - // Check for success AND ensure the result object actually exists - if (rpc.Api.isSimulationSuccess(result) && result.result) { - return parseSorobanResult(result.result.retval as any) as boolean; + if (!rpc.Api.isSimulationSuccess(result) || !result.result) { + return null; + } + + const decoded: unknown = parseSorobanResult(result.result.retval as any); + return typeof decoded === 'boolean' ? decoded : null; + } + + /** + * Compatibility boolean query for existing SDK consumers. + * + * A confirmed `true` remains true. A confirmed `false` or unavailable + * simulation remains false for backwards compatibility. Prefer + * `getComplianceStatus()` when callers must distinguish those cases. + */ + public async checkWhitelist(address: string): Promise { + return (await this.readWhitelist(address)) === true; + } + + /** + * Returns the protocol whitelist state without overstating an unavailable + * read as a compliance decision. + */ + public async getComplianceStatus( + address: string, + ): Promise { + this.assertValidAddress(address, 'read'); + const observedAt = new Date().toISOString(); + + try { + const whitelisted = await this.readWhitelist(address); + + if (whitelisted === true) { + return { + address, + status: 'approved', + code: 'WHITELIST_APPROVED', + eligible: true, + observedAt, + }; + } + + if (whitelisted === false) { + return { + address, + status: 'not-approved', + code: 'WHITELIST_NOT_APPROVED', + eligible: false, + observedAt, + }; + } + + return { + address, + status: 'unavailable', + code: 'READ_UNAVAILABLE', + eligible: null, + observedAt, + }; + } catch (error) { + if ( + error instanceof ComplianceLifecycleError && + error.code === 'INVALID_ADDRESS' + ) { + throw error; + } + + return { + address, + status: 'unavailable', + code: 'READ_UNAVAILABLE', + eligible: null, + observedAt, + }; + } + } + + /** + * Describes the lifecycle capabilities present in this SDK build. + * + * This is a capability/configuration diagnostic only. It does not query an + * investor's whitelist state and it is not legal/KYC advice. + */ + public diagnoseLifecycle(): ComplianceLifecycleDiagnostic { + return { + module: 'available', + read: { + supported: true, + contractMethod: 'is_whitelisted', + }, + adminUpdate: { + supported: false, + requiresSigner: true, + reasonCode: 'CONTRACT_WRITE_METHOD_UNAVAILABLE', + }, + signerConfigured: this.client.keypair !== undefined, + legalStatus: 'not-assessed', + }; + } + + /** + * Explicitly gated admin mutation path. + * + * The current SDK source has no verified contract whitelist-write method. + * Requiring a signer first preserves the mutating-call security boundary, + * then the method fails with a typed unsupported-operation error instead of + * inventing a Soroban ABI call that could target the wrong function. + */ + public async updateWhitelist( + address: string, + _approved: boolean, + ): Promise { + this.assertValidAddress(address, 'admin-update'); + + if (!this.client.keypair) { + throw new ComplianceLifecycleError( + 'SIGNER_REQUIRED', + 'admin-update', + 'A signer is required before attempting a compliance admin update.', + ); + } + + throw new ComplianceLifecycleError( + 'ADMIN_UPDATE_UNSUPPORTED', + 'admin-update', + 'This SDK build does not expose a verified contract whitelist-write method.', + ); + } + + private assertValidAddress( + address: string, + operation: 'read' | 'admin-update', + ): void { + if ( + typeof address !== 'string' || + !StrKey.isValidEd25519PublicKey(address) + ) { + throw new ComplianceLifecycleError( + 'INVALID_ADDRESS', + operation, + 'Compliance operations require a valid Stellar account address.', + ); } - return false; } } From a6483048ef3fb5f1612e6c7fad339fd0103f9e47 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:58:11 -0400 Subject: [PATCH 4/7] feat(compliance): export lifecycle API --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index b498dcf..2dea47d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ export type { export * from './types/client-factory'; export * from './errors/client-factory'; export { ComplianceModule } from './compliance'; +export * from './types/compliance-lifecycle'; +export * from './errors/compliance'; export { AssetModule } from './asset'; export { InvestorModule } from './investor/portfolio'; export { RoleModule } from './role'; From 8d3bcd127207599426d7464ae750326b0936680a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:58:44 -0400 Subject: [PATCH 5/7] test(compliance): cover lifecycle read and admin gates --- tests/compliance-lifecycle.test.ts | 151 +++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/compliance-lifecycle.test.ts diff --git a/tests/compliance-lifecycle.test.ts b/tests/compliance-lifecycle.test.ts new file mode 100644 index 0000000..a791932 --- /dev/null +++ b/tests/compliance-lifecycle.test.ts @@ -0,0 +1,151 @@ +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { + AegisClient, + ComplianceLifecycleError, +} from '../src'; + +const BASE_CONFIG = { + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: 'C_COMPLIANCE_LIFECYCLE_TEST', +}; + +function makeClient(keypair?: Keypair): AegisClient { + return new AegisClient({ + ...BASE_CONFIG, + ...(keypair ? { keypair } : {}), + }); +} + +describe('Compliance lifecycle', () => { + it('rejects invalid Stellar addresses with a typed error before RPC work', async () => { + const client = makeClient(); + + await expect( + client.compliance.checkWhitelist('not-a-stellar-address'), + ).rejects.toMatchObject({ + name: 'ComplianceLifecycleError', + code: 'INVALID_ADDRESS', + operation: 'read', + }); + }); + + it('returns an approved status for a confirmed whitelist read', async () => { + const client = makeClient(); + const address = Keypair.random().publicKey(); + jest + .spyOn(client.compliance as any, 'readWhitelist') + .mockResolvedValue(true); + + await expect( + client.compliance.getComplianceStatus(address), + ).resolves.toMatchObject({ + address, + status: 'approved', + code: 'WHITELIST_APPROVED', + eligible: true, + }); + }); + + it('distinguishes a confirmed negative read from an unavailable read', async () => { + const client = makeClient(); + const address = Keypair.random().publicKey(); + const read = jest.spyOn(client.compliance as any, 'readWhitelist'); + + read.mockResolvedValueOnce(false); + await expect( + client.compliance.getComplianceStatus(address), + ).resolves.toMatchObject({ + status: 'not-approved', + code: 'WHITELIST_NOT_APPROVED', + eligible: false, + }); + + read.mockResolvedValueOnce(null); + await expect( + client.compliance.getComplianceStatus(address), + ).resolves.toMatchObject({ + status: 'unavailable', + code: 'READ_UNAVAILABLE', + eligible: null, + }); + }); + + it('maps thrown read failures to unavailable without exposing provider details', async () => { + const client = makeClient(); + const address = Keypair.random().publicKey(); + jest + .spyOn(client.compliance as any, 'readWhitelist') + .mockRejectedValue(new Error('opaque-provider-detail')); + + const status = await client.compliance.getComplianceStatus(address); + + expect(status).toMatchObject({ + status: 'unavailable', + code: 'READ_UNAVAILABLE', + eligible: null, + }); + expect(JSON.stringify(status)).not.toContain('opaque-provider-detail'); + }); + + it('requires a signer before the explicitly gated admin update path', async () => { + const client = makeClient(); + const address = Keypair.random().publicKey(); + + await expect( + client.compliance.updateWhitelist(address, true), + ).rejects.toMatchObject({ + name: 'ComplianceLifecycleError', + code: 'SIGNER_REQUIRED', + operation: 'admin-update', + }); + }); + + it('reports the missing contract write ABI after signer validation', async () => { + const client = makeClient(Keypair.random()); + const address = Keypair.random().publicKey(); + + await expect( + client.compliance.updateWhitelist(address, false), + ).rejects.toMatchObject({ + name: 'ComplianceLifecycleError', + code: 'ADMIN_UPDATE_UNSUPPORTED', + operation: 'admin-update', + }); + }); + + it('diagnoses lifecycle capability without serializing signer material', () => { + const signer = Keypair.random(); + const client = makeClient(signer); + const diagnostic = client.compliance.diagnoseLifecycle(); + const serialized = JSON.stringify(diagnostic); + + expect(diagnostic).toEqual({ + module: 'available', + read: { + supported: true, + contractMethod: 'is_whitelisted', + }, + adminUpdate: { + supported: false, + requiresSigner: true, + reasonCode: 'CONTRACT_WRITE_METHOD_UNAVAILABLE', + }, + signerConfigured: true, + legalStatus: 'not-assessed', + }); + expect(serialized).not.toContain(signer.publicKey()); + expect(serialized).not.toContain(signer.secret()); + }); + + it('exports ComplianceLifecycleError for typed caller handling', () => { + const error = new ComplianceLifecycleError( + 'ADMIN_UPDATE_UNSUPPORTED', + 'admin-update', + 'unsupported', + ); + + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('ADMIN_UPDATE_UNSUPPORTED'); + }); +}); From eea1d1b81caec1a05a84d9616a33a1fc4ef71b3f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:59:15 -0400 Subject: [PATCH 6/7] docs(compliance): explain lifecycle and admin gate --- docs/compliance-lifecycle.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/compliance-lifecycle.md diff --git a/docs/compliance-lifecycle.md b/docs/compliance-lifecycle.md new file mode 100644 index 0000000..b61590d --- /dev/null +++ b/docs/compliance-lifecycle.md @@ -0,0 +1,110 @@ +# Compliance Lifecycle Client + +The SDK compliance lifecycle surface separates three questions that should not +be conflated: + +1. What did the deployed contract report for `is_whitelisted`? +2. Was the read unavailable or malformed? +3. Can this SDK build safely submit a whitelist mutation? + +The current contract-facing SDK has a verified read method, +`is_whitelisted`, but it does not expose a verified whitelist-write contract +method. The lifecycle client therefore supports typed reads and **explicitly +gates** admin mutations rather than guessing an ABI entrypoint. + +## Read the current whitelist state + +Use `getComplianceStatus()` when an application needs to distinguish a +confirmed negative result from an unavailable read. + +```ts +const status = await client.compliance.getComplianceStatus(investorAddress); + +switch (status.status) { + case 'approved': + // Contract returned true. + break; + case 'not-approved': + // Contract returned false. + break; + case 'unavailable': + // No reliable boolean was observed. Do not treat this as a denial. + break; +} +``` + +The legacy `checkWhitelist()` method remains boolean-compatible: only a +confirmed `true` returns true. A confirmed false or unusable simulation +returns false. New code that needs failure-state fidelity should prefer +`getComplianceStatus()`. + +Both methods validate the address as a Stellar Ed25519 account before attempting +RPC work. Invalid input throws `ComplianceLifecycleError` with code +`INVALID_ADDRESS`. + +## Admin whitelist updates + +```ts +try { + await client.compliance.updateWhitelist(investorAddress, true); +} catch (error) { + if (error instanceof ComplianceLifecycleError) { + console.log(error.code); + } +} +``` + +The current behavior is deliberately gated: + +- without a configured signer, the method throws `SIGNER_REQUIRED`; +- with a signer, it throws `ADMIN_UPDATE_UNSUPPORTED`. + +That ordering preserves the mutating-call signer boundary while making the +contract limitation explicit. It does **not** manufacture an `add_whitelist`, +`set_whitelist`, or similarly named Soroban call that has not been verified +against the deployed contract. + +Role-aware clients can additionally use +`createComplianceOperatorClient(...).assertWhitelistAccess()` or +`createAdminClient(...).assertWhitelistAccess()` to enforce their declared +SDK capability before entering an admin flow. Those role assertions are +client-side guardrails; the deployed contract remains authoritative for actual +authorization once a real write ABI exists. + +## Capability diagnostic + +`diagnoseLifecycle()` returns a small, serializable capability snapshot: + +```ts +const diagnostic = client.compliance.diagnoseLifecycle(); +``` + +It reports: + +- read support and the verified `is_whitelisted` method; +- that admin mutation is currently unsupported and requires a signer; +- whether a signer is configured as a boolean; +- `legalStatus: "not-assessed"`. + +The diagnostic never includes signer key material. + +## Error model + +`ComplianceLifecycleError.code` is one of: + +| Code | Meaning | +| --- | --- | +| `INVALID_ADDRESS` | The target is not a valid Stellar Ed25519 account address. | +| `SIGNER_REQUIRED` | A mutating compliance flow was attempted without a signer. | +| `ADMIN_UPDATE_UNSUPPORTED` | This SDK build has no verified contract whitelist-write method. | + +Expected RPC/read failures do not leak provider messages through +`getComplianceStatus()`; they resolve to the typed `unavailable` state. + +## Compliance boundary + +Whitelist state is protocol data, not a legal conclusion. A positive contract +read does not by itself prove KYC/AML completion, regulatory eligibility, or +permission to perform a particular RWA transaction. Applications should treat +contract authorization as authoritative for protocol actions and use qualified +compliance processes for legal/regulatory determinations. From 3cbe41fa582da77d4506b1485372425a2373dc17 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 02:59:46 -0400 Subject: [PATCH 7/7] docs(api): document compliance lifecycle surface --- docs/api-reference.md | 98 ++++++++++++++++++++++++++++++------------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..21263fc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -27,48 +27,88 @@ Either `environment` or both `rpcUrl` and `networkPassphrase` must be provided. ## `ComplianceModule` -Whitelist / KYC verification module. Accessed via `client.compliance`. Wraps the contract's `is_whitelisted` read-only function. +Whitelist/compliance lifecycle module. Accessed via `client.compliance`. The +current verified contract read method is `is_whitelisted`. This SDK build does +not expose a verified whitelist-write contract method, so admin mutation is +explicitly gated instead of guessing an ABI entrypoint. + +See [Compliance Lifecycle Client](./compliance-lifecycle.md) for usage and +security/compliance boundaries. ### `checkWhitelist(address: string): Promise` -Queries the contract to check if a user is KYC-approved (whitelisted). +Compatibility boolean query. Returns `true` only when the contract simulation +produces a confirmed boolean true. A confirmed false or unusable simulation +returns false. -**Signature** -```typescript -public async checkWhitelist(address: string): Promise -``` +The address is validated as a Stellar Ed25519 account before RPC work. +Invalid input throws `ComplianceLifecycleError` with +`code: "INVALID_ADDRESS"`. Thrown RPC failures pass through the client's typed +network-failure boundary. -**Parameters** -* `address` (string): The Stellar public key (`G...`) to check. +Prefer `getComplianceStatus()` when an application must distinguish a confirmed +negative result from an unavailable read. -**Returns** -`Promise` — `true` if the simulated call to `is_whitelisted` succeeds and decodes to `true`. Resolves to `false` both when the contract reports the address is not whitelisted, *and* when the simulation does not succeed or returns no result — the current implementation does not distinguish those two cases in its return value. +### `getComplianceStatus(address: string): Promise` -**Errors** -* If `simulateTransaction` itself throws (network failure, malformed request, etc.), the error is logged via `console.error` and then re-thrown as-is. It is the raw error from the underlying `@stellar/stellar-sdk` RPC call — `checkWhitelist` does not wrap it in `PortfolioError` or any other typed error. -* A failed/unsuccessful simulation that does *not* throw is swallowed and reported as `false` (see Returns above), not as an error. +Returns one of three conservative protocol states: -**Example** -```typescript -import { AegisClient } from '@aegis/sdk'; -import { Networks } from '@stellar/stellar-sdk'; +| Status | Code | Meaning | +| --- | --- | --- | +| `approved` | `WHITELIST_APPROVED` | Contract returned true. | +| `not-approved` | `WHITELIST_NOT_APPROVED` | Contract returned false. | +| `unavailable` | `READ_UNAVAILABLE` | No reliable boolean was observed or the read failed. | -const client = new AegisClient({ - rpcUrl: 'https://soroban-testnet.stellar.org', - networkPassphrase: Networks.TESTNET, - contractId: 'C...', // Aegis Protocol Contract ID -}); +`eligible` is true/false only for confirmed contract booleans and null when the +read is unavailable. Provider error details are not copied into the returned +snapshot. -try { - const isWhitelisted = await client.compliance.checkWhitelist('G_USER_PUBLIC_KEY'); - console.log('Is User Whitelisted?', isWhitelisted); -} catch (error) { - // Raised on RPC/network failure during simulation — not a PortfolioError. - console.error('Whitelist check failed:', error); +### `diagnoseLifecycle(): ComplianceLifecycleDiagnostic` + +Returns a serializable capability snapshot describing the verified read method, +whether a signer is configured, and the admin-write capability state. The +current admin state is: + +```ts +{ + supported: false, + requiresSigner: true, + reasonCode: 'CONTRACT_WRITE_METHOD_UNAVAILABLE' } ``` -> **Open note:** `checkWhitelist` passes the raw invocation object returned by `contract.call(...)` directly as the `transaction` field to `simulateTransaction` (cast through `as any`), rather than assembling a full `Transaction` via `TransactionBuilder` the way `AssetModule.mint`/`transfer` do. The source itself flags this with a comment ("Cast required depending on SDK version wrapper"), so the exact request shape expected by `simulateTransaction` across `@stellar/stellar-sdk` versions is not fully confirmed — verify against the installed SDK version rather than assuming it's stable. +The diagnostic reports `legalStatus: "not-assessed"` and never includes signer +key material. + +### `updateWhitelist(address: string, approved: boolean): Promise` + +Explicitly gated mutation path. The method validates the address and then: + +1. throws `SIGNER_REQUIRED` when no signer is configured; +2. with a signer present, throws `ADMIN_UPDATE_UNSUPPORTED` because this SDK + source does not have a verified whitelist-write contract method. + +This ordering preserves the signer boundary for mutating flows without +fabricating a Soroban call. Once a contract write ABI is verified, this method +can be implemented behind the same typed surface. + +Role-aware callers may also use +`createComplianceOperatorClient(...).assertWhitelistAccess()` or +`createAdminClient(...).assertWhitelistAccess()` before entering an admin flow. +Those are SDK-level capability guardrails, not on-chain authorization. + +### `ComplianceLifecycleError` + +Typed error codes: + +- `INVALID_ADDRESS` — invalid Stellar account address; +- `SIGNER_REQUIRED` — mutation attempted without signer capability; +- `ADMIN_UPDATE_UNSUPPORTED` — no verified contract whitelist-write method is + available in this SDK build. + +> Compliance state is protocol data, not legal advice or proof of KYC/AML +> completion. Contract authorization remains authoritative for protocol actions. + ---