diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index fbc3015250..8398136b0b 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -102,6 +102,7 @@ "registry-coordinator-client-node", "registry-coordinator-client-py", "registry-thunderid-tooling", + "registry-rstack", "registry-cli-docs", "registry-cli-reference", "registry-language-server", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2c96bcc53..89bdfa44cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1778,6 +1778,9 @@ jobs: - name: Test release candidate manifest and promotion verifier run: python3 -m unittest release/scripts/test_release_candidate.py + - name: Test rstack release component inventory + run: python3 -m unittest release/scripts/test_rstack_components.py + - name: Test public release verifier run: python3 -m unittest release/scripts/test_verify_public_release.py diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index e07ac2ed10..f7da89afd6 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -624,6 +624,13 @@ jobs: -p registry-evidence -p registry-evidencectl \ -p registry-evidence-oid4vci \ --target "${{ matrix.target }}" + rstack_in_release="$(python3 release/scripts/release_roster.py \ + rstack-in-release "${{ needs.validate.outputs.version }}")" + if [[ "${rstack_in_release}" == true ]]; then + cargo build --release --locked \ + -p registry-rstack --bin rstack \ + --target "${{ matrix.target }}" + fi IFS=. read -r release_major release_minor _release_patch \ <<<"${{ needs.validate.outputs.version }}" include_breg=0 @@ -664,6 +671,13 @@ jobs: --target "${{ matrix.target }}" fi mkdir -p platform + if [[ "${rstack_in_release}" == true ]]; then + asset="rstack-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" + cp "target/${{ matrix.target }}/release/rstack" "platform/${asset}" + chmod 0755 "platform/${asset}" + test "$("platform/${asset}" --version)" = \ + "rstack ${{ needs.validate.outputs.version }}" + fi for evidence_binary in evidence evidencectl evidence-oid4vci; do asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/${evidence_binary}" \ @@ -2042,6 +2056,24 @@ jobs: "${binary} ${{ needs.validate.outputs.version }}" done rm candidate/bundle-root/SHA256SUMS + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" == true ]]; then + rstack_installer="rstack-${{ needs.validate.outputs.tag }}-install.sh" + awk -v version="${{ needs.validate.outputs.tag }}" ' + $0 == "default_version=\"\"" { + print "default_version=\"" version "\"" + rendered = 1 + next + } + { print } + END { if (!rendered) exit 1 } + ' crates/registry-rstack/install.sh \ + > "candidate/bundle-root/${rstack_installer}" + chmod 0755 "candidate/bundle-root/${rstack_installer}" + cp "candidate/bundle-root/${rstack_installer}" \ + candidate/bundle-root/rstack-install.sh + chmod 0755 candidate/bundle-root/rstack-install.sh + fi for name in ${RELEASE_IMAGE_NAMES}; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" @@ -2067,6 +2099,7 @@ jobs: set -euo pipefail canonical="inputs/candidate-canonical-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" tag="${{ needs.validate.outputs.tag }}" + version="${{ needs.validate.outputs.version }}" bundle="candidate/registry-stack-${tag}-candidate.tar.gz" created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" expires_at="$(date -u -d "${created_at} + 7 days" +%Y-%m-%dT%H:%M:%SZ)" @@ -2097,6 +2130,12 @@ jobs: '{name:$name,kind:$kind,size:$size,sha256:$sha256}' done | jq -s . )" + components=null + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" == true ]]; then + components="$(printf '%s' "${payloads}" \ + | python3 release/scripts/rstack_components.py --version "${version}")" + fi images="$( for name in ${RELEASE_IMAGE_NAMES}; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" @@ -2134,6 +2173,7 @@ jobs: --arg created_at "${created_at}" \ --arg expires_at "${expires_at}" \ --argjson payloads "${payloads}" \ + --argjson components "${components}" \ --argjson images "${images}" \ --arg docs_name "${docs_name}" \ --arg docs_sha "$(sha256sum "candidate/bundle-root/${docs_name}" | awk '{print $1}')" \ @@ -2179,11 +2219,48 @@ jobs: size: $bundle_size, sha256: $bundle_sha } - }' > candidate/candidate-manifest.draft.json + } + (if $components == null then {} else {components:$components} end)' \ + > candidate/candidate-manifest.draft.json python3 release/scripts/release_candidate.py seal-candidate \ --draft candidate/candidate-manifest.draft.json \ --output candidate/release-candidate-manifest.json + - name: Smoke release-pinned rstack installation from verified local assets + if: ${{ needs.validate.outputs.version != '' }} + shell: bash + run: | + set -euo pipefail + version="${{ needs.validate.outputs.version }}" + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" != true ]]; then + exit 0 + fi + tag="${{ needs.validate.outputs.tag }}" + assets="${GITHUB_WORKSPACE}/candidate/bundle-root" + cp candidate/release-candidate-manifest.json \ + "${assets}/registry-stack-${tag}-release-manifest.json" + ( + cd "${assets}" + sha256sum -- * > SHA256SUMS + ) + destination="${RUNNER_TEMP}/rstack-install-smoke" + bash "${assets}/rstack-${tag}-install.sh" \ + --asset-dir "${assets}" \ + --bin-dir "${destination}/bin" \ + --install-root "${destination}/lib" \ + breg evidence + test -L "${destination}/bin/rstack" + test "$(find "${destination}/bin" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 + test "$("${destination}/bin/rstack" --version)" = "rstack ${version}" + "${destination}/bin/rstack" components list --format json \ + | jq -e '.components | any(.name == "breg" and .installed == true)' \ + >/dev/null + "${destination}/bin/rstack" components list --format json \ + | jq -e '.components | any(.name == "evidence" and .installed == true)' \ + >/dev/null + test "$("${destination}/bin/rstack" breg --version)" = "bregctl ${version}" + test "$("${destination}/bin/rstack" evidence --version)" = "evidencectl ${version}" + - name: Upload one candidate manifest and bundle uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/Cargo.lock b/Cargo.lock index 58155978f4..aab6c3ca32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5397,9 +5397,11 @@ dependencies = [ "registry-evidence", "registry-evidence-oid4vci", "registry-evidencectl", + "registry-manifest-cli", "registry-messaging", "registry-messagingctl", "registry-render", + "registry-rstack", "registry-scheduling", "registry-schedulingctl", "serde", @@ -5451,6 +5453,7 @@ dependencies = [ "registry-scheduling-core", "reqwest 0.12.28", "rhai", + "rustix 1.1.5", "rustls", "schemars", "serde", @@ -5938,6 +5941,7 @@ dependencies = [ name = "registry-manifest-cli" version = "0.44.0" dependencies = [ + "clap", "jsonschema", "registry-manifest-core", "registry-platform-buildinfo", @@ -6516,6 +6520,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "registry-rstack" +version = "0.44.0" +dependencies = [ + "clap", + "fs2", + "hex", + "registry-platform-canonical-json", + "rustix 1.1.5", + "serde", + "serde_json", + "sha2 0.11.1", + "tempfile", + "ureq", +] + [[package]] name = "registry-scheduling" version = "0.44.0" diff --git a/Cargo.toml b/Cargo.toml index 376181969a..df52dae5ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ members = [ "crates/registry-thunderid-tooling", "crates/registry-manifest-core", "crates/registry-manifest-cli", + "crates/registry-rstack", "crates/registry-record", "crates/registry-render", "crates/registry-breg-client", @@ -118,6 +119,8 @@ registry-evidencectl = { path = "crates/registry-evidencectl", version = "0.44.0 registry-language-server = { path = "crates/registry-language-server", version = "0.44.0" } registry-linkml = { path = "crates/registry-linkml", version = "0.44.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.44.0" } +registry-manifest-cli = { path = "crates/registry-manifest-cli", version = "0.44.0" } +registry-rstack = { path = "crates/registry-rstack", version = "0.44.0" } registry-record = { path = "crates/registry-record", version = "0.44.0" } registry-render = { path = "crates/registry-render", version = "0.44.0" } registry-breg-client = { path = "crates/registry-breg-client", version = "0.44.0" } diff --git a/crates/CLIENTS.md b/crates/CLIENTS.md index d028d0fa4d..8c368e35b6 100644 --- a/crates/CLIENTS.md +++ b/crates/CLIENTS.md @@ -39,7 +39,7 @@ or unify distinct authority contracts. Shared HTTP/token primitives live in with the attempt rolled back), and never after a 4xx status line: `registry_platform_httputil::client::retry_keyed_mutation` owns its loop and waits, the BReg, Casework, Coordinator, Messaging, and Scheduling clients - expose the count as `with_max_mutation_retries` (2 by default, at most 2, + expose the count as `with_maximum_mutation_retries` (2 by default, at most 2, 0 disables), and the resend repeats the caller's exact key, headers, and body. The other clients never resend. Reads and unkeyed operations are never resent, and diff --git a/crates/registry-breg-client-node/README.md b/crates/registry-breg-client-node/README.md index 14392dac14..ef4db430c9 100644 --- a/crates/registry-breg-client-node/README.md +++ b/crates/registry-breg-client-node/README.md @@ -114,11 +114,19 @@ Fixed-scale decimals remain JSON strings. Null and absent members remain distinc Do not round unsupported input to make it pass. JSON whitespace and equivalent number spellings may be canonicalized; the promise concerns values and types. +Client configuration uses `maximumResponseBytes`; the retired +`maxResponseBytes` spelling is refused. Optional configuration values are +omitted rather than set to `null`. `trustedRootCertificates` accepts PEM text, +and the supplied bundle keeps the Rust client's explicit trust-root semantics. +`attemptTimeoutMilliseconds` bounds one outbound attempt, including its body, +and `connectTimeoutMilliseconds` bounds connection setup. + Actions retain their original opaque authority and exact retry semantics. A keyed mutation (create, patch, attachment upload or delete, Proposal transition, immediate Action, batch, tombstone, statistics publish or withdraw) whose outcome is unknown is resent identically under the same key up to -`maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. The +`maximumMutationRetries` additional times: 0 to 2, default 2, and 0 sends it +once, while the default permits up to three total attempts. The outcome is unknown after a timeout or broken exchange once the request was sent, an unusable answer, or a 5xx other than `action.handler_failed` and `statistical_dataset.domain_violation`, which the engine returns only with the diff --git a/crates/registry-breg-client-node/__test__/binding.test.js b/crates/registry-breg-client-node/__test__/binding.test.js index 3977fc875d..dbdf4b4eca 100644 --- a/crates/registry-breg-client-node/__test__/binding.test.js +++ b/crates/registry-breg-client-node/__test__/binding.test.js @@ -146,22 +146,36 @@ test('nullable record options use request defaults', async () => { }); test('constructors reject unsupported fields and unsafe integers', () => { + assert.ok(new BaseRegistryClient({ + baseUrl, + attemptTimeoutMilliseconds: 5_000, + connectTimeoutMilliseconds: 2_000, + maximumMutationRetries: 1, + })); assert.throws(() => new BaseRegistryClient({ baseUrl, retry: true }), (error) => ( error instanceof BaseRegistryClientError && error.kind === 'configuration' )); assert.throws(() => new BaseRegistryClient({ baseUrl, - maxResponseBytes: Number.MAX_SAFE_INTEGER + 1, + maximumResponseBytes: Number.MAX_SAFE_INTEGER + 1, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new BaseRegistryClient({ + baseUrl, + maxResponseBytes: 8 * 1024 * 1024, }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); }); test('a configuration value the sanitizer refuses is a configuration error', () => { for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a negative number', { baseUrl, maximumResponseBytes: -1 }], + ['a fractional number', { baseUrl, maximumResponseBytes: 1.5 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['a null member', { baseUrl, maximumResponseBytes: null }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['an undefined member', { baseUrl, userAgent: undefined }], ['a function', { baseUrl, userAgent: () => 'breg-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { @@ -174,7 +188,7 @@ test('a configuration value the sanitizer refuses is a configuration error', () } }); -test('nullable private-key JWT durations use provider defaults', () => { +test('omitted private-key JWT durations use provider defaults and null is refused', () => { const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); const clientKey = privateKey.export({ format: 'jwk' }); clientKey.alg = 'ES256'; @@ -186,12 +200,29 @@ test('nullable private-key JWT durations use provider defaults', () => { tokenEndpoint: 'https://issuer.invalid/oauth/token', clientId: 'breg-node-binding-test-client', clientKey, - assertionLifetimeSeconds: null, - refreshMarginSeconds: null, + attemptTimeoutMilliseconds: 5_000, }, }, }; assert.ok(new BaseRegistryClient(config)); + assert.throws(() => new BaseRegistryClient({ + ...config, + authorization: { + privateKeyJwt: { + ...config.authorization.privateKeyJwt, + requestTimeoutMilliseconds: 5_000, + }, + }, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new BaseRegistryClient({ + ...config, + authorization: { + privateKeyJwt: { + ...config.authorization.privateKeyJwt, + assertionLifetimeSeconds: null, + }, + }, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); assert.throws(() => new BaseRegistryClient({ ...config, authorization: { diff --git a/crates/registry-breg-client-node/__test__/exchange-authorization.test.js b/crates/registry-breg-client-node/__test__/exchange-authorization.test.js index 6bfc75e197..1e334e298a 100644 --- a/crates/registry-breg-client-node/__test__/exchange-authorization.test.js +++ b/crates/registry-breg-client-node/__test__/exchange-authorization.test.js @@ -29,6 +29,26 @@ async function form(request) { return new URLSearchParams(Buffer.concat(chunks).toString()); } +test('exchange configuration rejects null, retired keys, and unsafe integers before I/O', () => { + const exchange = { + client: clientConfig('https://issuer.example/token', 'urn:records', ['records:get']), + context: { issuer: 'https://portal.example', subject: 'person-1', audience: 'https://issuer.example', generation: 'verified-1', deadlineSeconds: Math.floor(Date.now() / 1000) + 120 }, + firstParty: { key: key(), attributes: { registry_actor_kind: 'human' } }, + }; + for (const changed of [ + { ...exchange, context: { ...exchange.context, grantId: null } }, + { ...exchange, client: { ...exchange.client, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 } }, + { ...exchange, client: { ...exchange.client, requestTimeoutMilliseconds: 100 } }, + { ...exchange, client: { ...exchange.client, attemptTimeoutSeconds: 0.1 } }, + { ...exchange, client: { ...exchange.client, request_timeout_milliseconds: 100 } }, + ]) { + assert.throws( + () => new BaseRegistryClient({ baseUrl: 'https://registry.example', authorization: { exchange: changed } }), + (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration', + ); + } +}); + test('first-party option signs one verified person context and caches its token', async () => { const exchanges = []; const { server, origin } = await serve(async (request, response) => { diff --git a/crates/registry-breg-client-node/__test__/facade.test-d.ts b/crates/registry-breg-client-node/__test__/facade.test-d.ts index 1b663407c0..48b238c19a 100644 --- a/crates/registry-breg-client-node/__test__/facade.test-d.ts +++ b/crates/registry-breg-client-node/__test__/facade.test-d.ts @@ -30,6 +30,15 @@ import { } from '..' declare const client: BaseRegistryClient +new BaseRegistryClient({ + baseUrl: 'https://registry.example.test/', + maximumResponseBytes: 8 * 1024 * 1024, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +// @ts-expect-error The abbreviated response-bound spelling is retired. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxResponseBytes: 1024 }) +// @ts-expect-error Optional configuration values are omitted rather than set to null. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maximumResponseBytes: null }) declare const failure: BaseRegistryClientError if (failure.constraint?.constraintKind === 'compare') { failure.constraint.left.toUpperCase() @@ -236,8 +245,17 @@ client.createRecordJson(create, { wide: 9007199254740992 }, 'exact-create') client.statisticsLive('population', null, null, 'reader', 'csv').then((raw) => raw.reprDigest?.toUpperCase()) -const once = new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 }) +const once = new BaseRegistryClient({ + baseUrl: 'https://registry.example.test/', + attemptTimeoutMilliseconds: 30_000, + connectTimeoutMilliseconds: 10_000, + maximumMutationRetries: 0, +}) void once +// @ts-expect-error Retired option names are refused before I/O. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', requestTimeoutMilliseconds: 30_000 }) +// @ts-expect-error Retired option names are refused before I/O. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 }) function mayHaveTakenEffect(error: BaseRegistryClientError): boolean { const unknown: boolean = error.outcomeUnknown diff --git a/crates/registry-breg-client-node/__test__/immediate-actions.test.js b/crates/registry-breg-client-node/__test__/immediate-actions.test.js index 9a18342053..0feb6fb6b1 100644 --- a/crates/registry-breg-client-node/__test__/immediate-actions.test.js +++ b/crates/registry-breg-client-node/__test__/immediate-actions.test.js @@ -111,7 +111,7 @@ test('an immediate action recovers after a lost response from persisted bytes an try { const baseUrl = `http://127.0.0.1:${server.address().port}`; // The process gives up after the lost answer instead of resending it. - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 0 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 0 }); const binding = (await client.registryContract('writer')).selectAction('update-item', 'writer'); const conditions = await client.actionTargetConditions(binding, { targetId }); const inputs = { targetId, label: 'new' }; diff --git a/crates/registry-breg-client-node/__test__/mutation-retry.test.js b/crates/registry-breg-client-node/__test__/mutation-retry.test.js index 39c258d44b..d737b43fb2 100644 --- a/crates/registry-breg-client-node/__test__/mutation-retry.test.js +++ b/crates/registry-breg-client-node/__test__/mutation-retry.test.js @@ -74,7 +74,7 @@ test('a publication answered 503 is resent once under the same key and succeeds' test('a retry ceiling of zero sends a publication once and reports the outcome unknown', async (context) => { const { baseUrl, requests } = await serve(context, [unavailable, publication]); - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 0 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 0 }); await assert.rejects(publish(client), (error) => { assert.ok(error instanceof BaseRegistryClientError); @@ -95,7 +95,7 @@ test('a refusal or a failure before commit is never resent and reports the outco }), ]) { const { baseUrl, requests } = await serve(context, [refusal, publication]); - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 2 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(publish(client), (error) => { assert.equal(error.kind, 'problem'); @@ -108,17 +108,21 @@ test('a refusal or a failure before commit is never resent and reports the outco }); test('a retry ceiling outside zero to two is a configuration error', () => { - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new BaseRegistryClient({ baseUrl: 'https://registry.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof BaseRegistryClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof BaseRegistryClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); } + assert.throws( + () => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries: 1 }), + (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration', + ); }); test('a request refused before any exchange reports the outcome known', async () => { diff --git a/crates/registry-breg-client-node/__test__/read-parity.test.js b/crates/registry-breg-client-node/__test__/read-parity.test.js index 7bb281c3c3..cbb1157350 100644 --- a/crates/registry-breg-client-node/__test__/read-parity.test.js +++ b/crates/registry-breg-client-node/__test__/read-parity.test.js @@ -117,7 +117,7 @@ test('exact JSON reads preserve and follow the largest server cursor envelope', try { const client = new BaseRegistryClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, - maxResponseBytes: 32 * 1024, + maximumResponseBytes: 32 * 1024, }); const first = await client.listRecordsJson('companies', { top: 1, count: true }); assert.equal(first.continuation.skiptoken.length, cursor.length); diff --git a/crates/registry-breg-client-node/__test__/token-exchange.test.js b/crates/registry-breg-client-node/__test__/token-exchange.test.js index f1cf3a1099..fc1e63bfef 100644 --- a/crates/registry-breg-client-node/__test__/token-exchange.test.js +++ b/crates/registry-breg-client-node/__test__/token-exchange.test.js @@ -22,7 +22,8 @@ test('exchange preserves service cache and sends exact independent grant request const clientKey = crypto.generateKeyPairSync('ec', {namedCurve:'prime256v1'}).privateKey.export({format:'jwk'}); Object.assign(clientKey, {alg:'ES256', kid:'synthetic-test'}); const config = {tokenEndpoint:`http://127.0.0.1:${server.address().port}/token`, clientId:'test-client', - clientKey, resource:'urn:test:resource', scopes:['records:get']}; + clientKey, resource:'urn:test:resource', scopes:['records:get'], attemptTimeoutMilliseconds:5_000, + connectTimeoutMilliseconds:2_000}; const provider = new PrivateKeyJwt(config); assert.equal(await provider.bearerToken(), 'service-token'); assert.deepEqual(await Promise.all([provider.exchange('first-subject'), provider.exchange('second-subject')]), @@ -38,7 +39,10 @@ test('exchange preserves service cache and sends exact independent grant request } assert.notEqual(requests[1].get('client_assertion'), requests[2].get('client_assertion')); await assert.rejects(provider.exchange('bad\nsubject'), error => error instanceof BaseRegistryClientError && !error.message.includes('bad')); - await assert.rejects(new PrivateKeyJwt({...config, resource:null}).exchange('subject')); + assert.throws(() => new PrivateKeyJwt({...config, resource:null}), + error => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new PrivateKeyJwt({...config, requestTimeoutMilliseconds:5_000}), + error => error instanceof BaseRegistryClientError && error.kind === 'configuration'); assert.equal(requests.length, 3); } finally { await new Promise(resolve => server.close(resolve)); } }); diff --git a/crates/registry-breg-client-node/client.d.ts b/crates/registry-breg-client-node/client.d.ts index c31200f41d..f821e856b8 100644 --- a/crates/registry-breg-client-node/client.d.ts +++ b/crates/registry-breg-client-node/client.d.ts @@ -62,24 +62,24 @@ export interface PrivateKeyJwtConfig { * authentication). Defaults to the token endpoint URL. This is not the * `resource` of the token request. */ - audience?: string | null + audience?: string /** * The RFC 8707 resource indicator the token is requested for: the resource * server's registered identifier, not a URL to fetch. */ - resource?: string | null + resource?: string /** * The scopes requested for the token, sent as one space-delimited `scope` * parameter. A requested scope may narrow the client's registered * permission set; it can never widen it. */ - scopes?: ReadonlyArray | null - assertionLifetimeSeconds?: SafeInteger | null - refreshMarginSeconds?: SafeInteger | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + scopes?: ReadonlyArray + assertionLifetimeSeconds?: SafeInteger + refreshMarginSeconds?: SafeInteger + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } /** OAuth credentials are returned to the caller. Keep them out of logs and persistence. */ @@ -98,7 +98,7 @@ export interface ExchangeContext { audience: string generation: string deadlineSeconds: SafeInteger - grantId?: string | null + grantId?: string } export type ExchangePrivateKeyJwtConfig = PrivateKeyJwtConfig & { @@ -118,10 +118,10 @@ export interface RemoteExchangeSource { bootstrap: ExchangePrivateKeyJwtConfig bootstrapResource: string bootstrapScope: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } export type ExchangeAuthorizationConfig = @@ -135,12 +135,12 @@ export type BaseRegistryAuthorization = export interface BaseRegistryClientConfig { baseUrl: string - authorization?: BaseRegistryAuthorization | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + authorization?: BaseRegistryAuthorization + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times a keyed mutation (create, patch, attachment upload or * delete, lifecycle action, immediate action, batch, tombstone, statistics @@ -149,7 +149,7 @@ export interface BaseRegistryClientConfig { * number from 0 to 2, default 2; 0 sends it once. Any other value is a * `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type RecordFormat = 'json' | 'json-ld' diff --git a/crates/registry-breg-client-node/index.d.ts b/crates/registry-breg-client-node/index.d.ts index 1faceed70e..0b0a4683e4 100644 --- a/crates/registry-breg-client-node/index.d.ts +++ b/crates/registry-breg-client-node/index.d.ts @@ -66,7 +66,7 @@ export declare class BaseRegistryClient { /** * Invoke one metadata-selected immediate action. An invocation whose * outcome is unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ invokeAction(binding: BRegActionBinding, inputs: any, idempotencyKey: string, conditions?: BRegActionTargetConditions | undefined | null): Promise /** @@ -83,7 +83,7 @@ export declare class BaseRegistryClient { /** * Explicitly send a recovered invocation under its original key. An * invocation whose outcome is unknown is resent identically under that - * key, at most `maxMutationRetries` times. + * key, at most `maximumMutationRetries` times. */ executeRecoveredAction(binding: BRegActionBinding, recovered: BRegRecoveredAction): Promise /** Execute a recovered proposal start with its exact original request and key. */ @@ -99,7 +99,7 @@ export declare class BaseRegistryClient { /** * Execute one metadata-selected atomic batch. A batch whose outcome is * unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ batchRecords(binding: BRegBatchBinding, request: any, idempotencyKey: string): Promise /** diff --git a/crates/registry-breg-client-node/src/lib.rs b/crates/registry-breg-client-node/src/lib.rs index 145e7d37b0..9db2710e87 100644 --- a/crates/registry-breg-client-node/src/lib.rs +++ b/crates/registry-breg-client-node/src/lib.rs @@ -472,6 +472,21 @@ fn only_fields( Ok(()) } +fn refuse_null_fields( + object: &Map, + fields: &[&str], + kind: &'static str, + message: &'static str, +) -> Result<()> { + if fields + .iter() + .any(|field| matches!(object.get(*field), Some(Value::Null))) + { + return Err(binding_error(kind, message)); + } + Ok(()) +} + fn required_string( object: &Map, field: &str, @@ -595,7 +610,7 @@ fn private_key_jwt(value: &Value) -> Result { "scopes", "assertionLifetimeSeconds", "refreshMarginSeconds", - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", "userAgent", "trustedRootCertificates", @@ -603,6 +618,22 @@ fn private_key_jwt(value: &Value) -> Result { "configuration", "authorization.privateKeyJwt contains an unsupported field", )?; + refuse_null_fields( + object, + &[ + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.privateKeyJwt options must be omitted instead of null", + )?; let endpoint = required_string( object, "tokenEndpoint", @@ -673,11 +704,11 @@ fn private_key_jwt(value: &Value) -> Result { } if let Some(value) = optional_u64( object, - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "configuration", - "private-key JWT request timeout must be a non-negative integer", + "private-key JWT attempt timeout must be a non-negative integer", )? { - config = config.with_request_timeout(Duration::from_millis(value)); + config = config.with_attempt_timeout(Duration::from_millis(value)); } if let Some(value) = optional_u64( object, @@ -752,6 +783,199 @@ fn bearer_value(token: registry_breg_client::BearerToken) -> Result { .ok_or_else(|| binding_error("token", "the issued credential is invalid")) } +fn validate_exchange_client(value: &Value, what: &'static str) -> Result<()> { + let object = required_object(value, what)?; + only_fields( + object, + &[ + "tokenEndpoint", + "clientId", + "clientKey", + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "exchange client contains an unsupported field", + )?; + refuse_null_fields( + object, + &[ + "audience", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "exchange client options must be omitted instead of null", + )?; + optional_i64( + object, + "assertionLifetimeSeconds", + "exchange assertionLifetimeSeconds must be a safe integer", + )?; + optional_i64( + object, + "refreshMarginSeconds", + "exchange refreshMarginSeconds must be a safe integer", + )?; + optional_u64( + object, + "attemptTimeoutMilliseconds", + "configuration", + "exchange attemptTimeoutMilliseconds must be a non-negative safe integer", + )?; + optional_u64( + object, + "connectTimeoutMilliseconds", + "configuration", + "exchange connectTimeoutMilliseconds must be a non-negative safe integer", + )?; + Ok(()) +} + +fn validate_exchange_config(value: &Value) -> Result<()> { + let object = required_object(value, "authorization.exchange must be an object")?; + only_fields( + object, + &["client", "context", "firstParty", "remote"], + "configuration", + "authorization.exchange contains an unsupported field", + )?; + refuse_null_fields( + object, + &["firstParty", "remote"], + "configuration", + "authorization.exchange options must be omitted instead of null", + )?; + + validate_exchange_client( + object.get("client").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.client must be present", + ) + })?, + "authorization.exchange.client must be an object", + )?; + + let context = required_object( + object.get("context").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.context must be present", + ) + })?, + "authorization.exchange.context must be an object", + )?; + only_fields( + context, + &[ + "issuer", + "subject", + "audience", + "generation", + "deadlineSeconds", + "grantId", + ], + "configuration", + "authorization.exchange.context contains an unsupported field", + )?; + refuse_null_fields( + context, + &["grantId"], + "configuration", + "authorization.exchange.context options must be omitted instead of null", + )?; + let deadline = context.get("deadlineSeconds").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.context.deadlineSeconds must be present", + ) + })?; + safe_integer( + deadline, + 0, + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "configuration", + "authorization.exchange.context.deadlineSeconds must be a non-negative safe integer", + )?; + + if let Some(first_party) = object.get("firstParty") { + let first_party = required_object( + first_party, + "authorization.exchange.firstParty must be an object", + )?; + only_fields( + first_party, + &["key", "attributes"], + "configuration", + "authorization.exchange.firstParty contains an unsupported field", + )?; + } + + if let Some(remote) = object.get("remote") { + let remote = required_object(remote, "authorization.exchange.remote must be an object")?; + only_fields( + remote, + &[ + "endpoint", + "bootstrap", + "bootstrapResource", + "bootstrapScope", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.exchange.remote contains an unsupported field", + )?; + refuse_null_fields( + remote, + &[ + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.exchange.remote options must be omitted instead of null", + )?; + validate_exchange_client( + remote.get("bootstrap").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.remote.bootstrap must be present", + ) + })?, + "authorization.exchange.remote.bootstrap must be an object", + )?; + optional_u64( + remote, + "attemptTimeoutMilliseconds", + "configuration", + "remote exchange attemptTimeoutMilliseconds must be a non-negative safe integer", + )?; + optional_u64( + remote, + "connectTimeoutMilliseconds", + "configuration", + "remote exchange connectTimeoutMilliseconds must be a non-negative safe integer", + )?; + } + Ok(()) +} + fn authorization_provider(value: &Value) -> Result>> { if value.is_null() { return Ok(None); @@ -776,6 +1000,7 @@ fn authorization_provider(value: &Value) -> Result .map(|provider| Some(Arc::new(provider) as Arc)); } if let Some(value) = object.get("exchange") { + validate_exchange_config(value)?; return exchange_authorization_from_json(value) .map(|provider| Some(Arc::new(provider) as Arc)) .map_err(|error| mapped_error(token_error_value(error))); @@ -793,16 +1018,30 @@ fn client_from_config(value: Value) -> Result { &[ "baseUrl", "authorization", - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", - "maxResponseBytes", + "maximumResponseBytes", "userAgent", "trustedRootCertificates", - "maxMutationRetries", + "maximumMutationRetries", ], "configuration", "client configuration contains an unsupported field", )?; + refuse_null_fields( + object, + &[ + "authorization", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "maximumResponseBytes", + "userAgent", + "trustedRootCertificates", + "maximumMutationRetries", + ], + "configuration", + "client configuration options must be omitted instead of null", + )?; let base_url = required_string( object, "baseUrl", @@ -819,11 +1058,11 @@ fn client_from_config(value: Value) -> Result { } if let Some(value) = optional_u64( object, - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "configuration", - "requestTimeoutMilliseconds must be a non-negative integer", + "attemptTimeoutMilliseconds must be a non-negative integer", )? { - config = config.with_request_timeout(Duration::from_millis(value)); + config = config.with_attempt_timeout(Duration::from_millis(value)); } if let Some(value) = optional_u64( object, @@ -835,27 +1074,27 @@ fn client_from_config(value: Value) -> Result { } if let Some(value) = optional_u64( object, - "maxResponseBytes", + "maximumResponseBytes", "configuration", - "maxResponseBytes must be a non-negative integer", + "maximumResponseBytes must be a non-negative integer", )? { - config = config.with_max_response_bytes(value); + config = config.with_maximum_response_bytes(value); } if let Some(value) = optional_u64( object, - "maxMutationRetries", + "maximumMutationRetries", "configuration", - "maxMutationRetries must be a whole number from 0 to 2", + "maximumMutationRetries must be a whole number from 0 to 2", )? { // Only a value in the `u8` range reaches the client, which refuses one // above its own bound. let value = u8::try_from(value).map_err(|_| { binding_error( "configuration", - "maxMutationRetries must be a whole number from 0 to 2", + "maximumMutationRetries must be a whole number from 0 to 2", ) })?; - config = config.with_max_mutation_retries(value); + config = config.with_maximum_mutation_retries(value); } if let Some(value) = optional_string( object, @@ -3186,7 +3425,7 @@ impl BaseRegistryClient { /// Invoke one metadata-selected immediate action. An invocation whose /// outcome is unknown is resent identically under the same key, at most - /// `maxMutationRetries` times. + /// `maximumMutationRetries` times. #[napi] pub async fn invoke_action( &self, @@ -3252,7 +3491,7 @@ impl BaseRegistryClient { /// Explicitly send a recovered invocation under its original key. An /// invocation whose outcome is unknown is resent identically under that - /// key, at most `maxMutationRetries` times. + /// key, at most `maximumMutationRetries` times. #[napi] pub async fn execute_recovered_action( &self, @@ -3408,7 +3647,7 @@ impl BaseRegistryClient { /// Execute one metadata-selected atomic batch. A batch whose outcome is /// unknown is resent identically under the same key, at most - /// `maxMutationRetries` times. + /// `maximumMutationRetries` times. #[napi] pub async fn batch_records( &self, diff --git a/crates/registry-breg-client-py/README.md b/crates/registry-breg-client-py/README.md index 8fb0c783c9..3e349825eb 100644 --- a/crates/registry-breg-client-py/README.md +++ b/crates/registry-breg-client-py/README.md @@ -121,11 +121,18 @@ client.batch_records( All action inputs, conditions, batch fields, item counts, correction context, and byte limits are validated by the Rust client before network I/O. +Client configuration uses `maximum_response_bytes`; the retired +`max_response_bytes` spelling is refused. Passing `None` for a typed optional +constructor argument has the same effect as omitting that argument. +`attempt_timeout_seconds` bounds one outbound attempt, including its body, and +`connect_timeout_seconds` bounds connection setup. + A keyed mutation (create, patch, attachment upload or delete, Proposal transition, immediate action, batch, tombstone, statistics publish or withdraw) whose outcome is unknown is resent identically under the same key up to -`max_mutation_retries` times, a `BaseRegistryClient` keyword: 0 to 2, default -2, and 0 sends it once. The outcome is unknown after a timeout or broken +`maximum_mutation_retries` additional times, a `BaseRegistryClient` keyword: 0 +to 2, default 2, and 0 sends it once, while the default permits up to three +total attempts. The outcome is unknown after a timeout or broken exchange once the request was sent, an unusable answer, or a 5xx other than `action.handler_failed` and `statistical_dataset.domain_violation`, which the engine returns only with the attempt rolled back. When the returned diff --git a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi index 21e03d061b..b844654bc8 100644 --- a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi +++ b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi @@ -48,7 +48,7 @@ class ExchangeKeyClient(TypedDict): audience: NotRequired[str | None] assertion_lifetime_seconds: NotRequired[int | None] refresh_margin_seconds: NotRequired[int | None] - request_timeout_seconds: NotRequired[float | None] + attempt_timeout_seconds: NotRequired[float | None] connect_timeout_seconds: NotRequired[float | None] user_agent: NotRequired[str | None] trusted_root_certificates: NotRequired[str | None] @@ -62,7 +62,7 @@ class RemoteExchangeSource(TypedDict): bootstrap: ExchangeKeyClient bootstrap_resource: str bootstrap_scope: str - request_timeout_seconds: NotRequired[float | None] + attempt_timeout_seconds: NotRequired[float | None] connect_timeout_seconds: NotRequired[float | None] user_agent: NotRequired[str | None] trusted_root_certificates: NotRequired[str | None] @@ -302,12 +302,12 @@ class BaseRegistryClient: self, base_url: str, authorization: dict[str, Any] | None = None, - request_timeout_seconds: float | None = None, + attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, user_agent: str | None = None, - max_response_bytes: int | None = None, + maximum_response_bytes: int | None = None, trusted_root_certificates: bytes | None = None, - max_mutation_retries: int | None = None, + maximum_mutation_retries: int | None = None, ) -> None: ... def health(self) -> dict[str, Any]: ... def ready(self) -> dict[str, Any]: ... diff --git a/crates/registry-breg-client-py/src/convert.rs b/crates/registry-breg-client-py/src/convert.rs index 1196506f7f..a1ff86e839 100644 --- a/crates/registry-breg-client-py/src/convert.rs +++ b/crates/registry-breg-client-py/src/convert.rs @@ -409,7 +409,7 @@ pub(crate) fn private_key_jwt( "scopes", "assertion_lifetime_seconds", "refresh_margin_seconds", - "request_timeout_seconds", + "attempt_timeout_seconds", "connect_timeout_seconds", "user_agent", ], @@ -442,8 +442,8 @@ pub(crate) fn private_key_jwt( if let Some(value) = optional_i64(value, "refresh_margin_seconds", WHAT)? { config = config.with_refresh_margin_seconds(value); } - if let Some(value) = optional_f64(value, "request_timeout_seconds", WHAT)? { - config = config.with_request_timeout(duration(value, "private-key JWT request timeout")?); + if let Some(value) = optional_f64(value, "attempt_timeout_seconds", WHAT)? { + config = config.with_attempt_timeout(duration(value, "private-key JWT attempt timeout")?); } if let Some(value) = optional_f64(value, "connect_timeout_seconds", WHAT)? { config = config.with_connect_timeout(duration(value, "private-key JWT connect timeout")?); @@ -497,10 +497,10 @@ pub fn config_from_parts( base_url: &str, authorization: &Value, roots: Option>, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, + request_timeout: Option, + connect_timeout: Option, user_agent: Option, - max_response_bytes: Option, + maximum_response_bytes: Option, trusted_root_certificates: Option>, ) -> Result { let base_url = @@ -509,17 +509,17 @@ pub fn config_from_parts( if let Some(provider) = authorization_provider(authorization, roots)? { config = config.with_token_provider(provider); } - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(value, "request_timeout_seconds")?); + if let Some(value) = request_timeout { + config = config.with_attempt_timeout(value); } - if let Some(value) = connect_timeout_seconds { - config = config.with_connect_timeout(duration(value, "connect_timeout_seconds")?); + if let Some(value) = connect_timeout { + config = config.with_connect_timeout(value); } if let Some(value) = user_agent { config = config.with_user_agent(value); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(value); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(value); } if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); diff --git a/crates/registry-breg-client-py/src/lib.rs b/crates/registry-breg-client-py/src/lib.rs index 88195c8038..c607c18046 100644 --- a/crates/registry-breg-client-py/src/lib.rs +++ b/crates/registry-breg-client-py/src/lib.rs @@ -3,7 +3,7 @@ #![deny(unsafe_code)] -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use breg_client_sdk::{ verify_webhook_delivery as verify_core_webhook_delivery, BRegActionAvailabilityRequest, @@ -30,7 +30,7 @@ use breg_client_sdk::{ use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyBytes, PyDict, PyString}, + types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyString}, }; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; @@ -235,7 +235,7 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { py, MappedError::binding( "configuration", - "max_mutation_retries must be a whole number from 0 to 2", + "maximum_mutation_retries must be a whole number from 0 to 2", ), ) }; @@ -245,6 +245,46 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { value.extract::().map_err(|_| invalid()) } +fn configuration_error(py: Python<'_>, message: &'static str) -> PyErr { + to_py_err(py, MappedError::binding("configuration", message)) +} + +fn timeout_duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if value.is_exact_instance_of::() { + return value + .extract::() + .ok() + .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) + .ok_or_else(|| { + configuration_error(py, "client timeouts must be finite non-negative seconds") + }); + } + if value.is_exact_instance_of::() { + return value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + configuration_error(py, "client timeouts must be finite non-negative seconds") + }); + } + Err(configuration_error( + py, + "client timeouts must be finite non-negative seconds", + )) +} + +fn parse_maximum_response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if !value.is_exact_instance_of::() { + return Err(configuration_error( + py, + "maximum_response_bytes must be a non-negative integer", + )); + } + value.extract::().map_err(|_| { + configuration_error(py, "maximum_response_bytes must be a non-negative integer") + }) +} + fn record_format(py: Python<'_>, value: &str) -> PyResult { match value { "json" => Ok(BRegRecordFormat::Json), @@ -1640,18 +1680,18 @@ struct BaseRegistryClient { #[pymethods] impl BaseRegistryClient { #[new] - #[pyo3(signature = (base_url, authorization=None, request_timeout_seconds=None, connect_timeout_seconds=None, user_agent=None, max_response_bytes=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, authorization=None, attempt_timeout_seconds=None, connect_timeout_seconds=None, user_agent=None, maximum_response_bytes=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, authorization: Option<&Bound<'_, PyAny>>, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, + connect_timeout_seconds: Option<&Bound<'_, PyAny>>, user_agent: Option, - max_response_bytes: Option, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let (authorization, private_roots) = authorization_from_python(authorization) .map_err(|error| conversion_error(py, "configuration", error))?; @@ -1659,15 +1699,21 @@ impl BaseRegistryClient { base_url, &authorization, private_roots, - request_timeout_seconds, - connect_timeout_seconds, + attempt_timeout_seconds + .map(|value| timeout_duration(py, value)) + .transpose()?, + connect_timeout_seconds + .map(|value| timeout_duration(py, value)) + .transpose()?, user_agent, - max_response_bytes, + maximum_response_bytes + .map(|value| parse_maximum_response_bytes(py, value)) + .transpose()?, trusted_root_certificates, ) .map_err(|error| config_error(py, error))?; - let config = match max_mutation_retries { - Some(value) => config.with_max_mutation_retries(mutation_retries(py, value)?), + let config = match maximum_mutation_retries { + Some(value) => config.with_maximum_mutation_retries(mutation_retries(py, value)?), None => config, }; let inner = py diff --git a/crates/registry-breg-client-py/tests/python/test_construction.py b/crates/registry-breg-client-py/tests/python/test_construction.py index 9c746fc599..0b08d49e78 100644 --- a/crates/registry-breg-client-py/tests/python/test_construction.py +++ b/crates/registry-breg-client-py/tests/python/test_construction.py @@ -15,7 +15,12 @@ class ConstructionTests(unittest.TestCase): def test_constructs_with_a_deployment_base(self) -> None: self.assertIsInstance( - BaseRegistryClient("https://registry.example.invalid/tenant"), + BaseRegistryClient( + "https://registry.example.invalid/tenant", + attempt_timeout_seconds=5, + connect_timeout_seconds=2, + maximum_mutation_retries=1, + ), BaseRegistryClient, ) @@ -24,6 +29,37 @@ def test_invalid_configuration_has_a_stable_kind(self) -> None: BaseRegistryClient("not a URL") self.assertEqual(raised.exception.kind, "configuration") + def test_none_means_omission_for_optional_configuration(self) -> None: + client = BaseRegistryClient( + "https://registry.example.invalid/tenant", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + user_agent=None, + maximum_response_bytes=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, BaseRegistryClient) + + def test_numeric_configuration_rejects_booleans_and_overflow(self) -> None: + for options in ( + {"attempt_timeout_seconds": True}, + {"connect_timeout_seconds": False}, + {"maximum_response_bytes": True}, + {"maximum_response_bytes": 2**64}, + ): + with self.subTest(options=options), self.assertRaises(BaseRegistryClientError) as raised: + BaseRegistryClient("https://registry.example.invalid/tenant", **options) + self.assertEqual(raised.exception.kind, "configuration") + + def test_retired_top_level_option_names_are_rejected(self) -> None: + for options in ( + {"request_timeout_seconds": 30}, + {"max_mutation_retries": 1}, + ): + with self.subTest(options=options), self.assertRaises(TypeError): + BaseRegistryClient("https://registry.example.invalid/tenant", **options) + def test_unknown_authorization_shape_is_rejected(self) -> None: with self.assertRaises(BaseRegistryClientError) as raised: BaseRegistryClient( diff --git a/crates/registry-breg-client-py/tests/python/test_mutation_parity.py b/crates/registry-breg-client-py/tests/python/test_mutation_parity.py index 3883539573..948f257768 100644 --- a/crates/registry-breg-client-py/tests/python/test_mutation_parity.py +++ b/crates/registry-breg-client-py/tests/python/test_mutation_parity.py @@ -564,7 +564,7 @@ def test_immediate_action_application_identity_preserves_nil_record_ids(self) -> def test_immediate_action_recovers_after_a_lost_response(self) -> None: # The process gives up after the lost answer instead of resending it. self.client = BaseRegistryClient( - f"http://127.0.0.1:{self.server.server_port}", max_mutation_retries=0 + f"http://127.0.0.1:{self.server.server_port}", maximum_mutation_retries=0 ) binding = self.contract.select_action( "rename-company", "company-writer" diff --git a/crates/registry-breg-client-py/tests/python/test_mutation_retry.py b/crates/registry-breg-client-py/tests/python/test_mutation_retry.py index 65a6a0dabb..dfb77f3f38 100644 --- a/crates/registry-breg-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-breg-client-py/tests/python/test_mutation_retry.py @@ -120,7 +120,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, PUBLICATION) - client = BaseRegistryClient(self.base_url, max_mutation_retries=0) + client = BaseRegistryClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(BaseRegistryClientError) as raised: self.publish(client) @@ -135,7 +135,7 @@ def test_a_refusal_or_a_failure_before_commit_is_never_resent(self) -> None: for refusal in (VERSION_CONFLICT, DOMAIN_VIOLATION): with self.subTest(status=refusal[0]): self.script(refusal, PUBLICATION) - client = BaseRegistryClient(self.base_url, max_mutation_retries=2) + client = BaseRegistryClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(BaseRegistryClientError) as raised: self.publish(client) @@ -148,11 +148,11 @@ def test_a_refusal_or_a_failure_before_commit_is_never_resent(self) -> None: def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - BaseRegistryClient(self.base_url, max_mutation_retries=retries) + BaseRegistryClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(BaseRegistryClientError) as raised: - BaseRegistryClient(self.base_url, max_mutation_retries=retries) + BaseRegistryClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-breg-client/README.md b/crates/registry-breg-client/README.md index decce5d043..1a0db46e93 100644 --- a/crates/registry-breg-client/README.md +++ b/crates/registry-breg-client/README.md @@ -194,10 +194,10 @@ statistical publication and withdrawal) whose outcome is unknown, with the same key, headers, and body bytes, at most twice by default, waiting 250 ms and then 500 ms, or the service's `Retry-After` when that is longer and at most 5 seconds. A longer requested wait ends the retries. Configure the count -with `BaseRegistryClientConfig::with_max_mutation_retries` (0 to 2; 0 disables +with `BaseRegistryClientConfig::with_maximum_mutation_retries` (0 to 2; 0 disables the resend). Reads, ingestion run calls, and action target-condition requests -are never resent. Each attempt has the full request timeout, so a call can -take up to three request timeouts plus the waits. +are never resent. Each attempt has the full attempt timeout, so a call can +take up to three attempt timeouts plus the waits. BReg holds a response under the key only for a committed success, so a resend after a 5xx either replays that success or runs the operation again. diff --git a/crates/registry-breg-client/src/client.rs b/crates/registry-breg-client/src/client.rs index 068ecda79f..1bb5874bf5 100644 --- a/crates/registry-breg-client/src/client.rs +++ b/crates/registry-breg-client/src/client.rs @@ -977,7 +977,10 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, maximum_bytes.min(self.config.max_response_bytes)) + .read( + response, + maximum_bytes.min(self.config.maximum_response_bytes), + ) .await?; Ok(BRegComplete { value: BRegRawDocument::new(media_type, body), @@ -1447,7 +1450,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; crate::strict_json::from_slice(&body) .map_err(|_| body_failure(status.as_u16(), trace.clone()))?; @@ -1574,7 +1577,7 @@ impl BaseRegistryClient { let engine_version = breg_engine_version(&headers); let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; let representation_digest = match digest { Some((value, expected)) if expected == Sha256::digest(&body).as_slice() => { @@ -1750,7 +1753,7 @@ impl BaseRegistryClient { /// Send one idempotency-keyed mutation and decode its answer, resending /// the identical request under the same key while its outcome stays - /// unknown, at most `max_mutation_retries` times. The credential is + /// unknown, at most `maximum_mutation_retries` times. The credential is /// acquired once, so every attempt carries the same headers and body bytes. async fn send_keyed( &self, @@ -1763,7 +1766,7 @@ impl BaseRegistryClient { { let builder = self.authorize(builder, Credential::Optional).await?; let (builder, decode) = (&builder, &decode); - retry_keyed_mutation(self.config.max_mutation_retries, || async move { + retry_keyed_mutation(self.config.maximum_mutation_retries, || async move { let Some(attempt) = builder.try_clone() else { return KeyedMutationAttempt::Settled(Err( BaseRegistryClientError::invalid_request( @@ -1809,7 +1812,7 @@ impl BaseRegistryClient { response, expected_media, etag_expectation, - self.config.max_response_bytes, + self.config.maximum_response_bytes, ) .await } @@ -1853,7 +1856,10 @@ impl BaseRegistryClient { let engine_version = breg_engine_version(&headers); let body = self .transport - .read(response, self.config.max_response_bytes.min(maximum_bytes)) + .read( + response, + self.config.maximum_response_bytes.min(maximum_bytes), + ) .await?; Ok(BRegWire { body, @@ -1909,7 +1915,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; Ok(BRegWire { body, @@ -1966,7 +1972,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; let mut metadata = BRegResponseMetadata::new(trace_id, Some(etag)); if let Some(location) = location { diff --git a/crates/registry-breg-client/src/config.rs b/crates/registry-breg-client/src/config.rs index 451824a20e..77bb5808c2 100644 --- a/crates/registry-breg-client/src/config.rs +++ b/crates/registry-breg-client/src/config.rs @@ -7,8 +7,8 @@ use url::Url; use zeroize::Zeroizing; use crate::{ - BaseRegistryClientError, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_RESPONSE_BYTES, - DEFAULT_REQUEST_TIMEOUT, + BaseRegistryClientError, DEFAULT_ATTEMPT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAXIMUM_RESPONSE_BYTES, }; /// Transport policy for one Base Registry Engine deployment. @@ -16,12 +16,12 @@ use crate::{ pub struct BaseRegistryClientConfig { pub(crate) base_url: Url, pub(crate) token_provider: Option>, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl BaseRegistryClientConfig { @@ -30,12 +30,12 @@ impl BaseRegistryClientConfig { Self { base_url, token_provider: None, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } @@ -46,8 +46,8 @@ impl BaseRegistryClientConfig { } #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; + pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self { + self.attempt_timeout = timeout; self } @@ -58,8 +58,8 @@ impl BaseRegistryClientConfig { } #[must_use] - pub fn with_max_response_bytes(mut self, maximum: u64) -> Self { - self.max_response_bytes = maximum; + pub fn with_maximum_response_bytes(mut self, maximum: u64) -> Self { + self.maximum_response_bytes = maximum; self } @@ -81,8 +81,8 @@ impl BaseRegistryClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -97,17 +97,17 @@ impl BaseRegistryClientConfig { "the service base URL must be an HTTPS URL, or loopback HTTP, with no credentials, query, or fragment", ) })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(BaseRegistryClientError::configuration( - "request and connection timeouts must be greater than zero", + "attempt and connection timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 { + if self.maximum_response_bytes == 0 { return Err(BaseRegistryClientError::configuration( "the response body bound must be greater than zero", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(BaseRegistryClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -122,11 +122,11 @@ impl fmt::Debug for BaseRegistryClientConfig { .debug_struct("BaseRegistryClientConfig") .field("base_url", &"") .field("token_provider", &self.token_provider.is_some()) - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent", &self.user_agent.is_some()) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -163,7 +163,7 @@ mod tests { fn the_mutation_retry_count_is_bounded() { let url = Url::parse("https://registry.example.invalid/").expect("fixture URL"); let error = BaseRegistryClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1) + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1) .validate() .expect_err("a retry count above the bound is refused"); assert!(matches!( @@ -174,13 +174,26 @@ mod tests { )); for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(BaseRegistryClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } assert_eq!( - BaseRegistryClientConfig::new(url).max_mutation_retries, + BaseRegistryClientConfig::new(url).maximum_mutation_retries, DEFAULT_MUTATION_RETRIES ); } + + #[test] + fn the_response_bound_keeps_its_default_and_refuses_zero() { + let url = Url::parse("https://registry.example.invalid/").expect("fixture URL"); + assert_eq!( + BaseRegistryClientConfig::new(url.clone()).maximum_response_bytes, + DEFAULT_MAXIMUM_RESPONSE_BYTES + ); + assert!(BaseRegistryClientConfig::new(url) + .with_maximum_response_bytes(0) + .validate() + .is_err()); + } } diff --git a/crates/registry-breg-client/src/lib.rs b/crates/registry-breg-client/src/lib.rs index 792b3f4ae9..147b588058 100644 --- a/crates/registry-breg-client/src/lib.rs +++ b/crates/registry-breg-client/src/lib.rs @@ -10,7 +10,7 @@ //! `statistical_dataset.domain_violation`, which the engine returns only with //! the attempt rolled back) is resent, byte for byte and under the same key //! and credential, at most -//! `BaseRegistryClientConfig::with_max_mutation_retries` times (2 by default, +//! `BaseRegistryClientConfig::with_maximum_mutation_retries` times (2 by default, //! 0 disables it). Reads and unkeyed exchanges, such as ingestion runs and //! action target conditions, are never resent. A 5xx answer may follow a //! commit: when `BaseRegistryClientError::is_outcome_unknown` is still true, @@ -71,9 +71,9 @@ pub use statistics::*; pub use uuid::Uuid; pub use webhook::*; -pub const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +pub const DEFAULT_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; +pub const DEFAULT_MAXIMUM_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; /// Invalid, duplicate-containing, overlarge, or numerically lossy JSON input. #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] @@ -83,7 +83,7 @@ pub struct BRegExactJsonError; /// Decode bounded JSON without duplicate members or silently rounded literals. /// Mutation builders additionally enforce BReg's supported binary64 integer model. pub fn decode_exact_json(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(BRegExactJsonError); } strict_json::from_slice(bytes).map_err(|()| BRegExactJsonError) diff --git a/crates/registry-breg-client/src/proposal.rs b/crates/registry-breg-client/src/proposal.rs index 4ce521f72a..7d75592f3b 100644 --- a/crates/registry-breg-client/src/proposal.rs +++ b/crates/registry-breg-client/src/proposal.rs @@ -415,7 +415,7 @@ impl BRegProposal { self.input.as_ref() } pub fn from_slice(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(refusal()); } let value = crate::strict_json::from_slice(bytes).map_err(|_| refusal())?; @@ -896,7 +896,7 @@ pub struct BRegPreparedProposal { } impl BRegPreparedProposal { pub fn from_slice(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(refusal()); } let value = crate::strict_json::from_slice(bytes).map_err(|_| refusal())?; diff --git a/crates/registry-breg-client/src/transport.rs b/crates/registry-breg-client/src/transport.rs index 23a17d4d93..b158bda52f 100644 --- a/crates/registry-breg-client/src/transport.rs +++ b/crates/registry-breg-client/src/transport.rs @@ -13,7 +13,7 @@ use crate::{BRegProtocolFailure, BaseRegistryClientConfig, BaseRegistryClientErr pub(crate) struct Transport { pub(crate) http: reqwest::Client, pub(crate) base_url: ServiceBaseUrl, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, } impl Transport { @@ -22,7 +22,7 @@ impl Transport { BaseRegistryClientError::configuration("the service base URL is not usable") })?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config @@ -34,7 +34,7 @@ impl Transport { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, + maximum_response_bytes: config.maximum_response_bytes, }) } @@ -67,7 +67,7 @@ impl Transport { response: Response, maximum: u64, ) -> Result, BaseRegistryClientError> { - read_bounded(response, maximum.min(self.max_response_bytes)) + read_bounded(response, maximum.min(self.maximum_response_bytes)) .await .map_err(|error| BaseRegistryClientError::transport(read_failure_kind(&error))) } diff --git a/crates/registry-breg-client/tests/http_boundary.rs b/crates/registry-breg-client/tests/http_boundary.rs index 8f20aa6e7f..2f34e24b36 100644 --- a/crates/registry-breg-client/tests/http_boundary.rs +++ b/crates/registry-breg-client/tests/http_boundary.rs @@ -458,7 +458,7 @@ async fn test_client( let mut config = BaseRegistryClientConfig::new( Url::parse(&format!("http://{address}/tenant/prefix")).expect("base URL"), ) - .with_max_response_bytes(maximum); + .with_maximum_response_bytes(maximum); if let Some(provider) = provider { config = config.with_token_provider(provider); } diff --git a/crates/registry-breg-client/tests/mutation_retry.rs b/crates/registry-breg-client/tests/mutation_retry.rs index ae397ac181..66cc4a9323 100644 --- a/crates/registry-breg-client/tests/mutation_retry.rs +++ b/crates/registry-breg-client/tests/mutation_retry.rs @@ -235,7 +235,7 @@ fn assert_identical_resends(observations: &[Captured]) { #[tokio::test] async fn a_timed_out_mutation_is_resent_identically_under_the_same_key() { let (address, script, server) = serve(&[Answer::Stall, Answer::Published]).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let published = publish(&client) .await @@ -395,7 +395,7 @@ async fn the_retry_count_is_honored() { .await; let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = publish(&client(config)) .await @@ -417,8 +417,8 @@ async fn zero_retries_disables_the_resend() { let (address, script, server) = serve(&[Answer::Stall, Answer::Published]).await; let error = publish(&client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), )) .await .expect_err("the only attempt timed out"); @@ -569,7 +569,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let error = publish(&client) .await .expect_err("the refusal body never arrives"); diff --git a/crates/registry-breg-client/tests/proposal_document_boundary.rs b/crates/registry-breg-client/tests/proposal_document_boundary.rs index 8bf3553905..04ed55c865 100644 --- a/crates/registry-breg-client/tests/proposal_document_boundary.rs +++ b/crates/registry-breg-client/tests/proposal_document_boundary.rs @@ -81,7 +81,7 @@ impl Fixture { let token = Arc::new(CountingToken::default()); let client = BaseRegistryClient::new( BaseRegistryClientConfig::new(format!("http://{address}/").parse().unwrap()) - .with_max_mutation_retries(0) + .with_maximum_mutation_retries(0) .with_token_provider(token.clone()), ) .unwrap(); @@ -1493,7 +1493,7 @@ async fn proposal_input_snapshot_bound_is_checked_before_protected_io() { let mut saved: Value = serde_json::from_slice(attempt.as_bytes()).unwrap(); saved["body"] = json!(json!({"input":oversized}).to_string()); let saved = serde_json::to_vec(&saved).unwrap(); - assert!(saved.len() < registry_breg_client::DEFAULT_MAX_RESPONSE_BYTES as usize); + assert!(saved.len() < registry_breg_client::DEFAULT_MAXIMUM_RESPONSE_BYTES as usize); assert!( BRegPreparedProposal::from_slice(&saved).is_err(), "{operation}:{state}:oversized restored input" diff --git a/crates/registry-breg-client/tests/read_parity.rs b/crates/registry-breg-client/tests/read_parity.rs index abfed54573..e49f283a3d 100644 --- a/crates/registry-breg-client/tests/read_parity.rs +++ b/crates/registry-breg-client/tests/read_parity.rs @@ -130,10 +130,14 @@ fn response(media: &str, value: Value, link: bool, etag: bool) -> Response } async fn client(provider: Arc) -> (BaseRegistryClient, Arc>>) { - client_with_max_response_bytes(provider, registry_breg_client::DEFAULT_MAX_RESPONSE_BYTES).await + client_with_maximum_response_bytes( + provider, + registry_breg_client::DEFAULT_MAXIMUM_RESPONSE_BYTES, + ) + .await } -async fn client_with_max_response_bytes( +async fn client_with_maximum_response_bytes( provider: Arc, maximum: u64, ) -> (BaseRegistryClient, Arc>>) { @@ -152,7 +156,7 @@ async fn client_with_max_response_bytes( let config = BaseRegistryClientConfig::new(Url::parse(&format!("http://{address}/prefix")).unwrap()) .with_token_provider(provider) - .with_max_response_bytes(maximum); + .with_maximum_response_bytes(maximum); (BaseRegistryClient::new(config).unwrap(), captured) } @@ -203,7 +207,7 @@ async fn snapshot_valid_at_uses_the_servers_canonical_utc_representation() { async fn geojson_uses_the_configured_response_body_bound() { let provider = Arc::new(CountingToken(AtomicUsize::new(0))); let high_limit = 3 * 1024 * 1024; - let (client, _) = client_with_max_response_bytes(provider.clone(), high_limit).await; + let (client, _) = client_with_maximum_response_bytes(provider.clone(), high_limit).await; let feature = client .get_geojson_record("large-geojson", RECORD_ID, &BRegGeoJsonOptions::default()) .await @@ -238,7 +242,7 @@ async fn geojson_uses_the_configured_response_body_bound() { 2 * 1024 * 1024 ); - let (client, _) = client_with_max_response_bytes(provider, 2 * 1024 * 1024).await; + let (client, _) = client_with_maximum_response_bytes(provider, 2 * 1024 * 1024).await; let error = client .get_geojson_record("large-geojson", RECORD_ID, &BRegGeoJsonOptions::default()) .await diff --git a/crates/registry-breg-client/tests/write_http_boundary.rs b/crates/registry-breg-client/tests/write_http_boundary.rs index cec76eccea..36d6fba1a9 100644 --- a/crates/registry-breg-client/tests/write_http_boundary.rs +++ b/crates/registry-breg-client/tests/write_http_boundary.rs @@ -193,7 +193,7 @@ async fn serve_test_client(responses: Vec, max_mutation_retries: u Url::parse(&format!("http://{address}/tenant/base")).expect("base URL"), ) .with_token_provider(token.clone()) - .with_max_mutation_retries(max_mutation_retries); + .with_maximum_mutation_retries(max_mutation_retries); TestClient { client: BaseRegistryClient::new(config).expect("client"), requests, diff --git a/crates/registry-breg-mcp/README.md b/crates/registry-breg-mcp/README.md index 4cab52772b..5bdd76c605 100644 --- a/crates/registry-breg-mcp/README.md +++ b/crates/registry-breg-mcp/README.md @@ -161,9 +161,11 @@ accepted, 1 when something is refused (or a warning is reported under `serve` refuses a file `check` would refuse, with the same lines on standard error, then resolves every secret before it listens, and answers until `SIGINT` or `SIGTERM`. Logs are JSON lines on standard output from the gateway -alone, at the level `BREG_MCP_LOG` names: `error`, `warn`, or `info` (the -default). Any other value, including a filter directive, is refused on -standard error with exit status 2 before any work. +alone. `BREG_MCP_LOG` selects the level when set, otherwise the runtime file's +top-level `logLevel` applies, with `info` as its default. Both sources accept +only `error`, `warn`, or `info`; any other value, including a filter directive, +is refused. `check` validates `logLevel`, reads no environment override, and +emits no operational log. The runtime file below is [`products/breg/examples/mcp-runtime/runtime.yaml`](../../products/breg/examples/mcp-runtime/runtime.yaml); diff --git a/crates/registry-breg-mcp/src/config.rs b/crates/registry-breg-mcp/src/config.rs index b85ea99cf3..9711fac2a3 100644 --- a/crates/registry-breg-mcp/src/config.rs +++ b/crates/registry-breg-mcp/src/config.rs @@ -43,6 +43,33 @@ pub const RUNTIME_KIND: &str = "BRegMcpRuntimeConfig"; /// The single path the MCP endpoint is served at. pub const MCP_PATH: &str = "/mcp"; +/// The operational log level. The vocabulary is closed so configuration +/// cannot turn on debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + +const fn default_log_level() -> LogLevel { + LogLevel::Info +} + /// The most characters a citizen-facing service text may hold. const MAXIMUM_SERVICE_TEXT_CHARS: usize = 4096; /// The most items a client, scope, or algorithm list may hold. @@ -109,6 +136,11 @@ const REMOVED_KEYS: &[RemovedKey] = &[ pub struct RuntimeConfig { pub api_version: String, pub kind: String, + /// Operational log level when `BREG_MCP_LOG` is not set. Defaults to + /// `info`; accepted values are `error`, `warn`, and `info`. + #[serde(default = "default_log_level")] + #[cfg_attr(feature = "schema", schemars(default))] + pub log_level: LogLevel, pub listener: PrivateListenerConfig, pub secret_providers: SecretProvidersConfig, /// Who may call the gateway: the chat hosts' authorization server. @@ -1115,6 +1147,7 @@ rateLimits: #[test] fn a_complete_document_loads() { let config = load(&document()).expect("document loads"); + assert_eq!(config.log_level.as_str(), "info"); assert_eq!(config.registry.access_profile.as_str(), "citizen-agent"); assert_eq!(config.limits.request_bytes(), 64 * 1024); assert_eq!(config.registry.attempt_timeout_milliseconds.get(), 10_000); @@ -1129,6 +1162,34 @@ rateLimits: )); } + #[test] + fn log_level_accepts_only_the_closed_vocabulary() { + for accepted in ["error", "warn", "info"] { + let text = document().replacen( + "kind: BRegMcpRuntimeConfig\n", + &format!("kind: BRegMcpRuntimeConfig\nlogLevel: {accepted}\n"), + 1, + ); + assert_eq!( + load(&text).expect("level loads").log_level.as_str(), + accepted + ); + } + for refused_value in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused_value.is_empty() { + "''" + } else { + refused_value + }; + let text = document().replacen( + "kind: BRegMcpRuntimeConfig\n", + &format!("kind: BRegMcpRuntimeConfig\nlogLevel: {yaml_value}\n"), + 1, + ); + refused(&text, "config.unknown-variant", "/logLevel"); + } + } + #[test] fn the_committed_example_loads() { let example = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/registry-breg-mcp/src/main.rs b/crates/registry-breg-mcp/src/main.rs index 8e4c38eef0..70255a5468 100644 --- a/crates/registry-breg-mcp/src/main.rs +++ b/crates/registry-breg-mcp/src/main.rs @@ -8,7 +8,7 @@ use clap::Parser; use registry_breg_mcp::{ check, cli::{Cli, Command}, - config::RuntimeConfig, + config::{LogLevel, RuntimeConfig}, runtime, }; use tracing::Level; @@ -19,9 +19,16 @@ const LOG_VARIABLE: &str = "BREG_MCP_LOG"; /// The operational log level. The vocabulary is closed so a typo cannot /// silently turn logging off or turn a dependency's logging on. -fn operational_log_level(value: Result) -> Result { +fn operational_log_level( + value: Result, + configured: LogLevel, +) -> Result { match value { - Err(std::env::VarError::NotPresent) => Ok(Level::INFO), + Err(std::env::VarError::NotPresent) => Ok(match configured { + LogLevel::Error => Level::ERROR, + LogLevel::Warn => Level::WARN, + LogLevel::Info => Level::INFO, + }), Ok(value) => match value.as_str() { "error" => Ok(Level::ERROR), "warn" => Ok(Level::WARN), @@ -54,13 +61,17 @@ fn main() -> ExitCode { )); } - let level = match operational_log_level(std::env::var(LOG_VARIABLE)) { - Ok(level) => level, - Err(error) => { - eprintln!("breg-mcp: {error}"); - return ExitCode::from(2); - } + let environment_level = match std::env::var(LOG_VARIABLE) { + Err(std::env::VarError::NotPresent) => None, + value => match operational_log_level(value, LogLevel::Info) { + Ok(level) => Some(level), + Err(error) => { + eprintln!("breg-mcp: {error}"); + return ExitCode::from(2); + } + }, }; + // A refused runtime file is reported in the shared human shape on // standard error (CFG-DIAG-2), naming members and never their values. let config = match RuntimeConfig::load(&cli.runtime_config) { @@ -70,6 +81,10 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; + let level = environment_level.unwrap_or_else(|| { + operational_log_level(Err(std::env::VarError::NotPresent), config.log_level) + .expect("the runtime log level was validated") + }); // JSON lines on standard output, from this crate only. tracing_subscriber::registry() .with( @@ -134,16 +149,29 @@ mod tests { #[test] fn the_log_level_vocabulary_is_closed() { + use registry_breg_mcp::config::LogLevel; + assert_eq!( - operational_log_level(Err(std::env::VarError::NotPresent)), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Info), Ok(Level::INFO) ); + assert_eq!( + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Warn), + Ok(Level::WARN) + ); + assert_eq!( + operational_log_level(Ok("error".to_owned()), LogLevel::Warn), + Ok(Level::ERROR) + ); for (value, level) in [ ("error", Level::ERROR), ("warn", Level::WARN), ("info", Level::INFO), ] { - assert_eq!(operational_log_level(Ok(value.to_owned())), Ok(level)); + assert_eq!( + operational_log_level(Ok(value.to_owned()), LogLevel::Info), + Ok(level) + ); } for refused in [ "debug", @@ -154,7 +182,7 @@ mod tests { "registry_breg_mcp=info", ] { assert_eq!( - operational_log_level(Ok(refused.to_owned())), + operational_log_level(Ok(refused.to_owned()), LogLevel::Info), Err("BREG_MCP_LOG must be one of error, warn, or info".to_owned()), "{refused:?}" ); diff --git a/crates/registry-breg-mcp/src/outbound.rs b/crates/registry-breg-mcp/src/outbound.rs index a89dad0db3..4d2ad8b95f 100644 --- a/crates/registry-breg-mcp/src/outbound.rs +++ b/crates/registry-breg-mcp/src/outbound.rs @@ -156,12 +156,12 @@ impl Outbound { BaseRegistryClientConfig::new(self.base_url.clone()), authorization, ) - .with_request_timeout(self.timeout) + .with_attempt_timeout(self.timeout) // The gateway owns same-key recovery: its keys are derived // deterministically and the chat host retries a call whose outcome // is unknown. Client resends stacked under that would multiply // the wait of an interactive turn. - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .map_err(|_| TokenError::Configuration { reason: "the registry client could not be configured", @@ -191,7 +191,7 @@ fn client_config( timeout: Duration, ) -> PrivateKeyJwtConfig { let config = PrivateKeyJwtConfig::new(token_endpoint.clone(), client_id, key.clone()) - .with_request_timeout(timeout); + .with_attempt_timeout(timeout); match assertion_audience { Some(audience) => config.with_audience(audience), None => config, diff --git a/crates/registry-breg-review/README.md b/crates/registry-breg-review/README.md index 2faf021f32..68e85fd7f3 100644 --- a/crates/registry-breg-review/README.md +++ b/crates/registry-breg-review/README.md @@ -89,13 +89,14 @@ block must stay literal. The redirect URI registered with the provider is loopback listener only: it drops `Secure`, the `__Host-` cookie prefix, and HSTS, and relaxes the provider endpoint policy to loopback HTTP. -`BREG_REVIEW_LOG` selects the operational log level: `error`, `warn`, or -`info` (the default). Any other value stops startup with exit status 2, so a -typo cannot silently change what is logged. `check` reads no level. The -operational log is JSON on standard output. A refused runtime file is -reported on standard error in the same lines `check` prints, and any other -startup refusal is one line on standard error; either way the process exits -with status 1. +`BREG_REVIEW_LOG` selects the operational log level when set, otherwise the +runtime file's top-level `logLevel` applies, with `info` as its default. Both +sources accept only `error`, `warn`, or `info`; any other value refuses startup +instead of falling back. `check` validates `logLevel`, reads no environment +override, and emits no operational log. The operational log is JSON on standard +output. A refused runtime file is reported on standard error in the same lines +`check` prints, and any other startup refusal is one line on standard error; +either way the process exits with status 1. ## Sign-in diff --git a/crates/registry-breg-review/src/config.rs b/crates/registry-breg-review/src/config.rs index 0faec6e7f5..8e5f7239b4 100644 --- a/crates/registry-breg-review/src/config.rs +++ b/crates/registry-breg-review/src/config.rs @@ -37,6 +37,33 @@ pub const RUNTIME_API_VERSION: &str = "id.registrystack.org/formats/breg/review- /// Kind of the review page runtime configuration document. pub const RUNTIME_KIND: &str = "BRegReviewRuntimeConfig"; +/// The operational log level. The vocabulary is closed so configuration +/// cannot turn on debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + +const fn default_log_level() -> LogLevel { + LogLevel::Info +} + /// The path the authorization server returns a person to after sign-in, /// relative to `publicOrigin`. pub const CALLBACK_PATH: &str = "/signin/callback"; @@ -92,6 +119,11 @@ const REMOVED_KEYS: &[RemovedKey] = &[ pub struct RuntimeConfig { pub api_version: String, pub kind: String, + /// Operational log level when `BREG_REVIEW_LOG` is not set. Defaults to + /// `info`; accepted values are `error`, `warn`, and `info`. + #[serde(default = "default_log_level")] + #[cfg_attr(feature = "schema", schemars(default))] + pub log_level: LogLevel, pub listener: PrivateListenerConfig, /// The browser-facing origin, such as `https://review.example`: an https /// URL with no path, query, or fragment; plain http is accepted only on @@ -961,6 +993,7 @@ audit: #[test] fn a_production_document_loads_with_its_defaults() { let config = load(&document()).expect("valid document"); + assert_eq!(config.log_level.as_str(), "info"); assert!(!config.development()); assert_eq!( config.redirect_uri().as_str(), @@ -979,6 +1012,34 @@ audit: assert_eq!(config.registry.target_input.as_str(), "address"); } + #[test] + fn log_level_accepts_only_the_closed_vocabulary() { + for accepted in ["error", "warn", "info"] { + let text = document().replacen( + "kind: BRegReviewRuntimeConfig\n", + &format!("kind: BRegReviewRuntimeConfig\nlogLevel: {accepted}\n"), + 1, + ); + assert_eq!( + load(&text).expect("level loads").log_level.as_str(), + accepted + ); + } + for refused_value in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused_value.is_empty() { + "''" + } else { + refused_value + }; + let text = document().replacen( + "kind: BRegReviewRuntimeConfig\n", + &format!("kind: BRegReviewRuntimeConfig\nlogLevel: {yaml_value}\n"), + 1, + ); + refused(&text, "config.unknown-variant", "/logLevel"); + } + } + #[test] fn the_committed_example_loads() { let example = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/registry-breg-review/src/lib.rs b/crates/registry-breg-review/src/lib.rs index 6f042398f7..14eccf5fb4 100644 --- a/crates/registry-breg-review/src/lib.rs +++ b/crates/registry-breg-review/src/lib.rs @@ -26,7 +26,7 @@ mod signin; mod templates; pub use config::{ - check_runtime, RuntimeCheck, RuntimeConfig, RuntimeConfigError, RUNTIME_API_VERSION, + check_runtime, LogLevel, RuntimeCheck, RuntimeConfig, RuntimeConfigError, RUNTIME_API_VERSION, RUNTIME_KIND, }; @@ -588,7 +588,7 @@ fn offline(config: &RuntimeConfig) -> Result { // Client resends stacked under each submit would multiply the wait of a // person looking at the page. let registry = BaseRegistryClient::new( - BaseRegistryClientConfig::new(base_url).with_max_mutation_retries(0), + BaseRegistryClientConfig::new(base_url).with_maximum_mutation_retries(0), ) .map_err(|error| RuntimeError::Registry(error.to_string()))?; let templates = diff --git a/crates/registry-breg-review/src/main.rs b/crates/registry-breg-review/src/main.rs index 42f4fa6911..42dfeae742 100644 --- a/crates/registry-breg-review/src/main.rs +++ b/crates/registry-breg-review/src/main.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::ExitCode; use registry_breg_review::check::{self, CheckRequest, OutputFormat}; -use registry_breg_review::{command, router, serve, RuntimeConfig}; +use registry_breg_review::{command, router, serve, LogLevel, RuntimeConfig}; use tracing::Level; use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; @@ -13,9 +13,16 @@ const LOG_VARIABLE: &str = "BREG_REVIEW_LOG"; /// The operational log level. The vocabulary is closed so a typo cannot /// silently turn logging off or on. -fn operational_log_level(value: Result) -> Result { +fn operational_log_level( + value: Result, + configured: LogLevel, +) -> Result { match value { - Err(std::env::VarError::NotPresent) => Ok(Level::INFO), + Err(std::env::VarError::NotPresent) => Ok(match configured { + LogLevel::Error => Level::ERROR, + LogLevel::Warn => Level::WARN, + LogLevel::Info => Level::INFO, + }), Ok(value) => match value.as_str() { "error" => Ok(Level::ERROR), "warn" => Ok(Level::WARN), @@ -54,13 +61,17 @@ async fn main() -> ExitCode { )); } - let level = match operational_log_level(std::env::var(LOG_VARIABLE)) { - Ok(level) => level, - Err(error) => { - eprintln!("breg-review: {error}"); - return ExitCode::from(2); - } + let environment_level = match std::env::var(LOG_VARIABLE) { + Err(std::env::VarError::NotPresent) => None, + value => match operational_log_level(value, LogLevel::Info) { + Ok(level) => Some(level), + Err(error) => { + eprintln!("breg-review: {error}"); + return ExitCode::from(2); + } + }, }; + // A refused runtime file is reported in the shared human shape on // standard error (CFG-DIAG-2), naming members and never their values. let config = match RuntimeConfig::load(path) { @@ -70,6 +81,10 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; + let level = environment_level.unwrap_or_else(|| { + operational_log_level(Err(std::env::VarError::NotPresent), config.log_level) + .expect("the runtime log level was validated") + }); tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() @@ -121,15 +136,25 @@ mod tests { #[test] fn the_log_level_vocabulary_is_closed() { + use registry_breg_review::LogLevel; + assert_eq!( - operational_log_level(Err(std::env::VarError::NotPresent)), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Info), Ok(Level::INFO) ); assert_eq!( - operational_log_level(Ok("warn".to_owned())), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Warn), + Ok(Level::WARN) + ); + assert_eq!( + operational_log_level(Ok("error".to_owned()), LogLevel::Warn), + Ok(Level::ERROR) + ); + assert_eq!( + operational_log_level(Ok("warn".to_owned()), LogLevel::Info), Ok(Level::WARN) ); - assert!(operational_log_level(Ok("debug".to_owned())).is_err()); - assert!(operational_log_level(Ok("trace".to_owned())).is_err()); + assert!(operational_log_level(Ok("debug".to_owned()), LogLevel::Info).is_err()); + assert!(operational_log_level(Ok("trace".to_owned()), LogLevel::Info).is_err()); } } diff --git a/crates/registry-breg/src/action_evidence_client.rs b/crates/registry-breg/src/action_evidence_client.rs index 2fab6480c0..930fae40bd 100644 --- a/crates/registry-breg/src/action_evidence_client.rs +++ b/crates/registry-breg/src/action_evidence_client.rs @@ -48,7 +48,7 @@ impl EvidenceProviderBinding { return Err(EvidenceAcquisitionFailure::Configuration); } let client = EvidenceClient::new( - config.with_max_response_bytes(MAXIMUM_ACTION_EVIDENCE_RESPONSE_BYTES), + config.with_maximum_response_bytes(MAXIMUM_ACTION_EVIDENCE_RESPONSE_BYTES), ) .map_err(|_| EvidenceAcquisitionFailure::Configuration)?; Ok(Self { diff --git a/crates/registry-breg/src/main.rs b/crates/registry-breg/src/main.rs index bb946adf25..b08654a680 100644 --- a/crates/registry-breg/src/main.rs +++ b/crates/registry-breg/src/main.rs @@ -5,8 +5,8 @@ use clap::Parser; use registry_breg::cli::Arguments; use registry_breg::runtime_config::load_runtime_config; use registry_breg::startup::{ - map_runtime_config_error, operational_log_level, prepare_loaded, serve, OperationalEvent, - OperationalLogLevel, + map_runtime_config_error, operational_log_level, prepare_loaded, select_operational_log_level, + serve, OperationalEvent, OperationalLogLevel, }; use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; @@ -14,24 +14,40 @@ use tracing_subscriber::prelude::*; #[tokio::main] async fn main() { let arguments = Arguments::parse(); - let level = match operational_log_level(std::env::var("BREG_LOG").ok().as_deref()) { - Ok(level) => level, - Err(error) => { + let environment_log_level = match std::env::var("BREG_LOG") { + Ok(value) => match operational_log_level(Some(&value)) { + Ok(_) => Some(value), + Err(error) => { + initialize_logging(OperationalLogLevel::Error); + OperationalEvent::StoppedWithError(error).emit(); + std::process::exit(2); + } + }, + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { initialize_logging(OperationalLogLevel::Error); - OperationalEvent::StoppedWithError(error).emit(); + OperationalEvent::StoppedWithError(registry_breg::startup::StartupError::Logging) + .emit(); std::process::exit(2); } }; - initialize_logging_filter(level); if !arguments.runtime_config.is_absolute() { + initialize_logging_filter( + select_operational_log_level(environment_log_level.as_deref(), "info") + .expect("the environment log level was validated"), + ); OperationalEvent::Stopped.emit(); std::process::exit(2); } - OperationalEvent::StartupBegan.emit(); let config = match load_runtime_config(&arguments.runtime_config) { Ok(config) => config, Err(error) => { + initialize_logging_filter( + select_operational_log_level(environment_log_level.as_deref(), "info") + .expect("the environment log level was validated"), + ); + OperationalEvent::StartupBegan.emit(); // The operator reads every reader diagnostic, with its position // and fix, on stderr; the operational log on stdout carries only // the closed refusal class. @@ -41,6 +57,13 @@ async fn main() { std::process::exit(1); } }; + let level = select_operational_log_level( + environment_log_level.as_deref(), + config.log_level().as_str(), + ) + .expect("the environment and runtime log levels were validated"); + initialize_logging_filter(level); + OperationalEvent::StartupBegan.emit(); let clients_unrestricted = config.authentication().oidc().admits_every_client(); let prepared = match prepare_loaded(config).await { Ok(prepared) => prepared, diff --git a/crates/registry-breg/src/review_store.rs b/crates/registry-breg/src/review_store.rs index 67d299518f..aac1512b80 100644 --- a/crates/registry-breg/src/review_store.rs +++ b/crates/registry-breg/src/review_store.rs @@ -281,8 +281,8 @@ impl ReviewAuthorityClient { } // Submission, cancellation, and result-poll claims all guard one // outbound exchange on this authority's client, so their lease is - // sized from that client's request timeout. - let lease_seconds = outbound_lease_seconds(client.request_timeout()); + // sized from that client's per-attempt timeout. + let lease_seconds = outbound_lease_seconds(client.attempt_timeout()); Ok(Self { authority, client, diff --git a/crates/registry-breg/src/runtime_config.rs b/crates/registry-breg/src/runtime_config.rs index 5807495799..774b99d6c0 100644 --- a/crates/registry-breg/src/runtime_config.rs +++ b/crates/registry-breg/src/runtime_config.rs @@ -123,6 +123,30 @@ const EVENT_DESTINATION_PATH_SCHEMA_PATTERN: &str = pub const RUNTIME_CONFIG_API_VERSION: &str = "id.registrystack.org/formats/breg/runtime/v1alpha1"; pub const RUNTIME_CONFIG_KIND: &str = "BRegRuntimeConfig"; +/// The operational log level selected by the runtime file when `BREG_LOG` +/// is not set. The closed vocabulary prevents configuration from enabling +/// debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + /// The apiVersion a runtime file carried before it took the shared identifier /// form, refused with the value that replaces it. const RETIRED_RUNTIME_API_VERSIONS: &[RetiredApiVersion<'static>] = &[RetiredApiVersion { @@ -845,6 +869,7 @@ const fn runtime_config_loader() -> RuntimeConfigLoader { #[derive(Clone)] pub struct RuntimeConfig { + log_level: LogLevel, listener: ListenerConfig, identity: DeploymentIdentity, secret_providers: SecretProvidersConfig, @@ -914,6 +939,7 @@ impl RuntimeConfig { metrics.validate_separate_binding(&listener)?; } Ok(Self { + log_level: raw.log_level, listener, identity, secret_providers, @@ -936,6 +962,11 @@ impl RuntimeConfig { }) } + #[must_use] + pub const fn log_level(&self) -> LogLevel { + self.log_level + } + pub fn activate_task_status( &self, compiled: &CompiledRegistry, @@ -997,7 +1028,7 @@ impl RuntimeConfig { .with_audience(oauth.assertion_audience.clone()) .with_resource(oauth.resource.clone()) .with_scopes(oauth.scopes.clone()) - .with_request_timeout(self.operational_timeouts.http_request) + .with_attempt_timeout(self.operational_timeouts.http_request) .with_connect_timeout(self.operational_timeouts.http_request); if let Some(reference) = &oauth.ca_bundle_ref { let ca = resolver.resolve_reference(reference)?; @@ -1044,7 +1075,7 @@ impl RuntimeConfig { .transpose()?; let client = registry_review_client::ReviewClient::new( registry_review_client::ReviewClientConfig::new(config.endpoint.clone()) - .with_request_timeout(self.operational_timeouts.http_request) + .with_attempt_timeout(self.operational_timeouts.http_request) .with_connect_timeout(self.operational_timeouts.http_request), ) .map_err(|_| RuntimeConfigError::InvalidBinding)?; @@ -1358,6 +1389,7 @@ impl fmt::Debug for RuntimeConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RuntimeConfig") + .field("log_level", &self.log_level) .field("listener", &self.listener) .field("identity", &self.identity) .field("secret_providers", &self.secret_providers) @@ -2805,6 +2837,10 @@ struct RawRuntimeConfig { api_version: String, #[cfg(feature = "schema")] kind: String, + /// Operational log level when `BREG_LOG` is not set. Defaults to `info`; + /// accepted values are `error`, `warn`, and `info`. + #[serde(default)] + log_level: LogLevel, listener: RawRegistryListener, identity: RawDeploymentIdentity, secret_providers: registry_platform_config::SecretProvidersConfig, diff --git a/crates/registry-breg/src/startup.rs b/crates/registry-breg/src/startup.rs index 2c04d86eaa..13d1ad6d74 100644 --- a/crates/registry-breg/src/startup.rs +++ b/crates/registry-breg/src/startup.rs @@ -2201,6 +2201,16 @@ pub fn operational_log_level(value: Option<&str>) -> Result { } } +/// Select the process environment override, then the authored runtime level. +/// Both inputs use the same closed vocabulary and an invalid value refuses +/// startup rather than falling back. +pub fn select_operational_log_level( + environment: Option<&str>, + configured: &str, +) -> Result { + operational_log_level(environment.or(Some(configured))) +} + /// Verify the complete local package first, then require the configured /// database id, the package digest as the active package, the schema /// fingerprint, ready maintenance state, ownership, RLS, and ACL catalog diff --git a/crates/registry-breg/tests/postgres_refactor.rs b/crates/registry-breg/tests/postgres_refactor.rs index 39275f61c8..7b4c9374a1 100644 --- a/crates/registry-breg/tests/postgres_refactor.rs +++ b/crates/registry-breg/tests/postgres_refactor.rs @@ -261,7 +261,7 @@ async fn facility_action_is_atomic_checks_references_and_recovers_exactly_once() .await; lost.client = registry_breg_client::BaseRegistryClient::new( registry_breg_client::BaseRegistryClientConfig::new(lost.base_url.parse().unwrap()) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .unwrap(); let lost_contract = lost diff --git a/crates/registry-breg/tests/runtime_config.rs b/crates/registry-breg/tests/runtime_config.rs index 84e47c7b29..5145b3d9b3 100644 --- a/crates/registry-breg/tests/runtime_config.rs +++ b/crates/registry-breg/tests/runtime_config.rs @@ -218,6 +218,48 @@ operationalTimeouts: ) } +#[test] +fn runtime_log_level_defaults_to_info_and_accepts_the_closed_vocabulary() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime(&fixture.secret_root, &fixture.package_root); + + let defaulted = + parse_runtime_config_with_env(&base, env_lookup).expect("the default log level loads"); + assert_eq!(defaulted.log_level().as_str(), "info"); + + for accepted in ["error", "warn", "info"] { + let configured = base.replacen( + &format!("kind: {RUNTIME_CONFIG_KIND}\n"), + &format!("kind: {RUNTIME_CONFIG_KIND}\nlogLevel: {accepted}\n"), + 1, + ); + let loaded = parse_runtime_config_with_env(&configured, env_lookup) + .unwrap_or_else(|error| panic!("{accepted:?} loads: {error}")); + assert_eq!(loaded.log_level().as_str(), accepted); + } +} + +#[test] +fn runtime_log_level_refuses_values_outside_the_closed_vocabulary() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime(&fixture.secret_root, &fixture.package_root); + + for refused in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused.is_empty() { "''" } else { refused }; + let configured = base.replacen( + &format!("kind: {RUNTIME_CONFIG_KIND}\n"), + &format!("kind: {RUNTIME_CONFIG_KIND}\nlogLevel: {yaml_value}\n"), + 1, + ); + let error = parse_runtime_config_with_env(&configured, env_lookup) + .expect_err("an unknown log level is refused"); + assert_eq!( + reader_refusal(&error), + ("config.unknown-variant", "/logLevel") + ); + } +} + fn runtime_with_event_destinations(fixture: &RuntimeFixture, bindings: &str) -> String { valid_runtime(&fixture.secret_root, &fixture.package_root).replace( "eventDestinations: {}\n", diff --git a/crates/registry-breg/tests/startup_http.rs b/crates/registry-breg/tests/startup_http.rs index 3f1421a7c5..a8b4d3acd2 100644 --- a/crates/registry-breg/tests/startup_http.rs +++ b/crates/registry-breg/tests/startup_http.rs @@ -28,7 +28,7 @@ use registry_breg::postgres::{ }; use registry_breg::runtime_config::{parse_runtime_config_with_env, RuntimeConfigError}; use registry_breg::startup::{ - operational_log_level, with_request_timeout_and_metrics_for_test, + operational_log_level, select_operational_log_level, with_request_timeout_and_metrics_for_test, with_request_timeout_for_test, BackgroundTask, BackgroundTaskStop, OperationalEvent, OperationalLogLevel, StartupError, WebhookStateTransitionCode, }; @@ -366,6 +366,16 @@ fn operational_log_level_is_a_closed_vocabulary() { assert!(operational_log_level(Some("error")).is_ok()); assert!(operational_log_level(Some("debug")).is_err()); assert!(operational_log_level(Some("breg=trace")).is_err()); + assert_eq!( + select_operational_log_level(None, "warn").expect("runtime level"), + tracing_subscriber::filter::LevelFilter::WARN + ); + assert_eq!( + select_operational_log_level(Some("error"), "warn").expect("environment override"), + tracing_subscriber::filter::LevelFilter::ERROR + ); + assert!(select_operational_log_level(Some("debug"), "info").is_err()); + assert!(select_operational_log_level(None, "debug").is_err()); } #[derive(Clone, Default)] diff --git a/crates/registry-bregctl/src/data_lifecycle.rs b/crates/registry-bregctl/src/data_lifecycle.rs index c55d289db0..e602662245 100644 --- a/crates/registry-bregctl/src/data_lifecycle.rs +++ b/crates/registry-bregctl/src/data_lifecycle.rs @@ -365,7 +365,7 @@ fn ingestion_client( token: &str, ) -> Result { let config = BaseRegistryClientConfig::new(base.as_url().clone()) - .with_request_timeout(DEFAULT_REQUEST_TIMEOUT) + .with_attempt_timeout(DEFAULT_REQUEST_TIMEOUT) .with_connect_timeout(DEFAULT_CONNECT_TIMEOUT) .with_user_agent(DATA_HTTP_USER_AGENT); let client = BaseRegistryClient::new(config).map_err(|_| DataLifecycleError::BRegUrl)?; diff --git a/crates/registry-bregctl/src/dev/examples/review.rs b/crates/registry-bregctl/src/dev/examples/review.rs index 458b9f60ab..f657ae9d23 100644 --- a/crates/registry-bregctl/src/dev/examples/review.rs +++ b/crates/registry-bregctl/src/dev/examples/review.rs @@ -13,8 +13,8 @@ pub(super) struct ReviewArgs { #[arg(long)] approve: bool, /// Casework authoring CLI from the same installation. - #[arg(long, default_value = "caseworkctl")] - caseworkctl_bin: PathBuf, + #[arg(long)] + caseworkctl_bin: Option, /// Casework runtime executable; otherwise resolve casework on PATH. #[arg(long)] casework_bin: Option, @@ -153,6 +153,24 @@ fn observation_is_pending(report: &Value) -> bool { } pub(super) fn prepare(args: ReviewArgs) -> Result { + // Resolve every rstack-managed helper before creating the destination, so + // an absent component or a conflicting override has no local effects. + let caseworkctl_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORKCTL, + args.caseworkctl_bin.as_deref(), + )? + .or(args.caseworkctl_bin) + .unwrap_or_else(|| PathBuf::from("caseworkctl")); + let breg_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::BREG, + args.breg_bin.as_deref(), + )? + .or(args.breg_bin); + let casework_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORK, + args.casework_bin.as_deref(), + )? + .or(args.casework_bin); let root = std::path::absolute(&args.directory)?; if root.exists() { bail!("review example requires a fresh destination; existing projects and sessions are preserved"); @@ -177,7 +195,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { &logs, )?; ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "init".into(), path(&casework), @@ -214,7 +232,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { "--database-port".into(), ports[2].to_string(), ]; - if let Some(binary) = &args.breg_bin { + if let Some(binary) = &breg_bin { start.extend(["--breg-bin".into(), path(binary)]); } let source = ctl(&self_bin, start, &logs)?; @@ -256,7 +274,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { .context("source state has no parent")? .join("build/package"); ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "provider".into(), "pair".into(), @@ -274,7 +292,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { &logs, )?; ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec!["check".into(), path(&casework)], &logs, )?; @@ -289,10 +307,10 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { "--database-port".into(), ports[4].to_string(), ]; - if let Some(binary) = &args.casework_bin { + if let Some(binary) = &casework_bin { start.extend(["--casework-bin".into(), path(binary)]); } - ctl(&args.caseworkctl_bin, start, &logs)?; + ctl(&caseworkctl_bin, start, &logs)?; let registration = ctl( &self_bin, vec![ @@ -335,7 +353,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { )?; if args.approve { let credentials = ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "dev".into(), "token".into(), diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index e63ae219b6..e5b5199b93 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -1069,6 +1069,12 @@ fn canonical_yaml(bytes: &[u8], name: &str, format: &FormatSpec<'_>) -> Result Result { + // Bind an rstack-managed invocation to its selected runtime before this + // command creates or changes any retained development state. + let managed_breg = crate::selected_binary::resolve_managed( + crate::selected_binary::BREG, + args.breg_bin.as_deref(), + )?; let project = project(&args.project)?; let requested_issuer = args .issuer_project @@ -1252,7 +1258,10 @@ fn start(args: StartArgs) -> Result { "the retained successor database was explicitly removed; a successor package cannot initialize empty records. Remove the project's .breg/dev directory, then run bregctl dev start to begin a fresh session" ); } - let breg = executable("breg", args.breg_bin.as_deref())?; + let breg = match managed_breg { + Some(path) => path, + None => executable("breg", args.breg_bin.as_deref())?, + }; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or // launches the supervisor: a breg from another release has to be named diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index de91e7fe0a..2eab3aa43c 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -11,7 +11,7 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; -use std::process::ExitCode; +use std::process::{Command as ProcessCommand, ExitCode}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -65,6 +65,7 @@ mod report; mod review_recovery; mod reviewed_migrations; mod safe_path; +mod selected_binary; mod starters; mod statistics_lifecycle; mod test_lifecycle; @@ -182,6 +183,12 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Run the production Base Registry Engine service with the selected runtime. + Serve(ServeArgs), + /// Operate the optional citizen-facing MCP gateway. + Mcp(McpArgs), + /// Operate the optional citizen review page. + Review(ReviewArgs), /// Create an authoring project in a new directory: a domain-neutral example, /// one derived from an embedded reference model with `--from`, or a shipped /// starter with `--template`. @@ -256,6 +263,67 @@ enum Command { FieldEncryption(FieldEncryptionArgs), } +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Base Registry Engine runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// BReg runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_BIN", value_name = "BINARY")] + breg_bin: Option, +} + +#[derive(Debug, Args)] +struct McpArgs { + /// MCP gateway executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_MCP_BIN", value_name = "BINARY")] + breg_mcp_bin: Option, + + #[command(subcommand)] + command: SupportingServiceCommand, +} + +#[derive(Debug, Args)] +struct ReviewArgs { + /// Review page executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_REVIEW_BIN", value_name = "BINARY")] + breg_review_bin: Option, + + #[command(subcommand)] + command: SupportingServiceCommand, +} + +#[derive(Debug, Subcommand)] +enum SupportingServiceCommand { + /// Check the runtime file offline, reading no secret and opening no socket. + Check(SupportingServiceCheckArgs), + /// Serve until terminated. + Serve(SupportingServiceServeArgs), +} + +#[derive(Debug, Args)] +struct SupportingServiceCheckArgs { + /// Runtime configuration file to check. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Exit 1 when a warning is reported. + #[arg(long)] + deny_warnings: bool, + + /// Fill environment expressions while checking the runtime file. + #[arg(long)] + environment: bool, +} + +#[derive(Debug, Args)] +struct SupportingServiceServeArgs { + /// Runtime configuration file for the service. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, +} + #[derive(Debug, Args)] struct FieldEncryptionArgs { #[command(subcommand)] @@ -2335,6 +2403,7 @@ where { let arguments: Vec = arguments.into_iter().map(Into::into).collect(); let machine_mode = requested_json(&arguments); + let format_explicit = report_format_requested(&arguments); if machine_mode && help_requested(&arguments) { let catalog = registry_cli_reference::binary_catalog( command(), @@ -2379,6 +2448,37 @@ where let format = cli.format; let result = match cli.command { + Command::Serve(args) => { + return serve(args, format, format_explicit, stdout, stderr); + } + Command::Mcp(args) => { + return supporting_service( + SupportingServiceInvocation { + command: "mcp", + spec: selected_binary::BREG_MCP, + explicit_binary: args.breg_mcp_bin, + action: args.command, + }, + format, + format_explicit, + stdout, + stderr, + ); + } + Command::Review(args) => { + return supporting_service( + SupportingServiceInvocation { + command: "review", + spec: selected_binary::BREG_REVIEW, + explicit_binary: args.breg_review_bin, + action: args.command, + }, + format, + format_explicit, + stdout, + stderr, + ); + } Command::Examples(args) => { return match dev::examples::run(args) { Ok(report) => write_examples_success(&report, format, stdout, stderr), @@ -2970,6 +3070,166 @@ where } } +fn serve( + args: ServeArgs, + format: OutputFormat, + format_explicit: bool, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if format_explicit { + return reject_service_format("serve", format, stdout, stderr); + } + + let binary = + match selected_binary::resolve_managed(selected_binary::BREG, args.breg_bin.as_deref()) { + Ok(Some(binary)) => binary, + Ok(None) => args.breg_bin.unwrap_or_else(|| PathBuf::from("breg")), + Err(error) => { + let _ = writeln!(stderr, "bregctl serve could not select breg: {error:#}"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command.arg("--runtime-config").arg(args.runtime_config); + replace_with_service(command, "breg", stderr) +} + +struct SupportingServiceInvocation { + command: &'static str, + spec: selected_binary::HelperSpec, + explicit_binary: Option, + action: SupportingServiceCommand, +} + +fn supporting_service( + invocation: SupportingServiceInvocation, + format: OutputFormat, + format_explicit: bool, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if matches!(&invocation.action, SupportingServiceCommand::Serve(_)) && format_explicit { + return reject_service_format(invocation.command, format, stdout, stderr); + } + + let binary = match selected_binary::resolve_managed( + invocation.spec, + invocation.explicit_binary.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => invocation + .explicit_binary + .unwrap_or_else(|| PathBuf::from(invocation.spec.binary)), + Err(error) => { + let _ = writeln!( + stderr, + "bregctl {} could not select {}: {error:#}", + invocation.command, invocation.spec.binary + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + match invocation.action { + SupportingServiceCommand::Serve(args) => { + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + } + SupportingServiceCommand::Check(args) => { + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("check") + .arg("--format") + .arg(match format { + OutputFormat::Human => "human", + OutputFormat::Json => "json", + }); + if args.deny_warnings { + command.arg("--deny-warnings"); + } + if args.environment { + command.arg("--environment"); + } + } + } + replace_with_service(command, invocation.spec.binary, stderr) +} + +fn reject_service_format( + command: &'static str, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let report = source_failure( + "usage", + diagnostic( + "usage.invalid", + "format", + &format!( + "--format does not apply to the {command} service because service logs keep the runtime contract" + ), + ), + DiagnosticArtifact::CommandArguments, + SuggestedAction::CorrectCommandUsage, + ); + match format { + OutputFormat::Json => { + let result = write_ctl_report(&mut *stdout, &report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)); + if result.is_err() { + let _ = writeln!(stderr, "bregctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + } + OutputFormat::Human => { + let _ = writeln!( + stderr, + "error: --format does not apply to the {command} service" + ); + } + } + ExitCode::from(USAGE_EXIT) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "bregctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "bregctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + fn proposal_retention_list( args: &ProposalRetentionListArgs, ) -> Result { @@ -7113,6 +7373,19 @@ fn requested_json(arguments: &[OsString]) -> bool { }) } +fn report_format_requested(arguments: &[OsString]) -> bool { + arguments + .iter() + .skip(1) + .take_while(|argument| *argument != "--") + .any(|argument| { + argument == "--format" + || argument + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }) +} + /// Describe a command line clap refused by the error kind and the argument /// name only, followed by the usage clap renders from the declared arguments. /// Clap's own message is never repeated because it quotes the rejected token, @@ -14066,6 +14339,9 @@ mod tests { assert_eq!( names, [ + "serve", + "mcp", + "review", "init", "check", "project", @@ -14098,6 +14374,35 @@ mod tests { ); } + #[test] + fn supporting_service_runtime_arguments_have_public_catalog_help() { + let reference = registry_cli_reference::command_reference( + command(), + None, + Some(registry_cli_reference::SYMBOLIC_LINK_REFUSAL), + ); + for service_name in ["mcp", "review"] { + let service = reference + .subcommands + .iter() + .find(|command| command.name == service_name) + .expect("supporting service subtree"); + for action_name in ["check", "serve"] { + let action = service + .subcommands + .iter() + .find(|command| command.name == action_name) + .expect("supporting service action"); + let runtime = action + .options + .iter() + .find(|option| option.display == "--runtime-config ") + .expect("runtime configuration option"); + assert!(runtime.description.contains("Runtime configuration file")); + } + } + } + #[test] fn example_reports_show_native_results_and_next_command_in_both_formats() { let report = json!({"ok":true,"command":"examples run","project":"/local/project","scenario":"first-record","attempt":"attempt-id","captures":{"first-record":{"id":"returned-id","entity":"entry"}},"results":{"get":{"data":{"name":"Synthetic record"}}},"nextCommand":"bregctl examples run reviewed-change . --step submit"}); diff --git a/crates/registry-bregctl/src/selected_binary.rs b/crates/registry-bregctl/src/selected_binary.rs new file mode 100644 index 0000000000..735ffebf97 --- /dev/null +++ b/crates/registry-bregctl/src/selected_binary.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const BREG: HelperSpec = HelperSpec { + binary: "breg", + component: "breg", + explicit_source: "--breg-bin or BREG_BIN", + managed_env: "RSTACK_BREG_BIN", +}; + +pub(crate) const BREG_MCP: HelperSpec = HelperSpec { + binary: "breg-mcp", + component: "breg", + explicit_source: "--breg-mcp-bin or BREG_MCP_BIN", + managed_env: "RSTACK_BREG_MCP_BIN", +}; + +pub(crate) const BREG_REVIEW: HelperSpec = HelperSpec { + binary: "breg-review", + component: "breg", + explicit_source: "--breg-review-bin or BREG_REVIEW_BIN", + managed_env: "RSTACK_BREG_REVIEW_BIN", +}; + +pub(crate) const CASEWORKCTL: HelperSpec = HelperSpec { + binary: "caseworkctl", + component: "casework", + explicit_source: "--caseworkctl-bin", + managed_env: "RSTACK_CASEWORKCTL_BIN", +}; + +pub(crate) const CASEWORK: HelperSpec = HelperSpec { + binary: "casework", + component: "casework", + explicit_source: "--casework-bin", + managed_env: "RSTACK_CASEWORK_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).with_context(|| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + bail!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + ); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).with_context(|| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + bail!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + ); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + bail!("helper is not an installed file at {}", path.display()); + } + Ok(fs::canonicalize(path)?) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + bail!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, bregctl will not install components automatically", + spec.binary, + spec.component + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture(root: &Path, spec: HelperSpec) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREG); + let resolved = resolve_managed_with_environment( + BREG, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), CASEWORK); + let override_path = root.path().join("other-casework"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("--casework-bin"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _selected) = fixture(root.path(), BREG); + let outside = root.path().join("outside-breg"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREG, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("RSTACK_BREG_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + CASEWORKCTL, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("rstack install casework"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(BREG, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-bregctl/tests/serve.rs b/crates/registry-bregctl/tests/serve.rs new file mode 100644 index 0000000000..8d24cbd924 --- /dev/null +++ b/crates/registry-bregctl/tests/serve.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(arguments) + .env("BREG_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_and_exit() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 17); + let capture = temp.path().join("capture"); + let child = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/runtime.yaml"]) + .env("BREG_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("bregctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17)); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "breg", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("BREG_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg/breg"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "breg", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + let poisoned = runtime(&poisoned_directory, "breg", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_BREG_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("BREG_BIN") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml"] + ); + assert_ne!(selected, poisoned); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-bregctl/tests/supporting_services.rs b/crates/registry-bregctl/tests/supporting_services.rs new file mode 100644 index 0000000000..1394b4a587 --- /dev/null +++ b/crates/registry-bregctl/tests/supporting_services.rs @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +#[derive(Clone, Copy)] +struct Service { + command: &'static str, + binary: &'static str, + unmanaged_env: &'static str, + managed_env: &'static str, +} + +const MCP: Service = Service { + command: "mcp", + binary: "breg-mcp", + unmanaged_env: "BREG_MCP_BIN", + managed_env: "RSTACK_BREG_MCP_BIN", +}; + +const REVIEW: Service = Service { + command: "review", + binary: "breg-review", + unmanaged_env: "BREG_REVIEW_BIN", + managed_env: "RSTACK_BREG_REVIEW_BIN", +}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(service: Service, binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(arguments) + .env(service.unmanaged_env, binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts") +} + +#[test] +fn unmanaged_service_serve_preserves_arguments_streams_process_status_and_lease() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "bregctl-launcher", + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_bregctl")) + .env(service.unmanaged_env, &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + launcher.env(backup, value); + } + } + let child = launcher.spawn().expect("bregctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17), "{}", service.binary); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); + } +} + +#[test] +fn unmanaged_service_serve_falls_back_to_path() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), service.binary, 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/path.yaml", + ]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove(service.unmanaged_env) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(21), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); + } +} + +#[test] +fn managed_service_serve_uses_the_selected_generation_instead_of_path() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg").join(service.binary); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, service.binary, 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, service.binary, 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env(service.managed_env, &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove(service.unmanaged_env) + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(19), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); + } +} + +#[test] +fn managed_service_does_not_fall_back_when_the_optional_binary_is_absent() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, service.binary, 97); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove(service.managed_env) + .env_remove(service.unmanaged_env) + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(3), "{}", service.binary); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(service.binary), "{stderr}"); + assert!(stderr.contains("rstack install breg"), "{stderr}"); + assert!(!capture.exists(), "a PATH runtime must not start"); + } +} + +#[test] +fn managed_service_refuses_a_same_version_unmanaged_override() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg").join(service.binary); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, service.binary, 19); + let outside = runtime(temp.path(), "same-version-outside", 97); + fs::write(&outside, fs::read(&selected).unwrap()).unwrap(); + let capture = temp.path().join("capture"); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env(service.unmanaged_env, &outside) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env(service.managed_env, &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(3), "{}", service.binary); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("selected generation"), "{stderr}"); + assert!(stderr.contains("matching version string"), "{stderr}"); + assert!(!capture.exists(), "the unmanaged override must not start"); + } +} + +#[test] +fn service_check_forwards_report_format_and_check_options() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 23); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + "--format", + "json", + service.command, + "check", + "--runtime-config", + "/tmp/check.yaml", + "--deny-warnings", + "--environment", + ], + ); + + assert_eq!(output.status.code(), Some(23), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + [ + "--runtime-config", + "/tmp/check.yaml", + "check", + "--format", + "json", + "--deny-warnings", + "--environment" + ] + ); + } +} + +#[test] +fn service_serve_rejects_explicit_json_report_format_before_launch() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 0); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "json", + ], + ); + + assert_eq!(output.status.code(), Some(2), "{}", service.binary); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the service must not start"); + } +} + +#[test] +fn service_serve_rejects_explicit_human_report_format_before_launch() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 0); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + "--format", + "human", + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2), "{}", service.binary); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the service must not start"); + } +} diff --git a/crates/registry-casework-breg/src/config.rs b/crates/registry-casework-breg/src/config.rs index 65f54df3f6..64b77d3962 100644 --- a/crates/registry-casework-breg/src/config.rs +++ b/crates/registry-casework-breg/src/config.rs @@ -154,7 +154,7 @@ pub fn build_provider( let endpoint = url::Url::parse(&binding.token_endpoint).map_err(|_| ReviewContextError::Invalid)?; let mut token = PrivateKeyJwtConfig::new(endpoint, client_id, key) - .with_request_timeout(Duration::from_secs(30)) + .with_attempt_timeout(Duration::from_secs(30)) .with_connect_timeout(Duration::from_secs(10)); if let Some(audience) = &binding.client_assertion_audience { token = token.with_audience(audience); @@ -170,8 +170,8 @@ pub fn build_provider( url::Url::parse(&binding.base_url).map_err(|_| ReviewContextError::Invalid)?, ) .with_token_provider(Arc::new(token)) - .with_max_response_bytes(1024 * 1024) - .with_request_timeout(Duration::from_secs(30)) + .with_maximum_response_bytes(1024 * 1024) + .with_attempt_timeout(Duration::from_secs(30)) .with_connect_timeout(Duration::from_secs(10)); let client = BaseRegistryClient::new(config).map_err(|_| ReviewContextError::Invalid)?; crate::BregContextProvider::new( diff --git a/crates/registry-casework-client-node/README.md b/crates/registry-casework-client-node/README.md index 50b4c88996..2610417184 100644 --- a/crates/registry-casework-client-node/README.md +++ b/crates/registry-casework-client-node/README.md @@ -7,6 +7,8 @@ Producer, human-review, and Directory calls take a bearer token and an explicit Casework profile. Current-source context operations also take the caller's source profile; submitted-context reviews need no provider or source profile. The binding does not retain credentials and exposes no browser credential API. +Transport configuration uses `attemptTimeoutMilliseconds`, +`connectTimeoutMilliseconds`, and `maximumResponseBytes`. `reviewTasks(token, profile, { ownership: 'assigned-to-me' })` filters the reviewer inbox before pagination. `supervisoryReviewTasks` provides current @@ -56,7 +58,7 @@ for expiry, revocation, and changes that require another approval. A keyed mutation whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. +key up to `maximumMutationRetries` times: 0 to 2, default 2, and 0 sends it once. Reads, unkeyed operations, and review request create and cancel are never resent. When the returned `CaseworkClientError` still reports `outcomeUnknown`, the mutation may have taken effect: recover by sending it again under the same diff --git a/crates/registry-casework-client-node/__test__/facade.test-d.ts b/crates/registry-casework-client-node/__test__/facade.test-d.ts index 88c8bcdc9a..e211d0bc8c 100644 --- a/crates/registry-casework-client-node/__test__/facade.test-d.ts +++ b/crates/registry-casework-client-node/__test__/facade.test-d.ts @@ -2,7 +2,17 @@ import { CaseworkClient, CaseworkClientError, submissionDigest, type ContentDige type PolicyBinding, type ReviewCreateRequest, type ReviewRequestAccepted, type ReviewTask, type SupervisoryReviewTask, type StaffingDiagnostic, type ReviewTaskDecisionRequest, type ReviewStaffingQuery, type ReviewCaseloadMoveRequest, type ReviewClockRecomputeRequest } from '../client' -const client = new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxMutationRetries: 0 }) +const client = new CaseworkClient({ baseUrl: 'https://casework.example.test/', attemptTimeoutMilliseconds: 1500, maximumResponseBytes: 1500, maximumMutationRetries: 0 }) +// @ts-expect-error the public response-bound name is maximumResponseBytes +new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxResponseBytes: 1500 }) +// @ts-expect-error null is not an omission in JavaScript configuration +new CaseworkClient({ baseUrl: 'https://casework.example.test/', attemptTimeoutMilliseconds: null }) +// @ts-expect-error trust roots are PEM text, not binary data +new CaseworkClient({ baseUrl: 'https://casework.example.test/', trustedRootCertificates: Buffer.from('certificate') }) +// @ts-expect-error the per-exchange timeout is named for one attempt +new CaseworkClient({ baseUrl: 'https://casework.example.test/', requestTimeoutMilliseconds: 1500 }) +// @ts-expect-error the retry ceiling uses the authored maximum spelling +new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxMutationRetries: 0 }) const token = 'one-call-secret', profile = 'staff', id = '00000000-0000-4000-8000-000000000001' const digest: ContentDigest = `sha256:${'a'.repeat(64)}` const subject: SubjectBinding = { source: 'registry', type: 'proposal', id: 'proposal-1', version: '1', digest } diff --git a/crates/registry-casework-client-node/__test__/facade.test.js b/crates/registry-casework-client-node/__test__/facade.test.js index 522156c741..3dfdbf192a 100644 --- a/crates/registry-casework-client-node/__test__/facade.test.js +++ b/crates/registry-casework-client-node/__test__/facade.test.js @@ -74,11 +74,20 @@ test('a configuration value the sanitizer refuses is a configuration error', () const { CaseworkClient, CaseworkClientError } = require('../client'); const baseUrl = 'https://casework.example/'; for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['an overflowing response bound', { baseUrl, maximumResponseBytes: 4294967297 }], + ['a negative attempt timeout', { baseUrl, attemptTimeoutMilliseconds: -1 }], + ['a fractional connect timeout', { baseUrl, connectTimeoutMilliseconds: 1.5 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['an unknown member', { baseUrl, maximumResponseByte: 1500 }], + ['the retired response-bound spelling', { baseUrl, maxResponseBytes: 1500 }], + ['the retired timeout spelling', { baseUrl, requestTimeoutMilliseconds: 1500 }], + ['the retired retry spelling', { baseUrl, maxMutationRetries: 0 }], ['an undefined member', { baseUrl, userAgent: undefined }], + ['a null member', { baseUrl, userAgent: null }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['a function', { baseUrl, userAgent: () => 'casework-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { diff --git a/crates/registry-casework-client-node/__test__/mutation-retry.test.js b/crates/registry-casework-client-node/__test__/mutation-retry.test.js index a4de938ec5..fd07c1f017 100644 --- a/crates/registry-casework-client-node/__test__/mutation-retry.test.js +++ b/crates/registry-casework-client-node/__test__/mutation-retry.test.js @@ -82,7 +82,7 @@ test('an absence answered 503 is resent once under the same key and succeeds', a }); test('a retry ceiling of zero sends an absence once and reports the outcome unknown', async (context) => { - const { client, requests } = await retrying(context, [UNAVAILABLE, CREATED], { maxMutationRetries: 0 }); + const { client, requests } = await retrying(context, [UNAVAILABLE, CREATED], { maximumMutationRetries: 0 }); const { CaseworkClientError } = require('../client'); await assert.rejects(client.createAbsence('one-call-secret', 'administrator', 2, 'absence-1', ABSENCE), (error) => { @@ -97,7 +97,7 @@ test('a retry ceiling of zero sends an absence once and reports the outcome unkn }); test('a 4xx refusal of an absence is never resent and reports the outcome known', async (context) => { - const { client, requests } = await retrying(context, [PRECONDITION_FAILED, CREATED], { maxMutationRetries: 2 }); + const { client, requests } = await retrying(context, [PRECONDITION_FAILED, CREATED], { maximumMutationRetries: 2 }); await assert.rejects(client.createAbsence('one-call-secret', 'administrator', 2, 'absence-1', ABSENCE), (error) => { assert.equal(error.kind, 'problem'); @@ -122,13 +122,13 @@ test('a request refused before any exchange reports the outcome known', async (c test('a retry ceiling outside zero to two is a configuration error', () => { const { CaseworkClient, CaseworkClientError } = require('../client'); - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new CaseworkClient({ baseUrl: 'https://casework.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new CaseworkClient({ baseUrl: 'https://casework.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new CaseworkClient({ baseUrl: 'https://casework.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof CaseworkClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new CaseworkClient({ baseUrl: 'https://casework.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof CaseworkClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index e5607c9ee1..4f2babc96c 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -8,11 +8,11 @@ export type JsonObject = { readonly [key: string]: JsonValue } export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times an idempotency-keyed mutation whose outcome is unknown is * resent, identically and under the same key, before its error is @@ -20,7 +20,7 @@ export interface CaseworkClientConfig { * unkeyed operations, and review request create and cancel are never * resent. Any other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type PageStatus = 'complete' | 'budget-exhausted' | 'source-unavailable' diff --git a/crates/registry-casework-client-node/client.js b/crates/registry-casework-client-node/client.js index b71d650f5b..c9bca29727 100644 --- a/crates/registry-casework-client-node/client.js +++ b/crates/registry-casework-client-node/client.js @@ -85,12 +85,50 @@ function sanitize(value, kind) { return cloneJson(value, { nodes: 0, bytes: 0, active: new WeakSet() }, 0, kind); } +function configurationValue(config) { + if (!config || typeof config !== 'object' || Array.isArray(config) || isProxy(config)) { + throw normalized({}, 'configuration'); + } + const prototype = Object.getPrototypeOf(config); + if (prototype !== Object.prototype && prototype !== null) { + throw normalized({}, 'configuration'); + } + const allowed = new Set([ + 'baseUrl', + 'attemptTimeoutMilliseconds', + 'connectTimeoutMilliseconds', + 'maximumResponseBytes', + 'userAgent', + 'trustedRootCertificates', + 'maximumMutationRetries', + ]); + const copy = Object.create(null); + for (const key of Reflect.ownKeys(config)) { + if (typeof key !== 'string' || !allowed.has(key)) throw normalized({}, 'configuration'); + const descriptor = Object.getOwnPropertyDescriptor(config, key); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw normalized({}, 'configuration'); + } + const value = descriptor.value; + if (key === 'baseUrl' || key === 'userAgent' || key === 'trustedRootCertificates') { + if (typeof value !== 'string') throw normalized({}, 'configuration'); + copy[key] = value; + } else { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { + throw normalized({}, 'configuration'); + } + copy[key] = value; + } + } + return copy; +} + const NativeCaseworkClient = native.CaseworkClient; class CaseworkClient { constructor(config) { try { - this.native = new NativeCaseworkClient(sanitize(config, 'configuration')); + this.native = new NativeCaseworkClient(configurationValue(config)); } catch (error) { throw normalized(error, 'configuration'); } diff --git a/crates/registry-casework-client-node/index.d.ts b/crates/registry-casework-client-node/index.d.ts index 995a725c46..8187f8f213 100644 --- a/crates/registry-casework-client-node/index.d.ts +++ b/crates/registry-casework-client-node/index.d.ts @@ -63,16 +63,16 @@ export declare class CaseworkClient { export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - maxResponseBytes?: number + maximumResponseBytes?: number userAgent?: string trustedRootCertificates?: string /** * How many times a keyed mutation whose outcome is unknown is resent under * the same idempotency key: 0 to 2, default 2, and 0 disables the resend. */ - maxMutationRetries?: number + maximumMutationRetries?: number } export interface CaseworkOutcome { diff --git a/crates/registry-casework-client-node/src/lib.rs b/crates/registry-casework-client-node/src/lib.rs index d8450550ac..cab48924a8 100644 --- a/crates/registry-casework-client-node/src/lib.rs +++ b/crates/registry-casework-client-node/src/lib.rs @@ -41,14 +41,14 @@ pub fn submission_digest( #[napi(object)] pub struct CaseworkClientConfig { pub base_url: String, - pub request_timeout_milliseconds: Option, - pub connect_timeout_milliseconds: Option, - pub max_response_bytes: Option, + pub attempt_timeout_milliseconds: Option, + pub connect_timeout_milliseconds: Option, + pub maximum_response_bytes: Option, pub user_agent: Option, pub trusted_root_certificates: Option, /// How many times a keyed mutation whose outcome is unknown is resent under /// the same idempotency key: 0 to 2, default 2, and 0 disables the resend. - pub max_mutation_retries: Option, + pub maximum_mutation_retries: Option, } /// Identify a human task grant read without positional string ambiguity. @@ -78,14 +78,16 @@ impl CaseworkClient { binding_error("configuration", "Casework client configuration is invalid") })?; let mut core = CoreConfig::new(base_url); - if let Some(value) = config.request_timeout_milliseconds { - core = core.with_request_timeout(Duration::from_millis(u64::from(value))); + if let Some(value) = config.attempt_timeout_milliseconds { + let value = positive_u32(value)?; + core = core.with_attempt_timeout(Duration::from_millis(u64::from(value))); } if let Some(value) = config.connect_timeout_milliseconds { + let value = positive_u32(value)?; core = core.with_connect_timeout(Duration::from_millis(u64::from(value))); } - if let Some(value) = config.max_response_bytes { - core = core.with_max_response_bytes(u64::from(value)); + if let Some(value) = config.maximum_response_bytes { + core = core.with_maximum_response_bytes(u64::from(positive_u32(value)?)); } if let Some(value) = config.user_agent { core = core.with_user_agent(value); @@ -93,8 +95,8 @@ impl CaseworkClient { if let Some(value) = config.trusted_root_certificates { core = core.with_trusted_root_certificates(value.into_bytes()); } - if let Some(value) = config.max_mutation_retries { - core = core.with_max_mutation_retries(mutation_retries(value)?); + if let Some(value) = config.maximum_mutation_retries { + core = core.with_maximum_mutation_retries(mutation_retries(value)?); } CoreClient::new(core) .map(|inner| Self { inner }) @@ -1256,6 +1258,19 @@ fn binding_error(kind: &'static str, message: &'static str) -> NapiError { ) } +/// Validate a JavaScript number before narrowing it to the native millisecond +/// and byte-count representation. +fn positive_u32(value: f64) -> Result { + if value.is_finite() && value.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(&value) { + Ok(value as u32) + } else { + Err(binding_error( + "configuration", + "Casework client configuration is invalid", + )) + } +} + /// The retry ceiling as a JavaScript number. Only a whole number in the /// `u8` range reaches the client, which refuses one above its own bound. fn mutation_retries(value: f64) -> Result { diff --git a/crates/registry-casework-client-py/README.md b/crates/registry-casework-client-py/README.md index fd18010792..92de339e4b 100644 --- a/crates/registry-casework-client-py/README.md +++ b/crates/registry-casework-client-py/README.md @@ -24,6 +24,8 @@ profile. Supply those explicitly on each call. Mutations also require the revision and idempotency key chosen by the caller; the binding never replaces them. Results use the canonical Rust client's camel-case wire DTOs inside a `{"kind": "complete", "value": ..., "trace_id": ...}` envelope. +Transport configuration uses `attempt_timeout_seconds`, +`connect_timeout_seconds`, and `maximum_response_bytes`. Requester review operations omit a source profile. Officer task reads, claims, assignment, delegation, drafts, decisions, history, notes, and review clocks @@ -62,7 +64,7 @@ or idempotency expiry explicitly; the binding never replaces a key. A keyed mutation whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `max_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. +key up to `maximum_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. Reads, unkeyed operations, and review request create and cancel are never resent. When the returned `CaseworkClientError` still reports `outcome_unknown`, the mutation may have taken effect: recover by sending it diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 3047d5f638..d191101aad 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -807,7 +807,7 @@ class ReviewClockRecomputeResult(TypedDict): def submission_digest(producer: str, source_namespace: str, request: ReviewCreateRequest) -> ContentDigest: ... class CaseworkClient: - def __init__(self, base_url: str, request_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, max_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, max_mutation_retries: int | None = None) -> None: ... + def __init__(self, base_url: str, attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, maximum_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, maximum_mutation_retries: int | None = None) -> None: ... def description(self, token: str, profile: str) -> Complete[Description]: ... def create_or_recover_review_request(self, token: str, profile: str, idempotency_key: str, request: ReviewCreateRequest, expected_submission_digest: ContentDigest) -> Complete[ReviewRequestAccepted]: ... def review_request(self, token: str, profile: str, request_id: Uuid) -> Complete[ReviewRequestView]: ... diff --git a/crates/registry-casework-client-py/src/lib.rs b/crates/registry-casework-client-py/src/lib.rs index c6275bd2f5..d3f19d7f46 100644 --- a/crates/registry-casework-client-py/src/lib.rs +++ b/crates/registry-casework-client-py/src/lib.rs @@ -22,7 +22,7 @@ use casework_client_sdk::{ use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyDict}, + types::{PyBool, PyDict, PyFloat, PyInt}, }; use serde::{de::DeserializeOwned, Serialize}; use url::Url; @@ -238,12 +238,55 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { value.extract::().map_err(|_| invalid()) } -fn duration(py: Python<'_>, value: f64) -> PyResult { - Duration::try_from_secs_f64(value).map_err(|_| { +fn duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if value.is_exact_instance_of::() { + let value = value.extract::().map_err(|_| { + binding_error( + py, + "configuration", + "Casework client configuration is invalid", + ) + })?; + Duration::try_from_secs_f64(value).map_err(|_| { + binding_error( + py, + "configuration", + "client timeouts must be finite non-negative seconds", + ) + }) + } else if value.is_exact_instance_of::() { + value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + binding_error( + py, + "configuration", + "Casework client configuration is invalid", + ) + }) + } else { + Err(binding_error( + py, + "configuration", + "Casework client configuration is invalid", + )) + } +} + +fn parse_maximum_response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if !value.is_exact_instance_of::() { + return Err(binding_error( + py, + "configuration", + "Casework client configuration is invalid", + )); + } + value.extract::().map_err(|_| { binding_error( py, "configuration", - "client timeouts must be finite non-negative seconds", + "Casework client configuration is invalid", ) }) } @@ -385,17 +428,17 @@ struct CaseworkClient { #[pymethods] impl CaseworkClient { #[new] - #[pyo3(signature = (base_url, request_timeout_seconds=None, connect_timeout_seconds=None, max_response_bytes=None, user_agent=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, - max_response_bytes: Option, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, + connect_timeout_seconds: Option<&Bound<'_, PyAny>>, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let base_url = Url::parse(base_url).map_err(|_| { binding_error( @@ -405,14 +448,14 @@ impl CaseworkClient { ) })?; let mut config = CaseworkClientConfig::new(base_url); - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(py, value)?); + if let Some(value) = attempt_timeout_seconds { + config = config.with_attempt_timeout(duration(py, value)?); } if let Some(value) = connect_timeout_seconds { config = config.with_connect_timeout(duration(py, value)?); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(value); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(parse_maximum_response_bytes(py, value)?); } if let Some(value) = user_agent { config = config.with_user_agent(value); @@ -420,8 +463,8 @@ impl CaseworkClient { if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); } - if let Some(value) = max_mutation_retries { - config = config.with_max_mutation_retries(mutation_retries(py, value)?); + if let Some(value) = maximum_mutation_retries { + config = config.with_maximum_mutation_retries(mutation_retries(py, value)?); } let inner = py .detach(|| RustClient::new(config)) diff --git a/crates/registry-casework-client-py/tests/python/test_construction.py b/crates/registry-casework-client-py/tests/python/test_construction.py index 190a76d7ac..96208e9494 100644 --- a/crates/registry-casework-client-py/tests/python/test_construction.py +++ b/crates/registry-casework-client-py/tests/python/test_construction.py @@ -15,11 +15,45 @@ def test_constructs_without_retaining_credentials(self) -> None: self.assertIsInstance(client, CaseworkClient) self.assertNotIn("token", repr(client).lower()) + def test_none_means_omission_for_optional_configuration(self) -> None: + client = CaseworkClient( + "https://casework.example.invalid/tenant", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + maximum_response_bytes=None, + user_agent=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, CaseworkClient) + def test_invalid_configuration_has_a_stable_kind(self) -> None: with self.assertRaises(CaseworkClientError) as raised: CaseworkClient("not a URL") self.assertEqual(raised.exception.kind, "configuration") + def test_numeric_configuration_is_lossless_and_uses_the_public_name(self) -> None: + invalid_configurations = ( + {"attempt_timeout_seconds": True}, + {"attempt_timeout_seconds": 2**64}, + {"connect_timeout_seconds": False}, + {"maximum_response_bytes": True}, + {"maximum_response_bytes": 2**64}, + ) + for options in invalid_configurations: + with self.subTest(options=options), self.assertRaises(CaseworkClientError) as raised: + CaseworkClient("https://casework.example.invalid/", **options) + self.assertEqual(raised.exception.kind, "configuration") + + with self.assertRaises(TypeError): + CaseworkClient( + "https://casework.example.invalid/", request_timeout_seconds=1.0 + ) + with self.assertRaises(TypeError): + CaseworkClient( + "https://casework.example.invalid/", max_mutation_retries=0 + ) + def test_token_error_does_not_repeat_credential(self) -> None: secret = "bad token with spaces canary" client = CaseworkClient("https://casework.example.invalid/") diff --git a/crates/registry-casework-client-py/tests/python/test_mutation_retry.py b/crates/registry-casework-client-py/tests/python/test_mutation_retry.py index ae704877df..c90ff29eb1 100644 --- a/crates/registry-casework-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-casework-client-py/tests/python/test_mutation_retry.py @@ -121,7 +121,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, CREATED) - client = CaseworkClient(self.base_url, max_mutation_retries=0) + client = CaseworkClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(CaseworkClientError) as raised: client.create_absence("one-call-token", "administrator", 2, KEY, ABSENCE) @@ -134,7 +134,7 @@ def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> N def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> None: self.script(PRECONDITION_FAILED, CREATED) - client = CaseworkClient(self.base_url, max_mutation_retries=2) + client = CaseworkClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(CaseworkClientError) as raised: client.create_absence("one-call-token", "administrator", 2, KEY, ABSENCE) @@ -147,11 +147,11 @@ def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> No def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - CaseworkClient(self.base_url, max_mutation_retries=retries) + CaseworkClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(CaseworkClientError) as raised: - CaseworkClient(self.base_url, max_mutation_retries=retries) + CaseworkClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-casework-client-py/tests/python/test_typing_contract.py b/crates/registry-casework-client-py/tests/python/test_typing_contract.py index 2acbc8da70..27bcbfce57 100644 --- a/crates/registry-casework-client-py/tests/python/test_typing_contract.py +++ b/crates/registry-casework-client-py/tests/python/test_typing_contract.py @@ -12,6 +12,32 @@ class TypingContractTests(unittest.TestCase): + def test_client_configuration_uses_the_canonical_authored_names(self) -> None: + module = ast.parse(STUB.read_text(encoding="utf-8")) + client = next( + node + for node in module.body + if isinstance(node, ast.ClassDef) and node.name == "CaseworkClient" + ) + constructor = next( + node + for node in client.body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + self.assertEqual( + [argument.arg for argument in constructor.args.args], + [ + "self", + "base_url", + "attempt_timeout_seconds", + "connect_timeout_seconds", + "maximum_response_bytes", + "user_agent", + "trusted_root_certificates", + "maximum_mutation_retries", + ], + ) + def test_admission_selects_a_policy_and_acceptance_keeps_the_exact_binding(self) -> None: module = ast.parse(STUB.read_text(encoding="utf-8")) classes = {node.name: node for node in module.body if isinstance(node, ast.ClassDef)} diff --git a/crates/registry-casework-client/README.md b/crates/registry-casework-client/README.md index 0098b7b482..53fc6de0f1 100644 --- a/crates/registry-casework-client/README.md +++ b/crates/registry-casework-client/README.md @@ -83,12 +83,12 @@ key, headers, and body bytes, at most twice by default, waiting 250 ms and then 500 ms, or the service's `Retry-After` when that is longer and at most 5 seconds. Casework answers every 503 with `Retry-After: 5`, so a resend after one waits 5 seconds. A longer requested wait ends the retries. Configure the -count with `CaseworkClientConfig::with_max_mutation_retries` (0 to 2; 0 +count with `CaseworkClientConfig::with_maximum_mutation_retries` (0 to 2; 0 disables the resend). Reads, unkeyed operations (task grant revocations, task assertions and previews), and the review request create and cancel calls, which go through the review client's single exchange, are -never resent. Each attempt has the full request timeout, so a call can take up -to three request timeouts plus the waits. +never resent. Each attempt has the full attempt timeout, so a call can take up +to three attempt timeouts plus the waits. When the returned error still reports `is_outcome_unknown()`, recover by sending the same request with the same key, which the service replays or diff --git a/crates/registry-casework-client/src/client.rs b/crates/registry-casework-client/src/client.rs index a9d58c636a..abba06a848 100644 --- a/crates/registry-casework-client/src/client.rs +++ b/crates/registry-casework-client/src/client.rs @@ -59,8 +59,8 @@ const ORIGINAL_ATTEMPT_HEADER: &str = "registry-casework-attempt"; pub struct CaseworkClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - max_mutation_retries: u8, + maximum_response_bytes: u64, + maximum_mutation_retries: u8, review: ProducerReviewClient, } @@ -69,8 +69,8 @@ impl fmt::Debug for CaseworkClient { formatter .debug_struct("CaseworkClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_response_bytes", &self.maximum_response_bytes) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -78,9 +78,13 @@ impl fmt::Debug for CaseworkClient { impl CaseworkClient { pub fn new(config: CaseworkClientConfig) -> Result { let mut review_config = ProducerReviewClientConfig::new(config.base_url.clone()) - .with_request_timeout(config.request_timeout) + .with_attempt_timeout(config.attempt_timeout) .with_connect_timeout(config.connect_timeout) - .with_max_response_bytes(config.max_response_bytes.min(MAXIMUM_REVIEW_RESPONSE_BYTES)); + .with_maximum_response_bytes( + config + .maximum_response_bytes + .min(MAXIMUM_REVIEW_RESPONSE_BYTES), + ); if let Some(user_agent) = &config.user_agent { review_config = review_config.with_user_agent(user_agent.clone()); } @@ -91,7 +95,7 @@ impl CaseworkClient { ProducerReviewClient::new(review_config).map_err(CaseworkClientError::from_review)?; let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -100,8 +104,8 @@ impl CaseworkClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - max_mutation_retries: config.max_mutation_retries, + maximum_response_bytes: config.maximum_response_bytes, + maximum_mutation_retries: config.maximum_mutation_retries, review, }) } @@ -636,7 +640,7 @@ impl CaseworkClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| CaseworkClientError::Transport { kind: read_failure_kind(&error), @@ -1510,7 +1514,7 @@ impl CaseworkClient { expected_status: StatusCode, ) -> Result, CaseworkClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(CaseworkClientError::invalid_request( "the request could not be prepared for sending", @@ -1539,7 +1543,7 @@ impl CaseworkClient { expected_status: StatusCode, ) -> Result, CaseworkClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(CaseworkClientError::invalid_request( "the request could not be prepared for sending", @@ -1586,7 +1590,7 @@ impl CaseworkClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| CaseworkClientError::Transport { kind: read_failure_kind(&error), diff --git a/crates/registry-casework-client/src/config.rs b/crates/registry-casework-client/src/config.rs index 54434f9fc9..251e0ee12b 100644 --- a/crates/registry-casework-client/src/config.rs +++ b/crates/registry-casework-client/src/config.rs @@ -14,12 +14,12 @@ const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; #[derive(Clone)] pub struct CaseworkClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl CaseworkClientConfig { @@ -27,18 +27,20 @@ impl CaseworkClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } + /// Set the timeout for one HTTP exchange attempt. An authorized same-key + /// mutation retry receives a fresh attempt timeout. #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } @@ -48,9 +50,10 @@ impl CaseworkClientConfig { self } + /// Set the response-body ceiling for every exchange. #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } @@ -71,8 +74,8 @@ impl CaseworkClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -80,12 +83,13 @@ impl CaseworkClientConfig { let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { CaseworkClientError::configuration("the service base URL is not usable") })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(CaseworkClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + if self.maximum_response_bytes == 0 || self.maximum_response_bytes > MAXIMUM_RESPONSE_BYTES + { return Err(CaseworkClientError::configuration( "the response body bound is outside the accepted range", )); @@ -99,7 +103,7 @@ impl CaseworkClientConfig { "the trusted root certificate bundle exceeds the accepted bound", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(CaseworkClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -113,15 +117,15 @@ impl fmt::Debug for CaseworkClientConfig { formatter .debug_struct("CaseworkClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", &self.trusted_root_certificates.is_some(), ) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -148,20 +152,37 @@ mod tests { fn the_mutation_retry_count_is_bounded_before_any_client_exists() { let url = Url::parse("https://casework.example.invalid/").expect("fixture URL"); let excessive_retries = CaseworkClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); assert!(matches!( excessive_retries.validate(), Err(CaseworkClientError::Configuration { .. }) )); for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(CaseworkClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } assert_eq!( - CaseworkClientConfig::new(url).max_mutation_retries, + CaseworkClientConfig::new(url).maximum_mutation_retries, DEFAULT_MUTATION_RETRIES ); } + + #[test] + fn the_maximum_response_bytes_are_bounded_before_any_client_exists() { + let url = Url::parse("https://casework.example.invalid/").expect("fixture URL"); + for maximum in [0, MAXIMUM_RESPONSE_BYTES + 1] { + assert!(matches!( + CaseworkClientConfig::new(url.clone()) + .with_maximum_response_bytes(maximum) + .validate(), + Err(CaseworkClientError::Configuration { .. }) + )); + } + assert!(CaseworkClientConfig::new(url) + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES) + .validate() + .is_ok()); + } } diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index c7de245350..92df35c3d5 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -5,7 +5,7 @@ //! generates one. A keyed mutation whose outcome is unknown (a timeout or //! broken exchange after the request was sent, or a 5xx answer) is resent, //! byte for byte and under the same key, at most -//! `CaseworkClientConfig::with_max_mutation_retries` times (2 by default, 0 +//! `CaseworkClientConfig::with_maximum_mutation_retries` times (2 by default, 0 //! disables it). Reads, unkeyed operations, and the review request create and //! cancel calls are never resent. A 5xx answer may follow a commit: when //! `CaseworkClientError::is_outcome_unknown` is still true, the safe recovery diff --git a/crates/registry-casework-client/tests/mutation_retry.rs b/crates/registry-casework-client/tests/mutation_retry.rs index acf2247c3a..e793e634de 100644 --- a/crates/registry-casework-client/tests/mutation_retry.rs +++ b/crates/registry-casework-client/tests/mutation_retry.rs @@ -270,7 +270,7 @@ async fn a_timed_out_absence_is_resent_identically_under_the_same_key() { ]) .await; let token = token(); - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let complete = client .create_absence( @@ -561,7 +561,7 @@ async fn the_retry_count_is_honored() { let token = token(); let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = client(config) .create_absence( @@ -590,8 +590,8 @@ async fn zero_retries_disables_the_resend() { let token = token(); let error = client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), ) .create_absence( CaseworkAuth::new(&token, "staff"), @@ -735,7 +735,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let token = token(); let error = client .create_absence( diff --git a/crates/registry-casework/tests/review_http.rs b/crates/registry-casework/tests/review_http.rs index 9492c91598..f38c89b87b 100644 --- a/crates/registry-casework/tests/review_http.rs +++ b/crates/registry-casework/tests/review_http.rs @@ -512,7 +512,7 @@ async fn native_review_client(app: axum::Router) -> (CaseworkClient, tokio::task .parse() .expect("test service URL"), ) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .expect("candidate native client"); (client, server) diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 7c66e3166c..d1277b4f2d 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -806,6 +806,12 @@ fn issuer_keys(issuer: &str) -> Result> { } fn start(args: StartArgs) -> Result { + // Bind an rstack-managed invocation to its selected runtime before this + // command creates or changes any retained development state. + let managed_casework = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORK, + args.casework_bin.as_deref(), + )?; let project = project(&args.project)?; let requested_issuer = args .issuer_project @@ -991,7 +997,10 @@ fn start(args: StartArgs) -> Result { if clients.integrations.is_some() { integrations::validate_bindings(&root, &project)?; } - let casework = executable("casework", args.casework_bin.as_deref())?; + let casework = match managed_casework { + Some(path) => path, + None => executable("casework", args.casework_bin.as_deref())?, + }; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or // launches the supervisor: a casework from another release has to diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 18e711f6c1..dd917c2fb6 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -9,6 +9,7 @@ mod provider_pair; mod report; #[cfg(feature = "schema")] pub mod schema; +mod selected_binary; mod templates; use anyhow::Result; use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; @@ -17,7 +18,7 @@ use std::{ ffi::OsString, io::{self, Write}, path::PathBuf, - process::ExitCode, + process::{Command as ProcessCommand, ExitCode}, }; #[derive(Debug, Parser)] #[command(name="caseworkctl",version=registry_platform_buildinfo::DISPLAY_VERSION,about="Compile and operate bounded review policies")] @@ -36,6 +37,8 @@ enum OutputFormat { } #[derive(Debug, Subcommand)] enum Command { + /// Run the production Casework service with the selected runtime. + Serve(ServeArgs), /// Create a standalone or source-backed ReviewPolicy project. Init { /// New project directory; existing destinations are never overwritten. @@ -108,6 +111,17 @@ enum Command { #[command(name = "__dev-service-guard", hide = true)] DevServiceGuard(dev::ServiceGuardArgs), } + +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Casework runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Casework runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "CASEWORK_BIN", value_name = "BINARY")] + casework_bin: Option, +} #[derive(Debug, Subcommand)] enum ProviderCommand { /// Create an approval project from a Base Registry Engine (BReg) Action and print missing configuration. @@ -130,9 +144,9 @@ enum ProviderCommand { /// Package directory written with a new project; omit to inspect the package without writing it. #[arg(long, value_name = "DIRECTORY", requires = "breg_package")] package_output: Option, - /// Matching bregctl executable used for public project and package inspection. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used for public project and package inspection; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, }, /// Verify native context and reader authority, then optionally retain its provider export. Pair { @@ -148,9 +162,9 @@ enum ProviderCommand { /// Base Registry Engine (BReg) action that declares the required review context. #[arg(long)] action_id: String, - /// Matching bregctl executable used to verify the producer package. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used to verify the producer package; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, /// Write the verified provider export and description reference after validation. #[arg(long)] apply: bool, @@ -193,9 +207,9 @@ struct CheckArgs { /// Limit native package comparison to this declared provider. #[arg(long, requires = "against_breg_package")] provider_id: Option, - /// Matching bregctl executable used for native package verification. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used for native package verification; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, /// Runtime configuration file #[arg(long, value_name = "FILE")] runtime_config: Option, @@ -215,6 +229,16 @@ pub fn main_entry_from( stderr: &mut impl Write, ) -> ExitCode { let args: Vec<_> = args.into_iter().collect(); + let format_supplied = args + .iter() + .skip(1) + .take_while(|arg| arg.as_os_str() != "--") + .any(|arg| { + arg.as_os_str() == "--format" + || arg + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); let json_output = args.iter().enumerate().any(|(index, arg)| { arg == "--format=json" || (arg == "--format" && args.get(index + 1).is_some_and(|value| value == "json")) @@ -246,15 +270,21 @@ pub fn main_entry_from( return ExitCode::from(report::USAGE_EXIT); } }; - let kind = command_kind(&cli.command); - let command = cli.command.report_command(); - if command == "internal" { - return match run(cli.command) { + let Cli { format, command } = cli; + let command = match command { + Command::Serve(args) => return serve(args, format, format_supplied, stdout, stderr), + command => command, + }; + let kind = command_kind(&command); + let report_command = command.report_command(); + if report_command == "internal" { + return match run(command) { Ok(_) => ExitCode::SUCCESS, Err(_) => ExitCode::from(1), }; } - let result = run(cli.command).and_then(|value| report::CliReport::from_value(command, value)); + let result = + run(command).and_then(|value| report::CliReport::from_value(report_command, value)); let (value, exit) = match result { Ok(value) => { let value = serde_json::to_value(value).unwrap_or_default(); @@ -289,18 +319,18 @@ pub fn main_entry_from( } else { report::ReportStatus::DomainRefusal }; - let value = report::CliReport::failure(command, status, diagnostics) + let value = report::CliReport::failure(report_command, status, diagnostics) .and_then(|value| serde_json::to_value(value).map_err(Into::into)) .unwrap_or_default(); (value, exit) } }; - let output = if matches!(cli.format, OutputFormat::Human) && exit != 0 { + let output = if matches!(format, OutputFormat::Human) && exit != 0 { stderr as &mut dyn Write } else { stdout as &mut dyn Write }; - let _ = match cli.format { + let _ = match format { OutputFormat::Json => report::write(&value, output), OutputFormat::Human => render_human(&value, output), }; @@ -430,6 +460,7 @@ impl Command { fn report_command(&self) -> &'static str { match self { Self::Init { .. } => "init", + Self::Serve(_) => "serve", Self::Check(_) => "check", Self::Explain(_) => "explain", Self::Test(_) => "test", @@ -458,6 +489,7 @@ impl Command { fn run(command: Command) -> Result { match command { + Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Init { project, template } => project::init(&project, &template), Command::Check(args) => { let mut result = project::check(&args.project, args.production, args.deny_warnings); @@ -471,11 +503,12 @@ fn run(command: Command) -> Result { } let mut report = result?; if let Some(package) = args.against_breg_package { + let bregctl = bregctl_binary(args.bregctl_bin.as_deref())?; report["bregPackage"] = provider_pair::compare( &args.project, &package, args.provider_id.as_deref(), - &args.bregctl_bin, + &bregctl, )?; } Ok(report) @@ -494,15 +527,18 @@ fn run(command: Command) -> Result { package_output, bregctl_bin, }, - } => provider_add::add( - &project, - &breg_project, - &action, - &pattern, - breg_package.as_deref(), - package_output.as_deref(), - &bregctl_bin, - ), + } => { + let bregctl = bregctl_binary(bregctl_bin.as_deref())?; + provider_add::add( + &project, + &breg_project, + &action, + &pattern, + breg_package.as_deref(), + package_output.as_deref(), + &bregctl, + ) + } Command::Provider { command: ProviderCommand::Pair { @@ -513,14 +549,17 @@ fn run(command: Command) -> Result { bregctl_bin, apply, }, - } => provider_pair::pair( - &project, - &provider_id, - &breg_package, - &action_id, - &bregctl_bin, - apply, - ), + } => { + let bregctl = bregctl_binary(bregctl_bin.as_deref())?; + provider_pair::pair( + &project, + &provider_id, + &breg_package, + &action_id, + &bregctl, + apply, + ) + } Command::Lifecycle => Ok( json!({"ok":true,"command":"lifecycle","lifecycles":[registry_casework_core::review_lifecycle()]}), ), @@ -556,6 +595,103 @@ fn run(command: Command) -> Result { Command::DevServiceGuard(args) => Ok(json!({"ok":dev::run_service_guard(args)?})), } } + +fn serve( + args: ServeArgs, + format: OutputFormat, + format_supplied: bool, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if format_supplied { + let diagnostic = registry_platform_yaml::Diagnostic::error( + "casework.usage.invalid", + "format", + "--format does not apply to the serve command because service logs keep the Casework runtime contract.", + "Remove --format and retry the serve command.", + ); + let result: Result<()> = match format { + OutputFormat::Human => writeln!(stderr, "{}", diagnostic.message).map_err(Into::into), + OutputFormat::Json => report::CliReport::failure( + "usage", + report::ReportStatus::UsageError, + vec![diagnostic], + ) + .and_then(|report| serde_json::to_value(report).map_err(Into::into)) + .and_then(|value| report::write(&value, stdout).map_err(Into::into)), + }; + if result.is_err() { + let _ = writeln!(stderr, "caseworkctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + return ExitCode::from(report::USAGE_EXIT); + } + + let binary = match selected_binary::resolve_managed( + selected_binary::CASEWORK, + args.casework_bin.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => args + .casework_bin + .unwrap_or_else(|| PathBuf::from("casework")), + Err(error) => { + let _ = writeln!( + stderr, + "caseworkctl serve could not select casework: {error:#}" + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + replace_with_service(command, "casework", stderr) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "caseworkctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "caseworkctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn bregctl_binary(explicit: Option<&std::path::Path>) -> Result { + Ok( + selected_binary::resolve_managed(selected_binary::BREGCTL, explicit)? + .or_else(|| explicit.map(std::path::Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("bregctl")), + ) +} + fn render_human(value: &Value, out: &mut (impl Write + ?Sized)) -> io::Result<()> { let diagnostics: Vec = serde_json::from_value(value["diagnostics"].clone()).map_err(io::Error::other)?; @@ -655,6 +791,39 @@ pub(crate) fn canonical_tempdir() -> tempfile::TempDir { mod tests { use super::*; #[test] + fn serve_refuses_explicit_human_format_before_starting_the_runtime() { + for arguments in [ + vec![ + OsString::from("caseworkctl"), + OsString::from("--format"), + OsString::from("human"), + OsString::from("serve"), + OsString::from("--runtime-config"), + OsString::from("/tmp/runtime.yaml"), + OsString::from("--casework-bin"), + OsString::from("/definitely/missing-casework"), + ], + vec![ + OsString::from("caseworkctl"), + OsString::from("serve"), + OsString::from("--runtime-config"), + OsString::from("/tmp/runtime.yaml"), + OsString::from("--casework-bin"), + OsString::from("/definitely/missing-casework"), + OsString::from("--format"), + OsString::from("human"), + ], + ] { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let result = main_entry_from(arguments, &mut stdout, &mut stderr); + + assert_eq!(result, ExitCode::from(report::USAGE_EXIT)); + assert!(stdout.is_empty()); + let refusal = String::from_utf8(stderr).unwrap(); + assert!(refusal.contains("--format does not apply to the serve command")); + } + } + #[test] fn removed_commands_are_not_exposed() { for name in ["source", "attempt", "retention", "simulate"] { assert!(Cli::try_parse_from(["caseworkctl", name]).is_err()); diff --git a/crates/registry-caseworkctl/src/selected_binary.rs b/crates/registry-caseworkctl/src/selected_binary.rs new file mode 100644 index 0000000000..7b30cc92ce --- /dev/null +++ b/crates/registry-caseworkctl/src/selected_binary.rs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const BREGCTL: HelperSpec = HelperSpec { + binary: "bregctl", + component: "breg", + explicit_source: "--bregctl-bin", + managed_env: "RSTACK_BREGCTL_BIN", +}; + +pub(crate) const CASEWORK: HelperSpec = HelperSpec { + binary: "casework", + component: "casework", + explicit_source: "--casework-bin or CASEWORK_BIN", + managed_env: "RSTACK_CASEWORK_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).with_context(|| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + bail!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + ); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).with_context(|| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + bail!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + ); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + bail!("helper is not an installed file at {}", path.display()); + } + Ok(fs::canonicalize(path)?) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + bail!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, caseworkctl will not install components automatically", + spec.binary, + spec.component + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::fs::symlink}; + + fn fixture(root: &Path, spec: HelperSpec) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let resolved = resolve_managed_with_environment( + BREGCTL, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_accepts_an_alias_of_the_selected_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let alias = root.path().join("bregctl-alias"); + symlink(&selected, &alias).unwrap(); + let resolved = resolve_managed_with_environment( + BREGCTL, + Some(&alias), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_helper_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let override_path = root.path().join("other-bregctl"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREGCTL, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("--bregctl-bin"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_helper_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _selected) = fixture(root.path(), BREGCTL); + let outside = root.path().join("outside-bregctl"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREGCTL, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("RSTACK_BREGCTL_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_casework_resolution_names_the_environment_override() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), CASEWORK); + let override_path = root.path().join("other-casework"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("CASEWORK_BIN"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("rstack install casework"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(BREGCTL, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-caseworkctl/tests/dev_lifecycle.rs b/crates/registry-caseworkctl/tests/dev_lifecycle.rs index a94e23264b..08f5f42be4 100644 --- a/crates/registry-caseworkctl/tests/dev_lifecycle.rs +++ b/crates/registry-caseworkctl/tests/dev_lifecycle.rs @@ -632,7 +632,7 @@ fn create_review_request_for( runtime.block_on(async { let client = CaseworkClient::new( CaseworkClientConfig::new(report["caseworkUrl"].as_str().unwrap().parse().unwrap()) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .expect("public Rust client"); let auth = || CaseworkAuth::new(&token, "requester"); diff --git a/crates/registry-caseworkctl/tests/serve.rs b/crates/registry-caseworkctl/tests/serve.rs new file mode 100644 index 0000000000..82c30ad076 --- /dev/null +++ b/crates/registry-caseworkctl/tests/serve.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(arguments) + .env("CASEWORK_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("caseworkctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_and_exit() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "casework", 17); + let capture = temp.path().join("capture"); + let child = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/runtime.yaml"]) + .env("CASEWORK_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("caseworkctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17)); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "casework", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("CASEWORK_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("caseworkctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/casework/casework"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "casework", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + let poisoned = runtime(&poisoned_directory, "casework", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_CASEWORK_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("CASEWORK_BIN") + .output() + .expect("caseworkctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); + assert_ne!(selected, poisoned); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "casework", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-cli-docs/Cargo.toml b/crates/registry-cli-docs/Cargo.toml index e0cb34db1c..1c8ebe534b 100644 --- a/crates/registry-cli-docs/Cargo.toml +++ b/crates/registry-cli-docs/Cargo.toml @@ -29,5 +29,7 @@ registry-scheduling.workspace = true registry-schedulingctl.workspace = true registry-messaging.workspace = true registry-messagingctl.workspace = true +registry-manifest-cli.workspace = true +registry-rstack.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index 9f417214b6..596c97cbfb 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -312,64 +312,22 @@ "constraints": [], "subcommands": [ { - "name": "init", - "invocation": "bregctl init", - "about": "Create an authoring project in a new directory: a domain-neutral example, one derived from an embedded reference model with `--from`, or a shipped starter with `--template`", + "name": "serve", + "invocation": "bregctl serve", + "about": "Run the production Base Registry Engine service with the selected runtime", "long_about": null, - "usage": "bregctl init [OPTIONS] ", - "arguments": [ - { - "display": "", - "description": "New directory that will receive the project closure. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], + "usage": "bregctl serve [OPTIONS] --runtime-config ", + "arguments": [], "options": [ { - "display": "--from ", - "description": "Derive the project from an embedded reference model instead of writing the example. Without `--selection` or `--starter`, the command asks which concepts and properties to select at the terminal", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [ - "publicschema" - ], - "environment": null - }, - { - "display": "--selection ", - "description": "A selection document naming the concepts and properties to derive; the written project echoes one under `model/selection.yaml`. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--starter ", - "description": "A selection shipped with the model, by name", - "always_required": false, + "display": "--runtime-config ", + "description": "Runtime configuration file passed unchanged to the Base Registry Engine runtime. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, - { - "display": "--template ", - "description": "Write one of the shipped starter registry projects verbatim instead of the plain example or a project derived from `--from`", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [ - "minimal-directory", - "facility-registry" - ], - "environment": null - }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -386,7 +344,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -394,128 +352,17 @@ "environment": null } ], - "constraints": [ - { - "kind": "requires-all", - "when": "--selection ", - "arguments": [ - "--from " - ] - }, - { - "kind": "requires-all", - "when": "--starter ", - "arguments": [ - "--from " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--from ", - "--template " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--selection ", - "--starter " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--selection ", - "--template " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--starter ", - "--template " - ] - } - ], + "constraints": [], "subcommands": [] }, { - "name": "check", - "invocation": "bregctl check", - "about": "Check a Base Registry Engine authoring project, package, tool file, or runtime file without opening a database", + "name": "mcp", + "invocation": "bregctl mcp", + "about": "Operate the optional citizen-facing MCP gateway", "long_about": null, - "usage": "bregctl check [OPTIONS] |--file >", - "arguments": [ - { - "display": "[PROJECT]", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], + "usage": "bregctl mcp [OPTIONS] ", + "arguments": [], "options": [ - { - "display": "--package ", - "description": "Closed package to verify against its sums, reporting the registry revision it rederives. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--file ", - "description": "One tool file to check on its own, read by its kind: journeys, schema-test credentials or receipt, a data checkpoint or import state, a migration descriptor, rehearsal receipt or backup binding, a model selection, or a development clients, example scenarios, session state or source preparation file. No secret, database, or network is read. With PROJECT, journeys are checked against that project. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--production", - "description": "Enforce production-only package closure requirements", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--deny-warnings", - "description": "Exit 1 when a warning is reported", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--environment", - "description": "Fill `${NAME}` expressions in the runtime file from the process environment and check the values they produce. Without it, each expression is checked by syntax and position only", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -540,80 +387,122 @@ "environment": null } ], - "constraints": [ - { - "kind": "required-one-or-more", - "when": null, - "arguments": [ - "[PROJECT]", - "--package ", - "--file " - ] - }, - { - "kind": "requires-all", - "when": "--environment", - "arguments": [ - "--runtime-config " - ] - }, + "constraints": [], + "subcommands": [ { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--file ", - "--package " - ] + "name": "check", + "invocation": "bregctl mcp check", + "about": "Check the runtime file offline, reading no secret and opening no socket", + "long_about": null, + "usage": "bregctl mcp check [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file to check. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Fill environment expressions while checking the runtime file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] }, { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--file ", - "--production" - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--file ", - "--runtime-config " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--package ", - "--production" - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--package ", - "--runtime-config " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--package ", - "[PROJECT]" - ] + "name": "serve", + "invocation": "bregctl mcp serve", + "about": "Serve until terminated", + "long_about": null, + "usage": "bregctl mcp serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file for the service. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] } - ], - "subcommands": [] + ] }, { - "name": "project", - "invocation": "bregctl project", - "about": "Maintain deterministic authoring project metadata", + "name": "review", + "invocation": "bregctl review", + "about": "Operate the optional citizen review page", "long_about": null, - "usage": "bregctl project [OPTIONS] ", + "usage": "bregctl review [OPTIONS] ", "arguments": [], "options": [ { @@ -643,26 +532,34 @@ "constraints": [], "subcommands": [ { - "name": "lock", - "invocation": "bregctl project lock", - "about": "Compute and write module source digests in registry.yaml", + "name": "check", + "invocation": "bregctl review check", + "about": "Check the runtime file offline, reading no secret and opening no socket", "long_about": null, - "usage": "bregctl project lock [OPTIONS] ", - "arguments": [ + "usage": "bregctl review check [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ { - "display": "", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "display": "--runtime-config ", + "description": "Runtime configuration file to check. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "options": [ + }, { - "display": "--check", - "description": "Refuse when registry.yaml is not already locked instead of rewriting it", + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Fill environment expressions while checking the runtime file", "always_required": false, "repeatable": false, "default_values": [], @@ -697,50 +594,22 @@ "subcommands": [] }, { - "name": "action-test", - "invocation": "bregctl project action-test", - "about": "Test a named Action plan function with bounded synthetic JSON", + "name": "serve", + "invocation": "bregctl review serve", + "about": "Serve until terminated", "long_about": null, - "usage": "bregctl project action-test [OPTIONS] --input --action ", - "arguments": [ - { - "display": "", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], + "usage": "bregctl review serve [OPTIONS] --runtime-config ", + "arguments": [], "options": [ { - "display": "--action ", - "description": "Compiled Action whose named plan function will run; pair with --input", + "display": "--runtime-config ", + "description": "Runtime configuration file for the service. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, - { - "display": "--input ", - "description": "Bounded strict JSON object using authored action input IDs, without an HTTP envelope; requires --action. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--expect ", - "description": "With --action and --input, assert exact effects or refusal and optional ordered evidenceCalls mocks without printing values. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -765,42 +634,70 @@ "environment": null } ], - "constraints": [ - { - "kind": "requires-all", - "when": "--action ", - "arguments": [ - "--input " - ] - }, - { - "kind": "requires-all", - "when": "--input ", - "arguments": [ - "--action " - ] - }, - { - "kind": "requires-all", - "when": "--expect ", - "arguments": [ - "--input ", - "--action " - ] - } - ], + "constraints": [], "subcommands": [] } ] }, { - "name": "module", - "invocation": "bregctl module", - "about": "Add generated modules to an authoring project", + "name": "init", + "invocation": "bregctl init", + "about": "Create an authoring project in a new directory: a domain-neutral example, one derived from an embedded reference model with `--from`, or a shipped starter with `--template`", "long_about": null, - "usage": "bregctl module [OPTIONS] ", - "arguments": [], + "usage": "bregctl init [OPTIONS] ", + "arguments": [ + { + "display": "", + "description": "New directory that will receive the project closure. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], "options": [ + { + "display": "--from ", + "description": "Derive the project from an embedded reference model instead of writing the example. Without `--selection` or `--starter`, the command asks which concepts and properties to select at the terminal", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [ + "publicschema" + ], + "environment": null + }, + { + "display": "--selection ", + "description": "A selection document naming the concepts and properties to derive; the written project echoes one under `model/selection.yaml`. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--starter ", + "description": "A selection shipped with the model, by name", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--template ", + "description": "Write one of the shipped starter registry projects verbatim instead of the plain example or a project derived from `--from`", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [ + "minimal-directory", + "facility-registry" + ], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -817,7 +714,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -825,129 +722,67 @@ "environment": null } ], - "constraints": [], - "subcommands": [ + "constraints": [ { - "name": "add", - "invocation": "bregctl module add", - "about": "Write a generated module into the project and pin it in registry.yaml", - "long_about": null, - "usage": "bregctl module add [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "consent", - "invocation": "bregctl module add consent", - "about": "Add consent for one subject entity: the privacy notice, its clauses, the principal link, the create-only consent decision, their self and steward actions, and five access profiles. Prints the requireConsent line that gates a permission on the decision", - "long_about": null, - "usage": "bregctl module add consent [OPTIONS] --subject ", - "arguments": [ - { - "display": "", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--subject ", - "description": "Entity whose rows consent decisions are about", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - } + "kind": "requires-all", + "when": "--selection ", + "arguments": [ + "--from " + ] + }, + { + "kind": "requires-all", + "when": "--starter ", + "arguments": [ + "--from " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--from ", + "--template " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--selection ", + "--starter " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--selection ", + "--template " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--starter ", + "--template " ] } - ] + ], + "subcommands": [] }, { - "name": "generate", - "invocation": "bregctl generate", - "about": "Write selected compiler artifacts to a new directory", + "name": "check", + "invocation": "bregctl check", + "about": "Check a Base Registry Engine authoring project, package, tool file, or runtime file without opening a database", "long_about": null, - "usage": "bregctl generate [OPTIONS] --output ", + "usage": "bregctl check [OPTIONS] |--file >", "arguments": [ { - "display": "", - "description": "Artifact family to write", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [ - "openapi", - "schemas", - "actions", - "manifest", - "metadata", - "sql", - "evidence-source", - "sdk" - ], - "environment": null - }, - { - "display": "", + "display": "[PROJECT]", "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": true, + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -956,8 +791,8 @@ ], "options": [ { - "display": "--production", - "description": "Enforce production-only package closure requirements", + "display": "--package ", + "description": "Closed package to verify against its sums, reporting the registry revision it rederives. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -965,17 +800,8 @@ "environment": null }, { - "display": "--output ", - "description": "New directory that will receive exactly the generated artifact inventory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--access-profile ", - "description": "Existing registry lookup access profile (evidence-source only)", + "display": "--file ", + "description": "One tool file to check on its own, read by its kind: journeys, schema-test credentials or receipt, a data checkpoint or import state, a migration descriptor, rehearsal receipt or backup binding, a model selection, or a development clients, example scenarios, session state or source preparation file. No secret, database, or network is read. With PROJECT, journeys are checked against that project. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -983,8 +809,8 @@ "environment": null }, { - "display": "--record-type ", - "description": "Compiled Record type to export (evidence-source only)", + "display": "--production", + "description": "Enforce production-only package closure requirements", "always_required": false, "repeatable": false, "default_values": [], @@ -992,26 +818,17 @@ "environment": null }, { - "display": "--selector ", - "description": "Exact lookup alternatives; repeat to expose several selectors", - "always_required": false, - "repeatable": true, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--fields ", - "description": "Explicit readable fact fields, separated by commas", + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", "always_required": false, - "repeatable": true, + "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--source-id ", - "description": "Stable source identity inside the Evidence authoring project", + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -1019,8 +836,8 @@ "environment": null }, { - "display": "--connection ", - "description": "Logical connection that the Evidence operator configures separately", + "display": "--environment", + "description": "Fill `${NAME}` expressions in the runtime file from the process environment and check the values they produce. Without it, each expression is checked by syntax and position only", "always_required": false, "repeatable": false, "default_values": [], @@ -1051,83 +868,82 @@ "environment": null } ], - "constraints": [], - "subcommands": [] - }, - { - "name": "dev", - "invocation": "bregctl dev", - "about": "Start or stop this project's retained local development services", - "long_about": null, - "usage": "bregctl dev [OPTIONS] [PROJECT]\n bregctl dev ", - "arguments": [ + "constraints": [ { - "display": "[PROJECT]", - "description": "Existing authored registry project. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ + "kind": "required-one-or-more", + "when": null, + "arguments": [ + "[PROJECT]", + "--package ", + "--file " + ] + }, { - "display": "--clients-file ", - "description": "Local clients, profile bindings, and optional seed records (default on first start: dev-clients.yaml in the project; retained for restarts). A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "requires-all", + "when": "--environment", + "arguments": [ + "--runtime-config " + ] }, { - "display": "--breg-port ", - "description": "Registry loopback port on first start (default 8090; retained for restarts)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--file ", + "--package " + ] }, { - "display": "--issuer-port ", - "description": "Local issuer loopback port on first start (default 8091; retained for restarts)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--file ", + "--production" + ] }, { - "display": "--issuer-project ", - "description": "Ready Base Registry Engine (BReg) dev project that owns the shared local issuer and client registrations. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--file ", + "--runtime-config " + ] }, { - "display": "--issuer-image ", - "description": "Immutable local candidate issuer image ID on first start; retained for restarts", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--package ", + "--production" + ] }, { - "display": "--database-port ", - "description": "PostgreSQL loopback port on first start (default 15432; retained for restarts)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--package ", + "--runtime-config " + ] }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--package ", + "[PROJECT]" + ] + } + ], + "subcommands": [] + }, + { + "name": "project", + "invocation": "bregctl project", + "about": "Maintain deterministic authoring project metadata", + "long_about": null, + "usage": "bregctl project [OPTIONS] ", + "arguments": [], + "options": [ { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -1155,73 +971,26 @@ "constraints": [], "subcommands": [ { - "name": "start", - "invocation": "bregctl dev start", - "about": "Start or reuse the project's retained local database and services", - "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL and ThunderID containers plus its Base Registry Engine (BReg) child. The database runs the pinned image docker.io/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, the Docker Official Images reference at that digest, or, when the compiled schema requires PostGIS, the Docker Hub image postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .breg/dev/logs directory. Catchable termination signals trigger owned cleanup, but an uncatchable outside kill can leave surviving service owners that need inspection before restart. products/breg/DEV.md documents this lifecycle in 'Native local BReg lifecycle' and its recovery in 'Retained state and recovery'.", - "usage": "bregctl dev start [OPTIONS] [PROJECT]", + "name": "lock", + "invocation": "bregctl project lock", + "about": "Compute and write module source digests in registry.yaml", + "long_about": null, + "usage": "bregctl project lock [OPTIONS] ", "arguments": [ { - "display": "[PROJECT]", - "description": "Existing authored registry project. A symbolic link at any component of this path is refused.", - "always_required": false, + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, - "default_values": [ - "." - ], + "default_values": [], "possible_values": [], "environment": null } ], "options": [ { - "display": "--clients-file ", - "description": "Local clients, profile bindings, and optional seed records (default on first start: dev-clients.yaml in the project; retained for restarts). A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--breg-port ", - "description": "Registry loopback port on first start (default 8090; retained for restarts)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--issuer-port ", - "description": "Local issuer loopback port on first start (default 8091; retained for restarts)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--issuer-project ", - "description": "Ready Base Registry Engine (BReg) dev project that owns the shared local issuer and client registrations. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--issuer-image ", - "description": "Immutable local candidate issuer image ID on first start; retained for restarts", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--database-port ", - "description": "PostgreSQL loopback port on first start (default 15432; retained for restarts)", + "display": "--check", + "description": "Refuse when registry.yaml is not already locked instead of rewriting it", "always_required": false, "repeatable": false, "default_values": [], @@ -1244,7 +1013,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -1256,84 +1025,44 @@ "subcommands": [] }, { - "name": "stop", - "invocation": "bregctl dev stop", - "about": "Stop only this project's supervised services, preserving its database, records, audit history, keys and built package", - "long_about": "Stop only this project's supervised services, preserving its database, records, audit history, keys and built package. Add --remove to also remove the owned container and its data volume, discarding records so the next start can take edited inputs; to change inputs while keeping the records, copy the authored files to a new project directory instead. products/breg/DEV.md documents the full stop and recovery behavior in 'Retained state and recovery'.", - "usage": "bregctl dev stop [OPTIONS] [PROJECT]", + "name": "action-test", + "invocation": "bregctl project action-test", + "about": "Test a named Action plan function with bounded synthetic JSON", + "long_about": null, + "usage": "bregctl project action-test [OPTIONS] --input --action ", "arguments": [ { - "display": "[PROJECT]", - "description": "Registry project whose owned services should stop while preserving records. A symbolic link at any component of this path is refused.", - "always_required": false, + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, - "default_values": [ - "." - ], + "default_values": [], "possible_values": [], "environment": null } ], "options": [ { - "display": "--remove", - "description": "Also remove the owned container and its data volume, discarding records, event receipts and seed checkpoints; the next start can then take edited inputs. The audit files under .breg/dev/audit are kept", - "always_required": false, + "display": "--action ", + "description": "Compiled Action whose named plan function will run; pair with --input", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "display": "--input ", + "description": "Bounded strict JSON object using authored action input IDs, without an HTTP envelope; requires --action. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "events", - "invocation": "bregctl dev events", - "about": "Show received local webhook deliveries, hiding projected values by default", - "long_about": null, - "usage": "bregctl dev events [OPTIONS] [PROJECT]", - "arguments": [ - { - "display": "[PROJECT]", - "description": "Local registry project whose retained deliveries should be inspected. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ + }, { - "display": "--include-payload", - "description": "Explicitly show projected values captured by the development receiver", + "display": "--expect ", + "description": "With --action and --input, assert exact effects or refusal and optional ordered evidenceCalls mocks without printing values. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -1364,221 +1093,40 @@ "environment": null } ], - "constraints": [], - "subcommands": [] - }, - { - "name": "export-client", - "invocation": "bregctl dev export-client", - "about": "Copy an explicitly selected retained local client credential pair", - "long_about": null, - "usage": "bregctl dev export-client [OPTIONS] --client --client-id-file --assertion-key-file [PROJECT]", - "arguments": [ - { - "display": "[PROJECT]", - "description": "Registry project containing the retained development session. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ + "constraints": [ { - "display": "--client ", - "description": "Explicit local client label from the retained session", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "requires-all", + "when": "--action ", + "arguments": [ + "--input " + ] }, { - "display": "--client-id-file ", - "description": "Client ID destination in an existing owner-only directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "requires-all", + "when": "--input ", + "arguments": [ + "--action " + ] }, { - "display": "--assertion-key-file ", - "description": "Assertion key destination in an existing owner-only directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "token", - "invocation": "bregctl dev token", - "about": "Acquire a fresh local client token and report its private header-file path", - "long_about": null, - "usage": "bregctl dev token [OPTIONS] [PROJECT]", - "arguments": [ - { - "display": "", - "description": "Registered local client ID", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "[PROJECT]", - "description": "Ready local project (defaults to the current directory). A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "grant", - "invocation": "bregctl dev grant", - "about": "Exchange an existing Casework approval using an explicit configured issuer connection", - "long_about": null, - "usage": "bregctl dev grant [OPTIONS] --grant --connection [PROJECT]", - "arguments": [ - { - "display": "", - "description": "Registered agent client ID listed under `clients` in the task connection file", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "[PROJECT]", - "description": "Existing project whose private directory receives the grant-specific header. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--grant ", - "description": "Existing Casework-approved grant UUID; this command does not approve tasks", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--connection ", - "description": "Task connection file (`PlatformTaskConnection`) naming the fixed target and each client's assertion key reference. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "kind": "requires-all", + "when": "--expect ", + "arguments": [ + "--input ", + "--action " + ] } ], - "constraints": [], "subcommands": [] } ] }, { - "name": "examples", - "invocation": "bregctl examples", - "about": "List or explicitly run retained local teaching examples", + "name": "module", + "invocation": "bregctl module", + "about": "Add generated modules to an authoring project", "long_about": null, - "usage": "bregctl examples [OPTIONS] ", + "usage": "bregctl module [OPTIONS] ", "arguments": [], "options": [ { @@ -1608,24 +1156,12 @@ "constraints": [], "subcommands": [ { - "name": "list", - "invocation": "bregctl examples list", - "about": "Describe authored local scenarios without starting services or creating credentials", + "name": "add", + "invocation": "bregctl module add", + "about": "Write a generated module into the project and pin it in registry.yaml", "long_about": null, - "usage": "bregctl examples list [OPTIONS] [PROJECT]", - "arguments": [ - { - "display": "[PROJECT]", - "description": "Authored project containing examples/scenarios.json. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], + "usage": "bregctl module add [OPTIONS] ", + "arguments": [], "options": [ { "display": "--format ", @@ -1652,223 +1188,94 @@ } ], "constraints": [], - "subcommands": [] - }, - { - "name": "run", - "invocation": "bregctl examples run", - "about": "Run or resume an example against the project's ready owned development instance", - "long_about": null, - "usage": "bregctl examples run [OPTIONS] [PROJECT]", - "arguments": [ - { - "display": "", - "description": "Bounded scenario identifier declared in this project's examples catalogue", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "subcommands": [ { - "display": "[PROJECT]", - "description": "Authored project whose owned local development instance is ready. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [ - "." + "name": "consent", + "invocation": "bregctl module add consent", + "about": "Add consent for one subject entity: the privacy notice, its clauses, the principal link, the create-only consent decision, their self and steward actions, and five access profiles. Prints the requireConsent line that gates a permission on the decision", + "long_about": null, + "usage": "bregctl module add consent [OPTIONS] --subject ", + "arguments": [ + { + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } ], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--input ", - "description": "Editable JSON input (default: the scenario's declared examples input file)", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--attempt ", - "description": "Resume exactly this retained execution attempt", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--new-attempt", - "description": "Start a distinct attempt, with new idempotency keys and ordinary uniqueness checks", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--from-attempt ", - "description": "Completed population attempt whose typed captures this scenario uses", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [ - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--attempt ", - "--new-attempt" - ] - } - ], - "subcommands": [] - }, - { - "name": "review", - "invocation": "bregctl examples review", - "about": "Create and connect fresh facility and canonical Casework development projects", - "long_about": null, - "usage": "bregctl examples review [OPTIONS] ", - "arguments": [ - { - "display": "", - "description": "Fresh destination for the facility and canonical Casework projects. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--approve", - "description": "Explicitly record approval as the local synthetic Casework staff fixture. The decision uses Casework's ordinary claim, context and decision APIs", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--caseworkctl-bin ", - "description": "Casework authoring CLI from the same installation", - "always_required": false, - "repeatable": false, - "default_values": [ - "caseworkctl" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--casework-bin ", - "description": "Casework runtime executable; otherwise resolve casework on PATH", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--breg-bin ", - "description": "Base Registry Engine (BReg) runtime executable; otherwise resolve breg on PATH", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" + "options": [ + { + "display": "--subject ", + "description": "Entity whose rows consent decisions are about", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "constraints": [], + "subcommands": [] } - ], - "constraints": [], - "subcommands": [] + ] } ] }, { - "name": "explain", - "invocation": "bregctl explain", - "about": "Explain compiled model, access, route, or event inventories", + "name": "generate", + "invocation": "bregctl generate", + "about": "Write selected compiler artifacts to a new directory", "long_about": null, - "usage": "bregctl explain [OPTIONS] [PROJECT]", + "usage": "bregctl generate [OPTIONS] --output ", "arguments": [ { - "display": "", - "description": "Compiled inventory to explain", + "display": "", + "description": "Artifact family to write", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [ - "model", - "access", - "routes", - "queries", + "openapi", + "schemas", "actions", - "proposals", - "events", - "lifecycle" + "manifest", + "metadata", + "sql", + "evidence-source", + "sdk" ], "environment": null }, { - "display": "[PROJECT]", - "description": "Base Registry Engine project directory. Required for every subject except `lifecycle`, which reports engine behaviour no project changes. A symbolic link at any component of this path is refused.", - "always_required": false, + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], @@ -1886,71 +1293,53 @@ "environment": null }, { - "display": "--scenario ", - "description": "For access only: bounded JSON with synthetic claims. Performs no token verification or record access. A symbolic link at any component of this path is refused.", - "always_required": false, + "display": "--output ", + "description": "New directory that will receive exactly the generated artifact inventory. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", + "display": "--access-profile ", + "description": "Existing registry lookup access profile (evidence-source only)", "always_required": false, "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], + "default_values": [], + "possible_values": [], "environment": null }, { - "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "display": "--record-type ", + "description": "Compiled Record type to export (evidence-source only)", "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "diff", - "invocation": "bregctl diff", - "about": "Compare an authoring candidate with a rederived closed package", - "long_about": null, - "usage": "bregctl diff [OPTIONS] <--runtime-config |--package > ", - "arguments": [ + }, { - "display": "", - "description": "Base Registry Engine authoring project directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, + "display": "--selector ", + "description": "Exact lookup alternatives; repeat to expose several selectors", + "always_required": false, + "repeatable": true, "default_values": [], "possible_values": [], "environment": null - } - ], - "options": [ + }, { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--fields ", + "description": "Explicit readable fact fields, separated by commas", "always_required": false, - "repeatable": false, + "repeatable": true, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--package ", - "description": "Closed package inspected for integrity only, without activation authority. A symbolic link at any component of this path is refused.", + "display": "--source-id ", + "description": "Stable source identity inside the Evidence authoring project", "always_required": false, "repeatable": false, "default_values": [], @@ -1958,115 +1347,8 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [ - { - "kind": "required-exactly-one", - "when": null, - "arguments": [ - "--runtime-config ", - "--package " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--package ", - "--runtime-config " - ] - } - ], - "subcommands": [] - }, - { - "name": "package", - "invocation": "bregctl package", - "about": "Build a deterministic production-profile package from a tested candidate", - "long_about": "Build a deterministic production-profile package from a tested candidate. The package is the promotable unit `bregctl apply` activates: it is built from a project `bregctl test` passed, so it needs that run's --test-receipt. `caseworkctl package` is a different verb that writes a Casework project's checked policy to its own package.", - "usage": "bregctl package [OPTIONS] --test-receipt --output ", - "arguments": [ - { - "display": "", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--baseline-package ", - "description": "Absolute directory of the chain tip package a successor follows. The same package directory is named in every environment. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--reviewed-migrations ", - "description": "Directory containing reviewed migration descriptors and evidence in package layout. Used identically by test and package; requires a baseline package. A symbolic link at any component of this path is refused.", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--schema-fingerprint ", - "description": "Exact managed-catalog SHA-256 produced by the reviewed PostgreSQL rehearsal. Read from the schema-test receipt when it is not supplied", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--test-receipt ", - "description": "Canonical receipt from a successful schema test of this exact candidate. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--output ", - "description": "New build directory that receives the schema-test receipt and the published package/. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--revision ", - "description": "One printable line recorded in the published package as REVISION", + "display": "--connection ", + "description": "Logical connection that the Evidence operator configures separately", "always_required": false, "repeatable": false, "default_values": [], @@ -2089,7 +1371,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -2097,38 +1379,32 @@ "environment": null } ], - "constraints": [ - { - "kind": "requires-all", - "when": "--reviewed-migrations ", - "arguments": [ - "--baseline-package " - ] - } - ], + "constraints": [], "subcommands": [] }, { - "name": "test", - "invocation": "bregctl test", - "about": "Execute the production schema-test journey suite for one package candidate", + "name": "dev", + "invocation": "bregctl dev", + "about": "Start or stop this project's retained local development services", "long_about": null, - "usage": "bregctl test [OPTIONS] --runtime-config ", + "usage": "bregctl dev [OPTIONS] [PROJECT]\n bregctl dev ", "arguments": [ { - "display": "", - "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "[PROJECT]", + "description": "Existing authored registry project. A symbolic link at any component of this path is refused.", + "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "." + ], "possible_values": [], "environment": null } ], "options": [ { - "display": "--baseline-package ", - "description": "Absolute directory of the chain tip package a successor follows. The same package directory is named in every environment. A symbolic link at any component of this path is refused.", + "display": "--clients-file ", + "description": "Local clients, profile bindings, and optional seed records (default on first start: dev-clients.yaml in the project; retained for restarts). A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -2136,8 +1412,8 @@ "environment": null }, { - "display": "--reviewed-migrations ", - "description": "Directory containing reviewed migration descriptors and evidence in package layout. Used identically by test and package; requires a baseline package. A symbolic link at any component of this path is refused.", + "display": "--breg-port ", + "description": "Registry loopback port on first start (default 8090; retained for restarts)", "always_required": false, "repeatable": false, "default_values": [], @@ -2145,17 +1421,17 @@ "environment": null }, { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--issuer-port ", + "description": "Local issuer loopback port on first start (default 8091; retained for restarts)", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--credentials ", - "description": "Absolute schema-test credential binding document. Required unless --fingerprint-only is given. A symbolic link at any component of this path is refused.", + "display": "--issuer-project ", + "description": "Ready Base Registry Engine (BReg) dev project that owns the shared local issuer and client registrations. A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, "default_values": [], @@ -2163,8 +1439,8 @@ "environment": null }, { - "display": "--output ", - "description": "New canonical schema-test receipt file. Required unless --fingerprint-only is given. A symbolic link at any component of this path is refused.", + "display": "--issuer-image ", + "description": "Immutable local candidate issuer image ID on first start; retained for restarts", "always_required": false, "repeatable": false, "default_values": [], @@ -2172,8 +1448,8 @@ "environment": null }, { - "display": "--fingerprint-only", - "description": "Only measure the schema fingerprint a fresh install of the candidate produces, the target a reviewed migration declares. Runs no fixtures and writes no receipt", + "display": "--database-port ", + "description": "PostgreSQL loopback port on first start (default 15432; retained for restarts)", "always_required": false, "repeatable": false, "default_values": [], @@ -2204,387 +1480,189 @@ "environment": null } ], - "constraints": [ - { - "kind": "requires-all", - "when": "--reviewed-migrations ", - "arguments": [ - "--baseline-package " - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--baseline-package ", - "--fingerprint-only" - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--credentials ", - "--fingerprint-only" - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--fingerprint-only", - "--output " - ] - }, + "constraints": [], + "subcommands": [ { - "kind": "mutually-exclusive", - "when": null, + "name": "start", + "invocation": "bregctl dev start", + "about": "Start or reuse the project's retained local database and services", + "long_about": "Start or reuse the project's retained local database and services. A resident supervisor owns this project's PostgreSQL and ThunderID containers plus its Base Registry Engine (BReg) child. The database runs the pinned image docker.io/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, the Docker Official Images reference at that digest, or, when the compiled schema requires PostGIS, the Docker Hub image postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, which the supervisor pulls on the first start. Each supervised prerequisite command may run for 120 seconds, and the database and each started service have 45 seconds to answer as ready. A start that passes a deadline fails, stops what it acquired, and keeps its owner-only diagnostics in the project's private .breg/dev/logs directory. Catchable termination signals trigger owned cleanup, but an uncatchable outside kill can leave surviving service owners that need inspection before restart. products/breg/DEV.md documents this lifecycle in 'Native local BReg lifecycle' and its recovery in 'Retained state and recovery'.", + "usage": "bregctl dev start [OPTIONS] [PROJECT]", "arguments": [ - "--fingerprint-only", - "--reviewed-migrations " - ] - } - ], - "subcommands": [] - }, - { - "name": "apply", - "invocation": "bregctl apply", - "about": "Apply one built package using the configured migration authority", - "long_about": null, - "usage": "bregctl apply [OPTIONS] --runtime-config --package ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--package ", - "description": "Absolute target package directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--initial", - "description": "Activate the first package in an uninitialized Registry database", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--backup ", - "description": "Reviewed backup binding the plan requires and the absolute backup binding file that satisfies it, as BINDING_PATH=BINDING_FILE", - "always_required": false, - "repeatable": true, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--operator-reference ", - "description": "Operator change reference, at most 512 bytes, recorded as a keyed hash in the activation ledger and audit", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--expected-digest ", - "description": "Package digest the target package must have, as plan and package print it; apply refuses another package before any database contact", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" + { + "display": "[PROJECT]", + "description": "Existing authored registry project. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [ + "." + ], + "possible_values": [], + "environment": null + } ], - "possible_values": [ - "human", - "json" + "options": [ + { + "display": "--clients-file ", + "description": "Local clients, profile bindings, and optional seed records (default on first start: dev-clients.yaml in the project; retained for restarts). A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--breg-port ", + "description": "Registry loopback port on first start (default 8090; retained for restarts)", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--issuer-port ", + "description": "Local issuer loopback port on first start (default 8091; retained for restarts)", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--issuer-project ", + "description": "Ready Base Registry Engine (BReg) dev project that owns the shared local issuer and client registrations. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--issuer-image ", + "description": "Immutable local candidate issuer image ID on first start; retained for restarts", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--database-port ", + "description": "PostgreSQL loopback port on first start (default 15432; retained for restarts)", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "plan", - "invocation": "bregctl plan", - "about": "Report what `bregctl apply` would activate for one package, running its checks without changing the database", - "long_about": "Report what `bregctl apply` would activate for one package, running its checks without changing the database. A plan uses the migration credential, because apply's checks read what only the migration role may read. It rolls back every check it runs, appends no audit entry, and holds the apply lock while it checks. Pending changes exit 0; a refusal exits 1 and names the fix.", - "usage": "bregctl plan [OPTIONS] --runtime-config --package ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--package ", - "description": "Absolute target package directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--backup ", - "description": "Reviewed backup binding to verify as apply would, as BINDING_PATH=BINDING_FILE; without it, the plan lists the bindings apply requires", - "always_required": false, - "repeatable": true, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--expected-digest ", - "description": "Package digest the target package must have, as package prints it; plan refuses another package before any database contact", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "constraints": [], + "subcommands": [] }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" + "name": "stop", + "invocation": "bregctl dev stop", + "about": "Stop only this project's supervised services, preserving its database, records, audit history, keys and built package", + "long_about": "Stop only this project's supervised services, preserving its database, records, audit history, keys and built package. Add --remove to also remove the owned container and its data volume, discarding records so the next start can take edited inputs; to change inputs while keeping the records, copy the authored files to a new project directory instead. products/breg/DEV.md documents the full stop and recovery behavior in 'Retained state and recovery'.", + "usage": "bregctl dev stop [OPTIONS] [PROJECT]", + "arguments": [ + { + "display": "[PROJECT]", + "description": "Registry project whose owned services should stop while preserving records. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [ + "." + ], + "possible_values": [], + "environment": null + } ], - "possible_values": [ - "human", - "json" + "options": [ + { + "display": "--remove", + "description": "Also remove the owned container and its data volume, discarding records, event receipts and seed checkpoints; the next start can then take edited inputs. The audit files under .breg/dev/audit are kept", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } ], - "environment": null + "constraints": [], + "subcommands": [] }, { - "display": "-h, --help", - "description": "Print help (see a summary with '-h')", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "status", - "invocation": "bregctl status", - "about": "Report the active package and the activation ledger the database records", - "long_about": "Report the active package and the activation ledger the database records. Status reads as the migration role in one read-only transaction and takes no apply lock, so it answers while an apply runs.", - "usage": "bregctl status [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help (see a summary with '-h')", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "doctor", - "invocation": "bregctl doctor", - "about": "Verify configured startup dependencies without binding a listener", - "long_about": null, - "usage": "bregctl doctor [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "verify", - "invocation": "bregctl verify", - "about": "Verify one configured package without opening runtime dependencies", - "long_about": null, - "usage": "bregctl verify [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "migration", - "invocation": "bregctl migration", - "about": "Inspect configured migration lifecycle metadata", - "long_about": null, - "usage": "bregctl migration [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "explain", - "invocation": "bregctl migration explain", - "about": "Explain the verified package's closed migration plan without executing it", + "name": "events", + "invocation": "bregctl dev events", + "about": "Show received local webhook deliveries, hiding projected values by default", "long_about": null, - "usage": "bregctl migration explain [OPTIONS] --runtime-config ", - "arguments": [], + "usage": "bregctl dev events [OPTIONS] [PROJECT]", + "arguments": [ + { + "display": "[PROJECT]", + "description": "Local registry project whose retained deliveries should be inspected. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [ + "." + ], + "possible_values": [], + "environment": null + } + ], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--include-payload", + "description": "Explicitly show projected values captured by the development receiver", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -2618,25 +1696,28 @@ "subcommands": [] }, { - "name": "reconcile", - "invocation": "bregctl migration reconcile", - "about": "Assess a Registry pinned by a failed activation, and execute only the safe transition it names", + "name": "export-client", + "invocation": "bregctl dev export-client", + "about": "Copy an explicitly selected retained local client credential pair", "long_about": null, - "usage": "bregctl migration reconcile [OPTIONS] --runtime-config --package --operator-reference ", - "arguments": [], - "options": [ + "usage": "bregctl dev export-client [OPTIONS] --client --client-id-file --assertion-key-file [PROJECT]", + "arguments": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "[PROJECT]", + "description": "Registry project containing the retained development session. A symbolic link at any component of this path is refused.", + "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "." + ], "possible_values": [], "environment": null - }, + } + ], + "options": [ { - "display": "--package ", - "description": "Absolute directory of the verified package the failed activation pinned. A symbolic link at any component of this path is refused.", + "display": "--client ", + "description": "Explicit local client label from the retained session", "always_required": true, "repeatable": false, "default_values": [], @@ -2644,8 +1725,8 @@ "environment": null }, { - "display": "--operator-reference ", - "description": "Operator change reference recorded as a keyed hash beside an executed transition", + "display": "--client-id-file ", + "description": "Client ID destination in an existing owner-only directory. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -2653,9 +1734,9 @@ "environment": null }, { - "display": "--execute", - "description": "Perform the single safe transition the assessment names", - "always_required": false, + "display": "--assertion-key-file ", + "description": "Assertion key destination in an existing owner-only directory. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], @@ -2687,63 +1768,17 @@ ], "constraints": [], "subcommands": [] - } - ] - }, - { - "name": "history", - "invocation": "bregctl history", - "about": "Run bounded, audited retained-history maintenance", - "long_about": null, - "usage": "bregctl history [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null }, { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "erase", - "invocation": "bregctl history erase", - "about": "Erase retained history for one record using an owner-only JSON request file", + "name": "token", + "invocation": "bregctl dev token", + "about": "Acquire a fresh local client token and report its private header-file path", "long_about": null, - "usage": "bregctl history erase [OPTIONS] --runtime-config --request-file ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "usage": "bregctl dev token [OPTIONS] [PROJECT]", + "arguments": [ { - "display": "--request-file ", - "description": "Absolute owner-only JSON erasure request file. Read through the parent directory this path resolves to, so a `..` component is refused. The file must carry no group or other permission bits, because it names the records the erasure covers. A symbolic link at any component of this path is refused.", + "display": "", + "description": "Registered local client ID", "always_required": true, "repeatable": false, "default_values": [], @@ -2751,14 +1786,18 @@ "environment": null }, { - "display": "--acknowledge-irreversible", - "description": "Acknowledge that erasure cannot be undone. Without this flag the command is refused before any file is read or any database connection is opened.", + "display": "[PROJECT]", + "description": "Ready local project (defaults to the current directory). A symbolic link at any component of this path is refused.", "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "." + ], "possible_values": [], "environment": null - }, + } + ], + "options": [ { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -2775,7 +1814,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -2787,16 +1826,37 @@ "subcommands": [] }, { - "name": "rebaseline", - "invocation": "bregctl history rebaseline", - "about": "Restore snapshot coverage from the current state using an owner-only JSON request file", + "name": "grant", + "invocation": "bregctl dev grant", + "about": "Exchange an existing Casework approval using an explicit configured issuer connection", "long_about": null, - "usage": "bregctl history rebaseline [OPTIONS] --runtime-config --request-file ", - "arguments": [], + "usage": "bregctl dev grant [OPTIONS] --grant --connection [PROJECT]", + "arguments": [ + { + "display": "", + "description": "Registered agent client ID listed under `clients` in the task connection file", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "[PROJECT]", + "description": "Existing project whose private directory receives the grant-specific header. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [ + "." + ], + "possible_values": [], + "environment": null + } + ], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--grant ", + "description": "Existing Casework-approved grant UUID; this command does not approve tasks", "always_required": true, "repeatable": false, "default_values": [], @@ -2804,8 +1864,8 @@ "environment": null }, { - "display": "--request-file ", - "description": "Absolute owner-only JSON rebaseline request file. Read through the parent directory this path resolves to, so a `..` component is refused. The file must carry no group or other permission bits, because it names the records the rebaseline covers. A symbolic link at any component of this path is refused.", + "display": "--connection ", + "description": "Task connection file (`PlatformTaskConnection`) naming the fixed target and each client's assertion key reference. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -2828,7 +1888,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -2842,11 +1902,11 @@ ] }, { - "name": "data", - "invocation": "bregctl data", - "about": "Validate, import, or export data through authenticated Registry HTTP APIs", + "name": "examples", + "invocation": "bregctl examples", + "about": "List or explicitly run retained local teaching examples", "long_about": null, - "usage": "bregctl data [OPTIONS] ", + "usage": "bregctl examples [OPTIONS] ", "arguments": [], "options": [ { @@ -2876,61 +1936,25 @@ "constraints": [], "subcommands": [ { - "name": "validate", - "invocation": "bregctl data validate", - "about": "Validate a JSONL import file against one closed package plan", + "name": "list", + "invocation": "bregctl examples list", + "about": "Describe authored local scenarios without starting services or creating credentials", "long_about": null, - "usage": "bregctl data validate [OPTIONS] --package --record-type --profile --operation --input ", - "arguments": [], - "options": [ - { - "display": "--package ", - "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--record-type ", - "description": "Compiled Record type identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--profile ", - "description": "Compiled access profile identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "usage": "bregctl examples list [OPTIONS] [PROJECT]", + "arguments": [ { - "display": "--operation ", - "description": "Import item operation", - "always_required": true, + "display": "[PROJECT]", + "description": "Authored project containing examples/scenarios.json. A symbolic link at any component of this path is refused.", + "always_required": false, "repeatable": false, - "default_values": [], - "possible_values": [ - "create", - "patch" + "default_values": [ + "." ], - "environment": null - }, - { - "display": "--input ", - "description": "JSON Lines import file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], "possible_values": [], "environment": null - }, + } + ], + "options": [ { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -2959,34 +1983,15 @@ "subcommands": [] }, { - "name": "import", - "invocation": "bregctl data import", - "about": "Import JSONL records through the ordinary authenticated batch API", + "name": "run", + "invocation": "bregctl examples run", + "about": "Run or resume an example against the project's ready owned development instance", "long_about": null, - "usage": "bregctl data import [OPTIONS] --package --breg-url --access-token-file --record-type --profile --operation --input --checkpoint ", - "arguments": [], - "options": [ - { - "display": "--package ", - "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--breg-url ", - "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "usage": "bregctl examples run [OPTIONS] [PROJECT]", + "arguments": [ { - "display": "--access-token-file ", - "description": "File containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", + "display": "", + "description": "Bounded scenario identifier declared in this project's examples catalogue", "always_required": true, "repeatable": false, "default_values": [], @@ -2994,56 +1999,48 @@ "environment": null }, { - "display": "--record-type ", - "description": "Compiled Record type identifier", - "always_required": true, + "display": "[PROJECT]", + "description": "Authored project whose owned local development instance is ready. A symbolic link at any component of this path is refused.", + "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "." + ], "possible_values": [], "environment": null - }, + } + ], + "options": [ { - "display": "--profile ", - "description": "Compiled access profile identifier", - "always_required": true, + "display": "--input ", + "description": "Editable JSON input (default: the scenario's declared examples input file)", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--operation ", - "description": "Import item operation", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [ - "create", - "patch" - ], - "environment": null - }, - { - "display": "--input ", - "description": "JSON Lines import file. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--attempt ", + "description": "Resume exactly this retained execution attempt", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--checkpoint ", - "description": "Import checkpoint file. A ctl-held .state sidecar is created beside it. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--new-attempt", + "description": "Start a distinct attempt, with new idempotency keys and ordinary uniqueness checks", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--max-chunks ", - "description": "Stop after this many committed chunks, for resumable operator runs", + "display": "--from-attempt ", + "description": "Completed population attempt whose typed captures this scenario uses", "always_required": false, "repeatable": false, "default_values": [], @@ -3074,92 +2071,66 @@ "environment": null } ], - "constraints": [], + "constraints": [ + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--attempt ", + "--new-attempt" + ] + } + ], "subcommands": [] }, { - "name": "export", - "invocation": "bregctl data export", - "about": "Export records through the ordinary authenticated list API", + "name": "review", + "invocation": "bregctl examples review", + "about": "Create and connect fresh facility and canonical Casework development projects", "long_about": null, - "usage": "bregctl data export [OPTIONS] --package --breg-url --access-token-file --record-type --profile --field --output --checkpoint ", - "arguments": [], - "options": [ - { - "display": "--package ", - "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--breg-url ", - "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--access-token-file ", - "description": "File containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "usage": "bregctl examples review [OPTIONS] ", + "arguments": [ { - "display": "--record-type ", - "description": "Compiled Record type identifier", + "display": "", + "description": "Fresh destination for the facility and canonical Casework projects. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - }, + } + ], + "options": [ { - "display": "--profile ", - "description": "Compiled export-enabled access profile identifier", - "always_required": true, + "display": "--approve", + "description": "Explicitly record approval as the local synthetic Casework staff fixture. The decision uses Casework's ordinary claim, context and decision APIs", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--field ", - "description": "Requested readable field. Repeat for every exported field", - "always_required": true, - "repeatable": true, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--output ", - "description": "JSON Lines output file. Existing output resumes from its checkpoint. A resume continues after the last page the checkpoint records and discards the output beyond it, which is at most the one page a run stopped between appending a page and publishing its checkpoint left behind. A longer tail is refused rather than discarded. The output and the checkpoint are usable only as a pair: one present without the other is refused, and removing the file that remains starts a fresh export. A path holding a `..` component is refused. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--caseworkctl-bin ", + "description": "Casework authoring CLI from the same installation", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--checkpoint ", - "description": "Export checkpoint file written after every page. The checkpoint is published after the page it records reaches the output, so it names the position a resume continues from. Keep it for as long as the output it belongs to, and give each export its own pair. A path holding a `..` component is refused. A symbolic link at any component of this path is refused.", - "always_required": true, + "display": "--casework-bin ", + "description": "Casework runtime executable; otherwise resolve casework on PATH", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--max-pages ", - "description": "Stop after this many pages, for bounded operator runs", + "display": "--breg-bin ", + "description": "Base Registry Engine (BReg) runtime executable; otherwise resolve breg on PATH", "always_required": false, "repeatable": false, "default_values": [], @@ -3182,7 +2153,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -3196,13 +2167,59 @@ ] }, { - "name": "statistics", - "invocation": "bregctl statistics", - "about": "Publish or withdraw statistical releases through authenticated Registry HTTP APIs", + "name": "explain", + "invocation": "bregctl explain", + "about": "Explain compiled model, access, route, or event inventories", "long_about": null, - "usage": "bregctl statistics [OPTIONS] ", - "arguments": [], + "usage": "bregctl explain [OPTIONS] [PROJECT]", + "arguments": [ + { + "display": "", + "description": "Compiled inventory to explain", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [ + "model", + "access", + "routes", + "queries", + "actions", + "proposals", + "events", + "lifecycle" + ], + "environment": null + }, + { + "display": "[PROJECT]", + "description": "Base Registry Engine project directory. Required for every subject except `lifecycle`, which reports engine behaviour no project changes. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], "options": [ + { + "display": "--production", + "description": "Enforce production-only package closure requirements", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--scenario ", + "description": "For access only: bounded JSON with synthetic claims. Performs no token verification or record access. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -3219,7 +2236,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -3228,229 +2245,160 @@ } ], "constraints": [], - "subcommands": [ + "subcommands": [] + }, + { + "name": "diff", + "invocation": "bregctl diff", + "about": "Compare an authoring candidate with a rederived closed package", + "long_about": null, + "usage": "bregctl diff [OPTIONS] <--runtime-config |--package > ", + "arguments": [ { - "name": "publish", - "invocation": "bregctl statistics publish", - "about": "Compute and publish one statistical release version", - "long_about": null, - "usage": "bregctl statistics publish [OPTIONS] --breg-url --access-token-file --dataset --period --status --profile --idempotency-key ", - "arguments": [], - "options": [ - { - "display": "--breg-url ", - "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--access-token-file ", - "description": "Absolute file containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--dataset ", - "description": "Compiled statistical dataset identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--period ", - "description": "Statistical period code to publish", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--status ", - "description": "Release status assigned to the new version", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [ - "provisional", - "final" - ], - "environment": null - }, - { - "display": "--profile ", - "description": "Compiled publisher access profile identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--idempotency-key ", - "description": "Caller-owned idempotency key for this exact publication request", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, + "display": "", + "description": "Base Registry Engine authoring project directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ { - "name": "withdraw", - "invocation": "bregctl statistics withdraw", - "about": "Withdraw one immutable statistical release version", - "long_about": null, - "usage": "bregctl statistics withdraw [OPTIONS] --breg-url --access-token-file --dataset --period --version --reason --profile --idempotency-key ", - "arguments": [], - "options": [ - { - "display": "--breg-url ", - "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--access-token-file ", - "description": "Absolute file containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--dataset ", - "description": "Compiled statistical dataset identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--period ", - "description": "Statistical period code containing the version", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--version ", - "description": "Positive immutable release version to withdraw", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--reason ", - "description": "Closed reason recorded with the withdrawal", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [ - "computation-error", - "source-data-error", - "disclosure-risk" - ], - "environment": null - }, - { - "display": "--profile ", - "description": "Compiled publisher access profile identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--idempotency-key ", - "description": "Caller-owned idempotency key for this exact withdrawal request", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--package ", + "description": "Closed package inspected for integrity only, without activation authority. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" ], - "constraints": [], - "subcommands": [] + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null } - ] + ], + "constraints": [ + { + "kind": "required-exactly-one", + "when": null, + "arguments": [ + "--runtime-config ", + "--package " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--package ", + "--runtime-config " + ] + } + ], + "subcommands": [] }, { - "name": "webhook", - "invocation": "bregctl webhook", - "about": "Inspect and operate configured webhook deliveries", - "long_about": null, - "usage": "bregctl webhook [OPTIONS] ", - "arguments": [], + "name": "package", + "invocation": "bregctl package", + "about": "Build a deterministic production-profile package from a tested candidate", + "long_about": "Build a deterministic production-profile package from a tested candidate. The package is the promotable unit `bregctl apply` activates: it is built from a project `bregctl test` passed, so it needs that run's --test-receipt. `caseworkctl package` is a different verb that writes a Casework project's checked policy to its own package.", + "usage": "bregctl package [OPTIONS] --test-receipt --output ", + "arguments": [ + { + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], "options": [ + { + "display": "--baseline-package ", + "description": "Absolute directory of the chain tip package a successor follows. The same package directory is named in every environment. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--reviewed-migrations ", + "description": "Directory containing reviewed migration descriptors and evidence in package layout. Used identically by test and package; requires a baseline package. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--schema-fingerprint ", + "description": "Exact managed-catalog SHA-256 produced by the reviewed PostgreSQL rehearsal. Read from the schema-test receipt when it is not supplied", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--test-receipt ", + "description": "Canonical receipt from a successful schema test of this exact candidate. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--output ", + "description": "New build directory that receives the schema-test receipt and the published package/. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--revision ", + "description": "One printable line recorded in the published package as REVISION", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -3467,7 +2415,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -3475,335 +2423,218 @@ "environment": null } ], - "constraints": [], - "subcommands": [ + "constraints": [ { - "name": "sample", - "invocation": "bregctl webhook sample", - "about": "Render one deterministic exact CloudEvents request with synthetic values", - "long_about": null, - "usage": "bregctl webhook sample [OPTIONS] --event ", + "kind": "requires-all", + "when": "--reviewed-migrations ", "arguments": [ - { - "display": "", - "description": "Base Registry Engine authoring project directory. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--event ", - "description": "Stable authored event identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] + "--baseline-package " + ] + } + ], + "subcommands": [] + }, + { + "name": "test", + "invocation": "bregctl test", + "about": "Execute the production schema-test journey suite for one package candidate", + "long_about": null, + "usage": "bregctl test [OPTIONS] --runtime-config ", + "arguments": [ + { + "display": "", + "description": "Base Registry Engine project directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--baseline-package ", + "description": "Absolute directory of the chain tip package a successor follows. The same package directory is named in every environment. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null }, { - "name": "list", - "invocation": "bregctl webhook list", - "about": "List bounded value-free delivery metadata, including superseded bindings", - "long_about": null, - "usage": "bregctl webhook list [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--limit ", - "description": "Maximum number of value-free delivery rows to return", - "always_required": false, - "repeatable": false, - "default_values": [ - "50" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] + "display": "--reviewed-migrations ", + "description": "Directory containing reviewed migration descriptors and evidence in package layout. Used identically by test and package; requires a baseline package. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null }, { - "name": "drain", - "invocation": "bregctl webhook drain", - "about": "Attempt due local hooks under the active package with bounded time and attempts", - "long_about": null, - "usage": "bregctl webhook drain [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--max-attempts ", - "description": "Maximum local attempts to start, from 1 to 1000", - "always_required": false, - "repeatable": false, - "default_values": [ - "100" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--timeout-seconds ", - "description": "Time budget in seconds, from 1 to 300. Unfinished leases remain for recovery", - "always_required": false, - "repeatable": false, - "default_values": [ - "30" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--credentials ", + "description": "Absolute schema-test credential binding document. Required unless --fingerprint-only is given. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--output ", + "description": "New canonical schema-test receipt file. Required unless --fingerprint-only is given. A symbolic link at any component of this path is refused.", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--fingerprint-only", + "description": "Only measure the schema fingerprint a fresh install of the candidate produces, the target a reviewed migration declares. Runs no fixtures and writes no receipt", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" ], - "constraints": [], - "subcommands": [] + "possible_values": [ + "human", + "json" + ], + "environment": null }, { - "name": "replay", - "invocation": "bregctl webhook replay", - "about": "Replay one eligible retained dead-letter using optimistic generation binding", - "long_about": null, - "usage": "bregctl webhook replay [OPTIONS] --runtime-config --event-id --delivery-id --expected-generation ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--event-id ", - "description": "Stable event UUID shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--delivery-id ", - "description": "Compiled delivery identifier shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--expected-generation ", - "description": "Current generation shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [ + { + "kind": "requires-all", + "when": "--reviewed-migrations ", + "arguments": [ + "--baseline-package " + ] }, { - "name": "discard", - "invocation": "bregctl webhook discard", - "about": "Permanently discard one retained delivery using optimistic generation binding", - "long_about": null, - "usage": "bregctl webhook discard [OPTIONS] --runtime-config --event-id --delivery-id --expected-generation ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--event-id ", - "description": "Stable event UUID shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--delivery-id ", - "description": "Compiled delivery identifier shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--expected-generation ", - "description": "Current generation shown by `webhook list`", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--baseline-package ", + "--fingerprint-only" + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--credentials ", + "--fingerprint-only" + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--fingerprint-only", + "--output " + ] + }, + { + "kind": "mutually-exclusive", + "when": null, + "arguments": [ + "--fingerprint-only", + "--reviewed-migrations " + ] } - ] + ], + "subcommands": [] }, { - "name": "proposal-retention", - "invocation": "bregctl proposal-retention", - "about": "Inspect and erase eligible Proposal retention detail", + "name": "apply", + "invocation": "bregctl apply", + "about": "Apply one built package using the configured migration authority", "long_about": null, - "usage": "bregctl proposal-retention [OPTIONS] ", + "usage": "bregctl apply [OPTIONS] --runtime-config --package ", "arguments": [], "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--package ", + "description": "Absolute target package directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--initial", + "description": "Activate the first package in an uninitialized Registry database", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--backup ", + "description": "Reviewed backup binding the plan requires and the absolute backup binding file that satisfies it, as BINDING_PATH=BINDING_FILE", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--operator-reference ", + "description": "Operator change reference, at most 512 bytes, recorded as a keyed hash in the activation ledger and audit", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--expected-digest ", + "description": "Package digest the target package must have, as plan and package print it; apply refuses another package before any database contact", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -3829,15 +2660,253 @@ } ], "constraints": [], - "subcommands": [ + "subcommands": [] + }, + { + "name": "plan", + "invocation": "bregctl plan", + "about": "Report what `bregctl apply` would activate for one package, running its checks without changing the database", + "long_about": "Report what `bregctl apply` would activate for one package, running its checks without changing the database. A plan uses the migration credential, because apply's checks read what only the migration role may read. It rolls back every check it runs, appends no audit entry, and holds the apply lock while it checks. Pending changes exit 0; a refusal exits 1 and names the fix.", + "usage": "bregctl plan [OPTIONS] --runtime-config --package ", + "arguments": [], + "options": [ { - "name": "list", - "invocation": "bregctl proposal-retention list", - "about": "List bounded value-free Proposal retention rows", - "long_about": null, - "usage": "bregctl proposal-retention list [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--package ", + "description": "Absolute target package directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--backup ", + "description": "Reviewed backup binding to verify as apply would, as BINDING_PATH=BINDING_FILE; without it, the plan lists the bindings apply requires", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--expected-digest ", + "description": "Package digest the target package must have, as package prints it; plan refuses another package before any database contact", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "status", + "invocation": "bregctl status", + "about": "Report the active package and the activation ledger the database records", + "long_about": "Report the active package and the activation ledger the database records. Status reads as the migration role in one read-only transaction and takes no apply lock, so it answers while an apply runs.", + "usage": "bregctl status [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "doctor", + "invocation": "bregctl doctor", + "about": "Verify configured startup dependencies without binding a listener", + "long_about": null, + "usage": "bregctl doctor [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "verify", + "invocation": "bregctl verify", + "about": "Verify one configured package without opening runtime dependencies", + "long_about": null, + "usage": "bregctl verify [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "migration", + "invocation": "bregctl migration", + "about": "Inspect configured migration lifecycle metadata", + "long_about": null, + "usage": "bregctl migration [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "explain", + "invocation": "bregctl migration explain", + "about": "Explain the verified package's closed migration plan without executing it", + "long_about": null, + "usage": "bregctl migration explain [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ { "display": "--runtime-config ", "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", @@ -3848,51 +2917,22 @@ "environment": null }, { - "display": "--action-id ", - "description": "Limit the value-free listing to one compiled Action", + "display": "--format ", + "description": "Emit the selected command's report in this format", "always_required": false, "repeatable": false, - "default_values": [], - "possible_values": [], + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], "environment": null }, { - "display": "--after-cursor ", - "description": "Cursor returned by the previous bounded list response", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--limit ", - "description": "Maximum number of Proposal retention rows to return", - "always_required": false, - "repeatable": false, - "default_values": [ - "50" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", + "display": "-h, --help", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -3904,11 +2944,11 @@ "subcommands": [] }, { - "name": "dry-run", - "invocation": "bregctl proposal-retention dry-run", - "about": "Count exactly what one Proposal retention erase would remove", + "name": "reconcile", + "invocation": "bregctl migration reconcile", + "about": "Assess a Registry pinned by a failed activation, and execute only the safe transition it names", "long_about": null, - "usage": "bregctl proposal-retention dry-run [OPTIONS] --runtime-config --action-id --proposal-id --submission-version ", + "usage": "bregctl migration reconcile [OPTIONS] --runtime-config --package --operator-reference ", "arguments": [], "options": [ { @@ -3921,8 +2961,8 @@ "environment": null }, { - "display": "--action-id ", - "description": "Compiled Action identifier", + "display": "--package ", + "description": "Absolute directory of the verified package the failed activation pinned. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -3930,8 +2970,8 @@ "environment": null }, { - "display": "--proposal-id ", - "description": "Exact Proposal UUID", + "display": "--operator-reference ", + "description": "Operator change reference recorded as a keyed hash beside an executed transition", "always_required": true, "repeatable": false, "default_values": [], @@ -3939,9 +2979,9 @@ "environment": null }, { - "display": "--submission-version ", - "description": "Exact proposal version to inspect or erase", - "always_required": true, + "display": "--execute", + "description": "Perform the single safe transition the assessment names", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -3973,13 +3013,49 @@ ], "constraints": [], "subcommands": [] + } + ] + }, + { + "name": "history", + "invocation": "bregctl history", + "about": "Run bounded, audited retained-history maintenance", + "long_about": null, + "usage": "bregctl history [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ { "name": "erase", - "invocation": "bregctl proposal-retention erase", - "about": "Erase eligible payload detail for one exact submission version", + "invocation": "bregctl history erase", + "about": "Erase retained history for one record using an owner-only JSON request file", "long_about": null, - "usage": "bregctl proposal-retention erase [OPTIONS] --runtime-config --action-id --proposal-id --submission-version ", + "usage": "bregctl history erase [OPTIONS] --runtime-config --request-file ", "arguments": [], "options": [ { @@ -3992,17 +3068,8 @@ "environment": null }, { - "display": "--action-id ", - "description": "Compiled Action identifier", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "--proposal-id ", - "description": "Exact Proposal UUID", + "display": "--request-file ", + "description": "Absolute owner-only JSON erasure request file. Read through the parent directory this path resolves to, so a `..` component is refused. The file must carry no group or other permission bits, because it names the records the erasure covers. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4010,9 +3077,9 @@ "environment": null }, { - "display": "--submission-version ", - "description": "Exact proposal version to inspect or erase", - "always_required": true, + "display": "--acknowledge-irreversible", + "description": "Acknowledge that erasure cannot be undone. Without this flag the command is refused before any file is read or any database connection is opened.", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -4034,7 +3101,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -4046,11 +3113,11 @@ "subcommands": [] }, { - "name": "cleanup-attachments", - "invocation": "bregctl proposal-retention cleanup-attachments", - "about": "Retry deletion of unreferenced external attachments without erasing request detail", + "name": "rebaseline", + "invocation": "bregctl history rebaseline", + "about": "Restore snapshot coverage from the current state using an owner-only JSON request file", "long_about": null, - "usage": "bregctl proposal-retention cleanup-attachments [OPTIONS] --runtime-config ", + "usage": "bregctl history rebaseline [OPTIONS] --runtime-config --request-file ", "arguments": [], "options": [ { @@ -4062,6 +3129,15 @@ "possible_values": [], "environment": null }, + { + "display": "--request-file ", + "description": "Absolute owner-only JSON rebaseline request file. Read through the parent directory this path resolves to, so a `..` component is refused. The file must carry no group or other permission bits, because it names the records the rebaseline covers. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -4078,7 +3154,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -4092,11 +3168,11 @@ ] }, { - "name": "review-recovery", - "invocation": "bregctl review-recovery", - "about": "Recover a lost review or an automatic application blocked by executor authorization", + "name": "data", + "invocation": "bregctl data", + "about": "Validate, import, or export data through authenticated Registry HTTP APIs", "long_about": null, - "usage": "bregctl review-recovery [OPTIONS] ", + "usage": "bregctl data [OPTIONS] ", "arguments": [], "options": [ { @@ -4126,16 +3202,16 @@ "constraints": [], "subcommands": [ { - "name": "resubmit", - "invocation": "bregctl review-recovery resubmit", - "about": "Submit the exact retained review request again under its original idempotency key", + "name": "validate", + "invocation": "bregctl data validate", + "about": "Validate a JSONL import file against one closed package plan", "long_about": null, - "usage": "bregctl review-recovery resubmit [OPTIONS] --runtime-config --action-id --proposal-id --submission-version ", + "usage": "bregctl data validate [OPTIONS] --package --record-type --profile --operation --input ", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--package ", + "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4143,8 +3219,8 @@ "environment": null }, { - "display": "--action-id ", - "description": "Compiled Action identifier", + "display": "--record-type ", + "description": "Compiled Record type identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4152,8 +3228,8 @@ "environment": null }, { - "display": "--proposal-id ", - "description": "Exact Proposal UUID", + "display": "--profile ", + "description": "Compiled access profile identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4161,8 +3237,20 @@ "environment": null }, { - "display": "--submission-version ", - "description": "Exact proposal version whose review or application to recover", + "display": "--operation ", + "description": "Import item operation", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [ + "create", + "patch" + ], + "environment": null + }, + { + "display": "--input ", + "description": "JSON Lines import file. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4197,16 +3285,16 @@ "subcommands": [] }, { - "name": "close", - "invocation": "bregctl review-recovery close", - "about": "Close an accepted review without a result so it stops waiting on its authority", + "name": "import", + "invocation": "bregctl data import", + "about": "Import JSONL records through the ordinary authenticated batch API", "long_about": null, - "usage": "bregctl review-recovery close [OPTIONS] --runtime-config --action-id --proposal-id --submission-version ", + "usage": "bregctl data import [OPTIONS] --package --breg-url --access-token-file --record-type --profile --operation --input --checkpoint ", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--package ", + "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4214,8 +3302,8 @@ "environment": null }, { - "display": "--action-id ", - "description": "Compiled Action identifier", + "display": "--breg-url ", + "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", "always_required": true, "repeatable": false, "default_values": [], @@ -4223,8 +3311,8 @@ "environment": null }, { - "display": "--proposal-id ", - "description": "Exact Proposal UUID", + "display": "--access-token-file ", + "description": "File containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4232,14 +3320,62 @@ "environment": null }, { - "display": "--submission-version ", - "description": "Exact proposal version whose review or application to recover", + "display": "--record-type ", + "description": "Compiled Record type identifier", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--profile ", + "description": "Compiled access profile identifier", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--operation ", + "description": "Import item operation", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [ + "create", + "patch" + ], + "environment": null + }, + { + "display": "--input ", + "description": "JSON Lines import file. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--checkpoint ", + "description": "Import checkpoint file. A ctl-held .state sidecar is created beside it. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, + { + "display": "--max-chunks ", + "description": "Stop after this many committed chunks, for resumable operator runs", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -4268,16 +3404,16 @@ "subcommands": [] }, { - "name": "retry-application", - "invocation": "bregctl review-recovery retry-application", - "about": "Requeue an automatic application blocked by executor authorization after correcting its credentials or grants", - "long_about": null, - "usage": "bregctl review-recovery retry-application [OPTIONS] --runtime-config --action-id --proposal-id --submission-version ", + "name": "export", + "invocation": "bregctl data export", + "about": "Export records through the ordinary authenticated list API", + "long_about": null, + "usage": "bregctl data export [OPTIONS] --package --breg-url --access-token-file --record-type --profile --field --output --checkpoint ", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--package ", + "description": "Absolute closed package directory used only for deterministic planning. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4285,8 +3421,8 @@ "environment": null }, { - "display": "--action-id ", - "description": "Compiled Action identifier", + "display": "--breg-url ", + "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", "always_required": true, "repeatable": false, "default_values": [], @@ -4294,8 +3430,8 @@ "environment": null }, { - "display": "--proposal-id ", - "description": "Exact Proposal UUID", + "display": "--access-token-file ", + "description": "File containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4303,8 +3439,8 @@ "environment": null }, { - "display": "--submission-version ", - "description": "Exact proposal version whose review or application to recover", + "display": "--record-type ", + "description": "Compiled Record type identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4312,79 +3448,8 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - } - ] - }, - { - "name": "evidence-retention", - "invocation": "bregctl evidence-retention", - "about": "Erase expired protected action Evidence using configured migration authority", - "long_about": null, - "usage": "bregctl evidence-retention [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "erase-expired", - "invocation": "bregctl evidence-retention erase-expired", - "about": "Delete expired assertion bytes and verification context; receipts remain replayable", - "long_about": null, - "usage": "bregctl evidence-retention erase-expired [OPTIONS] --runtime-config --before ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--profile ", + "description": "Compiled export-enabled access profile identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4392,88 +3457,26 @@ "environment": null }, { - "display": "--before ", - "description": "RFC 3339 expiry cutoff, no later than the current time", + "display": "--field ", + "description": "Requested readable field. Repeat for every exported field", "always_required": true, - "repeatable": false, + "repeatable": true, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, + "display": "--output ", + "description": "JSON Lines output file. Existing output resumes from its checkpoint. A resume continues after the last page the checkpoint records and discards the output beyond it, which is at most the one page a run stopped between appending a page and publishing its checkpoint left behind. A longer tail is refused rather than discarded. The output and the checkpoint are usable only as a pair: one present without the other is refused, and removing the file that remains starts a fresh export. A path holding a `..` component is refused. A symbolic link at any component of this path is refused.", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "constraints": [], - "subcommands": [] - } - ] - }, - { - "name": "idempotency-retention", - "invocation": "bregctl idempotency-retention", - "about": "Drop held idempotency responses past their receipt horizon using configured migration authority", - "long_about": null, - "usage": "bregctl idempotency-retention [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "erase-expired", - "invocation": "bregctl idempotency-retention erase-expired", - "about": "Drop held responses past their receipt horizon; every key stays spent and a retry is still refused", - "long_about": null, - "usage": "bregctl idempotency-retention erase-expired [OPTIONS] --runtime-config --before ", - "arguments": [], - "options": [ + }, { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--checkpoint ", + "description": "Export checkpoint file written after every page. The checkpoint is published after the page it records reaches the output, so it names the position a resume continues from. Keep it for as long as the output it belongs to, and give each export its own pair. A path holding a `..` component is refused. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4481,9 +3484,9 @@ "environment": null }, { - "display": "--before ", - "description": "RFC 3339 cutoff, no later than the current time; responses whose horizon passed before it are dropped", - "always_required": true, + "display": "--max-pages ", + "description": "Stop after this many pages, for bounded operator runs", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -4505,7 +3508,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -4519,11 +3522,11 @@ ] }, { - "name": "import-authority", - "invocation": "bregctl import-authority", - "about": "Open, close, and list the authorities that bound `import` runs", + "name": "statistics", + "invocation": "bregctl statistics", + "about": "Publish or withdraw statistical releases through authenticated Registry HTTP APIs", "long_about": null, - "usage": "bregctl import-authority [OPTIONS] ", + "usage": "bregctl statistics [OPTIONS] ", "arguments": [], "options": [ { @@ -4553,16 +3556,16 @@ "constraints": [], "subcommands": [ { - "name": "open", - "invocation": "bregctl import-authority open", - "about": "Open one bounded authority for an `import` grant of the active package", + "name": "publish", + "invocation": "bregctl statistics publish", + "about": "Compute and publish one statistical release version", "long_about": null, - "usage": "bregctl import-authority open [OPTIONS] --runtime-config --record-type --profile --max-items --operator-reference --reason ", + "usage": "bregctl statistics publish [OPTIONS] --breg-url --access-token-file --dataset --period --status --profile --idempotency-key ", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--breg-url ", + "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", "always_required": true, "repeatable": false, "default_values": [], @@ -4570,8 +3573,8 @@ "environment": null }, { - "display": "--record-type ", - "description": "Record type the authority admits creates for", + "display": "--access-token-file ", + "description": "Absolute file containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4579,8 +3582,8 @@ "environment": null }, { - "display": "--profile ", - "description": "Access profile holding the Record type's `import` grant", + "display": "--dataset ", + "description": "Compiled statistical dataset identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4588,8 +3591,8 @@ "environment": null }, { - "display": "--max-items ", - "description": "Most records every run under the authority may create, together", + "display": "--period ", + "description": "Statistical period code to publish", "always_required": true, "repeatable": false, "default_values": [], @@ -4597,28 +3600,20 @@ "environment": null }, { - "display": "--expires-in ", - "description": "How long the authority stays open: whole minutes, hours, or days (`90m`, `12h`, `7d`), at most 30 days. There is no extension", - "always_required": false, + "display": "--status ", + "description": "Release status assigned to the new version", + "always_required": true, "repeatable": false, - "default_values": [ - "7d" - ], - "possible_values": [], - "environment": null - }, - { - "display": "--input-sha256 ", - "description": "SHA-256 input digest a run must announce, as `bregctl data validate` reports it. The client computes it and the run records it; the server does not recompute it over the written items. Repeat to allow several; omit to admit any", - "always_required": false, - "repeatable": true, "default_values": [], - "possible_values": [], + "possible_values": [ + "provisional", + "final" + ], "environment": null }, { - "display": "--operator-reference ", - "description": "Operator change reference recorded as a keyed hash", + "display": "--profile ", + "description": "Compiled publisher access profile identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4626,8 +3621,8 @@ "environment": null }, { - "display": "--reason ", - "description": "Reason recorded as a keyed hash, never in clear", + "display": "--idempotency-key ", + "description": "Caller-owned idempotency key for this exact publication request", "always_required": true, "repeatable": false, "default_values": [], @@ -4662,16 +3657,16 @@ "subcommands": [] }, { - "name": "close", - "invocation": "bregctl import-authority close", - "about": "Close one authority; the next chunk of every run under it is blocked", + "name": "withdraw", + "invocation": "bregctl statistics withdraw", + "about": "Withdraw one immutable statistical release version", "long_about": null, - "usage": "bregctl import-authority close [OPTIONS] --runtime-config --authority-id --operator-reference --reason ", + "usage": "bregctl statistics withdraw [OPTIONS] --breg-url --access-token-file --dataset --period --version --reason --profile --idempotency-key ", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--breg-url ", + "description": "Base Registry Engine base URL. HTTP is accepted only for loopback hosts", "always_required": true, "repeatable": false, "default_values": [], @@ -4679,8 +3674,8 @@ "environment": null }, { - "display": "--authority-id ", - "description": "Identifier `import-authority open` reported", + "display": "--access-token-file ", + "description": "Absolute file containing one bearer access token and no other credential material. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], @@ -4688,8 +3683,8 @@ "environment": null }, { - "display": "--operator-reference ", - "description": "Operator change reference recorded as a keyed hash", + "display": "--dataset ", + "description": "Compiled statistical dataset identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4697,8 +3692,8 @@ "environment": null }, { - "display": "--reason ", - "description": "Reason recorded as a keyed hash, never in clear", + "display": "--period ", + "description": "Statistical period code containing the version", "always_required": true, "repeatable": false, "default_values": [], @@ -4706,43 +3701,8 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "close-expired", - "invocation": "bregctl import-authority close-expired", - "about": "Record every expiry and supersession already due", - "long_about": null, - "usage": "bregctl import-authority close-expired [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--version ", + "description": "Positive immutable release version to withdraw", "always_required": true, "repeatable": false, "default_values": [], @@ -4750,43 +3710,30 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, + "display": "--reason ", + "description": "Closed reason recorded with the withdrawal", + "always_required": true, "repeatable": false, - "default_values": [ - "human" - ], + "default_values": [], "possible_values": [ - "human", - "json" + "computation-error", + "source-data-error", + "disclosure-risk" ], "environment": null }, { - "display": "-h, --help", - "description": "Print help", - "always_required": false, + "display": "--profile ", + "description": "Compiled publisher access profile identifier", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "constraints": [], - "subcommands": [] - }, - { - "name": "list", - "invocation": "bregctl import-authority list", - "about": "List the newest authorities, read only, with the status each has reached", - "long_about": null, - "usage": "bregctl import-authority list [OPTIONS] --runtime-config ", - "arguments": [], - "options": [ + }, { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--idempotency-key ", + "description": "Caller-owned idempotency key for this exact withdrawal request", "always_required": true, "repeatable": false, "default_values": [], @@ -4823,11 +3770,11 @@ ] }, { - "name": "instance-claim", - "invocation": "bregctl instance-claim", - "about": "Inspect and adopt the claim naming the database the Registry serves from", + "name": "webhook", + "invocation": "bregctl webhook", + "about": "Inspect and operate configured webhook deliveries", "long_about": null, - "usage": "bregctl instance-claim [OPTIONS] ", + "usage": "bregctl webhook [OPTIONS] ", "arguments": [], "options": [ { @@ -4857,16 +3804,26 @@ "constraints": [], "subcommands": [ { - "name": "status", - "invocation": "bregctl instance-claim status", - "about": "Report the claimed database beside the one the runtime role reaches", + "name": "sample", + "invocation": "bregctl webhook sample", + "about": "Render one deterministic exact CloudEvents request with synthetic values", "long_about": null, - "usage": "bregctl instance-claim status [OPTIONS] --runtime-config ", - "arguments": [], + "usage": "bregctl webhook sample [OPTIONS] --event ", + "arguments": [ + { + "display": "", + "description": "Base Registry Engine authoring project directory. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", + "display": "--event ", + "description": "Stable authored event identifier", "always_required": true, "repeatable": false, "default_values": [], @@ -4901,11 +3858,11 @@ "subcommands": [] }, { - "name": "adopt", - "invocation": "bregctl instance-claim adopt", - "about": "Make the connected database, such as a restored copy, the one the claim names", - "long_about": "Make the connected database, such as a restored copy, the one the claim names. Run it once after any restore, logical or physical, before the restored database serves. On a database the claim already names, as after a point-in-time recovery or a snapshot, it claims the database again and supersedes every import authority the restore reopened.", - "usage": "bregctl instance-claim adopt [OPTIONS] --runtime-config ", + "name": "list", + "invocation": "bregctl webhook list", + "about": "List bounded value-free delivery metadata, including superseded bindings", + "long_about": null, + "usage": "bregctl webhook list [OPTIONS] --runtime-config ", "arguments": [], "options": [ { @@ -4918,11 +3875,13 @@ "environment": null }, { - "display": "--acknowledge-original-retired", - "description": "Acknowledge that every other copy of this Registry, including the database the claim names today when it is another one, no longer serves and never will again. Two databases serving one Registry become divergent writers of its records, import authorities, and outbox work. Without this flag adoption is refused before any database connection is opened.", + "display": "--limit ", + "description": "Maximum number of value-free delivery rows to return", "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "50" + ], "possible_values": [], "environment": null }, @@ -4942,7 +3901,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -4952,60 +3911,46 @@ ], "constraints": [], "subcommands": [] - } - ] - }, - { - "name": "field-encryption", - "invocation": "bregctl field-encryption", - "about": "Maintain field-encryption key material", - "long_about": null, - "usage": "bregctl field-encryption [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null }, { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "keygen", - "invocation": "bregctl field-encryption keygen", - "about": "Write one fresh base64 data key for the local-file provider. The key never reaches standard output and an existing file is never overwritten", + "name": "drain", + "invocation": "bregctl webhook drain", + "about": "Attempt due local hooks under the active package with bounded time and attempts", "long_about": null, - "usage": "bregctl field-encryption keygen [OPTIONS] --output ", + "usage": "bregctl webhook drain [OPTIONS] --runtime-config ", "arguments": [], "options": [ { - "display": "--output ", - "description": "Absolute output path for the base64 data key (written 0600, parents 0700)", + "display": "--runtime-config ", + "description": "Runtime configuration file. A symbolic link at any component of this path is refused.", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, + { + "display": "--max-attempts ", + "description": "Maximum local attempts to start, from 1 to 1000", + "always_required": false, + "repeatable": false, + "default_values": [ + "100" + ], + "possible_values": [], + "environment": null + }, + { + "display": "--timeout-seconds ", + "description": "Time budget in seconds, from 1 to 300. Unfinished leases remain for recovery", + "always_required": false, + "repeatable": false, + "default_values": [ + "30" + ], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -5034,11 +3979,11 @@ "subcommands": [] }, { - "name": "preflight", - "invocation": "bregctl field-encryption preflight", - "about": "Report what one reviewed backfill apply would do, before running it: value-free counts per covered field, the descriptor's explicit history choice, and the record names a unique blind index would refuse", + "name": "replay", + "invocation": "bregctl webhook replay", + "about": "Replay one eligible retained dead-letter using optimistic generation binding", "long_about": null, - "usage": "bregctl field-encryption preflight [OPTIONS] --runtime-config --package ", + "usage": "bregctl webhook replay [OPTIONS] --runtime-config --event-id --delivery-id --expected-generation ", "arguments": [], "options": [ { @@ -5051,8 +3996,26 @@ "environment": null }, { - "display": "--package ", - "description": "Absolute directory of the verified successor package the operator plans to apply. A symbolic link at any component of this path is refused.", + "display": "--event-id ", + "description": "Stable event UUID shown by `webhook list`", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--delivery-id ", + "description": "Compiled delivery identifier shown by `webhook list`", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--expected-generation ", + "description": "Current generation shown by `webhook list`", "always_required": true, "repeatable": false, "default_values": [], @@ -5087,11 +4050,11 @@ "subcommands": [] }, { - "name": "erase-history", - "invocation": "bregctl field-encryption erase-history", - "about": "Erase the retained plaintext history of flips that declared erase-and-rebaseline, then restore snapshot coverage with one rebaseline. Runs only after the flip's package is active", + "name": "discard", + "invocation": "bregctl webhook discard", + "about": "Permanently discard one retained delivery using optimistic generation binding", "long_about": null, - "usage": "bregctl field-encryption erase-history [OPTIONS] --runtime-config --request-file ", + "usage": "bregctl webhook discard [OPTIONS] --runtime-config --event-id --delivery-id --expected-generation ", "arguments": [], "options": [ { @@ -5104,8 +4067,26 @@ "environment": null }, { - "display": "--request-file ", - "description": "Absolute owner-only JSON erase-history request file. Read through the parent directory this path resolves to, so a `..` component is refused. The file must carry no group or other permission bits, because it authorizes destroying retained history. A symbolic link at any component of this path is refused.", + "display": "--event-id ", + "description": "Stable event UUID shown by `webhook list`", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--delivery-id ", + "description": "Compiled delivery identifier shown by `webhook list`", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--expected-generation ", + "description": "Current generation shown by `webhook list`", "always_required": true, "repeatable": false, "default_values": [], @@ -5128,7 +4109,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -5140,151 +4121,18 @@ "subcommands": [] } ] - } - ] - }, - { - "name": "casework", - "invocation": "casework", - "about": "Run and maintain Registry Casework", - "long_about": null, - "usage": "casework --runtime-config ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null }, { - "display": "-V, --version", - "description": "Print version", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "serve", - "invocation": "casework serve", - "about": "Run the Casework HTTP service", + "name": "proposal-retention", + "invocation": "bregctl proposal-retention", + "about": "Inspect and erase eligible Proposal retention detail", "long_about": null, - "usage": "casework --runtime-config serve", + "usage": "bregctl proposal-retention [OPTIONS] ", "arguments": [], "options": [ - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - } - ] - }, - { - "name": "caseworkctl", - "invocation": "caseworkctl", - "about": "Compile and operate bounded review policies", - "long_about": null, - "usage": "caseworkctl [OPTIONS] ", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Write human diagnostics and reports, or one typed JSON report", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "-V, --version", - "description": "Print version", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "init", - "invocation": "caseworkctl init", - "about": "Create a standalone or source-backed ReviewPolicy project", - "long_about": null, - "usage": "caseworkctl init [OPTIONS] ", - "arguments": [ - { - "display": "", - "description": "New project directory; existing destinations are never overwritten", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--template