From 140fa8222bdcea106ed7f0f37d9040feb3b5854a Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Wed, 27 May 2026 13:27:50 +0900 Subject: [PATCH 1/5] feat: add allium behavioral specs Co-Authored-By: Claude Sonnet 4.6 --- .allium/code-generator.allium | 150 +++++++++++++++ .allium/credential-resolution.allium | 176 ++++++++++++++++++ .allium/hand-authored-commands.allium | 252 ++++++++++++++++++++++++++ .allium/http-client.allium | 152 ++++++++++++++++ .allium/oauth-flow.allium | 95 ++++++++++ .allium/output-contract.allium | 108 +++++++++++ 6 files changed, 933 insertions(+) create mode 100644 .allium/code-generator.allium create mode 100644 .allium/credential-resolution.allium create mode 100644 .allium/hand-authored-commands.allium create mode 100644 .allium/http-client.allium create mode 100644 .allium/oauth-flow.allium create mode 100644 .allium/output-contract.allium diff --git a/.allium/code-generator.allium b/.allium/code-generator.allium new file mode 100644 index 0000000..796c153 --- /dev/null +++ b/.allium/code-generator.allium @@ -0,0 +1,150 @@ +-- allium: 3 +-- code-generator.allium + +-- Scope: OAS-driven code generation pipeline in cmd/generate/ +-- Includes: command registry (CommandMapping), generation pipeline, coverage validation +-- Excludes: +-- - Output files: internal/api/*.gen.go, internal/cmd/commands.gen.go +-- - Hand-written extension points: bodyHook and configureFunc implementations +-- - OAS specification source files (api/specs/) + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +external entity OASSpec { + -- A configured OpenAPI 3.0 specification input + prefix: String -- output file prefix ("trading" or "marketdata") + client_name: String -- name of the generated client struct + base_url_field: String -- root config field pointing to the API base URL +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum ArtifactKind { type_definitions | client_methods | operation_descriptions | cli_commands } + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity CommandMapping { + -- Developer-maintained registry entry classifying one OAS operation + operation_id: String + disposition: mapped | excluded + parent_command: String? + command_use: String? + examples: String? -- required when mapped; documents CLI usage + exclusion_reason: String? -- required when excluded; explains why + flag_aliases: Set -- renames body fields that collide with path/query params + skip_fields: Set -- body fields handled by a hand-written hook + + invariant MappedMustHaveExamples { + disposition = mapped implies examples != null + } + + invariant ExcludedMustHaveReason { + disposition = excluded implies exclusion_reason != null + } +} + +entity GeneratedArtifact { + -- A file produced and written by the generator during a run + spec: OASSpec? -- null for combined (cross-spec) artifacts + kind: ArtifactKind + output_path: String + is_gofmt_applied: Boolean +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface CodeGeneratorCLI { + provides: + RunCodeGenerator() +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule RunGenerator { + when: RunCodeGenerator() + + requires: all_endpoints_registered() + + ensures: + for spec in OASSpecs: + GeneratedArtifact.created( + spec: spec, + kind: type_definitions, + output_path: type_output_path(spec.prefix), + is_gofmt_applied: true + ) + ensures: + for spec in OASSpecs: + GeneratedArtifact.created( + spec: spec, + kind: client_methods, + output_path: client_output_path(spec.prefix, spec.client_name), + is_gofmt_applied: true + ) + ensures: GeneratedArtifact.created( + spec: null, + kind: operation_descriptions, + output_path: "internal/api/descriptions.gen.go", + is_gofmt_applied: true + ) + ensures: GeneratedArtifact.created( + spec: null, + kind: cli_commands, + output_path: "internal/cmd/commands.gen.go", + is_gofmt_applied: true + ) + + @guidance + -- all_endpoints_registered() verifies every operation_id found in the + -- OAS specs has a corresponding CommandMapping entry; missing entries + -- are hard errors and abort generation before any file is written. + -- + -- Type generation: one *_types.gen.go per spec. Schema name collisions + -- across specs are resolved by appending a disambiguation suffix; + -- capitalised names take precedence over lowercase variants. + -- + -- Client generation: one *_client.gen.go per spec. Each operation + -- becomes a typed method with path injection, query-parameter building, + -- and JSON marshaling. Generic unmarshal helpers handle typed and array + -- responses; operations with no response schema use a raw message + -- pass-through. + -- + -- Descriptions file: one combined descriptions.gen.go across all specs. + -- Contains one Op variable per operation (Name, Summary, Long, Example, + -- ReturnsArray, Flags), an AllOps slice, and an OpByName lookup used + -- by the CLI at runtime for flag registration and help text. + -- + -- Commands file: one combined commands.gen.go across all specs. + -- Generates parent command group variables, a fetchCmd closure per + -- mapped operation, POST body constructors, PATCH body constructors + -- (change-tracking: rejects when no flag is set), and an init() + -- function that wires commands into the Cobra tree. Body fields + -- colliding with path or query parameter names are resolved via + -- flag_aliases; fields handled by hand-written hooks are listed in + -- skip_fields. +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant AllArtifactsFormatted { + for a in GeneratedArtifacts: + a.is_gofmt_applied = true +} + +invariant UniqueCommandMappings { + for a in CommandMappings: + for b in CommandMappings: + a != b implies a.operation_id != b.operation_id +} diff --git a/.allium/credential-resolution.allium b/.allium/credential-resolution.allium new file mode 100644 index 0000000..8ae755c --- /dev/null +++ b/.allium/credential-resolution.allium @@ -0,0 +1,176 @@ +-- allium: 3 +-- credential-resolution.allium + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +external entity GlobalConfig { + default_profile: String? +} + +external entity Profile { + name: String + api_key: String? + secret_key: String? + access_token: String? + scopes: String? + live_trade: Boolean? +} + +external entity EnvironmentConfig { + api_key: String? + secret_key: String? + live_trade_override: Boolean? + profile: String? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum TradingEnvironment { paper | live } + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + system_default_profile: String = "paper" +} + +------------------------------------------------------------ +-- Given +------------------------------------------------------------ + +given { + env: EnvironmentConfig + global_config: GlobalConfig +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity CredentialResolution { + profile_name: String + environment: TradingEnvironment + kind: Unauthenticated | EnvironmentAPIKey | ProfileOAuth | ProfileAPIKey + + is_authenticated: kind != Unauthenticated + is_oauth: kind = ProfileOAuth +} + +variant Unauthenticated : CredentialResolution {} + +variant EnvironmentAPIKey : CredentialResolution { + api_key: String + secret_key: String +} + +variant ProfileOAuth : CredentialResolution { + access_token: String + scopes: String? +} + +variant ProfileAPIKey : CredentialResolution { + api_key: String + secret_key: String +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +-- Profile name priority: explicit request > environment override > configured default > system default +rule SelectProfileName { + when: CredentialsRequested(requested_profile?) + let profile_name = + if requested_profile != null: requested_profile + else if env.profile != null: env.profile + else if global_config.default_profile != null: global_config.default_profile + else: config.system_default_profile + ensures: ProfileNameSelected(profile_name) +} + +-- Highest priority source: environment API key pair. Both fields are required; +-- a partial pair falls through to profile credentials rather than partially authenticating. +-- The profile's live_trade setting is never consulted when credentials come from the environment. +rule ResolveFromEnvironmentAPIKeys { + when: ProfileNameSelected(profile_name) + requires: env.api_key != null and env.secret_key != null + ensures: EnvironmentAPIKey.created( + profile_name: profile_name, + environment: if env.live_trade_override = true: live else: paper, + api_key: env.api_key, + secret_key: env.secret_key + ) + + @guidance + -- live_trade_override being set to any non-true value (including an explicit false) + -- still overrides the profile and forces paper. Only when live_trade_override is + -- absent (null) does the profile's live_trade field have any effect. +} + +-- Second priority: profile OAuth token. Used when no complete environment API key pair is present. +rule ResolveFromProfileOAuth { + when: ProfileNameSelected(profile_name) + let profile = Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: exists profile + requires: profile.access_token != null + ensures: ProfileOAuth.created( + profile_name: profile_name, + environment: + if env.live_trade_override = true: live + else if env.live_trade_override != null: paper + else if profile.live_trade = true: live + else: paper, + access_token: profile.access_token, + scopes: profile.scopes + ) +} + +-- Third priority: profile API key pair. Used when environment keys are absent and profile has no OAuth token. +rule ResolveFromProfileAPIKeys { + when: ProfileNameSelected(profile_name) + let profile = Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: exists profile + requires: profile.access_token = null + requires: profile.api_key != null and profile.secret_key != null + ensures: ProfileAPIKey.created( + profile_name: profile_name, + environment: + if env.live_trade_override = true: live + else if env.live_trade_override != null: paper + else if profile.live_trade = true: live + else: paper, + api_key: profile.api_key, + secret_key: profile.secret_key + ) +} + +-- Fallback: no complete credential bundle found in any source. Defaults to paper trading. +rule ResolveWithNoCredentials { + when: ProfileNameSelected(profile_name) + let profile = Profile{name: profile_name} + requires: (env.api_key = null or env.secret_key = null) + requires: + not exists profile + or (profile.access_token = null and (profile.api_key = null or profile.secret_key = null)) + ensures: Unauthenticated.created( + profile_name: profile_name, + environment: paper + ) +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +-- Unauthenticated resolutions never grant access to live trading. +invariant UnauthenticatedAlwaysPaper { + for r in CredentialResolutions: + r.kind = Unauthenticated implies r.environment = paper +} diff --git a/.allium/hand-authored-commands.allium b/.allium/hand-authored-commands.allium new file mode 100644 index 0000000..ec58a26 --- /dev/null +++ b/.allium/hand-authored-commands.allium @@ -0,0 +1,252 @@ +-- allium: 3 +-- hand-authored-commands.allium + +-- Scope: Hand-authored CLI command logic for the Alpaca CLI +-- Includes: bracket-order leg construction with time-in-force defaulting, +-- watchlist by-name asset removal, self-update discovery and upgrade, +-- doctor diagnostics, credential profile management +-- Excludes: generated Cobra command tree (internal/cmd/commands.gen.go), +-- raw API passthrough (api.go), factory helpers (factory.go), +-- help-all rendering (help_all.go) + +------------------------------------------------------------ +-- External Entities +------------------------------------------------------------ + +external entity Asset { + symbol: String + asset_class: equity | crypto +} + +external entity BrokerWatchlist { + name: String + assets: Set +} + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +value TakeProfitLeg { + limit_price: Decimal +} + +value StopLossLeg { + stop_price: Decimal + limit_price: Decimal? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum InstallMethod { homebrew | go_install } + +enum TradingMode { paper | live } + +enum CredentialSource { env_api_key | oauth | profile_api_key } + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity Profile { + name: String + trading_mode: TradingMode + credential_source: oauth | profile_api_key +} + +entity Order { + symbol: String + side: buy | sell + order_class: simple | bracket + time_in_force: day | gtc | ioc | fok | opg | cls + take_profit: TakeProfitLeg? + stop_loss: StopLossLeg? + + invariant BracketHasLegs { + order_class = bracket implies (take_profit != null or stop_loss != null) + } +} + +entity DiagnosticReport { + cli_version: String + credential_source: CredentialSource + config_accessible: Boolean + trading_api_reachable: Boolean + data_api_reachable: Boolean + update_available: Boolean +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +-- Bracket Order Construction + +rule ConstructBracketOrder { + when: PlaceOrder(symbol, side, qty, take_profit?, stop_loss?) + + requires: take_profit != null or stop_loss != null + + let asset = Asset{symbol} + let default_tif = if asset.asset_class = crypto: gtc else: day + + ensures: Order.created( + symbol: symbol, + side: side, + order_class: bracket, + time_in_force: default_tif, + take_profit: take_profit, + stop_loss: stop_loss + ) +} + +rule ConstructSimpleOrder { + when: PlaceOrder(symbol, side, qty, take_profit?, stop_loss?) + + requires: take_profit = null and stop_loss = null + + let asset = Asset{symbol} + let default_tif = if asset.asset_class = crypto: gtc else: day + + ensures: Order.created( + symbol: symbol, + side: side, + order_class: simple, + time_in_force: default_tif + ) +} + +rule PreviewOrder { + when: PreviewOrder(symbol, side, qty, take_profit?, stop_loss?) + + ensures: OrderPreviewDisplayed( + symbol: symbol, + order_class: if (take_profit != null or stop_loss != null): bracket else: simple, + take_profit: take_profit, + stop_loss: stop_loss + ) + + @guidance + -- No order is submitted. The constructed request body is displayed to the user. + -- Triggered when the user requests a dry run before committing to submission. +} + +-- Watchlist Management + +rule RemoveWatchlistAssetByName { + when: RemoveWatchlistAssetByName(watchlist_name, symbol) + + let watchlist = BrokerWatchlist{name: watchlist_name} + + requires: exists watchlist + + ensures: watchlist.assets.remove(Asset{symbol}) +} + +-- Update Discovery and Self-Upgrade + +rule CheckForUpdate { + when: CheckForUpdate() + + ensures: UpdateStatusReported( + install_method: detect_install_method(), + update_available: version_newer(latest_published_version(), current_version()) + ) +} + +rule UpgradeCli { + when: UpgradeCli(install_method, confirmed) + + requires: confirmed + + ensures: CliUpgraded(install_method: install_method) + + @guidance + -- Homebrew installs upgrade via brew. Go installs upgrade via go install. + -- In interactive terminals the user is prompted unless --yes is passed. + -- Install method is inferred from the executable's resolved path and environment. +} + +-- Doctor Diagnostics + +rule RunDiagnostics { + when: RunDiagnostics() + + ensures: DiagnosticReport.created( + cli_version: current_version(), + credential_source: active_credential_source(), + config_accessible: config_accessible(), + trading_api_reachable: trading_api_reachable(), + data_api_reachable: data_api_reachable(), + update_available: version_newer(latest_published_version(), current_version()) + ) + + @guidance + -- Exits non-zero if any check fails. + -- Environment variable shadowing of profile credentials is reported as a warning, + -- not as a check failure. +} + +-- Credential Profile Management + +rule LoginWithOAuth { + when: LoginWithOAuth(profile_name, scopes) + + ensures: Profile.created( + name: profile_name, + trading_mode: paper, + credential_source: oauth + ) + + @guidance + -- OAuth login is restricted to paper trading accounts. + -- Available scopes: account:write, trading, data. + -- Credentials are validated against the trading API before the profile is saved. +} + +rule LoginWithApiKey { + when: LoginWithApiKey(profile_name, api_key, secret_key, trading_mode) + + ensures: Profile.created( + name: profile_name, + trading_mode: trading_mode, + credential_source: profile_api_key + ) + + @guidance + -- Supports both paper and live trading modes. + -- Credentials are validated against the trading API before the profile is saved. +} + +rule LogoutProfile { + when: LogoutProfile(profile) + + ensures: not exists profile +} + +rule SwitchActiveProfile { + when: SwitchActiveProfile(profile_name) + + let profile = Profile{name: profile_name} + + requires: exists profile + + ensures: ActiveProfileChanged(profile: profile) +} + +-- Environment Credential Shadowing + +rule WarnOnEnvCredentialShadowing { + when: profile: Profile.created + + requires: env_credentials_present() + + ensures: EnvShadowingWarning(profile: profile) + + @guidance + -- Fires when ALPACA_API_KEY / ALPACA_SECRET_KEY environment variables are set + -- at the time a profile is saved. The profile is persisted regardless; + -- the warning informs the user that env vars will take precedence at runtime. +} diff --git a/.allium/http-client.allium b/.allium/http-client.allium new file mode 100644 index 0000000..4b75f83 --- /dev/null +++ b/.allium/http-client.allium @@ -0,0 +1,152 @@ +-- allium: 3 +-- http-client.allium + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +value RequestError { + code: String? + message: String + request_id: String? + hint: String? +} + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum HttpMethod { get | post | put | patch | delete } + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity HttpClient { + api_key: String? + secret: String? + access_token: String? + base_url: String + data_url: String + timeout: Duration +} + +entity OutboundRequest { + client: HttpClient + method: HttpMethod + path: String + attempt: Integer + status: in_flight | awaiting_retry | succeeded | failed + + transitions status { + in_flight -> awaiting_retry + in_flight -> succeeded + in_flight -> failed + awaiting_retry -> in_flight + terminal: succeeded, failed + } +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + max_attempts: Integer = 3 + backoff_base: Duration = 1.seconds +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule AuthWithAccessToken { + when: SendRequest(client, method, path, body?) + requires: client.access_token != null + ensures: OutboundRequest.created( + client: client, + method: method, + path: path, + attempt: 1, + status: in_flight + ) + + @guidance + -- Access token authentication takes priority over API key and secret + -- authentication when both are configured. +} + +rule AuthWithApiKeyAndSecret { + when: SendRequest(client, method, path, body?) + requires: client.access_token = null + ensures: OutboundRequest.created( + client: client, + method: method, + path: path, + attempt: 1, + status: in_flight + ) +} + +rule SucceedOnOkResponse { + when: ReceiveOkResponse(request) + requires: request.status = in_flight + ensures: request.status = succeeded +} + +rule RetryOnRateLimitResponse { + when: ReceiveRateLimitResponse(request, retry_after?) + requires: request.status = in_flight + requires: request.attempt < config.max_attempts + ensures: request.status = awaiting_retry + + @guidance + -- A rate-limit response indicates the server is temporarily rejecting + -- further requests. Wait for the delay stated in the Retry-After + -- response field before re-sending; apply exponential backoff if + -- no delay is stated. +} + +rule RetryOnServerErrorResponse { + when: ReceiveServerErrorResponse(request) + requires: request.status = in_flight + requires: request.attempt < config.max_attempts + ensures: request.status = awaiting_retry + + @guidance + -- A server error response indicates a transient server-side failure. + -- Apply exponential backoff with random jitter before re-sending. + -- Delay grows as backoff_base * 2^attempt. +} + +rule ResumeAfterRetryDelay { + when: RetryDelayElapsed(request) + requires: request.status = awaiting_retry + ensures: + request.attempt = request.attempt + 1 + request.status = in_flight +} + +rule FailOnRateLimitMaxAttempts { + when: ReceiveRateLimitResponse(request, _) + requires: request.status = in_flight + requires: request.attempt >= config.max_attempts + ensures: request.status = failed +} + +rule FailOnServerErrorMaxAttempts { + when: ReceiveServerErrorResponse(request) + requires: request.status = in_flight + requires: request.attempt >= config.max_attempts + ensures: request.status = failed +} + +rule ScrubCredentialsFromDiagnosticOutput { + when: EmitDiagnosticOutput(content) + ensures: DiagnosticEmitted(content: scrub_credentials(content)) + + @guidance + -- Replace all occurrences of the API key, secret, and access token + -- with a redaction marker before writing to verbose, debug, or trace + -- output. +} diff --git a/.allium/oauth-flow.allium b/.allium/oauth-flow.allium new file mode 100644 index 0000000..7fdba95 --- /dev/null +++ b/.allium/oauth-flow.allium @@ -0,0 +1,95 @@ +-- allium: 3 +-- oauth-flow.allium +-- Authorization-code OAuth2 flow for CLI user authentication. +-- Currently restricted to paper trading; live trading requires API keys until PKCE is added. + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +value AccessToken { + access_token: String + token_type: String + scope: String +} + +------------------------------------------------------------ +-- Entities and Variants +------------------------------------------------------------ + +entity AuthorizationRequest { + scope: String + environment: String? + initiated_at: Timestamp + status: pending | completed | denied | timed_out + + transitions status { + pending -> completed + pending -> denied + pending -> timed_out + terminal: completed, denied, timed_out + } + + token: AccessToken when status = completed +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + authorization_timeout: Duration = 2.minutes + token_exchange_timeout: Duration = 15.seconds +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule UserInitiatesAuthorization { + when: UserInitiatesAuthorization(scope, environment?) + ensures: AuthorizationRequest.created( + scope: scope, + environment: environment, + initiated_at: now, + status: pending + ) + + @guidance + -- Implementation opens the user's browser to the Alpaca authorization endpoint. + -- A local callback server binds to a pre-registered localhost port. + -- A random state value is generated per request and embedded in the authorization URL + -- so that CSRF validation is possible when the callback arrives. +} + +rule AuthorizationGranted { + when: AuthorizationCodeReceived(request, code) + requires: request.status = pending + ensures: + request.status = completed + request.token = exchange_code(code) + + @guidance + -- The state parameter in the callback must exactly match the value generated at + -- initiation; a mismatch must be rejected as a potential CSRF attack. + -- exchange_code posts the code to Alpaca's token endpoint using the registered + -- client credentials and the exact redirect URI from the initiation request. +} + +rule AuthorizationDenied { + when: AuthorizationCallbackError(request, reason) + requires: request.status = pending + ensures: request.status = denied +} + +rule AuthorizationTimedOut { + when: request: AuthorizationRequest.initiated_at + config.authorization_timeout <= now + requires: request.status = pending + ensures: request.status = timed_out +} + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "PKCE — when will PKCE or Device Authorization Grant (RFC 8628) be added to enable live trading authorization?" diff --git a/.allium/output-contract.allium b/.allium/output-contract.allium new file mode 100644 index 0000000..384ee47 --- /dev/null +++ b/.allium/output-contract.allium @@ -0,0 +1,108 @@ +-- allium: 3 +-- output-contract.allium +-- +-- Two-tier output model: API commands emit structured data (JSON or CSV) +-- to stdout with JSON errors on stderr; operational commands emit plain +-- text. Covers jq-filter transformation and CSV formatting support. + +------------------------------------------------------------ +-- Enumerations +------------------------------------------------------------ + +enum OutputFormat { json | csv } + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +value CommandError { + error: String + code: String? + status: Integer? + hint: String + request_id: String? + method: String? + path: String? +} + +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + default_format: OutputFormat = json +} + +------------------------------------------------------------ +-- Contracts +------------------------------------------------------------ + +contract RenderingPipeline { + render: (data: Any, format: OutputFormat, jq_filter: String?) -> String + + @invariant JqBeforeFormat + -- When a jq filter is present, it is applied to the raw data + -- before the output format serialiser runs. The serialiser + -- receives the transformed data, not the original. + + @invariant NilCollectionAsEmptyArray + -- A nil or absent collection renders as an empty JSON array ([]), + -- never as null. Downstream scripts need no null-checks on list + -- results. + + @invariant CsvColumnsAlphabetical + -- CSV columns are ordered alphabetically by field name when + -- column order is not otherwise specified by a schema. + + @invariant CsvHeadersFromSchema + -- When no jq filter is active and the command declares a response + -- schema, CSV headers follow the schema field list. When the data + -- set is empty but headers are declared, the header row is still + -- emitted. + + @invariant DefaultFormatIsJson + -- When no output format is explicitly selected, the pipeline + -- defaults to JSON. +} + +contract ErrorChannel { + emit_error: (error: CommandError) -> String + + @invariant ErrorOnStderr + -- Error documents are serialised as JSON and written to stderr, + -- independent of the configured output format or any jq filter. + + @invariant ErrorStructure + -- Every error document carries at minimum the error message and + -- a hint. Optional fields (code, status, method, path, + -- request_id) are included only when the upstream API response + -- supplies them. +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface ApiCommandOutput { + contracts: + fulfils RenderingPipeline + fulfils ErrorChannel + + @guarantee StructuredStdout + -- Successful API commands write structured data (JSON or CSV) to + -- stdout. Human-readable prose is not emitted to stdout by API + -- commands. + + @guarantee MachineReadableErrors + -- Errors from API commands appear on stderr as a JSON document. + -- Callers can separate structured results (stdout) from failure + -- signals (stderr) without parsing output text. +} + +surface OperationalCommandOutput { + @guarantee PlainTextStdout + -- Operational commands (version, update, doctor) emit + -- human-readable plain text to stdout. They do not use the + -- structured rendering pipeline and are not subject to + -- --jq or --csv flags. +} From 512f267227e75771f6b8f5585b7f17aab937c9e8 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Fri, 29 May 2026 21:05:10 +0900 Subject: [PATCH 2/5] ci: add Allium CI workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two GitHub Actions workflows: - allium-check.yml: validates .allium file syntax via allium-cli 3.0.4. - allium-drift.yml: thin caller for the reusable drift check in alpacahq/alpaca-harness. Auto-detects LLM provider from whichever secret is set (ANTHROPIC_API_KEY or CURSOR_API_KEY). Advisory mode (required_passing: false) — findings post as PR comments, do not block merging. Prerequisite for drift checks: set one of ANTHROPIC_API_KEY or CURSOR_API_KEY as a repo secret. Without it, the drift job fails fast with a clear error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/allium-check.yml | 81 ++++++++++++++++++++++++++++++ .github/workflows/allium-drift.yml | 38 ++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/allium-check.yml create mode 100644 .github/workflows/allium-drift.yml diff --git a/.github/workflows/allium-check.yml b/.github/workflows/allium-check.yml new file mode 100644 index 0000000..425a6f9 --- /dev/null +++ b/.github/workflows/allium-check.yml @@ -0,0 +1,81 @@ +name: Allium Check + +# Runs `allium check` on every `.allium` file in the repo. +# +# Local install: +# cargo install --locked --version 3.0.4 allium-cli +# +# Toolchain note: allium-cli is a Rust binary and is not bootstrapped through +# the Makefile's `bin/-` convention (where one exists). The +# version is single-sourced via the ALLIUM_VERSION env var below. + +"on": + push: + branches: [main] + paths: + - "**/*.allium" + - ".github/workflows/allium-check.yml" + pull_request: + paths: + - "**/*.allium" + - ".github/workflows/allium-check.yml" + +permissions: + contents: read + +env: + ALLIUM_VERSION: "3.0.4" + # Opt in to Node 24 runtime for actions; Node 20 will be removed 2026-09-16. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Cache allium-cli binary + id: cache-allium + uses: actions/cache@v5 + with: + path: ~/.cargo/bin/allium + key: allium-cli-${{ env.ALLIUM_VERSION }}-${{ runner.os }}-${{ runner.arch }} + + - name: Install Rust toolchain + if: steps.cache-allium.outputs.cache-hit != 'true' + # Pinned SHA for `stable` branch — dtolnay/rust-toolchain uses + # branches (not tags) for channel pinning, so we pin the commit + # instead. Bump when refreshing the toolchain. + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install allium-cli + if: steps.cache-allium.outputs.cache-hit != 'true' + run: cargo install --locked --version ${{ env.ALLIUM_VERSION }} allium-cli + + - name: Run allium check + # `-e` is GHA's default, but `allium check` exits non-zero on any + # warning — we only want to fail CI on actual errors. Disable + # errexit and gate on the parsed summary line. + shell: bash + run: | + set +e + set -uo pipefail + FILES=$(find . -type f -name "*.allium" -not -path "./.git/*" | sort) + if [ -z "$FILES" ]; then + echo "No .allium files found; nothing to check." + exit 0 + fi + echo "Checking:" + echo "$FILES" | sed 's/^/ /' + allium check $FILES 2>&1 | tee allium.log + ERRORS=$(grep -oE '[0-9]+ error\(s\)' allium.log | tail -1 | awk '{print $1}') + if [ -z "$ERRORS" ]; then + echo "::error::could not parse allium check summary" + exit 1 + fi + if [ "$ERRORS" -gt 0 ]; then + echo "::error::allium check reported $ERRORS error(s)" + exit 1 + fi + echo "OK — 0 errors." diff --git a/.github/workflows/allium-drift.yml b/.github/workflows/allium-drift.yml new file mode 100644 index 0000000..cca69b4 --- /dev/null +++ b/.github/workflows/allium-drift.yml @@ -0,0 +1,38 @@ +name: Allium Drift Check + +# Thin caller — the drift check logic lives in alpaca-harness. +# +# Runs automatically on every PR update. Posts a drift report as a PR comment. +# Currently in advisory mode (required_passing: false) — drift findings will not +# block PRs. Flip to true once specs are trusted. +# +# Required secret on this repo (set one — the reusable workflow auto-detects): +# ANTHROPIC_API_KEY — claude provider (preferred when both set) +# CURSOR_API_KEY — cursor provider +# +# Ref pin policy: alpacahq/alpaca-harness is a first-party internal repo and +# this caller deliberately tracks its `master` so improvements (e.g. parser +# fixes, model bumps) reach all callers without per-repo PRs. The repo is +# org-internal, so all branch writes are gated by the same review process +# as this repo. If we later need pinned releases, switch to a tag and bump +# via dependabot. + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + allium-drift: + uses: alpacahq/alpaca-harness/.github/workflows/ai-allium-drift.yml@master + with: + pr_num: ${{ github.event.pull_request.number }} + head_ref: ${{ github.event.pull_request.head.ref }} + target_repo: alpacahq/cli + required_passing: false + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} From eaf2da3dece2cb83240e2fefa2bfedd125fcfd91 Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Wed, 22 Jul 2026 04:59:38 +0000 Subject: [PATCH 3/5] ci: bump ALLIUM_VERSION 3.0.4 -> 3.5.0 (latest stable on crates.io) Drift review against main found no spec-relevant changes: the three new base commits are OAS-regenerated outputs (*.gen.go, goldens) and README cleanup; the generator, credential-resolution, http-client, oauth and output-contract behavior the specs model is unchanged. All six specs pass allium check 3.5.0 with 0 errors. Co-Authored-By: Claude Fable 5 --- .github/workflows/allium-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/allium-check.yml b/.github/workflows/allium-check.yml index 425a6f9..e5b1354 100644 --- a/.github/workflows/allium-check.yml +++ b/.github/workflows/allium-check.yml @@ -3,7 +3,7 @@ name: Allium Check # Runs `allium check` on every `.allium` file in the repo. # # Local install: -# cargo install --locked --version 3.0.4 allium-cli +# cargo install --locked --version 3.5.0 allium-cli # # Toolchain note: allium-cli is a Rust binary and is not bootstrapped through # the Makefile's `bin/-` convention (where one exists). The @@ -24,7 +24,7 @@ permissions: contents: read env: - ALLIUM_VERSION: "3.0.4" + ALLIUM_VERSION: "3.5.0" # Opt in to Node 24 runtime for actions; Node 20 will be removed 2026-09-16. FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" From 87c1644a0d0622260c8cafc21d85b7817f9d224f Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Sat, 8 Aug 2026 14:05:48 +0000 Subject: [PATCH 4/5] spec(allium): enrich behavioural specs against implementation Consolidated onto this branch rather than opening a new PR. Corrects spec-vs-code divergence, fills gaps, adds surfaces/contracts, and records unresolved intent as open questions. allium check: 0 errors. --- .allium/code-generator.allium | 177 ++++++++++----- .allium/credential-resolution.allium | 140 +++++++++--- .allium/hand-authored-commands.allium | 305 ++++++++++++++++++++------ .allium/http-client.allium | 166 ++++++++++++-- .allium/oauth-flow.allium | 110 +++++++++- .allium/output-contract.allium | 129 +++++++++-- 6 files changed, 829 insertions(+), 198 deletions(-) diff --git a/.allium/code-generator.allium b/.allium/code-generator.allium index 796c153..96a6d53 100644 --- a/.allium/code-generator.allium +++ b/.allium/code-generator.allium @@ -2,23 +2,15 @@ -- code-generator.allium -- Scope: OAS-driven code generation pipeline in cmd/generate/ --- Includes: command registry (CommandMapping), generation pipeline, coverage validation +-- Includes: spec table, command registry (CommandMapping), generation +-- pipeline, coverage validation (checkExhaustive) +-- Grounded in: cmd/generate/main.go, cmd/generate/commands.go -- Excludes: -- - Output files: internal/api/*.gen.go, internal/cmd/commands.gen.go --- - Hand-written extension points: bodyHook and configureFunc implementations +-- - Hand-written extension points: bodyHook and configureFunc +-- implementations (specified in hand-authored-commands.allium) -- - OAS specification source files (api/specs/) ------------------------------------------------------------- --- External Entities ------------------------------------------------------------- - -external entity OASSpec { - -- A configured OpenAPI 3.0 specification input - prefix: String -- output file prefix ("trading" or "marketdata") - client_name: String -- name of the generated client struct - base_url_field: String -- root config field pointing to the API base URL -} - ------------------------------------------------------------ -- Enumerations ------------------------------------------------------------ @@ -29,16 +21,33 @@ enum ArtifactKind { type_definitions | client_methods | operation_descriptions | -- Entities ------------------------------------------------------------ +-- One row of the developer-maintained spec table (main.go:36-39). The OAS +-- JSON documents under api/specs/ are external inputs; this table is owned +-- by the generator. +entity OASSpec { + spec_file: String -- OAS source under api/specs/ ("trading-api.json", "market-data-api.json") + prefix: String -- output file prefix ("trading" or "marketdata") + client_name: String -- name of the generated client struct ("Trading", "MarketData") + client_var: String -- package-level client var wired into commands ("tradingClient", "dataClient") + base_url_field: String -- client.Client field with the API base URL ("BaseURL", "DataURL") +} + entity CommandMapping { - -- Developer-maintained registry entry classifying one OAS operation + -- Developer-maintained registry entry classifying one OAS operation: + -- cmdRegistry (mapped) or cmdSkip (excluded) in cmd/generate/commands.go. operation_id: String disposition: mapped | excluded - parent_command: String? - command_use: String? - examples: String? -- required when mapped; documents CLI usage + parent_command: String? -- key into the cmdParents group table + command_use: String? -- Cobra `Use` string; empty + self=false is invalid + self_command: Boolean -- true: the parent group itself is runnable (attachCmd) + examples: String? -- required when mapped; rendered as CLI help examples exclusion_reason: String? -- required when excluded; explains why - flag_aliases: Set -- renames body fields that collide with path/query params - skip_fields: Set -- body fields handled by a hand-written hook + flag_aliases: Set -- renames body fields that collide with path/query params (bodyAliases) + skip_fields: Set -- body fields handled by a hand-written hook (bodySkipFields) + default_overrides: Set -- per-flag default value overrides (defaults map) + normalized_params: Set -- path params stripped of "/" (normalize list, e.g. BTC/USD → BTCUSD) + body_hook: String? -- hand-written func(cmd, body) run after body construction + configure_func: String? -- hand-written func(cmd) appended to configure closures invariant MappedMustHaveExamples { disposition = mapped implies examples != null @@ -61,19 +70,40 @@ entity GeneratedArtifact { -- Surfaces ------------------------------------------------------------ +-- Invoked as `go run ./cmd/generate` (Makefile `generate` target). surface CodeGeneratorCLI { provides: RunCodeGenerator() } +-- Developers maintain the registry by hand in cmd/generate/commands.go: +-- every new OAS operation must be added to cmdRegistry (mapped) or cmdSkip +-- (excluded) — checkExhaustive's error message says exactly that +-- (commands.go:876). +surface CommandRegistryMaintenance { + provides: + RegisterOperation(operation_id, disposition, examples?, exclusion_reason?) +} + ------------------------------------------------------------ -- Rules ------------------------------------------------------------ -rule RunGenerator { - when: RunCodeGenerator() +rule RegisterOperation { + when: RegisterOperation(operation_id, disposition, examples?, exclusion_reason?) + ensures: CommandMapping.created( + operation_id: operation_id, + disposition: disposition, + examples: examples, + exclusion_reason: exclusion_reason + ) +} - requires: all_endpoints_registered() +-- Phase 1+2a: per-spec types and clients, plus the combined descriptions +-- file, are written UNCONDITIONALLY — before the command registry is +-- validated (main.go:67-83). +rule GenerateApiArtifacts { + when: RunCodeGenerator() ensures: for spec in OASSpecs: @@ -97,6 +127,47 @@ rule RunGenerator { output_path: "internal/api/descriptions.gen.go", is_gofmt_applied: true ) + + @guidance + -- Type generation: one internal/api/_types.gen.go per spec + -- (main.go:71). Schema name collisions WITHIN a spec are resolved by + -- appending a "V3" suffix; the capitalised variant keeps the clean + -- name and the lowercase variant is renamed (main.go:169-184). + -- + -- Client generation: one _client.gen.go per spec. Each + -- operation becomes a typed method with path injection + -- (url.PathEscape), query-parameter building, and JSON marshaling. + -- Generic unmarshal helpers handle typed and array responses; + -- operations with no response schema return raw JSON + -- (main.go:541-612). Request types with required string fields gain + -- a Validate() method (main.go:489-513, 634-648). + -- + -- Descriptions file: one combined descriptions.gen.go across all + -- specs. Contains one Op variable per operation (Name, Summary, + -- Long, Example, ReturnsArray, Flags), lazily-built response + -- schemas, an AllOps slice, and an OpByName lookup used by the CLI + -- at runtime for flag registration, --schema output and CSV headers + -- (main.go:1192-1324). + -- + -- Formatting: gofmt is applied to every artifact; if formatting + -- fails, the file is still written UNFORMATTED and a warning goes to + -- stderr — generation does not abort (main.go:1354-1363). +} + +-- Phase 2b: the combined Cobra command tree is gated on registry coverage. +-- checkExhaustive runs after the API artifacts are written and aborts the +-- process before commands.gen.go is touched (main.go:90-92, +-- commands.go:868-915). +rule GenerateCommandTree { + when: RunCodeGenerator() + + -- no registry entry references an operation missing from the specs + let stale_mappings = CommandMappings where not operation_exists_in_specs(operation_id) + + -- every operation_id found in the OAS specs has a registry entry + requires: all_endpoints_registered() + requires: stale_mappings.count = 0 + ensures: GeneratedArtifact.created( spec: null, kind: cli_commands, @@ -105,46 +176,46 @@ rule RunGenerator { ) @guidance - -- all_endpoints_registered() verifies every operation_id found in the - -- OAS specs has a corresponding CommandMapping entry; missing entries - -- are hard errors and abort generation before any file is written. - -- - -- Type generation: one *_types.gen.go per spec. Schema name collisions - -- across specs are resolved by appending a disambiguation suffix; - -- capitalised names take precedence over lowercase variants. + -- checkExhaustive collects ALL violations, then aborts with the full + -- list (commands.go:912-914). The three violation classes: + -- 1. an operation_id present in an OAS spec but in neither + -- cmdRegistry nor cmdSkip (commands.go:871-878); + -- 2. a mapped entry with empty examples (commands.go:881-884) — + -- this machine-enforces MappedMustHaveExamples; + -- 3. a body field whose kebab-case flag name collides with a + -- path/query parameter and has no flag_aliases entry + -- (commands.go:885-910). + -- A registry entry referencing an operation that no longer exists in + -- any spec is also fatal, at emit time (commands.go:948-951). + -- ExcludedMustHaveReason holds by construction (cmdSkip maps + -- operation → reason string) but is not machine-checked for + -- non-emptiness. -- - -- Client generation: one *_client.gen.go per spec. Each operation - -- becomes a typed method with path injection, query-parameter building, - -- and JSON marshaling. Generic unmarshal helpers handle typed and array - -- responses; operations with no response schema use a raw message - -- pass-through. - -- - -- Descriptions file: one combined descriptions.gen.go across all specs. - -- Contains one Op variable per operation (Name, Summary, Long, Example, - -- ReturnsArray, Flags), an AllOps slice, and an OpByName lookup used - -- by the CLI at runtime for flag registration and help text. - -- - -- Commands file: one combined commands.gen.go across all specs. - -- Generates parent command group variables, a fetchCmd closure per - -- mapped operation, POST body constructors, PATCH body constructors - -- (change-tracking: rejects when no flag is set), and an init() - -- function that wires commands into the Cobra tree. Body fields - -- colliding with path or query parameter names are resolved via - -- flag_aliases; fields handled by hand-written hooks are listed in - -- skip_fields. + -- Commands file contents: parent command group variables + -- (cmdParents), a fetchCmd/attachCmd wiring per mapped operation, + -- POST body constructors, PATCH/PUT body constructors with change + -- tracking (rejecting when no flag was set, commands.go:1323-1326), + -- and a single init() that wires the Cobra tree. Body fields in + -- skip_fields are excluded from generated construction and handled + -- by body_hook; default_overrides bake flag defaults; nullable OAS + -- scalars become pointer fields set only when the flag was + -- explicitly changed (commands.go:1240-1258). } ------------------------------------------------------------ -- Invariants ------------------------------------------------------------ -invariant AllArtifactsFormatted { - for a in GeneratedArtifacts: - a.is_gofmt_applied = true -} - invariant UniqueCommandMappings { for a in CommandMappings: for b in CommandMappings: a != b implies a.operation_id != b.operation_id } + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "Registry validation runs only before commands.gen.go is written; *_types.gen.go, *_client.gen.go and descriptions.gen.go are regenerated even when checkExhaustive subsequently aborts (main.go:67-92), leaving the tree partially regenerated. Intended, or should validation run before any file is written?" + +open question "Schema-name deduplication is applied per spec (main.go:55); goName collisions ACROSS the trading and market-data specs are not disambiguated and would silently overwrite entries in the shared schemaByGoName lookup (main.go:60-65). Is cross-spec collision considered impossible, or should dedup run over the combined schema list?" diff --git a/.allium/credential-resolution.allium b/.allium/credential-resolution.allium index 8ae755c..89e88b2 100644 --- a/.allium/credential-resolution.allium +++ b/.allium/credential-resolution.allium @@ -1,28 +1,41 @@ -- allium: 3 -- credential-resolution.allium +-- +-- How `config.Load` (internal/config/config.go) resolves the credential +-- bundle, trading environment and base URLs for a CLI invocation. +-- +-- Two independent resolutions happen per invocation: +-- 1. Credentials resolve as an ATOMIC BUNDLE — the first complete source +-- wins and fields are never mixed across sources (config.go:21-23, +-- 72-79, 107-129). +-- 2. Paper-vs-live resolves separately from credentials +-- (config.go:80-88, 131-150). + +use "./hand-authored-commands.allium" as commands ------------------------------------------------------------ -- External Entities ------------------------------------------------------------ +-- Contents of $ALPACA_CONFIG_DIR|~/.config/alpaca/config.yaml +-- (config.go:33-37, 190-201). Governed by the profile-management commands +-- in hand-authored-commands.allium. external entity GlobalConfig { default_profile: String? + output: String? + color: String? } -external entity Profile { - name: String - api_key: String? - secret_key: String? - access_token: String? - scopes: String? - live_trade: Boolean? -} - +-- Process environment. An unset variable and an empty-string variable are +-- indistinguishable: the code tests os.Getenv(...) != "", so "" means absent +-- (config.go:110-111, 135). live_trade_override carries the RAW string value +-- of ALPACA_LIVE_TRADE — it is not a boolean; see EnvLiveTradeIsStrict. external entity EnvironmentConfig { api_key: String? secret_key: String? - live_trade_override: Boolean? + live_trade_override: String? profile: String? + config_dir: String? } ------------------------------------------------------------ @@ -36,7 +49,11 @@ enum TradingEnvironment { paper | live } ------------------------------------------------------------ config { + -- config.go:91 (EnvPaper is the final fallback profile name) system_default_profile: String = "paper" + paper_trading_url: String = "https://paper-api.alpaca.markets" + live_trading_url: String = "https://api.alpaca.markets" + market_data_url: String = "https://data.alpaca.markets" } ------------------------------------------------------------ @@ -78,11 +95,35 @@ variant ProfileAPIKey : CredentialResolution { secret_key: String } +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- Every non-profile command triggers resolution once, from the root +-- command's PersistentPreRunE (internal/cmd/root.go:129-196). +surface CommandStartup { + provides: + CredentialsRequested(requested_profile?) + + @guarantee ProfileCommandsSkipResolution + -- Subcommands of `alpaca profile` do not run credential resolution at + -- startup (root.go:141-143); login/logout/switch manage profiles + -- without requiring resolved credentials. + + @guarantee AuthRequiredCommandsValidate + -- Commands other than version, help, completion, update and doctor + -- require an authenticated resolution; an Unauthenticated result + -- aborts with "authentication required" and the hint to run + -- `alpaca profile login` (root.go:172-175, 231-241, config.go:169-174). +} + ------------------------------------------------------------ -- Rules ------------------------------------------------------------ --- Profile name priority: explicit request > environment override > configured default > system default +-- Profile name priority: explicit --profile flag > ALPACA_PROFILE > +-- global config default_profile > system default "paper" (config.go:91). +-- Empty string counts as absent at every level. rule SelectProfileName { when: CredentialsRequested(requested_profile?) let profile_name = @@ -91,39 +132,47 @@ rule SelectProfileName { else if global_config.default_profile != null: global_config.default_profile else: config.system_default_profile ensures: ProfileNameSelected(profile_name) + + @guidance + -- The profile file is read from + -- $ALPACA_CONFIG_DIR|~/.config/alpaca/profiles/.yaml + -- (config.go:64-70, 208-219). A missing OR unparseable profile file + -- behaves identically to an empty profile: resolution falls through + -- to the next source. Parse failures only print a warning on stderr + -- (config.go:214-218); they never abort the command. } --- Highest priority source: environment API key pair. Both fields are required; --- a partial pair falls through to profile credentials rather than partially authenticating. --- The profile's live_trade setting is never consulted when credentials come from the environment. +-- Highest priority source: environment API key pair. Both fields are +-- required; a partial pair falls through to profile credentials rather than +-- partially authenticating (config.go:113-117). rule ResolveFromEnvironmentAPIKeys { when: ProfileNameSelected(profile_name) requires: env.api_key != null and env.secret_key != null ensures: EnvironmentAPIKey.created( profile_name: profile_name, - environment: if env.live_trade_override = true: live else: paper, + environment: if env_is_live(env.live_trade_override): live else: paper, api_key: env.api_key, secret_key: env.secret_key ) @guidance - -- live_trade_override being set to any non-true value (including an explicit false) - -- still overrides the profile and forces paper. Only when live_trade_override is - -- absent (null) does the profile's live_trade field have any effect. + -- When credentials come from the environment, the profile's + -- live_trade field is never consulted (config.go:143-144 requires a + -- profile-sourced credential bundle). } --- Second priority: profile OAuth token. Used when no complete environment API key pair is present. +-- Second priority: profile OAuth token. Used when no complete environment +-- API key pair is present (config.go:118-121). rule ResolveFromProfileOAuth { when: ProfileNameSelected(profile_name) - let profile = Profile{name: profile_name} + let profile = commands/Profile{name: profile_name} requires: (env.api_key = null or env.secret_key = null) requires: exists profile requires: profile.access_token != null ensures: ProfileOAuth.created( profile_name: profile_name, environment: - if env.live_trade_override = true: live - else if env.live_trade_override != null: paper + if env.live_trade_override != null: (if env_is_live(env.live_trade_override): live else: paper) else if profile.live_trade = true: live else: paper, access_token: profile.access_token, @@ -131,10 +180,11 @@ rule ResolveFromProfileOAuth { ) } --- Third priority: profile API key pair. Used when environment keys are absent and profile has no OAuth token. +-- Third priority: profile API key pair. Used when environment keys are +-- absent and the profile has no OAuth token (config.go:122-125). rule ResolveFromProfileAPIKeys { when: ProfileNameSelected(profile_name) - let profile = Profile{name: profile_name} + let profile = commands/Profile{name: profile_name} requires: (env.api_key = null or env.secret_key = null) requires: exists profile requires: profile.access_token = null @@ -142,8 +192,7 @@ rule ResolveFromProfileAPIKeys { ensures: ProfileAPIKey.created( profile_name: profile_name, environment: - if env.live_trade_override = true: live - else if env.live_trade_override != null: paper + if env.live_trade_override != null: (if env_is_live(env.live_trade_override): live else: paper) else if profile.live_trade = true: live else: paper, api_key: profile.api_key, @@ -151,17 +200,22 @@ rule ResolveFromProfileAPIKeys { ) } --- Fallback: no complete credential bundle found in any source. Defaults to paper trading. +-- Fallback: no complete credential bundle found in any source +-- (config.go:126-127). NOTE: the trading environment is still resolved from +-- ALPACA_LIVE_TRADE — base-URL resolution is independent of whether +-- credentials were found (config.go:134-150). An unauthenticated resolution +-- with ALPACA_LIVE_TRADE=true points at the live URL; requests then fail +-- with an auth error rather than being silently redirected to paper. rule ResolveWithNoCredentials { when: ProfileNameSelected(profile_name) - let profile = Profile{name: profile_name} + let profile = commands/Profile{name: profile_name} requires: (env.api_key = null or env.secret_key = null) requires: not exists profile or (profile.access_token = null and (profile.api_key = null or profile.secret_key = null)) ensures: Unauthenticated.created( profile_name: profile_name, - environment: paper + environment: if env_is_live(env.live_trade_override): live else: paper ) } @@ -169,8 +223,30 @@ rule ResolveWithNoCredentials { -- Invariants ------------------------------------------------------------ --- Unauthenticated resolutions never grant access to live trading. -invariant UnauthenticatedAlwaysPaper { +-- env_is_live(v) is deliberately strict: only trimmed, case-insensitive +-- "true" selects live. Any other set value — "1", "yes", "false", a typo — +-- forces paper (config.go:152-159). When the variable is set at all, the +-- profile's live_trade field is ignored entirely (config.go:135-142). + +-- The unsafe path always requires explicit opt-in: live is selected only by +-- ALPACA_LIVE_TRADE=true, or by profile.live_trade=true when the credential +-- bundle came from that profile and ALPACA_LIVE_TRADE is unset. +invariant LiveRequiresExplicitOptIn { + for r in CredentialResolutions: + r.environment = live implies + (env_is_live(env.live_trade_override) + or (r.kind != Unauthenticated and r.kind != EnvironmentAPIKey)) +} + +-- Environment-sourced credentials never inherit the profile's live_trade. +invariant EnvCredentialsIgnoreProfileLiveTrade { for r in CredentialResolutions: - r.kind = Unauthenticated implies r.environment = paper + (r.kind = EnvironmentAPIKey and env.live_trade_override = null) + implies r.environment = paper } + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "Unauthenticated + ALPACA_LIVE_TRADE=true resolves the live base URL (config.go:134-150). Auth-required commands abort before any request, but is pointing an unauthenticated invocation at the live URL intended, or should the paper default apply when no credentials resolve?" diff --git a/.allium/hand-authored-commands.allium b/.allium/hand-authored-commands.allium index ec58a26..46fb684 100644 --- a/.allium/hand-authored-commands.allium +++ b/.allium/hand-authored-commands.allium @@ -2,9 +2,11 @@ -- hand-authored-commands.allium -- Scope: Hand-authored CLI command logic for the Alpaca CLI --- Includes: bracket-order leg construction with time-in-force defaulting, +-- Includes: order-submit body hook (bracket legs, time-in-force defaulting, dry run), -- watchlist by-name asset removal, self-update discovery and upgrade, -- doctor diagnostics, credential profile management +-- Grounded in: internal/cmd/order.go, internal/cmd/watchlist.go, internal/cmd/update.go, +-- internal/cmd/update_check.go, internal/cmd/doctor.go, internal/cmd/auth.go -- Excludes: generated Cobra command tree (internal/cmd/commands.gen.go), -- raw API passthrough (api.go), factory helpers (factory.go), -- help-all rendering (help_all.go) @@ -13,12 +15,14 @@ -- External Entities ------------------------------------------------------------ +-- Broker-side resources; the Alpaca Trading API governs their lifecycle. + external entity Asset { symbol: String - asset_class: equity | crypto } external entity BrokerWatchlist { + id: String name: String assets: Set } @@ -27,10 +31,14 @@ external entity BrokerWatchlist { -- Value Types ------------------------------------------------------------ +-- The --take-profit flag accepts either a bare price (shorthand for +-- {"limit_price": }) or a raw JSON object (order.go:59-72). value TakeProfitLeg { limit_price: Decimal } +-- The --stop-loss flag accepts either a bare price (shorthand for +-- {"stop_price": }) or a raw JSON object (order.go:59-72). value StopLossLeg { stop_price: Decimal limit_price: Decimal? @@ -40,20 +48,41 @@ value StopLossLeg { -- Enumerations ------------------------------------------------------------ -enum InstallMethod { homebrew | go_install } - -enum TradingMode { paper | live } +-- Literals match the code exactly: "homebrew" and "goinstall" +-- (internal/cmd/update_check.go:11-13). +enum InstallMethod { homebrew | goinstall } enum CredentialSource { env_api_key | oauth | profile_api_key } +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- internal/cmd/auth.go:22 (defaultProfileName = config.EnvPaper) + default_profile_name: String = "paper" + -- internal/cmd/update.go:40, internal/cmd/doctor.go:89 + update_check_timeout: Duration = 10.seconds + -- internal/cmd/auth.go:352 (credential validation HTTP client) + validate_timeout: Duration = 10.seconds +} + ------------------------------------------------------------ -- Entities and Variants ------------------------------------------------------------ +-- A stored connection profile: one YAML file under +-- $ALPACA_CONFIG_DIR|~/.config/alpaca/profiles/.yaml +-- (internal/config/config.go:39-49, 208-243). Field-level shape matches +-- config.Profile. live_trade is a tri-state: absent means paper (safe +-- default), false means explicitly paper, true means live. entity Profile { name: String - trading_mode: TradingMode - credential_source: oauth | profile_api_key + api_key: String? + secret_key: String? + access_token: String? + scopes: String? + live_trade: Boolean? } entity Order { @@ -69,71 +98,130 @@ entity Order { } } +-- The structured document printed by `alpaca update --check` +-- (internal/cmd/update.go:50-61). +entity UpdateStatus { + current_version: String + latest_version: String + update_available: Boolean + install_method: InstallMethod + update_command: String +} + entity DiagnosticReport { cli_version: String credential_source: CredentialSource config_accessible: Boolean trading_api_reachable: Boolean data_api_reachable: Boolean - update_available: Boolean + -- null when the GitHub release check itself failed; doctor reports + -- "could not check for updates" without failing (doctor.go:88-98) + update_available: Boolean? +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +surface AlpacaCLIHandAuthored { + provides: + PlaceOrder(symbol, side, qty?, time_in_force?, take_profit?, stop_loss?, dry_run?) + RemoveWatchlistAssetByName(watchlist_name, symbol) + CheckForUpdate() + UpgradeCli(install_method, confirmed) + RunDiagnostics() + LoginWithOAuth(profile_name?, scopes?) + LoginWithApiKey(profile_name?, api_key, secret_key, live?) + LogoutProfile(profile_name?) + SwitchActiveProfile(profile_name) + + @guarantee NoLiveOAuthLogin + -- `alpaca profile login --live` without --api-key is rejected with an + -- error before any browser flow starts; OAuth login is paper-only + -- (auth.go:47-50). + + @guidance + -- Command mapping: PlaceOrder = `alpaca order submit` (generated + -- command + postOrderHook), RemoveWatchlistAssetByName = + -- `alpaca watchlist remove-by-name`, CheckForUpdate/UpgradeCli = + -- `alpaca update`, RunDiagnostics = `alpaca doctor`, the rest = + -- `alpaca profile login|logout|switch`. } ------------------------------------------------------------ -- Rules ------------------------------------------------------------ --- Bracket Order Construction +-- Order Construction (postOrderHook, internal/cmd/order.go) + +-- Time-in-force defaulting applies only when the user did not pass +-- --time-in-force. Crypto detection is purely syntactic: a "/" in the symbol +-- (e.g. BTC/USD) means crypto. No asset lookup is performed (order.go:30-35). rule ConstructBracketOrder { - when: PlaceOrder(symbol, side, qty, take_profit?, stop_loss?) + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) requires: take_profit != null or stop_loss != null + requires: dry_run != true - let asset = Asset{symbol} - let default_tif = if asset.asset_class = crypto: gtc else: day + let tif = + if time_in_force != null: time_in_force + else if is_crypto_pair_symbol(symbol): gtc + else: day ensures: Order.created( symbol: symbol, side: side, order_class: bracket, - time_in_force: default_tif, + time_in_force: tif, take_profit: take_profit, stop_loss: stop_loss ) + + @guidance + -- is_crypto_pair_symbol(s) is strings.Contains(s, "/") (order.go:31). + -- Presence of either leg flag forces order_class to "bracket" + -- (order.go:37-41); the user-supplied order class is overwritten. + -- Leg flags parse as shorthand price or raw JSON (order.go:59-72); + -- invalid JSON aborts the command before submission. } rule ConstructSimpleOrder { - when: PlaceOrder(symbol, side, qty, take_profit?, stop_loss?) + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) requires: take_profit = null and stop_loss = null + requires: dry_run != true - let asset = Asset{symbol} - let default_tif = if asset.asset_class = crypto: gtc else: day + let tif = + if time_in_force != null: time_in_force + else if is_crypto_pair_symbol(symbol): gtc + else: day ensures: Order.created( symbol: symbol, side: side, order_class: simple, - time_in_force: default_tif + time_in_force: tif ) } -rule PreviewOrder { - when: PreviewOrder(symbol, side, qty, take_profit?, stop_loss?) +rule DryRunOrder { + when: PlaceOrder(symbol, side, time_in_force?, take_profit?, stop_loss?, dry_run?) - ensures: OrderPreviewDisplayed( - symbol: symbol, - order_class: if (take_profit != null or stop_loss != null): bracket else: simple, - take_profit: take_profit, - stop_loss: stop_loss - ) + requires: dry_run = true + + ensures: OrderPreviewDisplayed(symbol: symbol) @guidance - -- No order is submitted. The constructed request body is displayed to the user. - -- Triggered when the user requests a dry run before committing to submission. + -- --dry-run returns the fully constructed request body (including + -- defaulted time-in-force and bracket legs) from the body hook, which + -- short-circuits submission; the body is rendered through the standard + -- output pipeline, so --jq and --csv apply to it (order.go:24-26, + -- commands.gen.go hook wiring, factory.go renderData). No order is + -- submitted. } --- Watchlist Management +-- Watchlist Management (internal/cmd/watchlist.go) rule RemoveWatchlistAssetByName { when: RemoveWatchlistAssetByName(watchlist_name, symbol) @@ -143,17 +231,42 @@ rule RemoveWatchlistAssetByName { requires: exists watchlist ensures: watchlist.assets.remove(Asset{symbol}) + + @guidance + -- Both --name and --symbol are required (watchlist.go:16-18). + -- Implemented as two API calls: resolve the watchlist by name, then + -- remove the symbol by watchlist ID (watchlist.go:20-31). A missing + -- watchlist fails with 'watchlist "" not found' before any + -- mutation. The updated watchlist is rendered on success. } --- Update Discovery and Self-Upgrade +-- Update Discovery and Self-Upgrade (internal/cmd/update.go, update_check.go) rule CheckForUpdate { when: CheckForUpdate() - ensures: UpdateStatusReported( + ensures: UpdateStatus.created( + current_version: current_version(), + latest_version: latest_published_version(), + update_available: version_newer(latest_published_version(), current_version()), install_method: detect_install_method(), - update_available: version_newer(latest_published_version(), current_version()) + update_command: upgrade_command(detect_install_method()) ) + + @guidance + -- latest_published_version() queries the GitHub releases API with a + -- config.update_check_timeout deadline (update.go:136-159). + -- version_newer compares numeric major.minor.patch, strips a leading + -- "v" and anything after "-" (pre-release), and is strictly greater + -- (update_check.go:55-78). + -- detect_install_method(): resolved executable path containing + -- "/Cellar/" or "/homebrew/" means homebrew; every other outcome is + -- goinstall (update_check.go:15-44 — the GOBIN/GOPATH branches also + -- return goinstall). + -- `alpaca update --check` prints this document as indented JSON to + -- stdout with "v" prefixes stripped, and never prompts (update.go:50-61). + -- The bare `alpaca` help output shows a best-effort update notice with + -- a 2-second timeout, silent on error or when current (update.go:120-130). } rule UpgradeCli { @@ -164,12 +277,17 @@ rule UpgradeCli { ensures: CliUpgraded(install_method: install_method) @guidance - -- Homebrew installs upgrade via brew. Go installs upgrade via go install. - -- In interactive terminals the user is prompted unless --yes is passed. - -- Install method is inferred from the executable's resolved path and environment. + -- confirmed means --yes was passed, or the user answered "y"/"yes" to + -- the interactive prompt (update.go:72-88). In a non-interactive + -- terminal without --yes, the upgrade command is printed but never + -- run, and the command exits successfully (update.go:72-76). + -- The upgrade shells out via `sh -c` to "brew upgrade alpacahq/tap/cli" + -- (homebrew) or "go install github.com/alpacahq/cli/cmd/alpaca@latest" + -- (goinstall), streaming output to the terminal (update.go:98-108, + -- update_check.go:46-53). } --- Doctor Diagnostics +-- Doctor Diagnostics (internal/cmd/doctor.go) rule RunDiagnostics { when: RunDiagnostics() @@ -184,46 +302,98 @@ rule RunDiagnostics { ) @guidance - -- Exits non-zero if any check fails. - -- Environment variable shadowing of profile credentials is reported as a warning, - -- not as a check failure. + -- Checks run in order: config directory exists, profiles exist, config + -- loads, credentials resolve, trading API GET /v2/account, data API + -- GET /v2/stocks/AAPL/trades/latest (10s client timeout), update check. + -- Exits non-zero ("some checks failed") if any check fails + -- (doctor.go:115-122). + -- A missing config directory or zero saved profiles is NOT a failure + -- when both ALPACA_API_KEY and ALPACA_SECRET_KEY are set + -- (doctor.go:24, 32-51). + -- No resolvable credentials aborts the remaining checks and fails + -- (doctor.go:60-63). + -- A failed update check is reported but does not fail doctor + -- (doctor.go:88-98). + -- Environment variable shadowing of profile credentials is reported as + -- a warning, not as a check failure (doctor.go:68). } --- Credential Profile Management +-- Credential Profile Management (internal/cmd/auth.go) rule LoginWithOAuth { - when: LoginWithOAuth(profile_name, scopes) + when: LoginWithOAuth(profile_name?, scopes?) - ensures: Profile.created( - name: profile_name, - trading_mode: paper, - credential_source: oauth - ) + let name = if profile_name != null: profile_name else: config.default_profile_name + + ensures: + Profile.created( + name: name, + access_token: granted_access_token(), + scopes: granted_scopes() + ) + ActiveProfileChanged(profile_name: name) @guidance - -- OAuth login is restricted to paper trading accounts. - -- Available scopes: account:write, trading, data. - -- Credentials are validated against the trading API before the profile is saved. + -- OAuth login is paper-only; --live is rejected (auth.go:47-50). + -- Scopes: --scope is comma-separated and converted to space-separated; + -- with no --scope on an interactive terminal, a multi-select prompt + -- offers account:write, trading, data (all pre-selected); otherwise the + -- default is all scopes (auth.go:63-74, 117-121). + -- The granted token is validated with GET /v2/account (Bearer auth) + -- against the paper trading API before the profile is saved, unless + -- --no-validate is passed (auth.go:81-87, 346-365). HTTP 401/403 means + -- invalid credentials; other 4xx/5xx is "unexpected response". + -- The saved profile stores access_token and the granted scope string; + -- live_trade is never written, so OAuth profiles resolve to paper + -- (auth.go:89-92). + -- Login also sets global default_profile to this profile, making it + -- active (auth.go:97-101). } rule LoginWithApiKey { - when: LoginWithApiKey(profile_name, api_key, secret_key, trading_mode) + when: LoginWithApiKey(profile_name?, api_key, secret_key, live?) - ensures: Profile.created( - name: profile_name, - trading_mode: trading_mode, - credential_source: profile_api_key - ) + let name = if profile_name != null: profile_name else: config.default_profile_name + + ensures: + Profile.created( + name: name, + api_key: api_key, + secret_key: secret_key, + live_trade: if live = true: true else: null + ) + ActiveProfileChanged(profile_name: name) @guidance - -- Supports both paper and live trading modes. - -- Credentials are validated against the trading API before the profile is saved. + -- Supports paper (default) and live (--live); live_trade is written + -- only for live profiles, paper profiles omit the field entirely + -- (auth.go:156-160, 201-207). + -- Missing --key/--secret are prompted interactively; the secret is + -- read without echo on a TTY (auth.go:168-186). Both are required; + -- empty values abort (auth.go:188-190). + -- Passing --secret on the command line prints a shell-history warning + -- (auth.go:163-166). + -- Credentials are validated with GET /v2/account using + -- APCA-API-KEY-ID / APCA-API-SECRET-KEY headers against the selected + -- environment before the profile is saved, unless --no-validate + -- (auth.go:192-199, 346-365). + -- Login also sets global default_profile to this profile + -- (auth.go:212-216). } rule LogoutProfile { - when: LogoutProfile(profile) + when: LogoutProfile(profile_name?) + + let name = if profile_name != null: profile_name else: config.default_profile_name + let profile = Profile{name: name} + + requires: exists profile ensures: not exists profile + + @guidance + -- Deleting a profile that does not exist fails with + -- 'profile "" not found' (auth.go:254-264). } rule SwitchActiveProfile { @@ -233,7 +403,12 @@ rule SwitchActiveProfile { requires: exists profile - ensures: ActiveProfileChanged(profile: profile) + ensures: ActiveProfileChanged(profile_name: profile_name) + + @guidance + -- Switching persists default_profile in the global config file + -- (auth.go:318-322). An unknown name fails, listing available profiles + -- and hinting at `alpaca profile login --name ` (auth.go:310-316). } -- Environment Credential Shadowing @@ -242,11 +417,19 @@ rule WarnOnEnvCredentialShadowing { when: profile: Profile.created requires: env_credentials_present() + requires: profile.access_token != null + or (profile.api_key != null and profile.secret_key != null) ensures: EnvShadowingWarning(profile: profile) @guidance - -- Fires when ALPACA_API_KEY / ALPACA_SECRET_KEY environment variables are set - -- at the time a profile is saved. The profile is persisted regardless; - -- the warning informs the user that env vars will take precedence at runtime. + -- Fires only when BOTH ALPACA_API_KEY and ALPACA_SECRET_KEY are set + -- AND the profile holds a complete credential bundle (an access token + -- or a full key pair) — env vars shadowing an empty profile is not + -- warned about (auth.go:225-234). + -- The profile is persisted regardless; the warning informs the user + -- that env vars take precedence at runtime. + -- The same warning is also printed by `alpaca doctor` (doctor.go:68) + -- and around the bare `alpaca` help output (root.go:114-125), not only + -- at profile save time. } diff --git a/.allium/http-client.allium b/.allium/http-client.allium index 4b75f83..ccbf778 100644 --- a/.allium/http-client.allium +++ b/.allium/http-client.allium @@ -1,13 +1,23 @@ -- allium: 3 -- http-client.allium +-- +-- Behaviour of the shared HTTP client (internal/client/client.go): auth +-- header selection, retry policy, error shaping, and credential scrubbing +-- of diagnostic output. ------------------------------------------------------------ -- Value Types ------------------------------------------------------------ +-- Shape of client.APIError (client.go:44-53). code is the numeric Alpaca +-- API error code parsed from the JSON error body; status is the HTTP +-- status (0 for transport failures). value RequestError { - code: String? + status: Integer + code: Integer? message: String + method: String? + path: String? request_id: String? hint: String? } @@ -18,6 +28,20 @@ value RequestError { enum HttpMethod { get | post | put | patch | delete } +------------------------------------------------------------ +-- Config +------------------------------------------------------------ + +config { + -- client.go:27 (maxRetries = 3): total send attempts, including the first + max_attempts: Integer = 3 + -- client.go:183: base delay is 500ms, doubling per attempt + backoff_base: Duration = 500.milliseconds + -- client.go:96, 103; overridable per invocation with --timeout + -- (root.go:190-192) + default_timeout: Duration = 30.seconds +} + ------------------------------------------------------------ -- Entities and Variants ------------------------------------------------------------ @@ -37,6 +61,7 @@ entity OutboundRequest { path: String attempt: Integer status: in_flight | awaiting_retry | succeeded | failed + error: RequestError when status = failed transitions status { in_flight -> awaiting_retry @@ -48,12 +73,48 @@ entity OutboundRequest { } ------------------------------------------------------------ --- Config +-- Surfaces ------------------------------------------------------------ -config { - max_attempts: Integer = 3 - backoff_base: Duration = 1.seconds +-- Generated typed client methods (internal/api/*_client.gen.go) and the raw +-- `alpaca api` passthrough all funnel through Client.Do / doWithRetry +-- (client.go:112-148). +surface TypedClientMethods { + provides: + SendRequest(client, method, path, body?) +} + +-- Stimuli from the Alpaca API server and the network. +surface UpstreamResponses { + provides: + ReceiveOkResponse(request) + ReceiveRateLimitResponse(request, retry_after?) + ReceiveServerErrorResponse(request) + ReceiveNonRetryableErrorResponse(request, http_status) + TransportFailure(request, cause) + + @guidance + -- ReceiveOkResponse: any 2xx/3xx (status < 400, client.go:260). + -- ReceiveRateLimitResponse: HTTP 429. + -- ReceiveServerErrorResponse: exactly 500, 502, 503 or 504 + -- (client.go:175-177); 501 and every other 5xx are NOT retryable. + -- ReceiveNonRetryableErrorResponse: any other status >= 400. + -- TransportFailure: DNS/TCP/TLS/timeout errors where no HTTP + -- response was received. +} + +-- The process clock: the retry backoff timer firing. +surface RetryScheduler { + provides: + RetryDelayElapsed(request) +} + +-- Verbose/debug/trace flags (and their ALPACA_VERBOSE / ALPACA_DEBUG / +-- ALPACA_TRACE env equivalents, root.go:152-160) route request/response +-- details to stderr. +surface DiagnosticOutput { + provides: + EmitDiagnosticOutput(content) } ------------------------------------------------------------ @@ -72,8 +133,12 @@ rule AuthWithAccessToken { ) @guidance - -- Access token authentication takes priority over API key and secret - -- authentication when both are configured. + -- Access-token auth sends "Authorization: Bearer " and takes + -- priority over API key/secret when both are configured + -- (client.go:207-208). Every request also carries + -- "User-Agent: alpaca-cli/" (client.go:216) and + -- "Content-Type: application/json" when a body is present + -- (client.go:213-215). } rule AuthWithApiKeyAndSecret { @@ -86,12 +151,23 @@ rule AuthWithApiKeyAndSecret { attempt: 1, status: in_flight ) + + @guidance + -- Key-pair auth sends the "APCA-API-KEY-ID" and + -- "APCA-API-SECRET-KEY" headers (client.go:209-211). If neither + -- credential is set the headers are sent empty; the server rejects + -- with 401 — the client itself never blocks an unauthenticated send. } rule SucceedOnOkResponse { when: ReceiveOkResponse(request) requires: request.status = in_flight ensures: request.status = succeeded + + @guidance + -- An empty 2xx body yields an empty result rather than a JSON parse + -- error (client.go:284-287); the command layer renders it as {} + -- (factory.go:94-103). } rule RetryOnRateLimitResponse { @@ -101,10 +177,12 @@ rule RetryOnRateLimitResponse { ensures: request.status = awaiting_retry @guidance - -- A rate-limit response indicates the server is temporarily rejecting - -- further requests. Wait for the delay stated in the Retry-After - -- response field before re-sending; apply exponential backoff if - -- no delay is stated. + -- Wait for the delay in the Retry-After response header, parsed as + -- integer seconds only (client.go:274-280); an absent or unparseable + -- header falls back to the exponential backoff used for server + -- errors (client.go:179-186). + -- Unless --quiet, a "Rate limited, retrying in ..." notice goes to + -- stderr; with --verbose every retry is announced (client.go:164-169). } rule RetryOnServerErrorResponse { @@ -114,9 +192,9 @@ rule RetryOnServerErrorResponse { ensures: request.status = awaiting_retry @guidance - -- A server error response indicates a transient server-side failure. - -- Apply exponential backoff with random jitter before re-sending. - -- Delay grows as backoff_base * 2^attempt. + -- Backoff before resend: backoff_base * 2^(attempt-1) plus uniform + -- random jitter in [0, delay/2] (client.go:179-186). With the + -- defaults: ~500ms, ~1s, ~2s (each +jitter). } rule ResumeAfterRetryDelay { @@ -141,12 +219,66 @@ rule FailOnServerErrorMaxAttempts { ensures: request.status = failed } +-- 4xx and non-retryable 5xx responses fail immediately, on any attempt. +rule FailOnNonRetryableErrorResponse { + when: ReceiveNonRetryableErrorResponse(request, http_status) + requires: request.status = in_flight + ensures: + request.status = failed + request.error = parse_api_error(http_status) + + @guidance + -- The error body is parsed as JSON into {code, message} + -- (client.go:261-262); a non-JSON or message-less body falls back to + -- the raw body text, then to the HTTP status text (client.go:262-267). + -- request_id comes from the X-Request-Id response header + -- (client.go:261). + -- Default hints are attached by status: 401 invalid credentials, + -- 403 forbidden, 422 validation, 429 rate limited (client.go:75-90). + -- A 401 whose body is not JSON gets a "possible proxy or wrong URL" + -- hint instead (client.go:268-273). +} + +-- Network-level failures are never retried: only *APIError values with a +-- retryable status re-enter the loop, and transport errors carry status 0 +-- (client.go:158-161, 175-177, 232-238). +rule FailOnTransportError { + when: TransportFailure(request, cause) + requires: request.status = in_flight + ensures: + request.status = failed + request.error = transport_error(cause) + + @guidance + -- The message is "could not reach : " with credentials + -- scrubbed from both URL and cause, plus a hint to check the + -- connection and run `alpaca doctor` (client.go:232-238, 312-321). +} + rule ScrubCredentialsFromDiagnosticOutput { when: EmitDiagnosticOutput(content) ensures: DiagnosticEmitted(content: scrub_credentials(content)) @guidance - -- Replace all occurrences of the API key, secret, and access token - -- with a redaction marker before writing to verbose, debug, or trace - -- output. + -- Every occurrence of the API key, secret and access token is + -- replaced with "[REDACTED]" in --verbose request lines, --debug + -- URLs, headers and response bodies, --trace output, and transport + -- error messages (client.go:219-256, 291-321). +} + +------------------------------------------------------------ +-- Invariants +------------------------------------------------------------ + +invariant AttemptsNeverExceedMax { + for r in OutboundRequests: + r.attempt <= config.max_attempts } + +------------------------------------------------------------ +-- Open Questions +------------------------------------------------------------ + +open question "The --debug request BODY is printed without credential scrubbing (client.go:221-223), while the request URL, headers and response body are scrubbed. Credentials normally travel in headers, not bodies — is the unscrubbed body intentional or an oversight?" + +open question "After the final failed retryable attempt, doWithRetry sleeps the full backoff delay once more before returning the error (client.go:150-172) — wasted latency of up to ~3s. Intended?" diff --git a/.allium/oauth-flow.allium b/.allium/oauth-flow.allium index 7fdba95..bad2c69 100644 --- a/.allium/oauth-flow.allium +++ b/.allium/oauth-flow.allium @@ -1,12 +1,15 @@ -- allium: 3 -- oauth-flow.allium --- Authorization-code OAuth2 flow for CLI user authentication. --- Currently restricted to paper trading; live trading requires API keys until PKCE is added. +-- Authorization-code OAuth2 flow for CLI user authentication +-- (internal/oauth/oauth.go, internal/oauth/config.go). +-- Currently restricted to paper trading; live trading requires API keys +-- until PKCE is added (internal/cmd/auth.go:47-50). ------------------------------------------------------------ -- Value Types ------------------------------------------------------------ +-- Shape of the token endpoint response (oauth.go:23-27). value AccessToken { access_token: String token_type: String @@ -20,6 +23,8 @@ value AccessToken { entity AuthorizationRequest { scope: String environment: String? + state: String + redirect_uri: String initiated_at: Timestamp status: pending | completed | denied | timed_out @@ -38,8 +43,42 @@ entity AuthorizationRequest { ------------------------------------------------------------ config { + -- oauth.go:73 (2-minute wait for the browser callback) authorization_timeout: Duration = 2.minutes + -- oauth.go:133 (HTTP client timeout for the token exchange) token_exchange_timeout: Duration = 15.seconds + authorize_url: String = "https://app.alpaca.markets/oauth/authorize" + token_url: String = "https://api.alpaca.markets/oauth/token" +} + +------------------------------------------------------------ +-- Surfaces +------------------------------------------------------------ + +-- `alpaca profile login` (OAuth path) drives the flow +-- (internal/cmd/auth.go:47-109). +surface ProfileLoginCommand { + provides: + UserInitiatesAuthorization(scope, environment?) +} + +-- The localhost callback endpoint the browser is redirected to +-- (oauth.go:83-115). +surface BrowserCallback { + provides: + AuthorizationCodeReceived(request, code, state) + AuthorizationCallbackError(request, reason) + + @guarantee CallbackPortsFixed + -- The callback server binds 127.0.0.1 on the first free port of the + -- pre-registered set 41920-41924 (oauth/config.go:12, + -- oauth.go:184-193); the redirect URI is + -- http://localhost:/callback (oauth.go:44). + + @guarantee NoPortNoFlow + -- If none of the callback ports can be bound, login fails before any + -- browser interaction, with a hint to free a port or close other + -- `alpaca profile login` instances (oauth.go:184-193). } ------------------------------------------------------------ @@ -51,41 +90,88 @@ rule UserInitiatesAuthorization { ensures: AuthorizationRequest.created( scope: scope, environment: environment, + state: random_state(), + redirect_uri: callback_redirect_uri(), initiated_at: now, status: pending ) @guidance - -- Implementation opens the user's browser to the Alpaca authorization endpoint. - -- A local callback server binds to a pre-registered localhost port. - -- A random state value is generated per request and embedded in the authorization URL - -- so that CSRF validation is possible when the callback arrives. + -- random_state() is 16 bytes from a cryptographic RNG, hex-encoded + -- (oauth.go:176-182); it is embedded as the `state` query parameter + -- of the authorization URL so the callback can be CSRF-validated. + -- The authorization URL carries response_type=code, client_id, + -- redirect_uri, state, plus scope and env when non-empty + -- (oauth.go:160-174). + -- The browser is opened via the platform opener (open/xdg-open/ + -- rundll32); if that fails, the URL is printed for manual use and + -- the flow keeps waiting (oauth.go:60-64, 195-206). } rule AuthorizationGranted { - when: AuthorizationCodeReceived(request, code) + when: AuthorizationCodeReceived(request, code, state) requires: request.status = pending + requires: state = request.state + requires: code != "" ensures: request.status = completed request.token = exchange_code(code) @guidance - -- The state parameter in the callback must exactly match the value generated at - -- initiation; a mismatch must be rejected as a potential CSRF attack. - -- exchange_code posts the code to Alpaca's token endpoint using the registered - -- client credentials and the exact redirect URI from the initiation request. + -- exchange_code POSTs grant_type=authorization_code, the code, the + -- CLI's registered client credentials and the exact redirect URI to + -- the token endpoint as a form body (oauth.go:117-131). + -- A non-200 token response, an unparseable body, or an empty + -- access_token in the response each fail the login with a + -- "token exchange failed" error (oauth.go:144-156) — the request + -- does NOT reach completed in those cases. +} + +-- A callback state that does not exactly match the value generated at +-- initiation is rejected as a potential CSRF attack: the browser gets +-- HTTP 400 and the CLI fails with "state mismatch" (oauth.go:99-103). +rule AuthorizationStateMismatch { + when: AuthorizationCodeReceived(request, code, state) + requires: request.status = pending + requires: state != request.state + ensures: request.status = denied +} + +-- A callback with a matching state but no authorization code fails with +-- "no authorization code received" (oauth.go:105-109). +rule AuthorizationMissingCode { + when: AuthorizationCodeReceived(request, code, state) + requires: request.status = pending + requires: state = request.state + requires: code = "" + ensures: request.status = denied } rule AuthorizationDenied { when: AuthorizationCallbackError(request, reason) requires: request.status = pending ensures: request.status = denied + + @guidance + -- Fires when the callback carries an `error` query parameter; the + -- error_description (or the error code when absent) is shown to the + -- user and rendered on the browser error page (oauth.go:85-94). + -- The browser page is static HTML; the description is HTML-escaped + -- (oauth.go:219-231). } rule AuthorizationTimedOut { when: request: AuthorizationRequest.initiated_at + config.authorization_timeout <= now requires: request.status = pending ensures: request.status = timed_out + + @guidance + -- The user sees "timed out waiting for authorization" with a hint to + -- complete the browser flow (oauth.go:73-75). + -- The race between a callback arriving and the timeout firing is + -- real and resolved first-event-wins by the select loop + -- (oauth.go:67-75); all four status transitions out of pending are + -- terminal, so the losing event has no effect. } ------------------------------------------------------------ @@ -93,3 +179,5 @@ rule AuthorizationTimedOut { ------------------------------------------------------------ open question "PKCE — when will PKCE or Device Authorization Grant (RFC 8628) be added to enable live trading authorization?" + +open question "The callback channel is first-writer-wins with capacity 1 (oauth.go:46); after a rejected callback (state mismatch / missing code) the flow fails immediately rather than continuing to wait for a legitimate callback. Is fail-fast the intended CSRF response, or should the server keep listening until the timeout?" diff --git a/.allium/output-contract.allium b/.allium/output-contract.allium index 384ee47..4729405 100644 --- a/.allium/output-contract.allium +++ b/.allium/output-contract.allium @@ -3,7 +3,10 @@ -- -- Two-tier output model: API commands emit structured data (JSON or CSV) -- to stdout with JSON errors on stderr; operational commands emit plain --- text. Covers jq-filter transformation and CSV formatting support. +-- text. Covers jq-filter transformation, CSV formatting, and process exit +-- codes. +-- Grounded in: internal/cmd/root.go (renderData, printJSONError, Execute), +-- internal/output/output.go, internal/output/jq.go, internal/cmd/factory.go. ------------------------------------------------------------ -- Enumerations @@ -15,14 +18,15 @@ enum OutputFormat { json | csv } -- Value Types ------------------------------------------------------------ +-- Shape of the JSON error document written to stderr (root.go:65-84). value CommandError { error: String - code: String? - status: Integer? + code: Integer + status: Integer hint: String - request_id: String? method: String? path: String? + request_id: String? } ------------------------------------------------------------ @@ -31,6 +35,11 @@ value CommandError { config { default_format: OutputFormat = json + exit_success: Integer = 0 + -- client.go:23 (ExitAPIError) + exit_error: Integer = 1 + -- client.go:24 (ExitAuthError, HTTP 401 only) + exit_auth_error: Integer = 2 } ------------------------------------------------------------ @@ -40,43 +49,92 @@ config { contract RenderingPipeline { render: (data: Any, format: OutputFormat, jq_filter: String?) -> String + @invariant FormatSelectionPrecedence + -- The output format resolves --csv flag > ALPACA_OUTPUT env var > + -- global config `output` field > json (root.go:163-167, + -- config.go:97). There is no --json flag; JSON is reached by not + -- selecting CSV at any level. + @invariant JqBeforeFormat -- When a jq filter is present, it is applied to the raw data -- before the output format serialiser runs. The serialiser - -- receives the transformed data, not the original. + -- receives the transformed data, not the original + -- (root.go:254-266). + + @invariant JqResultShape + -- A jq program producing exactly one value yields that value + -- unwrapped; zero or multiple values are collected into an array + -- (jq.go:40-44). Parse, compile and evaluation errors abort the + -- command with an "--jq:" prefixed error. @invariant NilCollectionAsEmptyArray -- A nil or absent collection renders as an empty JSON array ([]), - -- never as null. Downstream scripts need no null-checks on list - -- results. - - @invariant CsvColumnsAlphabetical - -- CSV columns are ordered alphabetically by field name when - -- column order is not otherwise specified by a schema. - - @invariant CsvHeadersFromSchema - -- When no jq filter is active and the command declares a response - -- schema, CSV headers follow the schema field list. When the data - -- set is empty but headers are declared, the header row is still - -- emitted. + -- never as null (output.go:28-35). Downstream scripts need no + -- null-checks on list results. + + @invariant EmptyResponseAsObject + -- Endpoints that return no content (204 etc.) render {} on stdout, + -- so stdout is always valid JSON in JSON mode (factory.go:94-103). + + @invariant CsvColumnsFromFirstRow + -- For a non-empty data set, CSV columns are the keys of the FIRST + -- row, sorted alphabetically (output.go:56-58). Keys missing from + -- later rows render as empty cells; keys appearing only in later + -- rows are dropped. Declared response schemas do NOT reorder + -- populated CSV output. + + @invariant CsvHeadersFromSchemaWhenEmpty + -- Schema-derived headers are used only when the data set is empty: + -- if no jq filter is active and the command declares a response + -- schema, an empty result still emits the header row + -- (output.go:45-55, root.go:262-263, 268-285). An empty result + -- with no schema headers emits nothing at all. + + @invariant CsvScalarFormatting + -- CSV cells: integral numbers render without decimals, other + -- numbers with two decimal places, booleans as true/false, null as + -- an empty cell (output.go:110-133). @invariant DefaultFormatIsJson -- When no output format is explicitly selected, the pipeline - -- defaults to JSON. + -- defaults to JSON, indented with two spaces (output.go:36-38). } contract ErrorChannel { emit_error: (error: CommandError) -> String @invariant ErrorOnStderr - -- Error documents are serialised as JSON and written to stderr, - -- independent of the configured output format or any jq filter. + -- Error documents are serialised as indented JSON and written to + -- stderr, independent of the configured output format or any jq + -- filter (root.go:65-84). @invariant ErrorStructure - -- Every error document carries at minimum the error message and - -- a hint. Optional fields (code, status, method, path, - -- request_id) are included only when the upstream API response - -- supplies them. + -- Every error document carries error, code, status and hint keys — + -- code and status are 0 and hint is "" when the failure was not an + -- API response (root.go:66-71). method, path and request_id are + -- included only when the upstream API response supplies them + -- (root.go:72-80). + + @invariant AllErrorsAreStructured + -- Every failure path funnels through Execute: API errors keep their + -- fields; any other error (flag parsing, validation, config + -- problems) is wrapped into an error document with just error/hint + -- text (root.go:50-63). No failure mode prints a bare non-JSON + -- message to stderr as its primary output. +} + +contract ExitStatus { + exit_code: (error: CommandError?) -> Integer + + @invariant ZeroOnSuccess + -- Successful commands exit 0. + + @invariant TwoOnAuthFailure + -- An API response of HTTP 401 exits with code 2 (client.go:68-73). + + @invariant OneOnAnyOtherFailure + -- Every other failure — API errors, usage errors, config errors — + -- exits with code 1 (root.go:50-62). } ------------------------------------------------------------ @@ -87,6 +145,7 @@ surface ApiCommandOutput { contracts: fulfils RenderingPipeline fulfils ErrorChannel + fulfils ExitStatus @guarantee StructuredStdout -- Successful API commands write structured data (JSON or CSV) to @@ -97,12 +156,34 @@ surface ApiCommandOutput { -- Errors from API commands appear on stderr as a JSON document. -- Callers can separate structured results (stdout) from failure -- signals (stderr) without parsing output text. + + @guarantee SchemaFlagShortCircuits + -- --schema prints a TypeScript-like response schema for the command + -- and exits without calling the API (root.go:136-140, 293-326). + + @guidance + -- Retry notices, verbose/debug/trace diagnostics and shadowing + -- warnings all go to stderr, never stdout. --quiet (or ALPACA_QUIET) + -- suppresses those notices and disables color (root.go:145-150, + -- client.go:164-169). } surface OperationalCommandOutput { + contracts: + fulfils ExitStatus + @guarantee PlainTextStdout -- Operational commands (version, update, doctor) emit -- human-readable plain text to stdout. They do not use the -- structured rendering pipeline and are not subject to -- --jq or --csv flags. + + @guarantee UpdateCheckIsJson + -- Exception: `alpaca update --check` prints a machine-readable + -- indented JSON status document to stdout (update.go:50-61); it is + -- still outside the rendering pipeline, so --jq/--csv do not apply. + + @guarantee NoAuthRequired + -- version, help, completion, update and doctor run without resolved + -- credentials (root.go:231-241). } From 51f2bbdef3453915d6ac6e6dc964665dcb68404a Mon Sep 17 00:00:00 2001 From: Yuki Hayashi Date: Sun, 9 Aug 2026 05:37:11 +0000 Subject: [PATCH 5/5] ci(allium): gate on diagnostic severity, not a grepped summary line The gate grepped for an "N error(s)" summary. allium 3.5.0 and 3.5.3 emit JSON and never print that string, so the job failed unconditionally, independent of spec content -- verified directly against both binaries. The raw exit code cannot replace the grep either: "allium check --help" documents exit 1 as "one or more errors OR WARNINGS were reported", and specs carry unavoidable warnings (externalEntity.missingSourceHint, use.unresolvedPath). Gating on the exit code would keep CI red for content-free reasons -- exactly what the original comment set out to avoid. The gate now parses diagnostics and fails only on severity == error. allium emits one JSON document per spec file, concatenated, so the stream is slurped with "jq -s". The legacy text summary is retained as a fallback because runner caches are not uniform across repos. If neither format parses, the job fails loudly rather than reporting green. Verified against real output: forex 0 err / 2 warn PASS, banking 0/7 PASS, cli 0/0 PASS, data-warehouse 4 err FAIL, unparseable input FAIL. --- .github/workflows/allium-check.yml | 63 +++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/.github/workflows/allium-check.yml b/.github/workflows/allium-check.yml index e5b1354..d4e16e7 100644 --- a/.github/workflows/allium-check.yml +++ b/.github/workflows/allium-check.yml @@ -54,28 +54,71 @@ jobs: run: cargo install --locked --version ${{ env.ALLIUM_VERSION }} allium-cli - name: Run allium check - # `-e` is GHA's default, but `allium check` exits non-zero on any - # warning — we only want to fail CI on actual errors. Disable - # errexit and gate on the parsed summary line. + # Gate on DIAGNOSTIC SEVERITY, not on the process exit code and not on + # a grepped summary line. + # + # * `allium check --help` documents exit 1 as "One or more errors OR + # WARNINGS were reported", so the exit code cannot express + # "errors only". Specs carry unavoidable warnings + # (externalEntity.missingSourceHint, use.unresolvedPath), so gating + # on it would keep this job red for content-free reasons. + # * The previous gate grepped for an "N error(s)" summary. allium + # 3.5.0 and 3.5.3 emit JSON and never print that string, so the job + # failed unconditionally, independent of spec content. + # * allium emits ONE JSON DOCUMENT PER SPEC FILE, concatenated, so the + # stream must be slurped (`jq -s`), not parsed as a single object. + # + # Older binaries printed a human summary instead of JSON and runner + # caches are not uniform, so the legacy format is still accepted. If + # NEITHER parses, the job fails loudly: a gate that cannot read its own + # input must never report green. shell: bash run: | - set +e set -uo pipefail FILES=$(find . -type f -name "*.allium" -not -path "./.git/*" | sort) if [ -z "$FILES" ]; then echo "No .allium files found; nothing to check." exit 0 fi - echo "Checking:" - echo "$FILES" | sed 's/^/ /' - allium check $FILES 2>&1 | tee allium.log - ERRORS=$(grep -oE '[0-9]+ error\(s\)' allium.log | tail -1 | awk '{print $1}') + echo "Checking:"; echo "$FILES" | sed 's/^/ /' + allium --version || true + + set +e + allium check $FILES >allium.out 2>allium.err + RC=$? + set -e + + if [ "$RC" -eq 2 ]; then + echo "::error::allium check could not resolve inputs (exit 2)" + cat allium.err || true + exit 1 + fi + + ERRORS="" + WARNINGS="" + if jq -e -s 'length > 0' allium.out >/dev/null 2>&1; then + ERRORS=$(jq -s '[.[].diagnostics[]? | select(.severity=="error")] | length' allium.out) + WARNINGS=$(jq -s '[.[].diagnostics[]? | select(.severity=="warning")] | length' allium.out) + jq -s -r '.[] | .spec_file as $f | .diagnostics[]? + | select(.severity=="error") + | "::error file=\($f),line=\(.location.line // 0)::\(.code): \(.message)"' \ + allium.out || true + else + ERRORS=$(grep -oE '[0-9]+ error\(s\)' allium.out | tail -1 | awk '{print $1}') + WARNINGS=$(grep -oE '[0-9]+ warning\(s\)' allium.out | tail -1 | awk '{print $1}') + [ -n "$ERRORS" ] && echo "note: parsed legacy text summary (binary predates JSON output)" + fi + if [ -z "$ERRORS" ]; then - echo "::error::could not parse allium check summary" + echo "::error::allium check output matched neither JSON nor the legacy summary (exit $RC)" + head -20 allium.out || true + cat allium.err || true exit 1 fi + + echo "errors: $ERRORS, warnings: ${WARNINGS:-0} (warnings do not gate)" if [ "$ERRORS" -gt 0 ]; then echo "::error::allium check reported $ERRORS error(s)" exit 1 fi - echo "OK — 0 errors." + echo "OK - 0 errors."