diff --git a/.github/fleet-compat-baseline.json b/.github/fleet-compat-baseline.json index 9e38bf0c098..a7d73f4f201 100644 --- a/.github/fleet-compat-baseline.json +++ b/.github/fleet-compat-baseline.json @@ -1,16 +1,16 @@ { - "commit": "15c8db96ce66921cdeb487bf6978030f8fbfea64", - "tag": "v0.3.31", + "commit": "d853248908ec308713d327f5c666111fbc76e09e", + "tag": "v0.3.32", "why": "Rolling previous-published-release compatibility baseline; advance after each stable release.", "exception_reason": null, "bundles": { "x86_64-unknown-linux-gnu": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "78759918a6a487e1a1bacd5d73489fe7560a396911c0cfb639325a413f9f2bdc" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "fbdd870ea0dbe01fdf1563ea2388f1ff4dccdc67761df6220a8f4684a8d370e7" }, "aarch64-apple-darwin": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-aarch64-apple-darwin.tar.gz", - "sha256": "f5bd99274dc0aaff49e562c09666be28af290157c363b0d441dd203343e7594b" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-aarch64-apple-darwin.tar.gz", + "sha256": "387d94c9782b3e2cb741dda7bad1e684c743dfe8318a4a5a829615fc2e46c024" } } } diff --git a/.github/messaging-compat-baseline.json b/.github/messaging-compat-baseline.json index 9e38bf0c098..a7d73f4f201 100644 --- a/.github/messaging-compat-baseline.json +++ b/.github/messaging-compat-baseline.json @@ -1,16 +1,16 @@ { - "commit": "15c8db96ce66921cdeb487bf6978030f8fbfea64", - "tag": "v0.3.31", + "commit": "d853248908ec308713d327f5c666111fbc76e09e", + "tag": "v0.3.32", "why": "Rolling previous-published-release compatibility baseline; advance after each stable release.", "exception_reason": null, "bundles": { "x86_64-unknown-linux-gnu": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "78759918a6a487e1a1bacd5d73489fe7560a396911c0cfb639325a413f9f2bdc" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "fbdd870ea0dbe01fdf1563ea2388f1ff4dccdc67761df6220a8f4684a8d370e7" }, "aarch64-apple-darwin": { - "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.31/fractal-aarch64-apple-darwin.tar.gz", - "sha256": "f5bd99274dc0aaff49e562c09666be28af290157c363b0d441dd203343e7594b" + "url": "https://github.com/compoundingtech/fractal/releases/download/v0.3.32/fractal-aarch64-apple-darwin.tar.gz", + "sha256": "387d94c9782b3e2cb741dda7bad1e684c743dfe8318a4a5a829615fc2e46c024" } } } diff --git a/.github/workflows/burst.yml b/.github/workflows/burst.yml new file mode 100644 index 00000000000..d395015426e --- /dev/null +++ b/.github/workflows/burst.yml @@ -0,0 +1,285 @@ +# Generated file - DO NOT EDIT +# Source: burst.yml.genie.ts + +name: burst + +run-name: "burst ${{ inputs.suite }} ${{ inputs.sha }} ${{ inputs.request }}" + +on: + workflow_dispatch: + inputs: + sha: + description: Full 40-character commit SHA to test + required: true + type: string + suite: + description: "One of: cargo-test, nextest, st-test-all" + required: true + type: string + args: + description: Extra arguments for cargo-test or nextest, split on spaces + required: false + type: string + default: "" + request: + description: Unique request id per dispatch ([A-Za-z0-9._-], at most 64) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: "burst-${{ inputs.request }}" + cancel-in-progress: false + +jobs: + pending: + name: pending + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write + defaults: + run: + shell: bash + env: + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + steps: + - name: Validate the request + run: | + [ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::burst runs only from main's workflow (got $GITHUB_REF)"; exit 1; } + sha_pattern='^[0-9a-f]{40}$' request_pattern='^[A-Za-z0-9._-]{1,64}$' args_pattern='^[A-Za-z0-9_:./=@ ,+-]*$' + [[ "$SHA" =~ $sha_pattern ]] || { echo "::error::sha must match $sha_pattern"; exit 1; } + [[ "$REQUEST" =~ $request_pattern ]] || { echo "::error::request must match $request_pattern"; exit 1; } + [[ "$ARGS" =~ $args_pattern ]] || { echo "::error::args must match $args_pattern"; exit 1; } + case "$SUITE" in + cargo-test|nextest|st-test-all) ;; + *) echo "::error::suite must be one of: cargo-test nextest st-test-all"; exit 1 ;; + esac + if [ "$SUITE" = st-test-all ] && [ -n "$ARGS" ]; then echo "::error::st-test-all takes no args"; exit 1; fi + - name: Report the run as pending + env: + GH_TOKEN: ${{ github.token }} + run: | + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"pending\",\"context\":\"burst/$SUITE\",\"description\":\"request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_REASON: Validates the request and posts a commit status without a checkout. + run: "printf 'Cache coverage: **not applicable** — %s\\n' \"$CACHE_REASON\" >> \"$GITHUB_STEP_SUMMARY\"" + burst: + name: burst + needs: [pending] + runs-on: ${{ fromJSON(vars.BURST_RUNS_ON || format('["nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3","namespace-features:github.run-id={0}"]', github.run_id)) }} + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_INCREMENTAL: "0" + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + outputs: + exit: ${{ steps.suite.outputs.exit }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + persist-credentials: false + fetch-depth: 1 + - name: Fetch the whole history without evidence tags + if: inputs.suite == 'st-test-all' + env: + FETCH_TOKEN: ${{ github.token }} + run: | + if [ -z "$(git config --local --get-regexp '^http\..*\.extraheader$' || true)" ]; then + export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$FETCH_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_VALUE_0 + fi + head=$(git rev-parse HEAD) + shallow=$(git rev-parse --git-path shallow) + errors=$(mktemp) + fetch() { + status=0 + git fetch --quiet --refetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' '^refs/tags/evidence/*' "$head" 2> "$errors" || status=$? + cat "$errors" >&2 + return "$status" + } + transport() { grep -Eq 'HTTP (408|429|5[0-9][0-9])|RPC failed|remote end hung up|early EOF|Connection (timed out|reset)|Operation timed out' "$errors"; } + [ ! -f "$shallow" ] || mv "$shallow" "$shallow.full-fetch" + if ! fetch && ! { transport && echo 'full history fetch: transport failure; trying once more' >&2 && fetch; }; then + [ ! -f "$shallow.full-fetch" ] || mv "$shallow.full-fetch" "$shallow" + exit 1 + fi + rm -f "$shallow.full-fetch" + git fsck --connectivity-only --no-dangling --no-progress + - name: Require the requested commit + run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }' + - name: Use the runner's own caches + run: | + if [ -n "${CI_LOCAL_CARGO_HOME:-}" ]; then echo CI_LOCAL_CACHES=1 >> "$GITHUB_ENV"; fi + printf 'CARGO_HOME=%s\nCI_CACHE_DIR=%s\n' "${CI_LOCAL_CARGO_HOME:-$RUNNER_TEMP/cargo-home}" "$RUNNER_TEMP/fractal-ci-cache" >> "$GITHUB_ENV" + - name: Restore the Cargo target and registry + id: cargo-cache + if: env.CI_LOCAL_CACHES != '1' && env.CI_BUILD_SNAPSHOT_HIT != '1' + uses: actions/cache/restore@v4 + with: + path: | + ${{ github.workspace }}/target + ${{ runner.temp }}/cargo-home/registry + ${{ runner.temp }}/cargo-home/git + key: "cargo-perf-cost-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'flake.lock', 'Cargo.toml', 'crates/**/Cargo.toml', '.cargo/config.toml') }}" + restore-keys: "cargo-perf-cost-${{ runner.os }}-" + - name: Restore the local Nix cache + id: nix-cache + if: env.CI_LOCAL_CACHES != '1' && env.CI_BUILD_SNAPSHOT_HIT != '1' + uses: actions/cache/restore@v4 + with: + path: "${{ runner.temp }}/fractal-ci-cache" + key: "nix5-perf-cost-${{ runner.os }}-${{ hashFiles('flake.lock', 'flake.nix', 'nix/**/*.nix', '.github/fleet-compat-baseline.json', '.github/messaging-compat-baseline.json') }}" + restore-keys: | + nix5-perf-cost-${{ runner.os }}- + nix4-perf-cost-${{ runner.os }}- + - # v3.24.1 + name: Install Nix + uses: DeterminateSystems/determinate-nix-action@92957199488000b908fdad19f63711000965d8f3 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + extra-conf: | + experimental-features = nix-command flakes + accept-flake-config = true + connect-timeout = 10 + stalled-download-timeout = 60 + download-attempts = 3 + extra-substituters = https://overeng-effect-utils.cachix.org + extra-trusted-public-keys = overeng-effect-utils.cachix.org-1:KFmqYNF6Q7ZzVYPl2znpJYZGEolage9YNCA9res6vKc= + access-tokens = github.com=${{ github.token }} + summarize: true + - name: Isolate test home and XDG state + run: | + # Cargo's home and the CI cache directory live under RUNNER_TEMP, not HOME: tests get an isolated HOME, + # and actions/cache expands a leading tilde against the HOME of the step that runs it. + printf 'CARGO_HOME=%s\nCI_CACHE_DIR=%s\n' "${CI_LOCAL_CARGO_HOME:-$RUNNER_TEMP/cargo-home}" "$RUNNER_TEMP/fractal-ci-cache" >> "$GITHUB_ENV" + home="$RUNNER_TEMP/test-home" + mkdir -p "$home" "$home/.config" "$home/.cache" "$home/.local/state" + printf 'HOME=%s\nXDG_CONFIG_HOME=%s/.config\nXDG_CACHE_HOME=%s/.cache\nXDG_STATE_HOME=%s/.local/state\n' "$home" "$home" "$home" "$home" >> "$GITHUB_ENV" + - name: Use the cached Nix outputs + if: env.CI_LOCAL_CACHES != '1' + run: 'bash scripts/ci-nix-cache use || echo "::warning::the local Nix cache is unavailable; this run builds everything"' + - name: Materialize megarepo members + env: + MEGAREPO_STORE: "${{ runner.temp }}/megarepo" + run: | + python3 scripts/prepare-pnpm-source-inputs.py apply + printf 'MEGAREPO_STORE=%s\n' "$MEGAREPO_STORE" >> "$GITHUB_ENV" + if: inputs.suite == 'st-test-all' + - name: Run the suite + id: suite + env: + ST_TEST_ALL: | + python3 - <<'BURST_ST_TEST_ALL' + import importlib.machinery, importlib.util, shlex, subprocess, sys + loader = importlib.machinery.SourceFileLoader('st_test', 'scripts/st-test') + module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) + loader.exec_module(module) + skip = set(module.KEYED) | {module.RUST_TESTS, module.WEB} + status = 0 + for name, argv in module.LOCAL.items(): + if name in skip: + continue + print(f'burst: {name}: {shlex.join(argv)}', flush=True) + code = subprocess.run(argv, stdin=subprocess.DEVNULL).returncode + print(f'burst: {name} exit={code}', flush=True) + status = status or code + sys.exit(status) + BURST_ST_TEST_ALL + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + run: | + read -r -a argv <<< "$ARGS" + case "$SUITE" in + cargo-test) command=(nix develop -c cargo test --locked "${argv[@]}") ;; + nextest) command=(nix develop -c cargo nextest run --locked "${argv[@]}") ;; + st-test-all) command=(bash -c "$ST_TEST_ALL") ;; + esac + log="$RUNNER_TEMP/burst.log" + start=$(date +%s) + code=0 + set -o pipefail + "${command[@]}" 2>&1 | tee "$log" || code=$? + line="burst suite=$SUITE sha=$SHA request=$REQUEST exit=$code seconds=$(( $(date +%s) - start ))" + printf 'exit=%s\nline=%s\n' "$code" "$line" >> "$GITHUB_OUTPUT" + printf '%s\n' "$line" >> "$GITHUB_STEP_SUMMARY" + printf '%s\n' "$line" | tee -a "$log" + - name: Retain the suite log + if: always() && steps.suite.outcome != 'skipped' + uses: actions/upload-artifact@v4 + with: + name: "burst-log-${{ inputs.request }}" + path: "${{ runner.temp }}/burst.log" + if-no-files-found: warn + retention-days: 14 + - name: Finish with the suite exit + if: always() + env: + EXIT: ${{ steps.suite.outputs.exit }} + LINE: ${{ steps.suite.outputs.line }} + run: | + [ -n "$EXIT" ] || { echo "::error::the suite did not run"; exit 1; } + printf '%s\n' "$LINE" + exit "$EXIT" + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_ENTRIES: '[{"name":"Restore the Cargo target and registry","outcome":"${{ steps.cargo-cache.outcome }}","hit":"${{ steps.cargo-cache.outputs.cache-hit }}","matched":"${{ steps.cargo-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false},{"name":"Restore the local Nix cache","outcome":"${{ steps.nix-cache.outcome }}","hit":"${{ steps.nix-cache.outputs.cache-hit }}","matched":"${{ steps.nix-cache.outputs.cache-matched-key }}","opaqueFallback":false,"sourceOnly":false,"avoidable":"","lookup":false}]' + run: 'python3 "${CI_CACHE_AUDIT_SCRIPT:-scripts/ci-cache-audit}"' + report: + name: report + needs: [pending, burst] + if: always() && needs.pending.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write + defaults: + run: + shell: bash + env: + SHA: ${{ inputs.sha }} + SUITE: ${{ inputs.suite }} + ARGS: ${{ inputs.args }} + REQUEST: ${{ inputs.request }} + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + steps: + - name: Report the result on the commit + env: + GH_TOKEN: ${{ github.token }} + EXIT: ${{ needs.burst.outputs.exit }} + run: | + if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi + curl -fsS --retry 3 -X POST \ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \ + -d "{\"state\":\"$state\",\"context\":\"burst/$SUITE\",\"description\":\"exit=${EXIT:-none} request=$REQUEST\",\"target_url\":\"$RUN_URL\"}" > /dev/null + - name: Report job cache coverage + if: always() + shell: bash + env: + CACHE_REASON: Posts the final commit status without a checkout. + run: "printf 'Cache coverage: **not applicable** — %s\\n' \"$CACHE_REASON\" >> \"$GITHUB_STEP_SUMMARY\"" diff --git a/.github/workflows/burst.yml.genie.ts b/.github/workflows/burst.yml.genie.ts new file mode 100644 index 00000000000..5556ef777a7 --- /dev/null +++ b/.github/workflows/burst.yml.genie.ts @@ -0,0 +1,180 @@ +import { auditCaches } from './cache-audit.ts' +import { buildSnapshotPrepare, buildSnapshotRestore } from './build-snapshot.ts' +import { scopeLinuxNixSandbox } from './nix-sandbox-scope.ts' +import { defaultActionlintConfig, githubWorkflow } from '../../repos/effect-utils/genie/external.ts' +import { buildEnv, cargoCacheStep, commonSetupSteps, fullHistoryCheckoutSteps, megarepoApplyStep, nixCacheStep } from './workspace-ci.ts' + +/** + * Burst: one allowlisted suite on one exact commit, dispatched on demand (docs/ci.md, "Burst runs"). + * The suite commands come from this workflow on main; the scripts they call come from the requested commit. + * The dispatch input, the checkout and the `burst/` commit status name the same SHA. + */ +const suites = ['cargo-test', 'nextest', 'st-test-all'] as const + +// Optional work: the same 8x16 shape and lowest queue class as Performance, below merge groups and PR checks. +// `vars.BURST_RUNS_ON` (a JSON label array) replaces it without a workflow change. +const defaultRunsOn = JSON.stringify([ + 'nscloud-ubuntu-24.04-amd64-8x16-with-features;job.priority=3', + 'namespace-features:github.run-id={0}', +]) +const runsOn = `\${{ fromJSON(vars.BURST_RUNS_ON || format('${defaultRunsOn}', github.run_id)) }}` + +// Main upkeep's perf-cost job keeps the default dev shell's Nix outputs and a Cargo home (registry, proc macros, +// build scripts) on main; restoring them keeps a burst run from starting fully cold. Restore only: burst saves nothing. +const fromPerfCost = }>(step: T): T => ({ + ...step, + with: Object.fromEntries(Object.entries(step.with).map(([key, value]) => [key, value.replaceAll('${{ github.job }}', 'perf-cost')])), +}) +const setupSteps = commonSetupSteps + .filter((step) => step !== buildSnapshotRestore && step !== buildSnapshotPrepare + && !('uses' in step && String(step.uses).startsWith('actions/checkout@')) + && !('name' in step && step.name === 'Fetch the whole history without evidence tags')) + .map((step) => step === cargoCacheStep ? fromPerfCost(cargoCacheStep) : step === nixCacheStep ? fromPerfCost(nixCacheStep) : step) + +const validate = `[ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::burst runs only from main's workflow (got $GITHUB_REF)"; exit 1; } +sha_pattern='^[0-9a-f]{40}$' request_pattern='^[A-Za-z0-9._-]{1,64}$' args_pattern='^[A-Za-z0-9_:./=@ ,+-]*$' +[[ "$SHA" =~ $sha_pattern ]] || { echo "::error::sha must match $sha_pattern"; exit 1; } +[[ "$REQUEST" =~ $request_pattern ]] || { echo "::error::request must match $request_pattern"; exit 1; } +[[ "$ARGS" =~ $args_pattern ]] || { echo "::error::args must match $args_pattern"; exit 1; } +case "$SUITE" in + ${suites.join('|')}) ;; + *) echo "::error::suite must be one of: ${suites.join(' ')}"; exit 1 ;; +esac +if [ "$SUITE" = st-test-all ] && [ -n "$ARGS" ]; then echo "::error::st-test-all takes no args"; exit 1; fi` + +const postStatus = (state: string, description: string) => `curl -fsS --retry 3 -X POST \\ + -H "Authorization: Bearer $GH_TOKEN" -H 'Accept: application/vnd.github+json' \\ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$SHA" \\ + -d "{\\"state\\":\\"${state}\\",\\"context\\":\\"burst/$SUITE\\",\\"description\\":\\"${description}\\",\\"target_url\\":\\"$RUN_URL\\"}" > /dev/null` + +// st-test-all: every whole-run check in scripts/st-test's LOCAL table at this commit, unsigned and in its order. +// Report readers (linux-clippy, linux-fleet-compat, rust-tests) read linux-gate's run; fractal-web is path-filtered +// and stays with Workspace CI. Every check runs; the first failure's exit is the suite's. +const stTestAll = `python3 - <<'BURST_ST_TEST_ALL' +import importlib.machinery, importlib.util, shlex, subprocess, sys +loader = importlib.machinery.SourceFileLoader('st_test', 'scripts/st-test') +module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) +loader.exec_module(module) +skip = set(module.KEYED) | {module.RUST_TESTS, module.WEB} +status = 0 +for name, argv in module.LOCAL.items(): + if name in skip: + continue + print(f'burst: {name}: {shlex.join(argv)}', flush=True) + code = subprocess.run(argv, stdin=subprocess.DEVNULL).returncode + print(f'burst: {name} exit={code}', flush=True) + status = status or code +sys.exit(status) +BURST_ST_TEST_ALL` + +const runSuite = `read -r -a argv <<< "$ARGS" +case "$SUITE" in + cargo-test) command=(nix develop -c cargo test --locked "\${argv[@]}") ;; + nextest) command=(nix develop -c cargo nextest run --locked "\${argv[@]}") ;; + st-test-all) command=(bash -c "$ST_TEST_ALL") ;; +esac +log="$RUNNER_TEMP/burst.log" +start=$(date +%s) +code=0 +set -o pipefail +"\${command[@]}" 2>&1 | tee "$log" || code=$? +line="burst suite=$SUITE sha=$SHA request=$REQUEST exit=$code seconds=$(( $(date +%s) - start ))" +printf 'exit=%s\\nline=%s\\n' "$code" "$line" >> "$GITHUB_OUTPUT" +printf '%s\\n' "$line" >> "$GITHUB_STEP_SUMMARY" +printf '%s\\n' "$line" | tee -a "$log"` + +const requestEnv = { + SHA: '${{ inputs.sha }}', + SUITE: '${{ inputs.suite }}', + ARGS: '${{ inputs.args }}', + REQUEST: '${{ inputs.request }}', + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', +} + +// Only the two status jobs hold `statuses: write`, and neither checks out the requested commit: +// the suite job runs that commit's code with a read-only token. +export default githubWorkflow(scopeLinuxNixSandbox(auditCaches({ + name: 'burst', + 'run-name': 'burst ${{ inputs.suite }} ${{ inputs.sha }} ${{ inputs.request }}', + on: { + workflow_dispatch: { + inputs: { + sha: { description: 'Full 40-character commit SHA to test', required: true, type: 'string' }, + suite: { description: `One of: ${suites.join(', ')}`, required: true, type: 'string' }, + args: { description: 'Extra arguments for cargo-test or nextest, split on spaces', required: false, type: 'string', default: '' }, + request: { description: 'Unique request id per dispatch ([A-Za-z0-9._-], at most 64)', required: true, type: 'string' }, + }, + }, + }, + permissions: { contents: 'read' }, + concurrency: { group: 'burst-${{ inputs.request }}', 'cancel-in-progress': false }, + actionlint: defaultActionlintConfig, + jobs: { + pending: { + name: 'pending', + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 5, + permissions: { statuses: 'write' }, + defaults: { run: { shell: 'bash' } }, + env: requestEnv, + steps: [ + { name: 'Validate the request', run: validate }, + { name: 'Report the run as pending', env: { GH_TOKEN: '${{ github.token }}' }, run: postStatus('pending', 'request=$REQUEST') }, + ], + }, + burst: { + name: 'burst', + needs: ['pending'], + 'runs-on': runsOn, + 'timeout-minutes': 180, + defaults: { run: { shell: 'bash' } }, + env: { ...buildEnv, ...requestEnv }, + outputs: { exit: '${{ steps.suite.outputs.exit }}' }, + steps: [ + // st-test-all's checks read the history (genie freshness, the CI script self-tests); the other suites need one commit. + ...fullHistoryCheckoutSteps({ ref: '${{ inputs.sha }}', 'persist-credentials': false }, {}, "inputs.suite == 'st-test-all'"), + { + name: 'Require the requested commit', + run: 'head=$(git rev-parse HEAD); [ "$head" = "$SHA" ] || { echo "::error::checked out $head, not $SHA"; exit 1; }', + }, + ...setupSteps, + { ...megarepoApplyStep, if: "inputs.suite == 'st-test-all'" }, + { name: 'Run the suite', id: 'suite', env: { ST_TEST_ALL: stTestAll, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' }, run: runSuite }, + { + name: 'Retain the suite log', + if: "always() && steps.suite.outcome != 'skipped'", + uses: 'actions/upload-artifact@v4', + with: { name: 'burst-log-${{ inputs.request }}', path: '${{ runner.temp }}/burst.log', 'if-no-files-found': 'warn', 'retention-days': 14 }, + }, + { + name: 'Finish with the suite exit', + if: 'always()', + env: { EXIT: '${{ steps.suite.outputs.exit }}', LINE: '${{ steps.suite.outputs.line }}' }, + run: `[ -n "$EXIT" ] || { echo "::error::the suite did not run"; exit 1; } +printf '%s\\n' "$LINE" +exit "$EXIT"`, + }, + ], + }, + // A suite job that failed before the suite, was cancelled or timed out has no exit: the status is error. + report: { + name: 'report', + needs: ['pending', 'burst'], + if: "always() && needs.pending.result == 'success'", + 'runs-on': 'ubuntu-latest', + 'timeout-minutes': 5, + permissions: { statuses: 'write' }, + defaults: { run: { shell: 'bash' } }, + env: requestEnv, + steps: [{ + name: 'Report the result on the commit', + env: { GH_TOKEN: '${{ github.token }}', EXIT: '${{ needs.burst.outputs.exit }}' }, + run: `if [ -z "$EXIT" ]; then state=error; elif [ "$EXIT" = 0 ]; then state=success; else state=failure; fi +${postStatus('$state', 'exit=${EXIT:-none} request=$REQUEST')}`, + }], + }, + }, +}, { + pending: 'Validates the request and posts a commit status without a checkout.', + report: 'Posts the final commit status without a checkout.', +}))) diff --git a/.st/evidence-skip-policy.json b/.st/evidence-skip-policy.json index 75244fe17d3..1b5e25c3928 100644 --- a/.st/evidence-skip-policy.json +++ b/.st/evidence-skip-policy.json @@ -264,7 +264,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], @@ -500,7 +500,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ], diff --git a/.st/required-lanes.json b/.st/required-lanes.json index 8c038a42302..f3784896b86 100644 --- a/.st/required-lanes.json +++ b/.st/required-lanes.json @@ -638,7 +638,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] @@ -845,7 +845,7 @@ "scripts/script-test-supervisor", "scripts/script_process.py", "scripts/sqlite_large_values.py", - "slo/targets.toml", + "slo/objectives.kdl", "vendor/libghostty-vt-sys", "wit" ] diff --git a/.st/rust-test-inputs.json b/.st/rust-test-inputs.json index 1ab26178049..04e700374f2 100644 --- a/.st/rust-test-inputs.json +++ b/.st/rust-test-inputs.json @@ -237,7 +237,8 @@ "crates/fractal-daemon/hooks/claude-observe.sh", "crates/fractal-daemon/hooks/claude-statusline.sh", "crates/fractal-daemon/hooks/omp-channel.ts", - "crates/fractal-daemon/hooks/pi-channel.ts" + "crates/fractal-daemon/hooks/pi-channel.ts", + "slo/objectives.kdl" ] }, "fractal-migrate": { diff --git a/Cargo.lock b/Cargo.lock index d8927e4bd48..69958628352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2153,6 +2153,7 @@ dependencies = [ "fractal-terminal-direct", "getrandom 0.3.4", "hex", + "kdl", "libc", "reqwest", "rusqlite", @@ -2590,7 +2591,6 @@ dependencies = [ "smallclaims", "tempfile", "tokio", - "toml 0.9.12+spec-1.1.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts b/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts new file mode 100644 index 00000000000..2b9f3d3fce6 --- /dev/null +++ b/apps/fractal-web/.changelog/2026-11-10-pairing-readonly-status.ts @@ -0,0 +1,7 @@ +import type { ChangelogEntrySource } from '../src/changelog/contract.ts' + +export default { + title: 'Keep browser pairing status reads read-only', + summary: 'Checking browser pairing status with GET no longer changes pairing state or cookies. Pairing approval and credential cleanup use explicit same-origin POST requests while preserving the existing pairing flow.', + kind: 'fix', +} satisfies ChangelogEntrySource diff --git a/apps/fractal-web/server/pairing.integration.test.ts b/apps/fractal-web/server/pairing.integration.test.ts index 5f0744059d3..b62a2e39133 100644 --- a/apps/fractal-web/server/pairing.integration.test.ts +++ b/apps/fractal-web/server/pairing.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it } from 'vitest' import { createHash } from 'node:crypto' -import { createServer } from 'node:http' +import { createServer, request } from 'node:http' import type { Server } from 'node:http' import { once } from 'node:events' import { mkdtemp, rm } from 'node:fs/promises' @@ -22,7 +22,7 @@ const login = 'owner@example.com' const publisherAuth = `Bearer ${'r'.repeat(43)}` const device = 'd'.repeat(43) const flags = '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=' -const fixture = async () => { +const fixture = async (admitted = false) => { const root = await mkdtemp(join(tmpdir(), 'fractal-pairing-test-')) cleanups.push(() => rm(root, { recursive: true, force: true })) const received: { path: string; auth: string | undefined; body: Record; cookie: string | undefined }[] = [] @@ -49,7 +49,7 @@ const fixture = async () => { cleanups.push(() => close(daemon)) let listening: string | undefined const admit = tailnetAdmission({ origin, logins: [login], listener: () => listening }) - const server = createServer((req, res) => { void handlePairing(req, res, { socketPath, host: 'localhost', authorization: publisherAuth, timeoutMs: 1000 }, { admit }).catch(() => res.destroy()) }) + const server = createServer((req, res) => { void handlePairing(req, res, { socketPath, host: 'localhost', authorization: publisherAuth, timeoutMs: 1000 }, { admit: admitted ? () => true : admit }).catch(() => res.destroy()) }) server.listen(0, '127.0.0.1') await once(server, 'listening') cleanups.push(() => close(server)) @@ -57,14 +57,31 @@ const fixture = async () => { if (address === null || typeof address === 'string') throw new TypeError('Missing listener') listening = `127.0.0.1:${address.port}` const headers = { host: new URL(origin).host, origin, 'tailscale-user-login': login } - const call = (action: string, cookie?: string, overrides: Record = {}) => fetch(`http://127.0.0.1:${address.port}/_fractal/pair/${action}`, { - method: action === 'status' ? 'GET' : 'POST', headers: { ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }, - ...(action === 'start' ? { body: JSON.stringify({ deviceLabel: 'Personal browser' }) } : {}), - }) - return { - call, received, setStatus: (value: string) => { status = value }, setGrantStatus: (value: number) => { grantStatus = value }, - setFingerprint: (value: unknown) => { deviceKeyFingerprint = value }, + // Use the HTTP transport so Host is preserved. Node fetch may replace it with the + // loopback URL's host, which is not the HTTPS origin published by the proxy. + const call = (action: string, cookie?: string, overrides: Record = {}): Promise => { + const { promise, resolve, reject } = Promise.withResolvers() + const upstream = request({ + hostname: '127.0.0.1', port: address.port, path: `/_fractal/pair/${action}`, + method: action === 'status' ? 'GET' : 'POST', + headers: Object.fromEntries(Object.entries({ ...headers, ...(cookie === undefined ? {} : { cookie }), ...overrides }).filter((entry): entry is [string, string] => entry[1] !== undefined)), + }, response => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('error', reject) + response.once('end', () => { + const responseHeaders = new Headers() + for (const [name, values] of Object.entries(response.headersDistinct)) { + for (const value of values ?? []) responseHeaders.append(name, value) + } + resolve(new Response(Buffer.concat(chunks), { status: response.statusCode, headers: responseHeaders })) + }) + }) + upstream.once('error', reject) + upstream.end(action === 'start' ? JSON.stringify({ deviceLabel: 'Personal browser' }) : undefined) + return promise } + return { call, received, setStatus: (value: string) => { status = value }, setGrantStatus: (value: number) => { grantStatus = value }, setFingerprint: (value: unknown) => { deviceKeyFingerprint = value } } } it('keeps request secrets and grants only in secure cookies and revokes before forgetting', async () => { @@ -80,24 +97,35 @@ it('keeps request secrets and grants only in secure cookies and revokes before f expect(setup.received[0]).toEqual({ path: '/v1/client/pairing/browser-requests', auth: publisherAuth, cookie: undefined, body: { deviceLabel: 'Personal browser', tailnetLogin: login, requestKeyDigest: createHash('sha256').update(pending.requestKey).digest('hex'), } }) - expect(await (await setup.call('status', pairHeader)).json()).toEqual({ status: 'pending', displayCode: '123456' }) + const status = await setup.call('status', pairHeader) + expect(await status.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(status.headers.getSetCookie()).toEqual([]) + expect(setup.received).toHaveLength(1) + expect(await (await setup.call('redeem', pairHeader)).json()).toEqual({ status: 'pending', displayCode: '123456' }) setup.setStatus('approved') - const approved = await setup.call('status', pairHeader) + const approved = await setup.call('redeem', pairHeader) expect(await approved.json()).toEqual({ status: 'paired', credentialChanged: true }) expect(approved.headers.getSetCookie()).toEqual([`fractal_device=${device}${flags}2592000`, `fractal_pair=${flags}0`]) expect(setup.received[2]?.auth).toBe(publisherAuth) expect(setup.received[2]?.body).toEqual({ requestKey: pending.requestKey }) + const paired = await setup.call('status', `fractal_device=${device}`) + expect(await paired.json()).toEqual({ status: 'paired' }) + expect(paired.headers.getSetCookie()).toEqual([]) const forgotten = await setup.call('forget', `fractal_device=${device}; other=never-forward`) expect(await forgotten.json()).toEqual({ status: 'unpaired' }) expect(forgotten.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`, `fractal_pair=${flags}0`]) - expect(setup.received[3]).toEqual({ path: '/v1/client/pairing/browser-requests/revoke-current', auth: `Bearer ${device}`, body: {}, cookie: undefined }) + expect(setup.received[4]).toEqual({ path: '/v1/client/pairing/browser-requests/revoke-current', auth: `Bearer ${device}`, body: {}, cookie: undefined }) }) it.each(['denied', 'expired'])('clears a %s pairing cookie', async status => { const setup = await fixture() const start = await setup.call('start') const cookie = start.headers.getSetCookie()[0]!.split(';')[0]! setup.setStatus(status) - const result = await setup.call('status', cookie) + const read = await setup.call('status', cookie) + expect(await read.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(read.headers.getSetCookie()).toEqual([]) + expect(setup.received).toHaveLength(1) + const result = await setup.call('redeem', cookie) expect(await result.json()).toEqual({ status }) expect(result.headers.getSetCookie()).toEqual([`fractal_pair=${flags}0`]) }) @@ -117,7 +145,7 @@ it.each([42, 'sha256:short', `sha1:${'a'.repeat(43)}`])('rejects a malformed bro expect(start.status).toBe(502) expect(start.headers.getSetCookie()).toEqual([]) }) -it.each(['start', 'status', 'forget'])('refuses %s without admitted personal login or with cross-origin requests', async action => { +it.each(['start', 'status', 'forget', 'redeem'])('refuses %s without admitted personal login or with cross-origin requests', async action => { const setup = await fixture() for (const overrides of [{ 'tailscale-user-login': '' }, { 'tailscale-user-login': 'tag:dev-host' }, { origin: 'https://other.example' }]) { expect((await setup.call(action, undefined, overrides)).status).toBe(403) @@ -139,11 +167,60 @@ it('validates a device cookie with the daemon before reporting paired', async () expect(response.headers.getSetCookie()).toEqual([]) expect(setup.received).toEqual([{ path: '/v1/client/capabilities', auth: `Bearer ${device}`, cookie: undefined, body: {} }]) }) -it.each([401, 403])('clears a revoked or expired device cookie when daemon authentication returns %s', async status => { +it.each([401, 403])('clears a rejected device cookie only through POST when authentication returns %s', async status => { const setup = await fixture() setup.setGrantStatus(status) const response = await setup.call('status', `fractal_device=${device}`) - expect(await response.json()).toEqual({ status: 'unpaired', credentialChanged: true }) - expect(response.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`]) - expect(setup.received[0]?.auth).toBe(`Bearer ${device}`) + expect(await response.json()).toEqual({ status: 'unpaired', credentialRejected: true }) + expect(response.headers.getSetCookie()).toEqual([]) + const redeemed = await setup.call('redeem', `fractal_device=${device}`) + expect(await redeemed.json()).toEqual({ status: 'unpaired', credentialChanged: true }) + expect(redeemed.headers.getSetCookie()).toEqual([`fractal_device=${flags}0`]) + expect(setup.received.every(call => call.path === '/v1/client/capabilities' && call.auth === `Bearer ${device}`)).toBe(true) +}) + +it.each(['start', 'forget', 'redeem'])('enforces same-origin %s independently of admission', async action => { + const setup = await fixture(true) + for (const overrides of [ + { origin: undefined }, + { origin: 'https://other.example' }, + { origin: 'http://personal.example.ts.net' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { 'sec-fetch-site': 'none' }, + ]) { + const response = await setup.call(action, undefined, overrides) + expect(response.status).toBe(403) + expect(response.headers.getSetCookie()).toEqual([]) + } + expect(setup.received).toEqual([]) + expect((await setup.call(action, undefined, { origin: undefined, 'sec-fetch-site': 'same-origin' })).status).toBe(200) +}) + +it.each([undefined, 'fractal_pair=malformed', 'fractal_device=malformed'])('keeps unpaired GET read-only for %s', async cookie => { + const setup = await fixture() + const response = await setup.call('status', cookie) + expect(await response.json()).toEqual({ status: 'unpaired' }) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received).toEqual([]) +}) + +it('keeps a failed capability GET read-only', async () => { + const setup = await fixture() + setup.setGrantStatus(500) + const response = await setup.call('status', `fractal_device=${device}`) + expect(response.status).toBe(502) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received.map(call => call.path)).toEqual(['/v1/client/capabilities']) +}) + +it.each(['pending', 'approved', 'denied', 'expired', 'invalid'])('never redeems a pending cookie during GET even when the daemon outcome is %s', async status => { + const setup = await fixture() + const start = await setup.call('start') + const cookie = start.headers.getSetCookie()[0]!.split(';')[0]! + setup.setStatus(status) + const response = await setup.call('status', cookie) + expect(await response.json()).toEqual({ status: 'pending', displayCode: '123456' }) + expect(response.headers.getSetCookie()).toEqual([]) + expect(setup.received.map(call => call.path)).toEqual(['/v1/client/pairing/browser-requests']) }) diff --git a/apps/fractal-web/server/pairing.mts b/apps/fractal-web/server/pairing.mts index 80144292b94..71944c0fb62 100644 --- a/apps/fractal-web/server/pairing.mts +++ b/apps/fractal-web/server/pairing.mts @@ -71,9 +71,15 @@ export const handlePairing = async (req: IncomingMessage, res: ServerResponse, g if (!options.admit(req)) { json(res, 403, { error: 'Forbidden' }); return } const path = req.url?.split('?')[0] const action = path?.slice(prefix.length) - if ((action === 'status' ? 'GET' : 'POST') !== req.method || !['start', 'status', 'forget'].includes(action ?? '')) { + if ((action === 'status' ? 'GET' : 'POST') !== req.method || !['start', 'status', 'forget', 'redeem'].includes(action ?? '')) { json(res, 404, { error: 'Not found' }); return } + // Pairing cookies are HTTPS-only, including when TLS terminates at the admitted proxy. + const site = req.headers['sec-fetch-site'] + if (req.method === 'POST' && (site !== undefined ? site !== 'same-origin' + : req.headers.origin === undefined || req.headers.origin !== `https://${req.headers.host}`)) { + json(res, 403, { error: 'Forbidden' }); return + } try { if (action === 'forget') { const bearer = browserBearer(req.headers) @@ -111,14 +117,19 @@ export const handlePairing = async (req: IncomingMessage, res: ServerResponse, g if (bearer !== undefined) { const grant = await daemonCall(gateway, '/v1/client/capabilities', undefined, `Bearer ${bearer}`, 'GET') if (grant.status === 401 || grant.status === 403) { - res.setHeader('set-cookie', secureCookie(deviceCookie, '', 0)) - json(res, 200, { status: 'unpaired', credentialChanged: true }); return + if (action === 'redeem') { + res.setHeader('set-cookie', secureCookie(deviceCookie, '', 0)) + json(res, 200, { status: 'unpaired', credentialChanged: true }); return + } + json(res, 200, { status: 'unpaired', credentialRejected: true }); return } if (grant.status !== 200) { json(res, 502, { error: 'Could not validate this browser' }); return } json(res, 200, { status: 'paired' }); return } const pending = pendingPair(req) if (pending === undefined) { json(res, 200, { status: 'unpaired' }); return } + // The daemon exposes no read-only browser-request status endpoint. + if (action === 'status') { json(res, 200, { status: 'pending', displayCode: pending.displayCode }); return } const result = await daemonCall(gateway, `/v1/client/pairing/browser-requests/${encodeURIComponent(pending.requestId)}/redeem`, { requestKey: pending.requestKey }) const response = result.body if (result.status !== 200 || !record(response)) { json(res, 502, { error: 'Could not check browser pairing' }); return } diff --git a/apps/fractal-web/src/data/browserPairing.ts b/apps/fractal-web/src/data/browserPairing.ts index 03a4c9613a2..7b7282987b3 100644 --- a/apps/fractal-web/src/data/browserPairing.ts +++ b/apps/fractal-web/src/data/browserPairing.ts @@ -6,6 +6,7 @@ const decodeStatus = Schema.decodeUnknownSync(Schema.Struct({ displayCode: Schema.optional(Schema.String), deviceKeyFingerprint: Schema.optional(Schema.String), credentialChanged: Schema.optional(Schema.Boolean), + credentialRejected: Schema.optional(Schema.Boolean), })) const decodeStarted = Schema.decodeUnknownSync(Schema.Struct({ requestId: Schema.String, displayCode: Schema.String, deviceKeyFingerprint: Schema.optional(Schema.String) })) @@ -26,8 +27,8 @@ export const browserPairingPort = ({ baseUrl, fetch: fetchImpl = (input, init) = readonly baseUrl: string readonly fetch?: (input: URL, init: RequestInit) => Promise }): BrowserPairingPort => { - const request = (path: 'start' | 'status' | 'forget', body?: unknown) => fetchImpl(new URL(`/_fractal/pair/${path}`, baseUrl), { - method: body === undefined ? 'GET' : 'POST', + const request = (path: 'start' | 'status' | 'forget' | 'redeem', body?: unknown) => fetchImpl(new URL(`/_fractal/pair/${path}`, baseUrl), { + method: path === 'status' ? 'GET' : 'POST', credentials: 'same-origin', cache: 'no-store', headers: body === undefined ? { accept: 'application/json' } : { accept: 'application/json', 'content-type': 'application/json' }, @@ -40,7 +41,12 @@ export const browserPairingPort = ({ baseUrl, fetch: fetchImpl = (input, init) = return { status: async () => { const response = await request('status') - return response.status === 404 ? undefined : decodeStatus(await json(response)) + if (response.status === 404) return undefined + const status = decodeStatus(await json(response)) + // Reads never adopt or discard credentials. Explicit same-origin POSTs do that work. + return status.status === 'pending' || status.credentialRejected === true + ? decodeStatus(await json(await request('redeem', {}))) + : status }, start: async (deviceLabel) => decodeStarted(await json(await request('start', { deviceLabel }))), forget: async () => { diff --git a/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx b/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx index 059bccb5ebc..2ec66fa0a82 100644 --- a/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx +++ b/apps/fractal-web/src/web/BrowserPairing.integration.test.tsx @@ -274,10 +274,29 @@ describe('browserPairingPort', () => { expect(await paired.port.status()).toEqual({ status: 'pending', displayCode: 'K7Q-4MX' }) expect(paired.calls[0]!.url.href).toBe('https://fractal.example/_fractal/pair/status') expect(paired.calls[0]!.init).toMatchObject({ method: 'GET', credentials: 'same-origin', cache: 'no-store' }) + expect(paired.calls[1]!.url.pathname).toBe('/_fractal/pair/redeem') + expect(paired.calls[1]!.init).toMatchObject({ method: 'POST', credentials: 'same-origin', body: '{}' }) expect(await recorded(() => new Response('', { status: 404 })).port.status()).toBeUndefined() await expect(recorded(() => new Response('', { status: 500 })).port.status()).rejects.toBeInstanceOf(PairingHttpError) }) + it.each([ + [{ status: 'pending', displayCode: 'K7Q-4MX' }, { status: 'paired', credentialChanged: true }], + [{ status: 'unpaired', credentialRejected: true }, { status: 'unpaired', credentialChanged: true }], + ])('adopts or clears credentials by POST after reading %j', async (status, redeemed) => { + const { port, calls } = recorded(url => Response.json(url.pathname.endsWith('/status') ? status : redeemed)) + expect(await port.status()).toEqual(redeemed) + expect(calls.map(call => [call.url.pathname, call.init.method])).toEqual([ + ['/_fractal/pair/status', 'GET'], ['/_fractal/pair/redeem', 'POST'], + ]) + }) + + it.each(['paired', 'unpaired'])('does not POST for an ordinary %s status', async status => { + const { port, calls } = recorded(() => Response.json({ status })) + expect(await port.status()).toEqual({ status }) + expect(calls).toHaveLength(1) + }) + it('starts with the device label and forgets with a POST', async () => { const { port, calls } = recorded(url => url.pathname.endsWith('/start') ? Response.json({ requestId: 'pair-request/1', displayCode: 'K7Q-4MX', expiresAt: '2030-01-01T00:00:00Z' }) diff --git a/apps/ios/README.md b/apps/ios/README.md index d675550e14c..55b6ec66d20 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -131,10 +131,11 @@ CI and the web shell use the same preparation command. | --- | --- | --- | --- | | `dev` (default) | Fractal Dev, amber DEV-badged icon | `com.compoundingtech.fractal.starter` | Debug with Metro, simulator and device smoke tests | | `daily` | Fractal, ordinary icon | `com.compoundingtech.fractal` | Release build used every day on a phone | +| `test` | Fractal Test, ordinary icon | `com.compoundingtech.fractal.test` | Isolated client-control Release app; never the everyday pairing | -The two apps coexist on one phone and keep separate Keychain profiles, so each is paired on its own. Any other value fails the configuration. Changing variants requires a clean prebuild, and the same `APP_VARIANT` must stay exported for the Xcode build that embeds the bundle. The EAS simulator profile selects dev; preview selects daily. +The apps coexist and keep separate Keychain profiles, so each is paired on its own. Any other value fails the configuration. Changing variants requires a clean prebuild, and the same `APP_VARIANT` must stay exported for the Xcode build that embeds the bundle. The EAS simulator profile selects dev; preview selects daily. -The Expo name `fractal` and slug `fractal-starter` generate `ios/fractal.xcodeproj`, `ios/fractal.xcworkspace`, and the `fractal` target/scheme for both variants. Each variant's URL scheme matches its bundle identifier. Device signing keys use the Keychain service `com.compoundingtech.fractal.device-key`, isolated by the app's bundle identity. +The Expo name `fractal` and slug `fractal-starter` generate `ios/fractal.xcodeproj`, `ios/fractal.xcworkspace`, and the `fractal` app target/scheme for every variant. Device signing keys use the Keychain service `com.compoundingtech.fractal.device-key`, isolated by the app's bundle identity. The test variant additionally declares its own explicit keychain access group and app group; neither is shared with daily or dev. When moving from the former app identity, install the new app, pair it once, delete the old app by hand, and revoke the old device's grant. The new bundle identifier cannot read the old app's Keychain; there is deliberately no fallback to the former service. @@ -163,6 +164,20 @@ ref must support `APP_VARIANT=daily`; a ref without that variant is refused rather than replacing the separate Debug app. First prepare a signed artifact without phone access: +For the isolated test app, select `--variant test` (or `FRACTAL_IOS_VARIANT=test`) +and supply `--test-app-profile` / `FRACTAL_IOS_TEST_APP_PROFILE`, the installed +Apple Development profile name that Johannes prepared. `--test-runner-profile` / +`FRACTAL_IOS_TEST_RUNNER_PROFILE` supplies the separate XCUITest runner profile +when building the observation scheme. The lane uses manual signing for test, +never requests provisioning updates, and appends `ios-test-variant` to the +supplied state directory, isolating checkout, caches, version floor and artifacts. +Normal remains the default and keeps its existing state path and automatic +signing policy. Both variants run the same #6207 Fabric build and pre-install +guard; a snapshot with another bundle identifier is refused before installation. +The test app can bootstrap from an absent installed bundle; the normal lane +still requires its existing app readback. No lane configuration grants pairing, +enrollment, installation or process-launch consent. + ```sh python3 apps/ios/scripts/release_delivery.py \ --repo "$SOURCE_REPO" --state "$DELIVERY_STATE" \ @@ -267,7 +282,8 @@ framework output fails closed before linking. The hash participates in the build and native-generation identities and is recorded as `fabric_framework_hash` in `success.json`. A changed framework invalidates reuse. The worker uses `pnpm exec expo config` and clean `pnpm exec expo prebuild`, -then `pnpm run export:ios`, all with `APP_VARIANT=daily` and the daily Fabric +then `pnpm run export:ios`, with the selected variant (`APP_VARIANT=daily` by +default, `test` only when explicitly selected) and the daily Fabric settings pinned before invoking Xcode. Before publishing the signed snapshot, it requires defined Rust bridge symbols in the app executable: the real module and disabled stub share the same Swift class name, so that name alone is not proof. @@ -294,8 +310,9 @@ from the last successful fetch, updated on every fetch, including reverts and app-irrelevant commits. Its content key hashes the Git trees of `apps/ios` and its local-package closure together with the inputs outside the revision: the Xcode directory, Node/pnpm/CocoaPods paths, inherited `EXPO_PUBLIC_*` values, -the compiled Fabric framework hash, and the pinned native environment. Builds -always run with `APP_VARIANT=daily`, `FRACTAL_FABRIC=1`, +the compiled Fabric framework hash, and the pinned native environment. Test +profile selections are also build inputs. Builds always run with the selected +`APP_VARIANT` and `FRACTAL_FABRIC=1`, `FRACTAL_FABRIC_PROOF=0` (read by `FractalFabric.podspec`), and `FRACTAL_FABRIC_OFFLINE_DEBUG=0` (read by `metro.config.js`), with the legacy `ST3_` counterparts pinned to the same respective values. Inherited settings @@ -531,3 +548,94 @@ graph subject. Session and PTY IDs only navigate; placements require stable grap Opening a resource detail also reads its exact subject with the existing prefix filter, including subjects beyond the catalog windows. + +## Isolated native test client and independent observer + +`APP_VARIANT=test` selects **Fractal Test**, bundle `com.compoundingtech.fractal.test`, +app group `group.com.compoundingtech.fractal.test`, and sole keychain access group +`$(AppIdentifierPrefix)com.compoundingtech.fractal.test`. It keeps the same Expo +project/workspace and release application source as daily. The readable build-time +flag is `Constants.expoConfig.extra.testClientMode` in JavaScript and +`FractalTestClientMode` in the app's Info.plist; both are absent for dev/daily +(absence means normal mode, preserving the normal configuration unchanged). +This flag describes the binary, not enrollment or authorization. No command handlers +or automatic enrollment are enabled by this preparation. + +State isolation relies on the separate application sandbox and sole test keychain +access group, not on renaming existing state keys. `FractalDeviceKeyModule.swift` +stores P-256 key material as a generic password under service +`com.compoundingtech.fractal.device-key`; `FractalFabricModule.swift` similarly +stores its independent transport secret under `com.compoundingtech.fractal.fabric-proof`. +Neither specifies `kSecAttrAccessGroup`, so Keychain uses the application's default +access group. SecureStore profile calls in `store.tsx` also omit access-group options. +The local native modules do not use app-group containers or shared UserDefaults +suites. Normal modules and normal dev/daily entitlements are unchanged. The test app +must generate its own key and pair manually with a pinned fingerprint; never copy +normal-app credentials. The app-group entitlement reserves the isolated namespace; +the observer does not share that container or the application's keychain. + +`plugins/withTestClient.cjs` durably generates `FractalTestControl` (UI-testing +target and shared Release scheme) and copies `plugins/ClientControlUITests.swift` +into the disposable native project. The UI runner's base product bundle identifier +is `com.compoundingtech.fractal.test`; Xcode appends `.xctrunner`, giving +`com.compoundingtech.fractal.test.xctrunner`. Signing is manual, with app profile +`$(FRACTAL_IOS_TEST_APP_PROFILE)` and runner profile +`$(FRACTAL_IOS_TEST_RUNNER_PROFILE)`. Johannes performs provisioning/signing setup; +these commands do not create profiles or allow provisioning updates. + +After unsigned test prebuild, run `node scripts/assert_test_project.mjs` from +`apps/ios` to assert the generated product reference is exactly +`FractalTestControl.xctest` (not a Spotlight `.mdimporter`) and uses the +UI-testing product type and expected runner base identity. This checks project +generation only; it is not a signed/compiled runner receipt. + +After the applicable consent/signing gates, the command interface from `apps/ios` +is below. Use the daily delivery lane to enforce its guards and regenerate native +source with `APP_VARIANT=test`; do not install using an unguarded alternate path. +The commands illustrate the native build/observer interface, not permission to act +on a device. Destination, profile names and signing identity are supplied locally. + +```sh +APP_VARIANT=test pnpm exec expo prebuild --platform ios --no-install +xcodebuild build-for-testing \ + -workspace ios/fractal.xcworkspace -scheme FractalTestControl \ + -configuration Release -destination "$TEST_DESTINATION" \ + -derivedDataPath "$TEST_DERIVED_DATA" \ + FRACTAL_IOS_TEST_APP_PROFILE="$FRACTAL_IOS_TEST_APP_PROFILE" \ + FRACTAL_IOS_TEST_RUNNER_PROFILE="$FRACTAL_IOS_TEST_RUNNER_PROFILE" +xcodebuild test-without-building -xctestrun "$TEST_XCTESTRUN" \ + -destination "$TEST_DESTINATION" -resultBundlePath "$SMOKE_RESULT" \ + -only-testing:FractalTestControl/ClientControlUITests/testLaunchAndReadAccessibility +``` + +The smoke launches only the test bundle in a separate process and reads the existing +native **Now** tab. It never unlocks, dismisses system dialogs or launches the daily +bundle. Run it separately from a content observation because launching may reset the +route. Failed launches/locked devices are failures, not permission to unlock. + +For D-9, first have the authorized journey open the seeded item. In a copy of the +generated `.xctestrun`, set the test target's **runner** `EnvironmentVariables` +(inside `TestConfigurations[].TestTargets[]` for format 2; target dictionary for +format 1) from the independent seed, not from `device.result`: + +- `FRACTAL_D9_TITLE`: exact rendered seed title. +- `FRACTAL_D9_BODY`: exact plain-text seed body, rendered as one static-text + accessibility element. +- `FRACTAL_D9_BODY_SHA256`: lowercase SHA-256 hex of the seed body's exact UTF-8 + bytes, with no whitespace normalization. + +```sh +xcodebuild test-without-building -xctestrun "$SEEDED_TEST_XCTESTRUN" \ + -destination "$TEST_DESTINATION" -resultBundlePath "$D9_RESULT" \ + -only-testing:FractalTestControl/ClientControlUITests/testSeededTitleAndBodyDigest +``` + +This test does not relaunch, activate, navigate, send commands or read a client +report. It reads visible accessibility labels and hashes the observed body itself. +Missing expectations, background state, missing/split/truncated text or an invisible +element fail closed. Use a simple seed whose full body fits the visible viewport; +rich-text segmentation is not reconstructed by this observer. The accessibility +tree and screenshot are XCTest attachments; screenshots are not OCR proof. +The observer alone does not prove enrollment, grants, receipts, no read-state +mutation, or unchanged normal pairing: those remain separate journey evidence. + diff --git a/apps/ios/app.config.js b/apps/ios/app.config.js index c98723ea550..8af66b4c84f 100644 --- a/apps/ios/app.config.js +++ b/apps/ios/app.config.js @@ -1,30 +1,39 @@ const base = require('./app.json').expo; -// APP_VARIANT selects the installed identity. dev (default) is the Debug/Metro starter app; -// daily is the Release app used every day. Both coexist on one phone with separate Keychain profiles. -// The home-screen name is set through CFBundleDisplayName so Expo's `name`, and with it the -// generated Xcode project, workspace and scheme (`ios/fractal.xcworkspace`), stay the same for both. +// APP_VARIANT selects the installed identity: dev (default), daily, or the isolated +// Release test client. Keep Expo's name (and the generated fractal workspace) stable. module.exports = () => { const variant = process.env.APP_VARIANT ?? 'dev'; - if (variant !== 'dev' && variant !== 'daily') throw new Error('APP_VARIANT must be dev or daily'); + if (!['dev', 'daily', 'test'].includes(variant)) throw new Error('APP_VARIANT must be dev, daily or test'); const daily = variant === 'daily'; + const testClient = variant === 'test'; // LIVE-MIGRATION BRIDGE arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read — DELETE at contraction — https://github.com/compoundingtech/fractal/issues/3032 const push = (process.env.FRACTAL_IOS_PUSH ?? process.env.ST_IOS_PUSH) === '1'; // LIVE-MIGRATION END arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read return { ...base, - ...(push ? { extra: { ...base.extra, stIosPush: true } } : {}), + ...((push || testClient) ? { extra: { ...base.extra, ...(push ? { stIosPush: true } : {}), ...(testClient ? { testClientMode: true } : {}) } } : {}), plugins: [ ...(base.plugins ?? []), push ? ['expo-notifications', { mode: 'development' }] : './plugins/withoutPush', + ...(testClient ? ['./plugins/withTestClient.cjs'] : []), ], - icon: daily ? './assets/icon.png' : './assets/icon-dev.png', + icon: daily || testClient ? './assets/icon.png' : './assets/icon-dev.png', ios: { ...base.ios, - bundleIdentifier: daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', - infoPlist: { ...base.ios.infoPlist, CFBundleDisplayName: daily ? 'Fractal' : 'Fractal Dev' }, + bundleIdentifier: testClient ? 'com.compoundingtech.fractal.test' : daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', + ...(testClient ? { entitlements: { + ...base.ios.entitlements, + 'com.apple.security.application-groups': ['group.com.compoundingtech.fractal.test'], + 'keychain-access-groups': ['$(AppIdentifierPrefix)com.compoundingtech.fractal.test'], + } } : {}), + infoPlist: { + ...base.ios.infoPlist, + CFBundleDisplayName: testClient ? 'Fractal Test' : daily ? 'Fractal' : 'Fractal Dev', + ...(testClient ? { FractalTestClientMode: true } : {}), + }, }, }; }; diff --git a/apps/ios/appConfig.test.mjs b/apps/ios/appConfig.test.mjs index 438ff46a266..bb8ff9dfd1d 100644 --- a/apps/ios/appConfig.test.mjs +++ b/apps/ios/appConfig.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createRequire } from 'node:module'; const configure = createRequire(import.meta.url)('./app.config.js'); +const base = createRequire(import.meta.url)('./app.json').expo; const withVariant = (value, run) => { const saved = process.env.APP_VARIANT; @@ -23,6 +24,10 @@ test('daily installs as Fractal with the ordinary icon', () => { assert.equal(daily.ios.bundleIdentifier, 'com.compoundingtech.fractal'); assert.equal(daily.ios.supportsTablet, true); assert.equal(daily.updates, undefined); + assert.equal(daily.extra?.testClientMode, undefined); + assert.equal(daily.ios.infoPlist.FractalTestClientMode, undefined); + assert.equal(daily.plugins.includes('./plugins/withTestClient.cjs'), false); + assert.equal(daily.ios.entitlements, undefined); }); test('default and dev keep the starter identity with the DEV-badged icon', () => { @@ -33,8 +38,47 @@ test('default and dev keep the starter identity with the DEV-badged icon', () => assert.equal(dev.icon, './assets/icon-dev.png'); assert.equal(dev.ios.bundleIdentifier, 'com.compoundingtech.fractal.starter'); assert.deepEqual(withVariant('dev', configure), dev); + assert.equal(dev.extra?.testClientMode, undefined); + assert.equal(dev.ios.infoPlist.FractalTestClientMode, undefined); + assert.equal(dev.plugins.includes('./plugins/withTestClient.cjs'), false); + assert.equal(dev.ios.entitlements, undefined); +}); + +test('test is a separately named, isolated Release-source client with a native observer', () => { + const isolated = withVariant('test', configure); + assert.equal(isolated.name, 'fractal'); + assert.equal(isolated.slug, 'fractal-starter'); + assert.equal(isolated.ios.bundleIdentifier, 'com.compoundingtech.fractal.test'); + assert.equal(isolated.ios.infoPlist.CFBundleDisplayName, 'Fractal Test'); + assert.equal(isolated.icon, './assets/icon.png'); + assert.equal(isolated.extra.testClientMode, true); + assert.equal(isolated.ios.infoPlist.FractalTestClientMode, true); + assert.deepEqual(isolated.ios.entitlements['com.apple.security.application-groups'], ['group.com.compoundingtech.fractal.test']); + assert.deepEqual(isolated.ios.entitlements['keychain-access-groups'], ['$(AppIdentifierPrefix)com.compoundingtech.fractal.test']); + assert.equal(isolated.plugins.filter(plugin => plugin === './plugins/withTestClient.cjs').length, 1); + assert.equal(isolated.ios.supportsTablet, true); + assert.equal(isolated.updates, undefined); }); test('an unknown variant fails closed', () => { assert.throws(() => withVariant('typo', configure), /APP_VARIANT/); }); + +test('normal variants preserve the complete origin/main configuration', () => { + for (const variant of ['dev', 'daily']) { + const daily = variant === 'daily'; + const push = (process.env.FRACTAL_IOS_PUSH ?? process.env.ST_IOS_PUSH) === '1'; + const original = { + ...base, + ...(push ? { extra: { ...base.extra, stIosPush: true } } : {}), + plugins: [...(base.plugins ?? []), push ? ['expo-notifications', { mode: 'development' }] : './plugins/withoutPush'], + icon: daily ? './assets/icon.png' : './assets/icon-dev.png', + ios: { + ...base.ios, + bundleIdentifier: daily ? 'com.compoundingtech.fractal' : 'com.compoundingtech.fractal.starter', + infoPlist: { ...base.ios.infoPlist, CFBundleDisplayName: daily ? 'Fractal' : 'Fractal Dev' }, + }, + }; + assert.deepEqual(withVariant(variant, configure), original); + } +}); diff --git a/apps/ios/plugins/ClientControlUITests.swift b/apps/ios/plugins/ClientControlUITests.swift new file mode 100644 index 00000000000..590c64f179a --- /dev/null +++ b/apps/ios/plugins/ClientControlUITests.swift @@ -0,0 +1,70 @@ +import CryptoKit +import XCTest + +// Runs in com.compoundingtech.fractal.test.xctrunner, not the app process. +// No device.result, app export, networking, pairing, navigation or command handler +// participates in this observation. Expectations come from the independent seed. +final class ClientControlUITests: XCTestCase { + private let app = XCUIApplication(bundleIdentifier: "com.compoundingtech.fractal.test") + + override func setUpWithError() throws { + continueAfterFailure = false + } + + // Authorized test-instance bootstrap only. Never launches the everyday app, + // dismisses system dialogs, enters a passcode or tries to unlock the device. + func testLaunchAndReadAccessibility() { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15), "Test app did not reach foreground") + // Existing native root tab, not an artificial test-only view or client report. + let root = app.tabBars.buttons["Now"] + XCTAssertTrue(root.waitForExistence(timeout: 30), "Now tab is absent from the accessibility tree") + XCTAssertEqual(root.label, "Now") + attachObservation() + } + + // Run separately, after the granted journey has opened the seeded item. This + // deliberately does not relaunch/activate or navigate the app under observation. + // Supply these via the generated .xctestrun Test Runner EnvironmentVariables, + // not the app's launch environment. Omission fails, never silently skips D-9. + // Seed a plain-text body rendered as a single static-text accessibility label; + // a split/truncated/absent body fails closed (screenshots are not OCR proof). + func testSeededTitleAndBodyDigest() throws { + let title = try expected("FRACTAL_D9_TITLE") + let body = try expected("FRACTAL_D9_BODY") + let digest = try expected("FRACTAL_D9_BODY_SHA256") + XCTAssertNotNil(digest.range(of: "^[0-9a-f]{64}$", options: .regularExpression), "Expected lowercase SHA-256 hex") + XCTAssertEqual(sha256(body), digest, "Independent seed body and digest disagree") + XCTAssertEqual(app.state, .runningForeground, "Observation requires an already foreground test app") + let renderedTitle = app.staticTexts.matching(NSPredicate(format: "label == %@", title)).firstMatch + XCTAssertTrue(renderedTitle.waitForExistence(timeout: 15), "Seeded title is not rendered") + XCTAssertTrue(renderedTitle.isHittable, "Seeded title is not visible") + XCTAssertEqual(renderedTitle.label, title) + let renderedBody = app.staticTexts.matching(NSPredicate(format: "label == %@", body)).firstMatch + XCTAssertTrue(renderedBody.waitForExistence(timeout: 15), "Seeded body is not rendered as one accessible text element") + XCTAssertTrue(renderedBody.isHittable, "Seeded body is not visible") + // Hash the independently read accessibility label, never a digest the app reports. + XCTAssertEqual(sha256(renderedBody.label), digest) + attachObservation() + } + + private func expected(_ name: String) throws -> String { + let value = ProcessInfo.processInfo.environment[name] + return try XCTUnwrap(value.flatMap { $0.isEmpty ? nil : $0 }, "Missing independent seed expectation: \(name)") + } + + private func sha256(_ text: String) -> String { + SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func attachObservation() { + let tree = XCTAttachment(string: app.debugDescription) + tree.name = "Independent test-app accessibility tree" + tree.lifetime = .keepAlways + add(tree) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Test-app screenshot (attachment only; not OCR proof)" + screenshot.lifetime = .keepAlways + add(screenshot) + } +} diff --git a/apps/ios/plugins/withTestClient.cjs b/apps/ios/plugins/withTestClient.cjs new file mode 100644 index 00000000000..a4d4d1f7c7c --- /dev/null +++ b/apps/ios/plugins/withTestClient.cjs @@ -0,0 +1,105 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { withXcodeProject } = require('expo/config-plugins'); + +const bundle = 'com.compoundingtech.fractal.test'; +const targetName = 'FractalTestControl'; +const unquote = value => value?.replace(/^"|"$/g, ''); + +// This plugin is registered only by APP_VARIANT=test. Generated ios/ is disposable; +// the Swift observer is copied from release source, never patched after prebuild. +module.exports = function withTestClient(config) { + if (config.ios?.bundleIdentifier !== bundle || config.extra?.testClientMode !== true) { + throw new Error('withTestClient requires the isolated test identity'); + } + return withXcodeProject(config, config => { + const project = config.modResults; + const objects = project.hash.project.objects; + const app = project.getFirstTarget(); + const appName = unquote(app.firstTarget.name); + const configurations = target => objects.XCConfigurationList[target.buildConfigurationList] + .buildConfigurations.map(ref => objects.XCBuildConfiguration[ref.value].buildSettings); + for (const settings of configurations(app.firstTarget)) { + settings.CODE_SIGN_STYLE = 'Manual'; + settings.PROVISIONING_PROFILE_SPECIFIER = '"$(FRACTAL_IOS_TEST_APP_PROFILE)"'; + } + + let targetId = Object.keys(objects.PBXNativeTarget).find(id => + !id.endsWith('_comment') && unquote(objects.PBXNativeTarget[id].name) === targetName); + if (!targetId) { + targetId = project.generateUuid(); + const settings = { + PRODUCT_BUNDLE_IDENTIFIER: bundle, + PRODUCT_NAME: '"$(TARGET_NAME)"', + GENERATE_INFOPLIST_FILE: 'YES', + SWIFT_VERSION: '5.0', + IPHONEOS_DEPLOYMENT_TARGET: configurations(app.firstTarget)[0].IPHONEOS_DEPLOYMENT_TARGET ?? '15.1', + TARGETED_DEVICE_FAMILY: '"1,2"', + SDKROOT: 'iphoneos', + CODE_SIGN_STYLE: 'Manual', + PROVISIONING_PROFILE_SPECIFIER: '"$(FRACTAL_IOS_TEST_RUNNER_PROFILE)"', + // Xcode appends .xctrunner to PRODUCT_BUNDLE_IDENTIFIER for the UI runner. + // Do not give the runner any app-group or keychain-sharing entitlements. + TEST_TARGET_NAME: `"${appName}"`, + SKIP_INSTALL: 'YES', + LD_RUNPATH_SEARCH_PATHS: '"$(inherited) @executable_path/Frameworks @loader_path/Frameworks"', + }; + const list = project.addXCConfigurationList(['Debug', 'Release'].map(name => ({ + isa: 'XCBuildConfiguration', name, buildSettings: { ...settings }, + })), 'Release', `Build configuration list for PBXNativeTarget "${targetName}"`); + const product = project.addProductFile(`${targetName}.xctest`, { + target: targetId, sourceTree: 'BUILT_PRODUCTS_DIR', + }); + // Supplying explicitFileType to addProductFile appends the library's first + // cfbundle extension (.mdimporter). Set the type after the exact path exists. + objects.PBXFileReference[product.fileRef].explicitFileType = '"wrapper.cfbundle"'; + const nativeTarget = { + isa: 'PBXNativeTarget', name: targetName, productName: targetName, + productReference: product.fileRef, + productType: '"com.apple.product-type.bundle.ui-testing"', + buildConfigurationList: list.uuid, buildPhases: [], buildRules: [], dependencies: [], + }; + const target = { uuid: targetId, pbxNativeTarget: nativeTarget }; + project.addToPbxNativeTargetSection(target); + project.addToPbxProjectSection(target); + project.addTargetDependency(targetId, [app.uuid]); + project.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetId); + project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetId); + project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetId); + const group = project.addPbxGroup([], targetName, targetName); + const root = project.getFirstProject().firstProject; + project.addToPbxGroup(group.uuid, root.mainGroup); + project.addSourceFile('ClientControlUITests.swift', { target: targetId }, group.uuid); + } + const attributes = project.getFirstProject().firstProject.attributes; + attributes.TargetAttributes ??= {}; + attributes.TargetAttributes[targetId] = { TestTargetID: app.uuid, ProvisioningStyle: 'Manual' }; + attributes.TargetAttributes[app.uuid] = { + ...attributes.TargetAttributes[app.uuid], ProvisioningStyle: 'Manual', + }; + + const nativeRoot = config.modRequest.platformProjectRoot; + fs.mkdirSync(path.join(nativeRoot, targetName), { recursive: true }); + fs.copyFileSync(path.join(__dirname, 'ClientControlUITests.swift'), + path.join(nativeRoot, targetName, 'ClientControlUITests.swift')); + const schemeRoot = path.join(nativeRoot, `${appName}.xcodeproj`, 'xcshareddata', 'xcschemes'); + fs.mkdirSync(schemeRoot, { recursive: true }); + const reference = (id, name, product) => ``; + const appRef = reference(app.uuid, appName, `${appName}.app`); + const testRef = reference(targetId, targetName, `${targetName}.xctest`); + fs.writeFileSync(path.join(schemeRoot, `${targetName}.xcscheme`), ` + + + ${appRef} + ${testRef} + + ${testRef} + ${appRef} + ${appRef} + + + +`); + return config; + }); +}; diff --git a/apps/ios/scripts/assert_test_project.mjs b/apps/ios/scripts/assert_test_project.mjs new file mode 100644 index 00000000000..08c3e0ae8bc --- /dev/null +++ b/apps/ios/scripts/assert_test_project.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const expoRequire = createRequire(require.resolve('expo/package.json')); +const xcode = expoRequire('xcode'); +const projectPath = new URL('../ios/fractal.xcodeproj/project.pbxproj', import.meta.url); +const project = xcode.project(projectPath.pathname); +project.parseSync(); +const objects = project.hash.project.objects; +const unquote = value => value?.replace(/^"|"$/g, ''); +const target = Object.values(objects.PBXNativeTarget).find(value => + typeof value === 'object' && unquote(value.name) === 'FractalTestControl'); +assert.ok(target, 'Generated test target is missing'); +assert.equal(unquote(target.productType), 'com.apple.product-type.bundle.ui-testing'); +const product = objects.PBXFileReference[target.productReference]; +assert.equal(unquote(product.path), 'FractalTestControl.xctest'); +assert.equal(unquote(product.sourceTree), 'BUILT_PRODUCTS_DIR'); +assert.equal(unquote(product.explicitFileType ?? product.lastKnownFileType), 'wrapper.cfbundle'); +assert.equal(readFileSync(projectPath, 'utf8').includes('.xctest.mdimporter'), false); +for (const ref of objects.XCConfigurationList[target.buildConfigurationList].buildConfigurations) { + assert.equal(objects.XCBuildConfiguration[ref.value].buildSettings.PRODUCT_BUNDLE_IDENTIFIER, + 'com.compoundingtech.fractal.test'); +} +console.log('PASS generated UI-test product is FractalTestControl.xctest; UI-testing host identity base is com.compoundingtech.fractal.test'); diff --git a/apps/ios/scripts/release_delivery.py b/apps/ios/scripts/release_delivery.py index bf634721fd3..c6e276b8176 100755 --- a/apps/ios/scripts/release_delivery.py +++ b/apps/ios/scripts/release_delivery.py @@ -14,6 +14,19 @@ BUNDLE_ID = "com.compoundingtech.fractal" + +def bundle_id(variant): + return BUNDLE_ID + (".test" if variant == "test" else "") + + +def variant_state(state, variant): + """The normal lane retains its exact path; test never shares mutable state.""" + return state / "ios-test-variant" if variant == "test" else state + + +def build_environment(variant): + return {**DAILY_BUILD_ENV, "APP_VARIANT": "test" if variant == "test" else "daily"} + # LIVE-MIGRATION BRIDGE arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read — DELETE at contraction — https://github.com/compoundingtech/fractal/issues/3032 def ios_env(suffix, default=None, environment=os.environ): """Prefer Fractal; an explicitly empty value masks the legacy spelling.""" @@ -172,10 +185,12 @@ def build_identity(args, *, fabric_hash=None): "env": {key: value for key, value in sorted(os.environ.items()) if key.startswith("EXPO_PUBLIC_") and key not in ("EXPO_PUBLIC_FRACTAL_BUILD", "EXPO_PUBLIC_ST3_BUILD")}, - "pinned": DAILY_BUILD_ENV, + "pinned": build_environment(args.variant), "fabric_framework": (fabric_hash if fabric_hash is not None else framework_hash(args.state / "checkout", required=False)), } + if args.variant == "test": + identity["test_profiles"] = [args.test_app_profile, args.test_runner_profile] return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() @@ -187,7 +202,7 @@ def build_release(args): inputs_file = cache / "inputs.json" inputs = json.loads(inputs_file.read_text()) if inputs_file.exists() else {} env = os.environ.copy() - env.update(DAILY_BUILD_ENV, DEVELOPER_DIR=args.developer_dir, + env.update(build_environment(args.variant), DEVELOPER_DIR=args.developer_dir, EXPO_PUBLIC_FRACTAL_BUILD=args.commit[:12], EXPO_PUBLIC_ST3_BUILD=args.commit[:12], CI="1", LANG="en_US.UTF-8", LC_ALL="en_US.UTF-8") @@ -229,8 +244,8 @@ def build_release(args): print("Reusing locked pnpm workspace dependencies", flush=True) config = json.loads(run(["pnpm", "exec", "expo", "config", "--type", "public", "--json"], cwd=app, env=env, capture=True)) - if config["ios"]["bundleIdentifier"] != BUNDLE_ID: - raise ValueError("Tracked ref lacks the daily app variant; refusing to replace Debug") + if config["ios"]["bundleIdentifier"] != bundle_id(args.variant): + raise ValueError("Tracked ref lacks the selected app variant; refusing another identity") # Reuse the documented builder inside the same gated worker as Xcode. run(["sh", "modules/fractal-fabric/build.sh"], cwd=app, env=env) fabric_hash = framework_hash(repo) @@ -241,7 +256,7 @@ def build_release(args): "packages": identities, "config": config, "source": git(repo, "ls-tree", "-r", args.commit, "--", *native_paths), "developer_dir": args.developer_dir, "team": args.team, - "pod": shutil.which("pod"), "env": DAILY_BUILD_ENV, + "pod": shutil.which("pod"), "env": build_environment(args.variant), "fabric_framework": fabric_hash, }, sort_keys=True).encode()).hexdigest() regenerate = (inputs.get("native") != native_identity @@ -280,11 +295,16 @@ def build_release(args): raise ValueError("FractalFabricRust.xcframework changed before linking") derived = cache / "derived" run(["pnpm", "run", "export:ios"], cwd=app, env=env) + signing = (["CODE_SIGN_STYLE=Manual", f"DEVELOPMENT_TEAM={args.team}", + "CODE_SIGN_IDENTITY=Apple Development", + f"FRACTAL_IOS_TEST_APP_PROFILE={args.test_app_profile}", + f"FRACTAL_IOS_TEST_RUNNER_PROFILE={args.test_runner_profile}"] + if args.variant == "test" else + ["CODE_SIGN_STYLE=Automatic", f"DEVELOPMENT_TEAM={args.team}", + "CODE_SIGN_IDENTITY=Apple Development", "-allowProvisioningUpdates"]) run(["xcodebuild", "-workspace", str(workspace), "-scheme", workspace.stem, "-configuration", "Release", "-sdk", "iphoneos", "-destination", "generic/platform=iOS", - "-derivedDataPath", str(derived), "-jobs", "4", - "CODE_SIGN_STYLE=Automatic", f"DEVELOPMENT_TEAM={args.team}", - "CODE_SIGN_IDENTITY=Apple Development", "-allowProvisioningUpdates", "build"], + "-derivedDataPath", str(derived), "-jobs", "4", *signing, "build"], cwd=app, env=env) # DerivedData survives native identity changes. Select only the current Expo # product; unrelated products from earlier generated workspaces may remain. @@ -294,8 +314,8 @@ def build_release(args): raise ValueError("Expected one successful Release app") with (apps[0] / "Info.plist").open("rb") as file: info = plistlib.load(file) - if info.get("CFBundleIdentifier") != BUNDLE_ID: - raise ValueError("Built app is not the daily bundle") + if info.get("CFBundleIdentifier") != bundle_id(args.variant): + raise ValueError("Built app is not the selected bundle") if any(info.get(key) != value for key, value in stamp.items()): raise ValueError("Built app has stale source identity") if args.commit[:12].encode() not in (apps[0] / "main.jsbundle").read_bytes(): @@ -384,10 +404,12 @@ def read_installed(self, pending=None): if any(not isinstance(app, dict) or not isinstance(app.get("bundleIdentifier"), str) for app in apps): raise ValueError("Invalid installed-app entry") - matches = [app for app in apps if app["bundleIdentifier"] == BUNDLE_ID] - if len(matches) != 1: - raise ValueError("Expected one installed daily app") - version = matches[0].get("bundleVersion") if matches else None + matches = [app for app in apps if app["bundleIdentifier"] == bundle_id(self.args.variant)] + if len(matches) != 1 and not (self.args.variant == "test" and not matches): + raise ValueError("Expected one installed selected app") + # A new isolated test app has no previous install. The normal lane + # still requires its authoritative readback exactly as before. + version = matches[0].get("bundleVersion") if matches else "0" if matches and (not isinstance(version, str) or not version.isascii() or not version.isdigit()): raise ValueError("Invalid installed daily build version") except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): @@ -417,6 +439,10 @@ def verify_install_artifact(self, artifact): try: with (app / "Info.plist").open("rb") as file: info = plistlib.load(file) + if info.get("CFBundleIdentifier") != bundle_id(self.args.variant): + self.record("install_refused", commit=artifact["commit"], reason="bundle_mismatch", + build_version=artifact["build_version"]) + return False verify_fabric_linkage(app, info, self.env) except (OSError, ValueError, KeyError, subprocess.SubprocessError): # Invalidate retention and delivery intent, even if it already reached the phone. @@ -604,7 +630,7 @@ def reconcile(self): self.record("installed", commit=pending["commit"]) try: run(["xcrun", "devicectl", "device", "process", "launch", "--device", self.args.device, - BUNDLE_ID, "--timeout", "30"], + bundle_id(self.args.variant), "--timeout", "30"], env=self.env) except (OSError, subprocess.SubprocessError) as error: self.record("launch_failed", commit=pending["commit"], reason=str(error)) @@ -679,7 +705,10 @@ def build(self, repo, commit, key): str(Path(__file__).resolve()), "--build-worker", "--repo", str(repo), "--output", str(output), "--team", self.args.team, "--commit", commit, - "--build-version", version, "--developer-dir", self.args.developer_dir] + "--build-version", version, "--developer-dir", self.args.developer_dir, + "--variant", self.args.variant, + "--test-app-profile", self.args.test_app_profile or "", + "--test-runner-profile", self.args.test_runner_profile or ""] with (output / "build.log").open("a") as log: result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT) if result.returncode or not (output / "success.json").exists(): @@ -711,6 +740,10 @@ def main(): parser.add_argument("--repo", type=Path, required=True) parser.add_argument("--ref", default=ios_env("REF", "origin/main")) parser.add_argument("--state", type=Path) + parser.add_argument("--variant", choices=("normal", "test"), + default=ios_env("VARIANT", "normal")) + parser.add_argument("--test-app-profile", default=ios_env("TEST_APP_PROFILE")) + parser.add_argument("--test-runner-profile", default=ios_env("TEST_RUNNER_PROFILE")) parser.add_argument("--expected-origin", default=ios_env("EXPECTED_ORIGIN"), help="Exact trusted origin URL, supplied by the operator") parser.add_argument("--trusted-commit", default=ios_env("TRUSTED_COMMIT"), @@ -738,6 +771,10 @@ def main(): args.repo = args.repo.expanduser().resolve() if not args.team or not args.developer_dir: parser.error("--team/FRACTAL_IOS_TEAM and --developer-dir/DEVELOPER_DIR are required") + if args.variant not in ("normal", "test"): + parser.error("FRACTAL_IOS_VARIANT must be normal or test") + if args.variant == "test" and not args.test_app_profile: + parser.error("test variant requires --test-app-profile/FRACTAL_IOS_TEST_APP_PROFILE") if args.build_worker: build_release(args) return @@ -754,7 +791,7 @@ def main(): stdout=subprocess.PIPE, stderr=subprocess.PIPE) if validation.returncode or ref.startswith("-"): parser.error("--ref must be a valid branch name or full commit SHA") - args.state = args.state.expanduser().resolve() + args.state = variant_state(args.state.expanduser().resolve(), args.variant) delivery = Delivery(args) with (args.state / "delivery.lock").open("a") as lock: try: diff --git a/apps/ios/scripts/test_release_delivery.py b/apps/ios/scripts/test_release_delivery.py index acbd1bf75e4..f186c850b09 100644 --- a/apps/ios/scripts/test_release_delivery.py +++ b/apps/ios/scripts/test_release_delivery.py @@ -18,7 +18,8 @@ import time import unittest -from release_delivery import FABRIC_FRAMEWORK, framework_hash, ios_env +from release_delivery import (DAILY_BUILD_ENV, FABRIC_FRAMEWORK, build_environment, + bundle_id, framework_hash, ios_env, variant_state) SCRIPT = Path(__file__).with_name("release_delivery.py") @@ -37,6 +38,7 @@ args = sys.argv[1:] root = Path(os.environ["DELIVERY_FIXTURE_ROOT"]) control = json.loads((root / "control.json").read_text()) +bundle_id = "com.compoundingtech.fractal" + (".test" if os.environ.get("APP_VARIANT") == "test" else "") with (root / "commands.jsonl").open("a") as log: log.write(json.dumps({"command": name, "args": args, "cwd": os.getcwd(), "developer_dir": os.environ.get("DEVELOPER_DIR"), @@ -91,19 +93,19 @@ (Path.cwd() / package / "node_modules").mkdir(parents=True, exist_ok=True) sys.exit(0) if args == ["run", "export:ios"]: - assert os.environ["APP_VARIANT"] == "daily" + assert os.environ["APP_VARIANT"] in ("daily", "test") sys.exit(0) assert args[:2] == ["exec", "expo"], args if "config" in args: print(json.dumps({"name": control.get("expo_name", "Fractal"), "slug": "fractal", "ios": { - "bundleIdentifier": "com.compoundingtech.fractal"}})) + "bundleIdentifier": bundle_id}})) elif "prebuild" in args: (Path.cwd() / "ios" / "fractal.xcworkspace").mkdir(parents=True, exist_ok=True) (Path.cwd() / "ios" / "Podfile").write_text("# Fixture native project\n") native = Path.cwd() / "ios" / "fractal" native.mkdir(parents=True, exist_ok=True) with (native / "Info.plist").open("wb") as info: - plistlib.dump({"CFBundleIdentifier": "com.compoundingtech.fractal", + plistlib.dump({"CFBundleIdentifier": bundle_id, "CFBundleExecutable": "fractal", "CFBundleVersion": "0", "StBuildCommit": "unstamped"}, info) else: @@ -184,12 +186,18 @@ app = Path(next(arg for arg in args if arg.endswith(".app"))) with (app / "Info.plist").open("rb") as file: bundle = plistlib.load(file) - (root / "device-apps.json").write_text(json.dumps([{ + installed = root / "device-apps.json" + existing = json.loads(installed.read_text()) if installed.exists() else [ + {"bundleIdentifier": "com.compoundingtech.fractal", "bundleVersion": "0"}] + others = [record for record in existing + if record["bundleIdentifier"] != bundle["CFBundleIdentifier"]] + installed.write_text(json.dumps([*others, { "bundleIdentifier": bundle["CFBundleIdentifier"], "bundleVersion": bundle["CFBundleVersion"], }])) elif "launch" in args: - assert "com.compoundingtech.fractal" in args, args + assert any(identifier in args for identifier in ( + "com.compoundingtech.fractal", "com.compoundingtech.fractal.test")), args status = control.get("launch_exit", 0) else: raise AssertionError(args) @@ -206,6 +214,17 @@ class IosEnvironmentTests(unittest.TestCase): + def test_normal_identity_and_state_are_unchanged(self): + state = Path("/lane") + self.assertEqual(bundle_id("normal"), BUNDLE_ID) + self.assertEqual(variant_state(state, "normal"), state) + self.assertEqual(build_environment("normal"), DAILY_BUILD_ENV) + + def test_test_identity_state_and_pinned_guard_environment(self): + self.assertEqual(bundle_id("test"), BUNDLE_ID + ".test") + self.assertEqual(variant_state(Path("/lane"), "test"), Path("/lane/ios-test-variant")) + self.assertEqual(build_environment("test"), {**DAILY_BUILD_ENV, "APP_VARIANT": "test"}) + def test_fractal_values_win_and_empty_values_mask_legacy(self): for suffix in ("REF", "EXPECTED_ORIGIN", "TRUSTED_COMMIT", "DEVICE", "TEAM", "NOTIFY_FROM", "NOTIFY_TO", "MAX_DEFERRAL", "BUILD_BACKOFF", "BUILD_ONLY"): @@ -229,6 +248,49 @@ def test_fractal_values_win_and_empty_values_mask_legacy(self): class ReleaseDeliveryTests(unittest.TestCase): + def test_test_variant_has_separate_artifacts_and_same_fabric_guard(self): + self.env.update(FRACTAL_IOS_VARIANT="test", + FRACTAL_IOS_TEST_APP_PROFILE="Fractal Test Development", + FRACTAL_IOS_TEST_RUNNER_PROFILE="Fractal Test Runner Development") + self.configure(fabric_stub=True) + self.reconcile("--build-only") + test_state = self.state / "ios-test-variant" + self.assertFalse((self.state / "state.json").exists()) + data = json.loads((test_state / "state.json").read_text()) + self.assertIn("failed_build", data) + self.assertFalse(list((test_state / "artifacts").glob("*/success.json"))) + self.assertEqual(len(self.commands("nm")), 1) + self.configure(fabric_stub=False) + self.reconcile("--build-only") + receipts = list((test_state / "artifacts").glob("*/success.json")) + self.assertEqual(len(receipts), 1) + app = Path(json.loads(receipts[0].read_text())["app"]) + with (app / "Info.plist").open("rb") as file: + self.assertEqual(plistlib.load(file)["CFBundleIdentifier"], BUNDLE_ID + ".test") + self.assertTrue(app.is_relative_to(test_state)) + for command in self.commands("xcodebuild"): + self.assertIn("CODE_SIGN_STYLE=Manual", command["args"]) + self.assertNotIn("-allowProvisioningUpdates", command["args"]) + self.assertFalse(self.device_commands("install")) + normal_record = {"bundleIdentifier": BUNDLE_ID, "bundleVersion": "18528"} + (self.root / "device-apps.json").write_text(json.dumps([normal_record])) + self.reconcile() + self.assertEqual(len(self.device_commands("install")), 1) + self.assertIn(BUNDLE_ID + ".test", self.device_commands("launch")[0]["args"]) + installed = json.loads((self.root / "device-apps.json").read_text()) + self.assertEqual(len(installed), 2) + self.assertEqual([record for record in installed if record["bundleIdentifier"] == BUNDLE_ID], + [normal_record]) + + def test_cli_variant_overrides_environment_and_requires_test_profile(self): + self.env["FRACTAL_IOS_VARIANT"] = "test" + failed = self.invoke("--build-only") + self.assertNotEqual(failed.returncode, 0) + self.assertIn("test-app-profile", failed.stderr) + self.reconcile("--variant", "normal", "--build-only") + self.assertTrue((self.state / "state.json").exists()) + self.assertFalse((self.state / "ios-test-variant").exists()) + def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="release-delivery-tests-") self.addCleanup(temporary.cleanup) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 4176a4c342c..f10d869b99a 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -163,6 +163,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case pairingKeyBindingRequired case pairingAlreadyClaimed case pairingClaimInvalid + case pairingRouteNotAllowed + case pairingResultPending + case pairingResultExpired + case pairingRequestConflict + case pairingWaitBusy + case pairingRejected + case pairingExpired case suspendAuthoredSessionSelection case legacyActionReceiptUntrusted case arrangementExists @@ -257,6 +264,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "pairing-key-binding-required": .pairingKeyBindingRequired case "pairing-already-claimed": .pairingAlreadyClaimed case "pairing-claim-invalid": .pairingClaimInvalid + case "pairing-route-not-allowed": .pairingRouteNotAllowed + case "pairing-result-pending": .pairingResultPending + case "pairing-result-expired": .pairingResultExpired + case "pairing-request-conflict": .pairingRequestConflict + case "pairing-wait-busy": .pairingWaitBusy + case "pairing-rejected": .pairingRejected + case "pairing-expired": .pairingExpired case "invalid-answer": .invalidAnswer; case "not-answerable": .notAnswerable case "suspend-authored-session-selection": .suspendAuthoredSessionSelection case "legacy-action-receipt-untrusted": .legacyActionReceiptUntrusted @@ -353,6 +367,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .pairingKeyBindingRequired: "pairing-key-binding-required" case .pairingAlreadyClaimed: "pairing-already-claimed" case .pairingClaimInvalid: "pairing-claim-invalid" + case .pairingRouteNotAllowed: "pairing-route-not-allowed" + case .pairingResultPending: "pairing-result-pending" + case .pairingResultExpired: "pairing-result-expired" + case .pairingRequestConflict: "pairing-request-conflict" + case .pairingWaitBusy: "pairing-wait-busy" + case .pairingRejected: "pairing-rejected" + case .pairingExpired: "pairing-expired" case .invalidAnswer: "invalid-answer"; case .notAnswerable: "not-answerable" case .suspendAuthoredSessionSelection: "suspend-authored-session-selection" case .legacyActionReceiptUntrusted: "legacy-action-receipt-untrusted" @@ -1553,12 +1574,307 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } +public struct PairingExchangeEmpty: Codable, Sendable { + public init() {} + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.isEmpty else { throw pairingWireError(decoder, "Expected an empty pairing body") } + } + public func encode(to encoder: Encoder) throws { _ = encoder.container(keyedBy: PairingWireKey.self) } +} + +private struct PairingWireKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +private func pairingWireError(_ decoder: Decoder, _ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) +} + +private func pairingWireFields(_ decoder: Decoder, allowed: Set) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.allSatisfy({ allowed.contains($0.stringValue) }) else { + throw pairingWireError(decoder, "Unexpected pairing field") + } +} +/// Keep secret in memory only; do not log or persist a permit or its QR payload. +public struct PairingFabricPermit: Codable, Sendable { + public let nodeID, attemptID, secret: String + public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } + enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } +} +public enum PairingQrTag: String, Codable, Sendable { case pairing = "fractal.pairing" } +public enum PairingQrVersion: Sendable { + case v1 + case v2(PairingFabricPermit) +} + +/// Version two carries a fabric permit; version one cannot carry one. +public struct PairingQrPayload: Codable, Sendable { + public var tag: PairingQrTag { .pairing } + public let version: PairingQrVersion + public let gateway, id, code: String + public let expiresAtUnixMS: Int64 + public init(version: PairingQrVersion, gateway: String, id: String, code: String, expiresAtUnixMS: Int64) { + self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS + } + enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms", "fabric"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + _ = try fields.decode(PairingQrTag.self, forKey: .tag) + switch try fields.decode(UInt8.self, forKey: .version) { + case 1: + guard !fields.contains(.fabric) else { throw pairingWireError(decoder, "Version one must not carry fabric") } + version = .v1 + case 2: version = .v2(try fields.decode(PairingFabricPermit.self, forKey: .fabric)) + default: throw pairingWireError(decoder, "Unsupported pairing QR version") + } + gateway = try fields.decode(String.self, forKey: .gateway) + id = try fields.decode(String.self, forKey: .id) + code = try fields.decode(String.self, forKey: .code) + expiresAtUnixMS = try fields.decode(Int64.self, forKey: .expiresAtUnixMS) + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(tag, forKey: .tag) + switch version { + case .v1: try fields.encode(1, forKey: .version) + case .v2(let permit): + try fields.encode(2, forKey: .version) + try fields.encode(permit, forKey: .fabric) + } + try fields.encode(gateway, forKey: .gateway) + try fields.encode(id, forKey: .id) + try fields.encode(code, forKey: .code) + try fields.encode(expiresAtUnixMS, forKey: .expiresAtUnixMS) + } +} + +public enum PairingCapabilityState: String, Codable, Sendable { case granted } +public enum PairingAdvertisedCapability: String, Sendable, CaseIterable, Hashable, Codable { + case deviceKeyProofs = "device-key-proofs" + case fabricGatewayGrant = "fabric-gateway-grant" + case pairingKeyBinding = "pairing-key-binding" + public var id: String { rawValue } + public var version: UInt32 { self == .pairingKeyBinding ? 2 : 1 } + public var state: PairingCapabilityState { .granted } + enum CodingKeys: String, CodingKey { case id, version, state } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["id", "version", "state"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + let id = try fields.decode(String.self, forKey: .id) + guard let capability = Self(rawValue: id), + try fields.decode(UInt32.self, forKey: .version) == capability.version else { + throw pairingWireError(decoder, "Unsupported pairing capability or version") + } + _ = try fields.decode(PairingCapabilityState.self, forKey: .state) + self = capability + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(id, forKey: .id) + try fields.encode(version, forKey: .version) + try fields.encode(state, forKey: .state) + } +} + +public struct PairingAdvertisement: Codable, Sendable { + public let apiVersion: String + public let capabilities: [PairingAdvertisedCapability] + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) throws { + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw EncodingError.invalidValue(capabilities, .init(codingPath: [], debugDescription: "Expected one to three unique pairing capabilities")) + } + self.apiVersion = apiVersion; self.capabilities = capabilities + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + let capabilities = try fields.decode([PairingAdvertisedCapability].self, forKey: .capabilities) + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw pairingWireError(decoder, "Expected one to three unique pairing capabilities") + } + self.apiVersion = try fields.decode(String.self, forKey: .apiVersion) + self.capabilities = capabilities + } +} + +public enum PairingExchangeOperation: String, Codable, Sendable { + case capabilities, claim, approvalWait = "approval-wait", complete, ack +} + +/// The operation is derived from the canonical typed body, never independently supplied. +public enum PairingExchangeRequestBody: Sendable { + case capabilities + case claim(PairingKeyClaim) + case approvalWait(PairingApprovalWaitRequest) + case complete(PairingComplete) + case ack(PairingExchangeAck) + public var op: PairingExchangeOperation { + switch self { + case .capabilities: .capabilities + case .claim: .claim + case .approvalWait: .approvalWait + case .complete: .complete + case .ack: .ack + } + } +} + +public struct PairingExchangeRequest: Codable, Sendable { + public var v: UInt8 { 1 } + public let requestID: String + public var op: PairingExchangeOperation { body.op } + public let body: PairingExchangeRequestBody + public init(requestID: String, body: PairingExchangeRequestBody) { self.requestID = requestID; self.body = body } + public static func capabilities(requestID: String) -> Self { .init(requestID: requestID, body: .capabilities) } + public static func claim(requestID: String, body: PairingKeyClaim) -> Self { .init(requestID: requestID, body: .claim(body)) } + public static func approvalWait(requestID: String, body: PairingApprovalWaitRequest) -> Self { .init(requestID: requestID, body: .approvalWait(body)) } + public static func complete(requestID: String, body: PairingComplete) -> Self { .init(requestID: requestID, body: .complete(body)) } + public static func ack(requestID: String, body: PairingExchangeAck) -> Self { .init(requestID: requestID, body: .ack(body)) } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "op", "body"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + let bodyDecoder = try fields.superDecoder(forKey: .body) + switch try fields.decode(PairingExchangeOperation.self, forKey: .op) { + case .capabilities: + _ = try PairingExchangeEmpty(from: bodyDecoder) + body = .capabilities + case .claim: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof"]) + body = .claim(try PairingKeyClaim(from: bodyDecoder)) + case .approvalWait: + try pairingWireFields(bodyDecoder, allowed: ["pairing_id", "key_fingerprint"]) + body = .approvalWait(try PairingApprovalWaitRequest(from: bodyDecoder)) + case .complete: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof", "key_storage", "fabric_node_id"]) + body = .complete(try PairingComplete(from: bodyDecoder)) + case .ack: + try pairingWireFields(bodyDecoder, allowed: ["request_id"]) + body = .ack(try PairingExchangeAck(from: bodyDecoder)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + try fields.encode(op, forKey: .op) + switch body { + case .capabilities: try fields.encode(PairingExchangeEmpty(), forKey: .body) + case .claim(let body): try fields.encode(body, forKey: .body) + case .approvalWait(let body): try fields.encode(body, forKey: .body) + case .complete(let body): try fields.encode(body, forKey: .body) + case .ack(let body): try fields.encode(body, forKey: .body) + } + } +} +public struct PairingExchangeRefusal: Codable, Sendable { + public let code: ErrorCode + public let message: String + public init(code: ErrorCode, message: String) { self.code = code; self.message = message } +} +/// A success and a refusal cannot coexist or both be absent. +public enum PairingExchangeOutcome: Sendable { + case ok(Body) + case refused(PairingExchangeRefusal) +} + +/// One reply envelope and one common refusal for every operation. +public struct PairingExchangeReply: Codable, Sendable { + public var v: UInt8 { 1 } + public let requestID: String + public let outcome: PairingExchangeOutcome + public init(requestID: String, outcome: PairingExchangeOutcome) { self.requestID = requestID; self.outcome = outcome } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "ok", "body", "refusal"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + if try fields.decode(Bool.self, forKey: .ok) { + guard fields.contains(.body), !fields.contains(.refusal), !(try fields.decodeNil(forKey: .body)) else { + throw pairingWireError(decoder, "Successful pairing reply requires only a body") + } + outcome = .ok(try fields.decode(Body.self, forKey: .body)) + } else { + guard fields.contains(.refusal), !fields.contains(.body) else { + throw pairingWireError(decoder, "Refused pairing reply requires only a refusal") + } + outcome = .refused(try fields.decode(PairingExchangeRefusal.self, forKey: .refusal)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + switch outcome { + case .ok(let body): + try fields.encode(true, forKey: .ok) + try fields.encode(body, forKey: .body) + case .refused(let refusal): + try fields.encode(false, forKey: .ok) + try fields.encode(refusal, forKey: .refusal) + } + } +} +public struct PairingApprovalWaitRequest: Codable, Sendable { + public let pairingID, keyFingerprint: String + public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } + enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } +} +public enum PairingApprovalStatus: String, Codable, Sendable { case approved } +public struct PairingApprovalWaitApproved: Codable, Sendable { + public let status: PairingApprovalStatus + public let keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = .approved; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } +} +public struct PairingExchangeAck: Codable, Sendable { + public let requestID: String + public init(requestID: String) { self.requestID = requestID } + enum CodingKeys: String, CodingKey { case requestID = "request_id" } +} +public enum PairingAdmissionState: String, Codable, Sendable { case pending = "admission-pending" } +public struct PairingAdmissionPending: Codable, Sendable { + public let state: PairingAdmissionState + public let pairingID: String + public init(pairingID: String) { self.state = .pending; self.pairingID = pairingID } + enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } +} +public struct PairingResultRequest: Codable, Sendable { + public let apiVersion, attemptID, devicePublicKey, proof: String + public init(apiVersion: String, attemptID: String, devicePublicKey: String, proof: String) { self.apiVersion = apiVersion; self.attemptID = attemptID; self.devicePublicKey = devicePublicKey; self.proof = proof } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", attemptID = "attempt_id", devicePublicKey = "device_public_key", proof } +} +public struct PairingResultPending: Codable, Sendable { + public let code: ErrorCode + public let retryable: Bool + public let deadline: String + public init(deadline: String) { self.code = .pairingResultPending; self.retryable = true; self.deadline = deadline } +} public struct PairingChallenge: Codable, Sendable { public let kind, pairingID, code, expiresAt: String; public let personRootFingerprint: String? public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? enum CodingKeys: String, CodingKey { case kind, pairingID = "pairing_id", code, expiresAt = "expires_at", personRootFingerprint = "person_root_fingerprint", principal, grantKind = "grant_kind", grantID = "grant_id", grantExpiresAt = "grant_expires_at", policyRevision = "policy_revision", minimalReadMode = "minimal_read_mode" } } -public struct PairingComplete: Codable, Sendable { public let apiVersion: String; public let code: String; public let devicePublicKey: String; public var proof: String? = nil; public var keyStorage: String? = nil; public var fabricNodeID: String? = nil; enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } } +public struct PairingComplete: Codable, Sendable { + public let apiVersion, code, devicePublicKey: String + public var proof: String? + public var keyStorage: String? + public var fabricNodeID: String? + public init(apiVersion: String, code: String, devicePublicKey: String, proof: String? = nil, keyStorage: String? = nil, fabricNodeID: String? = nil) { + self.apiVersion = apiVersion; self.code = code; self.devicePublicKey = devicePublicKey + self.proof = proof; self.keyStorage = keyStorage; self.fabricNodeID = fabricNodeID + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } +} public enum PairedSession: Codable, Sendable { case person(PersonPairedSession), assistant(AssistantPairedSession) public init(from decoder: Decoder) throws { diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift new file mode 100644 index 00000000000..e84deed73c6 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/PairingModelsTests.swift @@ -0,0 +1,235 @@ +import XCTest +import Foundation +import FractalClient + +final class PairingModelsTests: XCTestCase { + private let apiVersion = "st3.client.v0" + private let refusal: [String: Any] = ["code": "pairing-rejected", "message": "Pairing rejected"] + private let approved: [String: Any] = ["status": "approved", "key_fingerprint": "fingerprint", "approved_at": "2026-11-10T12:00:00Z"] + private let permit: [String: Any] = ["node_id": "node/example", "attempt_id": "attempt/example", "secret": "secret"] + + private func decode(_ type: T.Type, _ object: [String: Any]) throws -> T { + try JSONDecoder().decode(type, from: JSONSerialization.data(withJSONObject: object)) + } + + private func object(_ value: T) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(value)) as? [String: Any]) + } + + private func roundTrip(_ type: T.Type, _ wire: [String: Any], file: StaticString = #filePath, line: UInt = #line) throws { + let value = try decode(type, wire) + XCTAssertEqual(try object(value) as NSDictionary, wire as NSDictionary, file: file, line: line) + } + + func testTypedRequestConstructorsRoundTripAllOperations() throws { + let requests: [PairingExchangeRequest] = [ + .capabilities(requestID: "request/capabilities"), + .claim(requestID: "request/claim", body: PairingKeyClaim(apiVersion: apiVersion, code: "code", devicePublicKey: "key", proof: "proof")), + .approvalWait(requestID: "request/wait", body: PairingApprovalWaitRequest(pairingID: "pairing/example", keyFingerprint: "fingerprint")), + .complete(requestID: "request/complete", body: PairingComplete(apiVersion: apiVersion, code: "code", devicePublicKey: "key", keyStorage: "secure-enclave")), + .ack(requestID: "request/ack", body: PairingExchangeAck(requestID: "request/complete")), + ] + let operations: [PairingExchangeOperation] = [.capabilities, .claim, .approvalWait, .complete, .ack] + for (request, operation) in zip(requests, operations) { + XCTAssertEqual(request.op, operation) + let wire = try object(request) + XCTAssertEqual(Set(wire.keys), ["v", "request_id", "op", "body"]) + XCTAssertEqual(wire["v"] as? Int, 1) + XCTAssertEqual(wire["op"] as? String, operation.rawValue) + try roundTrip(PairingExchangeRequest.self, wire) + } + } + + func testRequestRejectsOperationBodyDisagreementAndWrongVersion() throws { + let bodies: [(String, [String: Any])] = [ + ("capabilities", [:]), + ("claim", ["api_version": apiVersion, "code": "code", "device_public_key": "key", "proof": "proof"]), + ("approval-wait", ["pairing_id": "pairing/example", "key_fingerprint": "fingerprint"]), + ("complete", ["api_version": apiVersion, "code": "code", "device_public_key": "key", "key_storage": "secure-enclave"]), + ("ack", ["request_id": "request/complete"]), + ] + for (operation, _) in bodies { + for (bodyOperation, body) in bodies where operation != bodyOperation { + // A claim also satisfies complete's schema: proof is optional there. + if operation == "complete" && bodyOperation == "claim" { continue } + let wire: [String: Any] = ["v": 1, "request_id": "request/example", "op": operation, "body": body] + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire), "\(operation) accepted \(bodyOperation) body") + } + } + let valid: [String: Any] = ["v": 1, "request_id": "request/example", "op": "capabilities", "body": [:]] + for version in [0, 2, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["v"] = version + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire)) + } + for field in ["v", "request_id", "op", "body"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, wire)) + } + var unknown = valid + unknown["op"] = "unknown" + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, unknown)) + unknown = valid + unknown["body"] = NSNull() + XCTAssertThrowsError(try decode(PairingExchangeRequest.self, unknown)) + } + + func testReplyOutcomeRoundTripsSuccessAndCommonRefusal() throws { + let success = PairingExchangeReply(requestID: "request/wait", outcome: .ok(PairingApprovalWaitApproved(keyFingerprint: "fingerprint", approvedAt: "2026-11-10T12:00:00Z"))) + let successWire: [String: Any] = ["v": 1, "request_id": "request/wait", "ok": true, "body": approved] + XCTAssertEqual(try object(success) as NSDictionary, successWire as NSDictionary) + let decoded = try decode(PairingExchangeReply.self, successWire) + guard case .ok(let approval) = decoded.outcome else { return XCTFail("Expected approval success") } + XCTAssertEqual(approval.status, .approved) + try roundTrip(PairingExchangeReply.self, successWire) + + let rejected = PairingExchangeReply(requestID: "request/wait", outcome: .refused(PairingExchangeRefusal(code: .pairingRejected, message: "Pairing rejected"))) + let refusalWire: [String: Any] = ["v": 1, "request_id": "request/wait", "ok": false, "refusal": refusal] + XCTAssertEqual(try object(rejected) as NSDictionary, refusalWire as NSDictionary) + guard case .refused(let reason) = try decode(PairingExchangeReply.self, refusalWire).outcome else { + return XCTFail("Expected common refusal") + } + XCTAssertEqual(reason.code, .pairingRejected) + try roundTrip(PairingExchangeReply.self, refusalWire) + // The same envelope/refusal works for a different success-body type. + try roundTrip(PairingExchangeReply.self, refusalWire) + try roundTrip(PairingExchangeReply.self, + ["v": 1, "request_id": "request/ack", "ok": true, "body": [:]]) + } + + func testReplyRejectsEveryInconsistentOutcomeCombination() throws { + // Each field is absent, null, or populated. Only the two canonical combinations are valid. + let bodyValues: [Any?] = [nil, NSNull(), approved] + let refusalValues: [Any?] = [nil, NSNull(), refusal] + for ok in [false, true] { + for (bodyIndex, body) in bodyValues.enumerated() { + for (refusalIndex, reason) in refusalValues.enumerated() { + if (ok && bodyIndex == 2 && refusalIndex == 0) || (!ok && bodyIndex == 0 && refusalIndex == 2) { continue } + var wire: [String: Any] = ["v": 1, "request_id": "request/example", "ok": ok] + wire["body"] = body + wire["refusal"] = reason + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire), "ok=\(ok), body=\(bodyIndex), refusal=\(refusalIndex)") + } + } + } + let valid: [String: Any] = ["v": 1, "request_id": "request/example", "ok": true, "body": approved] + for version in [0, 2, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["v"] = version + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire)) + } + for field in ["v", "request_id", "ok", "body"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingExchangeReply.self, wire)) + } + for reason in [[:], ["code": "pairing-rejected"], ["message": "rejected"], "rejected", NSNull()] as [Any] { + XCTAssertThrowsError(try decode(PairingExchangeReply.self, + ["v": 1, "request_id": "request/example", "ok": false, "refusal": reason])) + } + } + + func testQrVersionsRoundTripWithVersionBoundFabric() throws { + let v1 = PairingQrPayload(version: .v1, gateway: "https://gateway.example", id: "pairing/example", code: "code", expiresAtUnixMS: 123) + let v2 = PairingQrPayload(version: .v2(PairingFabricPermit(nodeID: "node/example", attemptID: "attempt/example", secret: "secret")), gateway: "https://gateway.example", id: "pairing/example", code: "code", expiresAtUnixMS: 123) + for (payload, version) in [(v1, 1), (v2, 2)] { + let wire = try object(payload) + XCTAssertEqual(wire["_tag"] as? String, "fractal.pairing") + XCTAssertEqual(wire["version"] as? Int, version) + XCTAssertEqual(wire.keys.contains("fabric"), version == 2) + try roundTrip(PairingQrPayload.self, wire) + } + } + + func testQrRejectsWrongTagVersionAndFabricCombinations() throws { + let valid: [String: Any] = ["_tag": "fractal.pairing", "version": 1, "gateway": "https://gateway.example", "id": "pairing/example", "code": "code", "expires_at_unix_ms": 123] + for tag in ["pairing", "", 1, NSNull()] as [Any] { + var wire = valid + wire["_tag"] = tag + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + for version in [0, 3, -1, 256, "1", NSNull()] as [Any] { + var wire = valid + wire["version"] = version + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + for fabric in [permit, NSNull()] as [Any] { + var wire = valid + wire["fabric"] = fabric + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + var v2 = valid + v2["version"] = 2 + XCTAssertThrowsError(try decode(PairingQrPayload.self, v2)) + v2["fabric"] = NSNull() + XCTAssertThrowsError(try decode(PairingQrPayload.self, v2)) + for field in ["_tag", "version"] { + var wire = valid + wire.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingQrPayload.self, wire)) + } + } + + func testApprovalAndAdmissionConstantsAreValidatedAndRoundTrip() throws { + try roundTrip(PairingApprovalWaitApproved.self, approved) + let pending = PairingAdmissionPending(pairingID: "pairing/example") + let pendingWire: [String: Any] = ["state": "admission-pending", "pairing_id": "pairing/example"] + XCTAssertEqual(try object(pending) as NSDictionary, pendingWire as NSDictionary) + try roundTrip(PairingAdmissionPending.self, pendingWire) + for invalid in ["pending", "", 1, NSNull()] as [Any] { + var approval = approved + approval["status"] = invalid + XCTAssertThrowsError(try decode(PairingApprovalWaitApproved.self, approval)) + var admission = pendingWire + admission["state"] = invalid + XCTAssertThrowsError(try decode(PairingAdmissionPending.self, admission)) + } + var approval = approved + approval.removeValue(forKey: "status") + XCTAssertThrowsError(try decode(PairingApprovalWaitApproved.self, approval)) + XCTAssertThrowsError(try decode(PairingAdmissionPending.self, ["pairing_id": "pairing/example"])) + } + + func testAdvertisementCapabilitiesRoundTripAndValidateConstants() throws { + let advertisement = try PairingAdvertisement(apiVersion: apiVersion, capabilities: PairingAdvertisedCapability.allCases) + let wire = try object(advertisement) + try roundTrip(PairingAdvertisement.self, wire) + for capability in PairingAdvertisedCapability.allCases { + let valid = try object(capability) + XCTAssertEqual(valid["id"] as? String, capability.rawValue) + XCTAssertEqual(valid["version"] as? UInt32, capability == .pairingKeyBinding ? 2 : 1) + XCTAssertEqual(valid["state"] as? String, "granted") + try roundTrip(PairingAdvertisedCapability.self, valid) + for version in [0, capability == .pairingKeyBinding ? 1 : 2, 3, -1, "1", NSNull()] as [Any] { + var invalid = valid + invalid["version"] = version + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for state in ["denied", "", 1, NSNull()] as [Any] { + var invalid = valid + invalid["state"] = state + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for id in ["unknown", "", 1, NSNull()] as [Any] { + var invalid = valid + invalid["id"] = id + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + for field in ["id", "version", "state"] { + var invalid = valid + invalid.removeValue(forKey: field) + XCTAssertThrowsError(try decode(PairingAdvertisedCapability.self, invalid)) + } + } + let proof = try object(PairingAdvertisedCapability.deviceKeyProofs) + let grant = try object(PairingAdvertisedCapability.fabricGatewayGrant) + let binding = try object(PairingAdvertisedCapability.pairingKeyBinding) + for capabilities in [[], [proof, proof], [proof, grant, binding, proof]] { + XCTAssertThrowsError(try decode(PairingAdvertisement.self, ["api_version": apiVersion, "capabilities": capabilities])) + } + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [])) + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [.deviceKeyProofs, .deviceKeyProofs])) + XCTAssertThrowsError(try PairingAdvertisement(apiVersion: apiVersion, capabilities: [.deviceKeyProofs, .fabricGatewayGrant, .pairingKeyBinding, .deviceKeyProofs])) + } +} diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 54f9400e90d..57818eef7f0 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1895,6 +1895,13 @@ export type ErrorCode = ( | "pairing-key-binding-required" | "pairing-already-claimed" | "pairing-claim-invalid" + | "pairing-route-not-allowed" + | "pairing-result-pending" + | "pairing-result-expired" + | "pairing-request-conflict" + | "pairing-wait-busy" + | "pairing-rejected" + | "pairing-expired" | "suspend-authored-session-selection" | "stale-fence" | "cursor-gap" @@ -2983,13 +2990,65 @@ export type PageInfo = { export type PairedSession = (PersonPairedSession | AssistantPairedSession); +export type PairingAdmissionPending = { + pairing_id: Id; + state: "admission-pending"; +}; + +export type PairingAdmissionRecord = { + attempt_id: string; + bound_node_id: string; + cleanup_reason?: string; + cleanup_started_at?: Timestamp; + committed_at?: Timestamp; + completion?: PairedSession; + confirm_receipt?: { + committed_at: Timestamp; + node_id: string; + policy_revision: number; +}; + confirm_request: PairingFabricConfirmRequest; + creation_id: string; + device_public_key: string; + grant_id?: Id; + pairing_id: Id; + permit_deadline: Timestamp; + policy_revision?: number; + refused?: true; + remove_peer_receipt?: { + +}; + result_fetched_at?: Timestamp; + revoke_receipt?: { + +}; + state: "S1" | "S2" | "S3" | "S4" | "S5"; +}; + export type PairingAdvertisement = { api_version: "st3.client.v0" | "fractal.client.v0"; capabilities: Array<{ - id: "device-key-proofs" | "fabric-gateway-grant"; + id: "device-key-proofs" | "fabric-gateway-grant" | "pairing-key-binding"; state: "granted"; - version: 1; -}>; + version: 1 | 2; +} & ({ + id?: "device-key-proofs" | "fabric-gateway-grant"; + version?: 1; +} | { + id?: "pairing-key-binding"; + version?: 2; +})>; +}; + +export type PairingApprovalWaitApproved = { + approved_at: Timestamp; + key_fingerprint: string; + status: "approved"; +}; + +export type PairingApprovalWaitRequest = { + key_fingerprint: string; + pairing_id: Id; }; export type PairingBegin = (PersonPairingBegin | AssistantPairingBegin); @@ -3032,6 +3091,65 @@ export type PairingComplete = { proof?: string; }; +export type PairingExchangeRefusal = { + code: ErrorCode; + message: string; +}; + +export type PairingExchangeReply = { + body?: (PairingAdvertisement | PairingKeyStatus | PairingApprovalWaitApproved | PairingAdmissionPending | Record); + ok: boolean; + refusal?: PairingExchangeRefusal; + request_id: string; + v: 1; +} & ({ + ok?: true; + refusal?: never; +} | { + body?: never; + ok?: false; +}); + +export type PairingExchangeRequest = { + body: unknown; + op: "capabilities" | "claim" | "approval-wait" | "complete" | "ack"; + request_id: string; + v: 1; +} & ({ + body?: Record; + op?: "capabilities"; +} | { + body?: PairingKeyClaim; + op?: "claim"; +} | { + body?: PairingApprovalWaitRequest; + op?: "approval-wait"; +} | { + body?: PairingComplete; + op?: "complete"; +} | { + body?: { + request_id: string; +}; + op?: "ack"; +}); + +export type PairingFabricConfirmRequest = { + attempt_id: string; + expected_attempt_revision: number; + expected_node_id: string; + expected_policy_revision: number; + operation: "confirm"; + owner: string; + request_id: string; +}; + +export type PairingFabricPermit = { + attempt_id: string; + node_id: string; + secret: string; +}; + export type PairingKeyApproval = { api_version: "st3.client.v0" | "fractal.client.v0"; device_key_fingerprint: string; @@ -3051,6 +3169,34 @@ export type PairingKeyStatus = { status: "awaiting-key" | "awaiting-approval" | "approved" | "completed" | "expired"; }; +export type PairingQrPayload = { + _tag: "fractal.pairing"; + code: string; + expires_at_unix_ms: number; + fabric?: PairingFabricPermit; + gateway: string; + id: Id; + version: 1 | 2; +} & ({ + fabric?: never; + version?: 1; +} | { + version?: 2; +}); + +export type PairingResultPending = { + code: "pairing-result-pending"; + deadline: Timestamp; + retryable: true; +}; + +export type PairingResultRequest = { + api_version: "st3.client.v0" | "fractal.client.v0"; + attempt_id: string; + device_public_key: string; + proof: string; +}; + export type ParticipantRef = string; export type PersonAnswer = { diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index d346b54a0d5..74c52294d4f 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1755,6 +1755,13 @@ export const ErrorCode = /*#__PURE__*/ (() => openEnum([ "pairing-key-binding-required", "pairing-already-claimed", "pairing-claim-invalid", + "pairing-route-not-allowed", + "pairing-result-pending", + "pairing-result-expired", + "pairing-request-conflict", + "pairing-wait-busy", + "pairing-rejected", + "pairing-expired", "suspend-authored-session-selection", "stale-fence", "cursor-gap", @@ -4196,7 +4203,7 @@ export type MessageBodyEncoded = typeof MessageBody.Encoded /** Static unauthenticated pairing preflight; no envelope, presence update or graph information. */ export const PairingAdvertisement = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), - "capabilities": Schema.Array(Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(2)) + "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)).check(Schema.isUnique()) }).annotate({ identifier: "PairingAdvertisement", description: "Static unauthenticated pairing preflight; no envelope, presence update or graph information." }))() export type PairingAdvertisement = typeof PairingAdvertisement.Type export type PairingAdvertisementEncoded = typeof PairingAdvertisement.Encoded diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index 4c77a8f999b..e2d40c843f5 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -1012,6 +1012,13 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-key-binding-required"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-already-claimed"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-claim-invalid"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-route-not-allowed"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-result-pending"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-result-expired"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-request-conflict"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-wait-busy"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-rejected"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("pairing-expired"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("suspend-authored-session-selection"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("stale-fence"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("cursor-gap"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), @@ -1052,7 +1059,7 @@ export const ErrorEnvelope = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("invalid-answer"), "details": HarnessInvalidAnswerDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("not-answerable"), "details": HarnessNotAnswerableDetails, "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": Schema.Literal("internal"), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","pairing-key-mismatch","pairing-key-binding-required","pairing-already-claimed","pairing-claim-invalid","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "applied": optionalKey(Schema.Literals(["none","unknown"])), "code": unknownCase(["recipient-retired","agents-publication-row-too-large","identity-cycle","identity-occupied","identity-retired-source","identity-cross-kind","identity-unknown","lifecycle-unavailable","retirement-upgrade-required","retire-revision-mismatch","retire-incarnation-mismatch","invalid-custody-successor","custody-pending","attention-migrated","arrangement-exists","arrangement-folder-exists","arrangement-retired","arrangement-limit","arrangement-folder-deleted","arrangement-cycle","arrangement-body-too-large","arrangement-owner-forbidden","invalid-arrangement-subject","invalid-arrangement-action","invalid-arrangement-operations","invalid-arrangement-folder","invalid-arrangement-name","invalid-arrangement-key","invalid-arrangement-version","invalid-arrangement-body","unsupported-membership-container","membership-owner-forbidden","invalid-membership-member","invalid-membership-bucket","invalid-membership-key","invalid-membership-operations","membership-edit-too-large","invalid-subject-reference","not-found","forbidden","unsupported-capability","peer-too-old","validation-failed","idempotency-conflict","legacy-action-receipt-untrusted","issuer-required","pairing-origin-required","pairing-restart-required","pairing-key-mismatch","pairing-key-binding-required","pairing-already-claimed","pairing-claim-invalid","pairing-route-not-allowed","pairing-result-pending","pairing-result-expired","pairing-request-conflict","pairing-wait-busy","pairing-rejected","pairing-expired","suspend-authored-session-selection","stale-fence","cursor-gap","page-cursor-expired","agent-roster-not-ready","agent-roster-cut-not-ready","message-selectors-not-ready","rate-limited","runtime-not-local","runtime-authority-indeterminate","runtime-list-not-ready","runtime-list-ended","remote-unavailable","terminal-unavailable","inventory-unavailable","terminal-ended","timeline-history-incomplete","projection-detail-too-large","projection-detail-invalid-source","conversation-content-invalidated","transcript-unavailable","blob-too-large","unsupported-media-type","blob-content-mismatch","blob-quota-exceeded","blob-not-found","blob-expired","message-too-large","message-store-full","long-message-signature-unsupported","response-budget-exceeded","content-not-text","invalid-document-reference","document-not-synced","private-notes-unreachable","private-notes-carrier-conflict","private-notes-indeterminate","invalid-answer","not-answerable","internal"]), "details": Schema.StructWithRest(Schema.Struct({ "applied": optionalKey(Schema.Literals(["none","unknown"])), "refusal": optionalKey(MessageSendRefusal) }), [Schema.Record(Schema.String, Schema.Unknown)]), "error_version": Schema.Literals(["st3.client.error.v0","fractal.client.error.v0"]), "message": Schema.String, "request_id": Id, "retry_after_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.decodeTo(Schema.Duration.check(durationMillisRange), wholeUnits(1)))), "retryable": Schema.Boolean }) ], { mode: "oneOf" }).annotate({ identifier: "ErrorEnvelope" }))() export type ErrorEnvelope = typeof ErrorEnvelope.Type export type ErrorEnvelopeEncoded = typeof ErrorEnvelope.Encoded @@ -1292,6 +1299,70 @@ export const ObservationResponse = /*#__PURE__*/ (() => Schema.Struct({ export type ObservationResponse = typeof ObservationResponse.Type export type ObservationResponseEncoded = typeof ObservationResponse.Encoded +/** Exchange completion acknowledges durable S1 only, never a credential or a grant. */ +export const PairingAdmissionPending = /*#__PURE__*/ (() => Schema.Struct({ + "pairing_id": Id, + "state": Schema.Literal("admission-pending") +}).annotate({ identifier: "PairingAdmissionPending", description: "Exchange completion acknowledges durable S1 only, never a credential or a grant." }))() +export type PairingAdmissionPending = typeof PairingAdmissionPending.Type +export type PairingAdmissionPendingEncoded = typeof PairingAdmissionPending.Encoded + +/** Exact request persisted before calling Fabric confirm; retries use the identical body. */ +export const PairingFabricConfirmRequest = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String, + "expected_attempt_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + "expected_node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "expected_policy_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + "operation": Schema.Literal("confirm"), + "owner": Schema.String, + "request_id": Schema.String +}).annotate({ identifier: "PairingFabricConfirmRequest", description: "Exact request persisted before calling Fabric confirm; retries use the identical body." }))() +export type PairingFabricConfirmRequest = typeof PairingFabricConfirmRequest.Type +export type PairingFabricConfirmRequestEncoded = typeof PairingFabricConfirmRequest.Encoded + +/** Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation. */ +export const PairingAdmissionRecord = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String, + "bound_node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "cleanup_reason": optionalKey(Schema.String.check(Schema.isMinCodePoints(1))), + "cleanup_started_at": optionalKey(Timestamp), + "committed_at": optionalKey(Timestamp), + "completion": optionalKey(PairedSession), + /** Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed. */ + "confirm_receipt": optionalKey(Schema.Struct({ "committed_at": Timestamp, "node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), "policy_revision": Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })).annotate({ description: "Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed." }), + "confirm_request": PairingFabricConfirmRequest, + "creation_id": Schema.String, + "device_public_key": Schema.String, + "grant_id": optionalKey(Id), + "pairing_id": Id, + "permit_deadline": Timestamp, + "policy_revision": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + /** Present only for terminal refused S5 closures, which have no confirmation or grant. */ + "refused": optionalKey(Schema.Literal(true)).annotate({ description: "Present only for terminal refused S5 closures, which have no confirmation or grant." }), + "remove_peer_receipt": optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + "result_fetched_at": optionalKey(Timestamp), + "revoke_receipt": optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + "state": Schema.Literals(["S1","S2","S3","S4","S5"]) +}).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S2","S3"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "committed_at"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "policy_revision")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S3"))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "completion"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S4","S5"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "cleanup_reason"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "cleanup_started_at")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id"))))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((["S4","S5"] as ReadonlyArray).includes((o as Record)["state"])))))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "committed_at"))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "policy_revision")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5")))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "confirm_receipt")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "remove_peer_receipt")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5")))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "grant_id")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "revoke_receipt")))), { expected: "conditional object relationship" })).check(Schema.makeFilter((o: object) => !(((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "refused") || (((o as Record)["refused"] === true)))) && ((typeof o !== "object" || o === null || Array.isArray(o) || Object.prototype.hasOwnProperty.call(o, "refused")))) || (((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "committed_at") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "completion") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "confirm_receipt") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "grant_id") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "policy_revision") || (false))) && ((typeof o !== "object" || o === null || Array.isArray(o) || !Object.prototype.hasOwnProperty.call(o, "state") || (((o as Record)["state"] === "S5"))))), { expected: "conditional object relationship" })).annotate({ identifier: "PairingAdmissionRecord", description: "Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation." }))() +export type PairingAdmissionRecord = typeof PairingAdmissionRecord.Type +export type PairingAdmissionRecordEncoded = typeof PairingAdmissionRecord.Encoded + +export const PairingApprovalWaitApproved = /*#__PURE__*/ (() => Schema.Struct({ + "approved_at": Timestamp, + "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), + "status": Schema.Literal("approved") +}).annotate({ identifier: "PairingApprovalWaitApproved" }))() +export type PairingApprovalWaitApproved = typeof PairingApprovalWaitApproved.Type +export type PairingApprovalWaitApprovedEncoded = typeof PairingApprovalWaitApproved.Encoded + +/** Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state. */ +export const PairingApprovalWaitRequest = /*#__PURE__*/ (() => Schema.Struct({ + "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), + "pairing_id": Id +}).annotate({ identifier: "PairingApprovalWaitRequest", description: "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state." }))() +export type PairingApprovalWaitRequest = typeof PairingApprovalWaitRequest.Type +export type PairingApprovalWaitRequestEncoded = typeof PairingApprovalWaitRequest.Encoded + export const PersonPairingBegin = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), /** Absent means optional. Required seals the challenge before QR display and requires a signed scanning-key claim followed by trusted local approval. */ @@ -1333,12 +1404,28 @@ export const PairingComplete = /*#__PURE__*/ (() => Schema.Struct({ export type PairingComplete = typeof PairingComplete.Type export type PairingCompleteEncoded = typeof PairingComplete.Encoded -export const PairingKeyApproval = /*#__PURE__*/ (() => Schema.Struct({ - "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), - "device_key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))) -}).annotate({ identifier: "PairingKeyApproval" }))() -export type PairingKeyApproval = typeof PairingKeyApproval.Type -export type PairingKeyApprovalEncoded = typeof PairingKeyApproval.Encoded +export const PairingExchangeRefusal = /*#__PURE__*/ (() => Schema.Struct({ + "code": ErrorCode, + "message": Schema.String +}).annotate({ identifier: "PairingExchangeRefusal" }))() +export type PairingExchangeRefusal = typeof PairingExchangeRefusal.Type +export type PairingExchangeRefusalEncoded = typeof PairingExchangeRefusal.Encoded + +export const PairingKeyStatus = /*#__PURE__*/ (() => Schema.Struct({ + "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), + "expires_at": Timestamp, + "kind": Schema.Literal("pairing-key-status"), + "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]) +}).annotate({ identifier: "PairingKeyStatus" }))() +export type PairingKeyStatus = typeof PairingKeyStatus.Type +export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded + +export const PairingExchangeReply = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "body": Schema.Union([Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "capabilities": Schema.Array(Schema.Union([Schema.Struct({ "id": Schema.Literals(["device-key-proofs","fabric-gateway-grant"]), "state": Schema.Literal("granted"), "version": Schema.Literal(1) }), Schema.Struct({ "id": Schema.Literal("pairing-key-binding"), "state": Schema.Literal("granted"), "version": Schema.Literal(2) })], { mode: "oneOf" })).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)).check(Schema.isUnique()), "approved_at": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), "expires_at": Timestamp, "kind": Schema.Literal("pairing-key-status"), "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "approved_at": Timestamp, "key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))), "status": Schema.Literal("approved"), "api_version": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "pairing_id": Schema.optionalKey(Schema.Never), "state": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "pairing_id": Id, "state": Schema.Literal("admission-pending"), "api_version": Schema.optionalKey(Schema.Never), "approved_at": Schema.optionalKey(Schema.Never), "capabilities": Schema.optionalKey(Schema.Never), "device_key_fingerprint": Schema.optionalKey(Schema.Never), "expires_at": Schema.optionalKey(Schema.Never), "key_fingerprint": Schema.optionalKey(Schema.Never), "kind": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never) }), Schema.Record(Schema.String, Schema.Never)], { mode: "oneOf" }), "ok": Schema.Literal(true), "refusal": optionalKey(Schema.Never), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": optionalKey(Schema.Never), "ok": Schema.Literal(false), "refusal": PairingExchangeRefusal, "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) +], { mode: "oneOf" }).annotate({ identifier: "PairingExchangeReply" }))() +export type PairingExchangeReply = typeof PairingExchangeReply.Type +export type PairingExchangeReplyEncoded = typeof PairingExchangeReply.Encoded export const PairingKeyClaim = /*#__PURE__*/ (() => Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), @@ -1351,14 +1438,57 @@ export const PairingKeyClaim = /*#__PURE__*/ (() => Schema.Struct({ export type PairingKeyClaim = typeof PairingKeyClaim.Type export type PairingKeyClaimEncoded = typeof PairingKeyClaim.Encoded -export const PairingKeyStatus = /*#__PURE__*/ (() => Schema.Struct({ - "device_key_fingerprint": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u")))), - "expires_at": Timestamp, - "kind": Schema.Literal("pairing-key-status"), - "status": Schema.Literals(["awaiting-key","awaiting-approval","approved","completed","expired"]) -}).annotate({ identifier: "PairingKeyStatus" }))() -export type PairingKeyStatus = typeof PairingKeyStatus.Type -export type PairingKeyStatusEncoded = typeof PairingKeyStatus.Encoded +export const PairingExchangeRequest = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "body": Schema.Record(Schema.String, Schema.Never), "op": Schema.Literal("capabilities"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingKeyClaim, "op": Schema.Literal("claim"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingApprovalWaitRequest, "op": Schema.Literal("approval-wait"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": PairingComplete, "op": Schema.Literal("complete"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }), + Schema.Struct({ "body": Schema.Struct({ "request_id": Schema.String.check(Schema.isMinCodePoints(1)) }), "op": Schema.Literal("ack"), "request_id": Schema.String.check(Schema.isMinCodePoints(1)), "v": Schema.Literal(1) }) +], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("op")).annotate({ identifier: "PairingExchangeRequest" }))() +export type PairingExchangeRequest = typeof PairingExchangeRequest.Type +export type PairingExchangeRequestEncoded = typeof PairingExchangeRequest.Encoded + +/** Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target. */ +export const PairingFabricPermit = /*#__PURE__*/ (() => Schema.Struct({ + "attempt_id": Schema.String.check(Schema.isMinCodePoints(1)), + "node_id": Schema.String.check(Schema.isPattern(new RegExp("^[0-9a-f]{64}$", "u"))), + "secret": Schema.RedactedFromValue(Schema.String.check(Schema.isMinCodePoints(1))) +}).annotate({ identifier: "PairingFabricPermit", description: "Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target." }))() +export type PairingFabricPermit = typeof PairingFabricPermit.Type +export type PairingFabricPermitEncoded = typeof PairingFabricPermit.Encoded + +export const PairingKeyApproval = /*#__PURE__*/ (() => Schema.Struct({ + "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), + "device_key_fingerprint": Schema.String.check(Schema.isPattern(new RegExp("^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$", "u"))) +}).annotate({ identifier: "PairingKeyApproval" }))() +export type PairingKeyApproval = typeof PairingKeyApproval.Type +export type PairingKeyApprovalEncoded = typeof PairingKeyApproval.Encoded + +export const PairingQrPayload = /*#__PURE__*/ (() => Schema.Union([ + Schema.Struct({ "_tag": Schema.Literal("fractal.pairing"), "code": Schema.String.check(Schema.isPattern(new RegExp("^[A-Z2-9]{8}$", "u"))), "expires_at_unix_ms": Schema.Int, "fabric": optionalKey(Schema.Never), "gateway": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z][A-Za-z0-9+.-]*:", "u"))).check(Schema.isPattern(URI_REFERENCE, { expected: "an RFC 3986 URI reference" })), "id": Id, "version": Schema.Literal(1) }), + Schema.Struct({ "_tag": Schema.Literal("fractal.pairing"), "code": Schema.String.check(Schema.isPattern(new RegExp("^[A-Z2-9]{8}$", "u"))), "expires_at_unix_ms": Schema.Int, "fabric": PairingFabricPermit, "gateway": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z][A-Za-z0-9+.-]*:", "u"))).check(Schema.isPattern(URI_REFERENCE, { expected: "an RFC 3986 URI reference" })), "id": Id, "version": Schema.Literal(2) }) +], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("version")).annotate({ identifier: "PairingQrPayload" }))() +export type PairingQrPayload = typeof PairingQrPayload.Type +export type PairingQrPayloadEncoded = typeof PairingQrPayload.Encoded + +/** Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1. */ +export const PairingResultPending = /*#__PURE__*/ (() => Schema.Struct({ + "code": Schema.Literal("pairing-result-pending"), + "deadline": Timestamp, + "retryable": Schema.Literal(true) +}).annotate({ identifier: "PairingResultPending", description: "Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1." }))() +export type PairingResultPending = typeof PairingResultPending.Type +export type PairingResultPendingEncoded = typeof PairingResultPending.Encoded + +/** POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header. */ +export const PairingResultRequest = /*#__PURE__*/ (() => Schema.Struct({ + "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), + "attempt_id": Schema.String.check(Schema.isMinCodePoints(1)), + "device_public_key": Schema.String.check(Schema.isMinCodePoints(32)), + "proof": Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_-]{85}[AQgw]$", "u"))) +}).annotate({ identifier: "PairingResultRequest", description: "POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header." }))() +export type PairingResultRequest = typeof PairingResultRequest.Type +export type PairingResultRequestEncoded = typeof PairingResultRequest.Encoded /** A paired phone's APNs device token. The person and device come only from the paired session; any other field, including a person or device identity, is refused. The token is a secret: it is stored only on the member that issued the pairing and never appears in claims, replication, resources, logs or error messages. */ export const PushRegistration = /*#__PURE__*/ (() => Schema.Struct({ diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index d5800e82be5..1d7b1c36b56 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -3329,8 +3329,8 @@ fn mission_resources_filtered( json!({ "id": step.subject, "title": step.title, - "assignee": step.assigned_to, - "claimant": step.claimant, + "assignee": step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant": step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "state": client_work_state(&step.status), "since": client_timestamp(step.updated_at_unix_ms), "progress_at": step.progress_at_unix_ms.map(client_timestamp), @@ -3412,7 +3412,7 @@ fn mission_resources_filtered( && !matches!(step.status.as_str(), "completed" | "failed" | "cancelled" | "skipped") }).map(|wake| { json!({ - "assignee": wake.assignee, + "assignee": fractal_schema::canonical_agent_actor(&wake.assignee), "assignee_state": wake.assignee_state, "incarnation_id": wake.incarnation_id, "attempts": wake.attempts, @@ -3427,8 +3427,8 @@ fn mission_resources_filtered( "title": step.title, "state": client_work_state(&step.status), "attempt": step.attempt, - "assignee": step.assigned_to, - "claimant": step.claimant, + "assignee": step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant": step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "agentless": step.agentless, "keep_open": crate::mission::step_keeps_open(definition.map(|(definition, _)| definition), &step.step), "since": client_timestamp(step.updated_at_unix_ms), @@ -12250,8 +12250,15 @@ mission "differential" state="ready" { "runtime_id":"fresh-roster", "incarnation_id":"one"})); let mut published = state.store.subscribe_agent_roster(); crate::api::start_agent_roster(&state); - tokio::time::timeout(Duration::from_secs(5), published.wait_for(|revision| *revision > 0)) - .await.unwrap().unwrap(); + let complete = { + let store = state.store.clone(); + move |_: &u64| store.published_agent_roster(store.index().unwrap(), false).is_some() + }; + tokio::time::timeout(Duration::from_secs(5), published.wait_for(complete)) + .await.expect("the refresher publishes a complete initial roster").unwrap(); + // Freeze the refresher before advancing the graph so the unchanged-through read + // must use the old publication, not an identical one published at a newer cut. + let _admission = state.store.admit_agent_resources("other").await; let revision = *published.borrow_and_update(); let (_, Json(before)) = client_agents(State(state.clone()), Extension(new_client_snapshot(&state)), Query(ClientListQuery::default())).await.unwrap(); @@ -12279,7 +12286,6 @@ mission "differential" state="ready" { // A claim about the agent changes its card. Start a healthy attempt and hold it: // the fresh read must wait for this attempt's publication, not answer early. - let _admission = state.store.admit_agent_resources("other").await; append(subject, "harness.observed", json!({"state":"working", "driver":"codex", "incarnation_id":"one"})); let written = state.store.index().unwrap(); @@ -17067,7 +17073,7 @@ mission "example/looped" state="ready" { let root = tempfile::tempdir().unwrap(); let state = test_state_named(root.path(), "live-run-node"); let steps = (0..24).map(|i| format!( - " step \"step-{i:02}\" {{ assigned-to \"agent/worker-{i:02}\" }}\n" + " step \"step-{i:02}\" {{ assigned-to \"agent/live-run-node.worker-{i:02}\" }}\n" )).collect::(); let source = format!( "version 2\nmission \"example/live\" state=\"ready\" {{\n goal \"Keep live metadata.\"\n concurrent-runs max=10\n{steps}}}" diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 11bf9a71ee9..67a1e811601 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -653,6 +653,9 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary, .layer(DefaultBodyLimit::max(client_observations::MAX_BODY_BYTES))) .route("/v1/client/documents/content", get(client_documents::document_get)) .route("/v1/client/documents", get(client_documents::documents_list)) + .route("/v1/client/claims", get(records::client_list_claims)) + .route("/v1/client/claims/by-id/{id}", get(records::client_get_claim)) + .route("/v1/client/work-items/{*subject}", get(work::client_get_work)) .route("/v1/client/document-references", get(client_documents::document_references)) .route("/v1/client/push-registration", post(client_push::register) .delete(client_push::unregister) @@ -1625,12 +1628,12 @@ pub fn request_latency_snapshot() -> Vec { request_latency().lock().unwrap().snapshot() } -/// Only the targets' live windows, for the collector: see `slo/targets.toml`. +/// Only the targets' live windows, for the collector: see `slo/objectives.kdl`. pub fn request_latency_target_windows() -> Value { request_latency().lock().unwrap().targets_only(Instant::now()) } -/// Every target's live windows, and every path's: see `slo/targets.toml`. +/// Every target's live windows, and every path's: see `slo/objectives.kdl`. pub fn request_latency_windows() -> Value { request_latency().lock().unwrap().windows(Instant::now()) } @@ -1640,8 +1643,8 @@ fn note_cpu_busy(busy: smallclaims::windows::cpu_minutes::Busy) { request_latency().lock().unwrap_or_else(std::sync::PoisonError::into_inner).cpu_minutes().note(busy); } -/// Close each UTC minute of the member's CPU as it ends, for the CPU targets in -/// `slo/targets.toml`: read the daemon's CPU at the boundary beside the newest reading each of +/// Close each UTC minute of the member's CPU as it ends, for the `cpu` objective in +/// `slo/objectives.kdl`: read the daemon's CPU at the boundary beside the newest reading each of /// the `workers` replication workers this member runs has sent, note a replication catch-up, and /// check the seats of the minute closed one boundary earlier, once every harness observation /// stamped inside it has committed. The first boundary is this daemon's start. @@ -2776,11 +2779,11 @@ pub fn client_work_resources( // that chooses its next work and wake. let seat_ready = match actor { Some(actor) => { - let seat = if actor.contains('/') { - actor.to_owned() - } else { - format!("agent/{actor}") - }; + // Queue lookup uses the same canonical actor as the shared seat selector, + // including when persisted steps still use a legacy spelling. + let actor = smallclaims::store::normalize_actor(actor, "agent"); + let seat = fractal_schema::canonical_agent_actor(&actor) + .unwrap_or(std::borrow::Cow::Borrowed(&actor)); let order = store.seat_run_order(&seat)?; let steps = work .iter() @@ -3009,7 +3012,7 @@ fn client_work_values( "definition_id": work.definition_hash, "path": work.step, "title": work.title, - "assigned_to": work.assigned_to, + "assigned_to": work.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), "last_progress": work.progress_summary, "progress_at": work.progress_at_unix_ms.map(client_timestamp), "state": state, @@ -3017,7 +3020,7 @@ fn client_work_values( "gate_kind": gate_kind, "attempt": work.attempt, "readiness_epoch": work.readiness_epoch, - "claimant": work.claimant, + "claimant": work.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "claim_incarnation": work.claim_incarnation, "submitted_incarnation": work.submitted_incarnation, "claim_expires_at_unix_ms": work.claim_expires_at_unix_ms, @@ -11296,6 +11299,8 @@ pub fn slo_rows(store: &crate::store::Store) -> anyhow::Result> { let budgets = rows.iter().position(|row| row["kind"] == "budget").unwrap_or(rows.len()); rows.insert(budgets, crate::objectives::divergence(open)); } + let budgets = rows.iter().position(|row| row["kind"] == "budget").unwrap_or(rows.len()); + drop(rows.splice(budgets..budgets, crate::declared_objectives::rows(store, now)?)); Ok(rows) } diff --git a/crates/fractal-api-core/src/api/client_documents.rs b/crates/fractal-api-core/src/api/client_documents.rs index 77accac53c2..c083d869beb 100644 --- a/crates/fractal-api-core/src/api/client_documents.rs +++ b/crates/fractal-api-core/src/api/client_documents.rs @@ -28,6 +28,9 @@ pub(super) async fn document_get( Extension(session): Extension, Query(query): Query, ) -> Result, ApiError> { + if client_v0::external_grants::is_service(&session) { + return Err(client_v0::external_grants::service_documents_forbidden()); + } require_scope(&session, "read.projections")?; let (name, hash) = match query.name.rsplit_once('@') { Some((name, hash)) if !name.is_empty() && !hash.is_empty() => { @@ -86,6 +89,9 @@ pub(super) async fn documents_list( Extension(session): Extension, query: Query, ) -> Result, ApiError> { + if client_v0::external_grants::is_service(&session) { + return Err(client_v0::external_grants::service_documents_forbidden()); + } require_scope(&session, "read.projections")?; let Json(page) = list_documents(State(state), query).await?; Ok(Json(json!({ diff --git a/crates/fractal-api-core/src/api/client_push.rs b/crates/fractal-api-core/src/api/client_push.rs index 333637abbbd..1c90bfcb983 100644 --- a/crates/fractal-api-core/src/api/client_push.rs +++ b/crates/fractal-api-core/src/api/client_push.rs @@ -50,6 +50,11 @@ pub fn authenticate( .filter(|value| !value.is_empty()) .ok_or_else(denied)?; let digest = hex::encode(Sha256::digest(credential.as_bytes())); + if let Some(session) = super::client_v0::external_grants::authenticate(state, &digest, transport)? + && super::client_v0::external_grants::is_service(&session) { + super::client_v0::external_grants::authorize_route(&session, request)?; + return Ok(Authentication::Session(session)); + } let pairings = state.store .claims_for_kind_at("custom.client.pairing-completed", None, true, 10_000) .map_err(|_| unavailable())?; diff --git a/crates/fractal-api-core/src/api/client_reads.rs b/crates/fractal-api-core/src/api/client_reads.rs index ec963cbed86..8fd1379c990 100644 --- a/crates/fractal-api-core/src/api/client_reads.rs +++ b/crates/fractal-api-core/src/api/client_reads.rs @@ -1616,7 +1616,7 @@ pub async fn client_history( }; let store = state.store.clone(); let page = - blocking_store(move || records::generic_claims_page(&store, Default::default(), None, 0, Some(before_index), true, limit)) + blocking_store(move || records::generic_claims_page(&store, Default::default(), None, 0, Some(before_index), true, limit, None)) .await?; let has_more = page.next_cursor.is_some(); let items = page diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index 1875064e31f..4aedfbb7f04 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -760,7 +760,8 @@ pub fn authenticate( .split(',') .any(|feature| feature.trim() == "messages-paging.v2") }); - if let Some(web) = request.extensions().get::() { + if let Some(web) = request.extensions().get::() + && !request.headers().contains_key(AUTHORIZATION) { let mut session = ClientSession::local(Some(&web.person))?; session.actor = format!("client/local-web/{}", web.id); session.transport = "local-web"; @@ -813,6 +814,9 @@ pub fn authenticate( let digest = credential_digest(credential); if let Some(session) = assistant::authenticate(state, &digest, transport)? { assistant::authorize_route(&session, request)?; + if external_grants::is_service(&session) { + external_grants::audit_service_read(state, &session)?; + } return Ok(session); } let pairings = state @@ -1062,7 +1066,9 @@ pub fn mission_list_cards_with( else if steps.iter().any(|s| s.status=="blocked") {"blocked"} else {"system"}; let shown=steps.iter().map(|step| json!({ "id":step.subject,"path":step.step,"title":step.title,"state":client_work_state(&step.status), - "attempt":step.attempt,"assignee":step.assigned_to,"claimant":step.claimant, + "attempt":step.attempt, + "assignee":step.assigned_to.as_deref().and_then(fractal_schema::canonical_agent_actor), + "claimant":step.claimant.as_deref().and_then(fractal_schema::canonical_agent_actor), "agentless":step.agentless,"since":client_timestamp(step.updated_at_unix_ms), "started_at":timing.get(step.subject.as_str()).and_then(|times| times.0), "completed_at":timing.get(step.subject.as_str()).and_then(|times| times.1), diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants.rs b/crates/fractal-api-core/src/api/client_v0/external_grants.rs index 5189b0bf97b..f02d9c090ef 100644 --- a/crates/fractal-api-core/src/api/client_v0/external_grants.rs +++ b/crates/fractal-api-core/src/api/client_v0/external_grants.rs @@ -18,7 +18,7 @@ pub const PRINCIPAL: &str = "assistant/chatgpt"; pub const MCP_PRINCIPAL: &str = "assistant/mcp"; /// Closed authority profiles: callers cannot register a profile or mix its rights. -/// Service is reserved and cannot issue credentials or authorize any request. +/// Service authentication is supported, but production issuance remains disabled. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Profile { Assistant, @@ -95,6 +95,15 @@ fn admit_profile(state: &AppState, source: &Value) -> Result<(), ApiError> { } pub fn principal(source: &Value) -> Result<&str, ApiError> { + if Profile::stored(source)? == Profile::Service { + return source["principal"].as_str().filter(|principal| { + let mut parts = principal.split('/'); + let component = |part: Option<&str>| part.is_some_and(|part| !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))); + parts.next() == Some("plugin") && component(parts.next()) + && component(parts.next()) && parts.next().is_none() + }).ok_or_else(|| forbidden("invalid service principal")); + } crate::store::assistant_write::principal(source) .ok_or_else(|| forbidden("invalid assistant principal")) } @@ -225,8 +234,12 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) let source = fields(&policy)?; let principal = principal(source)?; let granted_fields = fields(granted)?; - assistant_profile(granted_fields)?; - assistant_profile(source)?; + let profile = Profile::stored(granted_fields)?; + profile.compatible(Profile::stored(source)?)?; + if profile == Profile::Service { + digest_only(granted_fields)?; + digest_only(source)?; + } if self::principal(granted_fields)? != principal || ["grant_id", "session_actor", "issuer"].iter().any(|key| source[*key] != granted_fields[*key]) || source["session_actor"].as_str().and_then(|actor| actor.strip_prefix(principal)) @@ -240,11 +253,14 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) if latest(state, &granted.subject, REVOKED)?.is_some() || source["expires_at_unix_ms"].as_u64().map(u128::from).unwrap_or(0) <= client_now_ms() || source["issuer"] != owning_person(state)? - || !matches!(source["minimal_read_mode"].as_str(), Some("approval-once" | "caller-bound-allow")) || source["disclosure_consent"] != true { + || (profile == Profile::Assistant + && (!matches!(source["minimal_read_mode"].as_str(), Some("approval-once" | "caller-bound-allow")) + || source["disclosure_consent"] != true)) { return Err(forbidden("assistant grant was revoked, expired, or has invalid policy")); } - let scopes = source["scopes"].as_array().ok_or_else(|| forbidden("assistant scopes absent"))? - .iter().map(|value| value.as_str().filter(|scope| SCOPES.contains(scope)).map(str::to_owned)) + let allowed = if profile == Profile::Service { SERVICE_SCOPES } else { SCOPES }; + let scopes = source["scopes"].as_array().ok_or_else(|| forbidden("external grant scopes absent"))? + .iter().map(|value| value.as_str().filter(|scope| allowed.contains(scope)).map(str::to_owned)) .collect::>>().ok_or_else(|| forbidden("assistant scopes invalid"))?; Ok(ClientSession { actor: source["session_actor"].as_str() .ok_or_else(|| forbidden("assistant actor absent"))?.into(), authority_actor: principal.into(), @@ -257,6 +273,9 @@ pub fn session(state: &AppState, granted: &ClaimRecord, transport: &'static str) } pub fn authorize_route(session: &ClientSession, request: &Request) -> Result<(), ApiError> { + if is_service(session) { + return authorize_service(session, request); + } if !matches!(session.authority_actor.as_str(), PRINCIPAL | MCP_PRINCIPAL) { return Err(forbidden("external grant profile has no allowed routes")); } @@ -273,6 +292,70 @@ pub fn authorize_route(session: &ClientSession, request: &Request) -> Resu Ok(()) } +pub const SERVICE_SCOPES: &[&str] = &["read.claims", "read.work", "read.documents"]; + +pub fn is_service(session: &ClientSession) -> bool { + session.authority_actor.starts_with("plugin/") +} + +/// This closed registry is also used for native aliases. HEAD and upgrades are not reads. +fn authorize_service(session: &ClientSession, request: &Request) -> Result<(), ApiError> { + let path = request.uri().path(); + let scope = match path { + "/v1/client/claims" | "/v1/claims" => Some("read.claims"), + "/v1/client/documents" | "/v1/client/documents/content" + | "/v1/documents" | "/v1/documents/content" => Some("read.documents"), + _ if ["/v1/client/claims/by-id/", "/v1/claims/by-id/"].iter() + .any(|prefix| path.strip_prefix(prefix).is_some_and(|id| !id.is_empty() && !id.contains('/'))) => + Some("read.claims"), + _ if ["/v1/client/work-items/", "/v1/work-items/"].iter() + .any(|prefix| path.strip_prefix(prefix).is_some_and(|subject| !subject.is_empty())) => + Some("read.work"), + _ => None, + }; + if request.method() != axum::http::Method::GET + || request.headers().contains_key(axum::http::header::UPGRADE) + || (session.transport == "fabric-loopback" && !path.starts_with("/v1/client/")) + || !scope.is_some_and(|scope| session.allows(scope)) { + return Err(forbidden("service requires a registered pure GET and its exact read scope")); + } + if scope == Some("read.documents") { + return Err(service_documents_forbidden()); + } + Ok(()) +} + +/// No consumer-owned document namespace is declared: the allowlist is deliberately empty. +pub fn service_documents_forbidden() -> ApiError { + forbidden("service document namespace allowlist is empty") +} + +/// The only graph mutation admitted on behalf of a service read. The daemon signs as itself. +pub const SERVICE_AUDIT_KINDS: &[&str] = &["custom.client.service.v1.read-window"]; +const AUDIT_WINDOW_MS: u128 = 60_000; +static SERVICE_AUDIT: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +pub fn audit_service_read(state: &AppState, session: &ClientSession) -> Result<(), ApiError> { + let _guard = SERVICE_AUDIT.lock().map_err(ApiError::internal)?; + let grant = session.pairing_grant.as_deref().ok_or_else(|| forbidden("service grant absent"))?; + let window = client_now_ms() / AUDIT_WINDOW_MS; + let subject = format!("custom/client/service-read-{}-{window}", credential_digest(grant)); + let kind = SERVICE_AUDIT_KINDS[0]; + if latest(state, &subject, kind)?.is_some() { + return Ok(()); + } + state.store.append_claim(&ClaimInput { + subject, kind: kind.into(), actor: Some(format!("daemon/{}", state.store.origin())), + fields: serde_json::from_value(json!({ + "version":1, "grant":grant, "principal":session.authority_actor, + "window_start_unix_ms":window * AUDIT_WINDOW_MS, + "window_ms":AUDIT_WINDOW_MS + })).map_err(ApiError::internal)?, + evidence: Vec::new(), expected_subject: Some(None), idempotency_key: None, + }).map_err(ApiError::bad)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -381,3 +464,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "external_grants_service_tests.rs"] +mod service_tests; diff --git a/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs new file mode 100644 index 00000000000..a2e375820fc --- /dev/null +++ b/crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs @@ -0,0 +1,502 @@ +use super::*; +use axum::body::to_bytes; +use crate::PersonWorkStore; + +const TOKEN: &str = "service-test-private-bearer-never-in-a-claim"; +const ID: &str = "121212121212121212121212"; + +// This module is compiled only by rustc --test. No feature flag, route, or production +// caller can reach this fixture; production append still rejects Profile::Service. +fn fixture(root: &std::path::Path, scopes: &[&str]) -> (AppState, ClaimRecord) { + let mut state = crate::api::tests_shared::state(root); + state.owning_person = Some("person/owner".into()); + let grant = state.store.append_claim(&ClaimInput { + subject: subject(ID).unwrap(), kind: GRANTED.into(), actor: Some("person/owner".into()), + fields: serde_json::from_value(json!({ + "profile":"service", "principal":"plugin/test/reader", "grant_id":ID, + "session_actor":format!("plugin/test/reader/session/{ID}"), + "issuer":"person/owner", "expires_at_unix_ms":u64::MAX, + "credential_hash":credential_digest(TOKEN), "scopes":scopes + })).unwrap(), evidence: vec![], expected_subject: Some(None), idempotency_key: None, + }).unwrap(); + (state, grant) +} + +async fn call(app: &Router, method: &str, path: &str, body: Value, upgrade: bool) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path) + .header("authorization", format!("Bearer {TOKEN}")) + .header("x-st3-person", "person/owner") + .header("x-st3-actor", "agent/operator") + .header("content-type", "application/json"); + if upgrade { builder = builder.header("upgrade", "websocket").header("connection", "upgrade"); } + let response = app.clone().oneshot(builder.body(Body::from(body.to_string())).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let envelope: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let value = if status.is_success() { envelope.get("value").cloned().unwrap_or(envelope) } else { envelope }; + (status, value) +} + +#[test] +fn service_fixture_does_not_enable_production_issuance_or_free_mode() { + let root = tempfile::tempdir().unwrap(); + let (state, grant) = fixture(root.path(), SERVICE_SCOPES); + let service = session(&state, &grant, "unix").unwrap(); + assert!(!acting_party(&service)); + assert!(!service.local_peer); + assert!(!service.allows("read.declarations")); + assert!(append(&state, subject("343434343434343434343434").unwrap(), GRANTED, + "person/owner", fields(&grant).unwrap().clone(), None).is_err()); + assert!(assistant_profile(fields(&grant).unwrap()).is_err()); + assert!(state.store.append_client_claim(&ClaimInput { + subject:subject("343434343434343434343434").unwrap(), kind:GRANTED.into(), + actor:Some("person/owner".into()), + fields:serde_json::from_value(fields(&grant).unwrap().clone()).unwrap(), + evidence:vec![], expected_subject:None, idempotency_key:None, + }).is_err()); +} + +#[tokio::test] +async fn service_r29_claim_parity_paging_bounds_redaction_and_credential_secrecy() { + let root = tempfile::tempdir().unwrap(); + let (state, grant) = fixture(root.path(), SERVICE_SCOPES); + let mut expected = Vec::new(); + for n in 0..7 { + expected.push(state.store.append_claim(&ClaimInput { + subject:"custom/service/paging".into(), kind:"custom.service-test.v1.item".into(), + actor:Some("person/owner".into()), fields:serde_json::from_value(json!({"number":n, + "desired":{"environment":{"TOKEN":"environment-secret"}}, "declaration":"declaration-secret"})).unwrap(), + evidence:vec![], expected_subject:None, idempotency_key:None, + }).unwrap().store_index); + } + for gateway in [false, true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + let mut ids = Vec::new(); + let mut after = grant.store_index; + loop { + let (status, page) = call(&app, "GET", &format!("/v1/client/claims?limit=2&after={after}&before={}",expected.last().unwrap()+1), Value::Null, false).await; + assert_eq!(status, StatusCode::OK, "{page}"); + let serialized = page.to_string(); + assert!(!serialized.contains("environment-secret")); + assert!(!serialized.contains("declaration-secret")); + ids.extend(page["claims"].as_array().unwrap().iter().map(|claim| { + assert_eq!(claim.as_object().unwrap().len(), 2); + assert_eq!(claim["withheld"], true); + claim["store_index"].as_u64().unwrap() + })); + let Some(next) = page["next_cursor"].as_u64() else { break }; + assert!(next > after); + after = next; + } + assert_eq!(ids, expected); + for query in ["limit=0", "limit=501", "limit=wrong", "after=invalid", "before=invalid"] { + let (status, _) = call(&app, "GET", &format!("/v1/client/claims?{query}"), Value::Null, false).await; + assert!(status.is_client_error(), "{query}: {status}"); + } + for limit in [1, 500] { + assert_eq!(call(&app,"GET",&format!("/v1/client/claims?limit={limit}"),Value::Null,false).await.0, StatusCode::OK); + } + let (status, value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",grant.id),Value::Null,false).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{value}"); + let (missing_status, missing) = call(&app,"GET","/v1/client/claims/by-id/nonexistent",Value::Null,false).await; + assert_eq!(status, missing_status); + // Correlation ids are fresh per HTTP request, independent of claim existence. + let mut normalized = value.clone(); + if let Some(request_id) = normalized.get_mut("request_id") { + *request_id = missing["request_id"].clone(); + } + assert_eq!(normalized.to_string().replace(&grant.id,"nonexistent"),missing.to_string()); + assert!(!value.to_string().contains(&credential_digest(TOKEN))); + assert!(!value.to_string().contains(TOKEN)); + assert!(!value.to_string().contains("\"credential\":")); + if !gateway { + let (status, raw) = call(&app,"GET",&format!("/v1/claims/by-id/{}",grant.id),Value::Null,false).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(raw["code"], value["code"]); + assert_eq!(raw["message"], value["message"]); + let (absent_status, absent) = call(&app,"GET","/v1/claims/by-id/nonexistent",Value::Null,false).await; + assert_eq!(status,absent_status); + let mut normalized = raw; + normalized["request_id"] = absent["request_id"].clone(); + assert_eq!(normalized.to_string().replace(&grant.id,"nonexistent"),absent.to_string()); + } + } +} + +#[tokio::test] +async fn service_missing_scopes_are_denied_on_both_routers_without_audit() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), &[]); + let before = state.store.index().unwrap(); + for app in [crate::api::router(state.clone()), crate::api::fabric_router(state.clone())] { + for path in ["/v1/client/claims", "/v1/client/claims/by-id/absent", "/v1/client/documents", + "/v1/client/documents/content?name=doc/test", "/v1/client/work-items/absent", + "/v1/claims", "/v1/documents", "/v1/work-items/absent"] { + assert_eq!(call(&app,"GET",path,Value::Null,false).await.0,StatusCode::FORBIDDEN,"{path}"); + } + } + assert_eq!(state.store.index().unwrap(), before); +} + +fn files(root: &std::path::Path) -> BTreeMap> { + fn visit(root: &std::path::Path, path: &std::path::Path, result: &mut BTreeMap>) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { visit(root, &path, result); } + else { + let name = path.file_name().unwrap().to_string_lossy(); + // SQLite files are checked logically by the complete claims/document state. + if !name.contains("sqlite") && !name.ends_with("-wal") && !name.ends_with("-shm") && !name.ends_with(".db") { + result.insert(path.strip_prefix(root).unwrap().to_owned(), std::fs::read(path).unwrap()); + } + } + } + } + let mut result = BTreeMap::new(); + visit(root, root, &mut result); + result +} + +#[tokio::test] +async fn service_r30_both_router_refusal_matrix_preserves_graph_documents_processes_files() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + std::fs::write(root.path().join("must-not-change"), "sentinel").unwrap(); + let documents = state.store.list_documents_page(None,None,true,None,500).unwrap(); + let claims = state.store.claims_page(None,None,0,None,false,500).unwrap(); + let before_files = files(root.path()); + #[cfg(target_os = "linux")] + let processes = std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(); + let routes = [ + "/v1/claims", "/v1/documents", "/v1/client/claims", "/v1/client/documents", + "/v1/client/assistant/propose", "/v1/client/actions", "/v1/client/actions/messages.send", + "/v1/client/pairings", "/v1/client/pairings/test/complete", "/v1/client/assistant/grants/test", + "/v1/client/push-registration", "/v1/work/start", "/v1/work/done", "/v1/work/cancel-ask", + "/v1/work/wake/step-run/test", "/v1/work/retry/step-run/test", "/v1/work/claim/step-run/test", + "/v1/messages", "/v1/intent/apply", "/v1/intent/preview", "/v1/notes", + "/v1/sessions/input/agent/test", "/v1/sessions/agent/test/signal", "/v1/repair/apply", + "/v1/internal/fleet/redeem", "/v1/internal/replication/receive", "/v1/backup", "/v1/future-route", + ]; + // Bodies carry valid write fields as well as spoofed person and actor headers. Admission + // runs before extraction, so no endpoint-specific validation is the refusal mechanism. + let body = json!({"subject":"custom/service/mutation", "kind":"custom.service-test.v1.write", + "actor":"person/owner", "fields":{"value":"mutated"}, "evidence":[], + "name":"doc/service-mutation", "bytes":[109,117,116,97,116,101,100], "idempotency_key":"service-write", + "from":"person/owner", "to":"person/owner", "body":"mutated", "kdl":"agent \"changed\" {}"}); + for app in [crate::api::router(state.clone()), crate::api::fabric_router(state.clone())] { + for route in routes { + for method in ["POST", "PUT", "PATCH", "DELETE"] { + let (status, response) = call(&app,method,route,body.clone(),false).await; + assert_eq!(status,StatusCode::FORBIDDEN,"{method} {route}: {response}"); + } + } + for action in ACTIONS { + let path = format!("/v1/client/actions/{action}"); + assert_eq!(call(&app,"POST",&path,body.clone(),false).await.0,StatusCode::FORBIDDEN,"action {action}"); + } + for route in ["/v1/backup", "/v1/sessions/terminal/agent/test", "/v1/client/assistant/summary", + "/v1/client/devices", "/v1/internal/fleet/status", "/v1/future-route", "/v1/client/future-route", + "/v1/client/agents/test/declaration", "/v1/desired/agent/test", "/v1/health"] { + assert_eq!(call(&app,"GET",route,Value::Null,false).await.0,StatusCode::FORBIDDEN,"GET {route}"); + } + for route in ["/v1/client/claims", "/v1/client/documents", "/v1/client/work-items/test", "/v1/claims"] { + assert_eq!(call(&app,"GET",route,Value::Null,true).await.0,StatusCode::FORBIDDEN,"upgrade {route}"); + assert_eq!(call(&app,"HEAD",route,Value::Null,false).await.0,StatusCode::FORBIDDEN,"HEAD {route}"); + } + } + assert_eq!(serde_json::to_value(state.store.claims_page(None,None,0,None,false,500).unwrap()).unwrap(),serde_json::to_value(claims).unwrap()); + assert_eq!(serde_json::to_value(state.store.list_documents_page(None,None,true,None,500).unwrap()).unwrap(),serde_json::to_value(documents).unwrap()); + assert_eq!(files(root.path()),before_files); + #[cfg(target_os = "linux")] + assert_eq!(std::fs::read_to_string("/proc/self/task".to_owned() + &format!("/{}/children",std::process::id())).unwrap(),processes); +} + +#[tokio::test] +async fn service_audit_is_daemon_owned_closed_and_bounded_per_grant_window() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let before = state.store.index().unwrap(); + let app = crate::api::fabric_router(state.clone()); + for _ in 0..20 { + assert_eq!(call(&app,"GET","/v1/client/claims",Value::Null,false).await.0,StatusCode::OK); + } + let changed = state.store.claims_page(None,None,before,None,false,500).unwrap().claims; + // A run crossing a minute boundary may produce two windows, never twenty records. + assert!(!changed.is_empty() && changed.len() <= 2); + for claim in changed { + assert!(SERVICE_AUDIT_KINDS.contains(&claim.kind.as_str())); + assert_eq!(claim.actor,Some(format!("daemon/{}",state.store.origin()))); + assert_eq!(claim.body["fields"]["principal"],"plugin/test/reader"); + assert!(!serde_json::to_string(&claim).unwrap().contains(TOKEN)); + } +} + +#[tokio::test] +async fn service_r29_work_parity_and_empty_document_allowlist() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let source = r#"version 2 +agent "reader" { command "true"; name "Reader" } +mission "service-work" state="ready" { + goal "Read without acknowledging work." + step "work" { assigned-to "agent/node.reader" } +} +"#; + let intent = crate::graph::parse_test_intent(source, "node").unwrap(); + let plan = state.store.mission(&intent, crate::model::IntentInput { + kdl:source.into(), source_name:None, + }).unwrap(); + state.store.apply(&intent, &plan.subject_tokens, "service-parity").unwrap(); + let run = state.store.create_mission_run(&MissionRunRequest { + mission:"service-work".into(), revision:None, workspace:root.path().to_string_lossy().into_owned(), + requester:Some("person/owner".into()), mode:Some("run".into()), inputs:BTreeMap::new(), + idempotency_key:"service-run".into(), + }).unwrap(); + let subject = &run.steps[0].subject; + state.store.set_step_state(subject,"ready",None).unwrap(); + let action = |name: &str, summary: &str| state.store.work_action(subject,name,&crate::model::WorkRequest { + wait_ms:None,actor:Some("agent/node.reader".into()),incarnation:Some("service-runtime".into()), + summary:Some(summary.into()),reason:None,evidence:vec![],idempotency_key:format!("service-{name}-{summary}"), + }).unwrap(); + action("claim","claim"); + action("progress","worker-progress-secret-sentinel"); + let progress = state.store.step_run(subject).unwrap().unwrap(); + assert_eq!(progress.progress_summary.as_deref(),Some("worker-progress-secret-sentinel")); + for app in [crate::api::router(state.clone()),crate::api::fabric_router(state.clone())] { + let (status,view) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert!(!view.to_string().contains("worker-progress-secret-sentinel")); + } + let ask = state.store.ask_person(&crate::model::PersonAskRequest { + legacy_request:None,person:"person/owner".into(),title:"Private question".into(), + reason:"Private reason".into(),actor:"agent/node.reader".into(),step:Some(subject.clone()), + new_run:None,incarnation:Some("service-runtime".into()),request:None, + deadline_ms:None,default_answer:None,idempotency_key:"service-person-ask".into(), + }).unwrap(); + state.store.finish_person_step(&crate::model::PersonStepResponse { + delegation:None,subject:ask.subject,actor:"person/owner".into(), + summary:"person-answer-secret-sentinel".into(),evidence:vec![],episode:None, + answer:None,idempotency_key:"service-person-answer".into(), + },false).unwrap(); + // Answering resumes the origin as ready, with its old lease released. + action("claim","resume-after-answer"); + action("complete","worker-completion-secret-sentinel"); + let completed = state.store.step_run(subject).unwrap().unwrap(); + assert_eq!(completed.completion_summary.as_deref(),Some("worker-completion-secret-sentinel")); + assert!(serde_json::to_string(&completed.person_answers).unwrap().contains("person-answer-secret-sentinel")); + let expected = serde_json::to_value(state.store.step_run(subject).unwrap().unwrap()).unwrap(); + let document = state.store.put_document_as("doc/service-parity", b"service document", &None, "service-doc", None).unwrap(); + for gateway in [false, true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + let (status, work) = call(&app,"GET",&format!("/v1/client/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{work}"); + for sentinel in ["worker-progress-secret-sentinel","worker-completion-secret-sentinel","person-answer-secret-sentinel"] { + assert!(!work.to_string().contains(sentinel)); + } + for field in ["subject","run","generation","step","attempt","status","claimant", + "claim_incarnation","submitted_incarnation","claim_expires_at_unix_ms", + "readiness_epoch","worker_reported","created_at_unix_ms","updated_at_unix_ms"] { + assert_eq!(work[field],expected[field],"{field}"); + } + assert_eq!(work.as_object().unwrap().len(),15); + for field in ["goals","constraints","title","person_answers","progress_summary", + "completion_summary","blocked_reason","under"] { + assert!(work.get(field).is_none(),"{field}"); + } + for path in [ + "/v1/client/documents?prefix=doc/service-parity", + "/v1/client/documents?history=true", + "/v1/client/documents/content?name=doc/service-parity", + ] { + assert_eq!(call(&app,"GET",path,Value::Null,false).await.0,StatusCode::FORBIDDEN); + } + if !gateway { + let (status, raw) = call(&app,"GET",&format!("/v1/work-items/{subject}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert_eq!(raw,work); + assert_eq!(call(&app,"GET","/v1/documents?history=true",Value::Null,false).await.0,StatusCode::FORBIDDEN); + assert_eq!(call(&app,"GET",&format!("/v1/documents/content?reference=doc/service-parity@{}",document.hash),Value::Null,false).await.0,StatusCode::FORBIDDEN); + } + } +} + +#[tokio::test] +async fn service_receipt_bodies_use_only_the_closed_field_schema() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(), SERVICE_SCOPES); + let receipt = state.store.append_claim(&ClaimInput { + subject:"gate-operation/service/work/review".into(),kind:"gate.result".into(), + actor:Some("person/owner".into()), + fields:serde_json::from_value(json!({"verdict":"pass","gate":"gate-name-secret-sentinel","baseline":true, + "value":{"declarations_kdl":"receipt-secret-sentinel","code_hash":"other-verifier"}, + "reason":"private-free-text"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for app in [crate::api::router(state.clone()),crate::api::fabric_router(state.clone())] { + let (status,value) = call(&app,"GET",&format!("/v1/client/claims/by-id/{}",receipt.id),Value::Null,false).await; + assert_eq!(status,StatusCode::OK,"{value}"); + assert_eq!(value["body"],json!({"fields":{"verdict":"pass","baseline":true}})); + assert!(!value.to_string().contains("gate-name-secret-sentinel")); + assert!(!value.to_string().contains("receipt-secret-sentinel")); + assert!(!value.to_string().contains("other-verifier")); + assert!(!value.to_string().contains("private-free-text")); + } +} + +async fn page_wire(app: &Router, path: &str) -> (StatusCode, String) { + let response = app.clone().oneshot(Request::builder().uri(path) + .header("authorization",format!("Bearer {TOKEN}")) + .body(Body::empty()).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(),usize::MAX).await.unwrap(); + let mut wire = String::from_utf8(bytes.to_vec()).unwrap(); + let envelope: Value = serde_json::from_str(&wire).unwrap(); + if let Some(correlation) = envelope["request_id"].as_str() { + wire = wire.replace(correlation,"request/test"); + } + (status,wire) +} + +#[tokio::test] +async fn service_selectors_match_only_disclosed_claim_kinds() { + let root = tempfile::tempdir().unwrap(); + let (state, _) = fixture(root.path(),SERVICE_SCOPES); + let mut withheld_indexes = Vec::new(); + let hidden_subject = "gate-operation/service/selected"; + let mixed_subject = "gate-operation/service/mixed"; + let hidden_owner = "mission-run/service-selected"; + let mixed_owner = "mission-run/service-mixed"; + for (subject,owner) in [(hidden_subject,hidden_owner),(mixed_subject,mixed_owner)] { + for n in 0..7 { + let claim = state.store.append_claim(&ClaimInput { + subject:subject.into(),kind:"gate.reviewer-reassigned".into(), + actor:None,fields:serde_json::from_value(json!({"request":format!("request-{n}")})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + withheld_indexes.push(claim.store_index); + if n == 0 { + state.store.connection.lock().unwrap().execute( + /* sqlite-large-value: allow-for-now lv-h16df3901f435 owner=fractal_api_core::api::client_v0::external_grants_service_tests TODO=bound-or-reference-values-and-reclamation */ + "INSERT INTO desired(subject,kind,revision,claim_id,body,owner_run) VALUES (?1,'gate-operation',?2,?2,'{}',?3)", + rusqlite::params![subject,claim.id,owner], + ).unwrap(); + } + } + } + assert_eq!(state.store.claims_page(None,Some(hidden_owner),0,None,false,500).unwrap().claims.len(),7); + let receipt = state.store.append_claim(&ClaimInput { + subject:mixed_subject.into(),kind:"gate.result".into(),actor:None, + fields:serde_json::from_value(json!({"verdict":"pass"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for gateway in [false,true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + call(&app,"GET","/v1/client/claims?limit=1",Value::Null,false).await; + for prefix in if gateway { vec!["/v1/client/claims"] } else { vec!["/v1/client/claims","/v1/claims"] } { + for suffix in ["&limit=1","&limit=500&order=desc","&limit=2&after=1&before=9223372036854775807"] { + for (selected,absent) in [ + (format!("subject={hidden_subject}"),"subject=gate-operation/service/unused".to_owned()), + (format!("subject_prefix={hidden_subject}"),"subject_prefix=gate-operation/service/unused".to_owned()), + ("kind=gate.reviewer-reassigned".to_owned(),"kind=gate.unknown".to_owned()), + (format!("subject_prefix={mixed_subject}&kind=gate.reviewer-reassigned"),"subject_prefix=gate-operation/service/unused&kind=gate.reviewer-reassigned".to_owned()), + (format!("subject={mixed_subject}&kind=gate.reviewer-reassigned"),"subject=gate-operation/service/unused&kind=gate.reviewer-reassigned".to_owned()), + (format!("owner_run={hidden_owner}"),"owner_run=mission-run/service-unused".to_owned()), + (format!("subject={hidden_subject}&owner_run={hidden_owner}"),"subject=gate-operation/service/unused&owner_run=mission-run/service-unused".to_owned()), + ] { + let actual = page_wire(&app,&format!("{prefix}?{selected}{suffix}")).await; + let missing = page_wire(&app,&format!("{prefix}?{absent}{suffix}")).await; + assert_eq!(actual,missing,"filtered empty pages are byte-identical except fresh correlation ids"); + assert_eq!(actual.0,StatusCode::OK); + let envelope: Value = serde_json::from_str(&actual.1).unwrap(); + let page = envelope.get("value").unwrap_or(&envelope); + assert_eq!(page,&json!({"claims":[],"next_cursor":null})); + } + for selector in [format!("subject={mixed_subject}"),format!("owner_run={mixed_owner}"), + format!("subject={mixed_subject}&owner_run={mixed_owner}"), + format!("subject_prefix={mixed_subject}"),"kind=gate.result".to_owned(), + format!("subject_prefix={mixed_subject}&kind=gate.result"), + format!("subject={mixed_subject}&kind=gate.result"), + format!("owner_run={mixed_owner}&kind=gate.result")] { + let (status,page) = call(&app,"GET",&format!("{prefix}?{selector}{suffix}"),Value::Null,false).await; + assert_eq!(status,StatusCode::OK); + assert!(page["next_cursor"].is_null()); + let claims = page["claims"].as_array().unwrap(); + assert_eq!(claims.len(),1); + assert_eq!(claims[0]["id"],receipt.id); + for claim in claims { + assert!(claim.get("withheld").is_none()); + assert!(!withheld_indexes.contains(&claim["store_index"].as_u64().unwrap())); + } + } + } + } + } +} + +#[tokio::test] +async fn service_subject_selectors_are_literal_canonical_names() { + use crate::store::identities::{IdentityKind, IdentityStore, RenameRequest}; + let root = tempfile::tempdir().unwrap(); + let (state,_) = fixture(root.path(),SERVICE_SCOPES); + let source = r#"version 2 +agent "literal-reader" { command "true" } +mission "literal-service" state="ready" { + goal "Exercise canonical selectors." + step "work" { assigned-to "agent/node.literal-reader" } +} +"#; + let intent = crate::graph::parse_test_intent(source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"literal-selectors").unwrap(); + let identities = [ + (IdentityKind::Agent,"agent/node.literal-reader","agent/node.literal-reader-alias","agent.renamed"), + (IdentityKind::Mission,"mission/literal-service","mission/literal-service-alias","mission.renamed"), + ]; + for (kind,subject,alias,_) in identities { + state.store.rename_identity(kind,&RenameRequest { + from:subject.into(),to:alias.into(),actor:"person/owner".into(), + reason:"Exercise canonical selectors.".into(),expected_naming_revision:None, + expected_declaration:None,idempotency_key:format!("literal-{subject}"), + },&[]).unwrap(); + assert_eq!(crate::store::identities::resolve_reference_conn(&state.store.readers.get(),alias).unwrap(),subject); + } + for lifecycle in ["unknown","invalid"] { + for (_,subject,_,kind) in identities { + state.store.connection.lock().unwrap().execute( + /* sqlite-large-value: allow-for-now lv-h6825b80ba287 owner=fractal_api_core::api::client_v0::external_grants_service_tests TODO=chunk-or-reference-exact-wire-values */ + "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position,raw,state,subject_hint,kind_hint,updated_at_unix_ms) + VALUES(?1,'test-peer',1,?1,0,'{}',?2,?3,?4,'1')", + rusqlite::params![format!("record/{lifecycle}/{subject}"),lifecycle,subject,kind], + ).unwrap(); + } + for (_,subject,alias,_) in identities { + for name in [subject,alias] { + let error = crate::store::identities::resolve_reference_conn(&state.store.readers.get(),name).unwrap_err(); + assert_eq!(error.code,"lifecycle-unavailable"); + assert_eq!(error.details["reason"],if lifecycle == "invalid" { "conflict" } else { "pending-replication" }); + } + } + for gateway in [false,true] { + let app = if gateway { crate::api::fabric_router(state.clone()) } else { crate::api::router(state.clone()) }; + call(&app,"GET","/v1/client/claims?limit=1",Value::Null,false).await; + for prefix in if gateway { vec!["/v1/client/claims"] } else { vec!["/v1/client/claims","/v1/claims"] } { + for (_,subject,alias,_) in identities { + for name in [subject,alias] { + for owner in ["","&owner_run=mission-run/literal-unused"] { + for bounds in ["&limit=1","&limit=500&order=desc","&after=1&before=9223372036854775807"] { + let selected = page_wire(&app,&format!("{prefix}?subject={name}{owner}{bounds}")).await; + let absent = page_wire(&app,&format!("{prefix}?subject=agent/node.literal-unused{owner}{bounds}")).await; + assert_eq!(selected,absent); + assert_eq!(selected.0,StatusCode::OK); + } + } + } + } + } + } + let native = crate::api::router(state.clone()); + let response = native.oneshot(Request::builder().uri("/v1/claims?subject=agent/node.literal-reader") + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::CONFLICT); + } +} diff --git a/crates/fractal-api-core/src/api/custom.rs b/crates/fractal-api-core/src/api/custom.rs index a0bfa0c51a6..ae991e81485 100644 --- a/crates/fractal-api-core/src/api/custom.rs +++ b/crates/fractal-api-core/src/api/custom.rs @@ -436,7 +436,7 @@ mod tests { assert!(!second.page.has_more); assert!(second.page.next_cursor.is_none()); assert!(second.page.cursor_expires_at.is_none()); - let tail = records::generic_claims_page(&state.store, Default::default(), None, 0, Some(oldest.store_index + 1), true, 1).unwrap(); + let tail = records::generic_claims_page(&state.store, Default::default(), None, 0, Some(oldest.store_index + 1), true, 1, None).unwrap(); assert_eq!(tail.claims[0].id, oldest.id); assert!(tail.next_cursor.is_none(), "hidden tail must not create another page"); } diff --git a/crates/fractal-api-core/src/api/published_lists_tests.rs b/crates/fractal-api-core/src/api/published_lists_tests.rs index f08a29010f5..4dbd18529ab 100644 --- a/crates/fractal-api-core/src/api/published_lists_tests.rs +++ b/crates/fractal-api-core/src/api/published_lists_tests.rs @@ -287,9 +287,110 @@ fn fold_work_checked(store: &Store) -> (Arc>, bool) { } } } + assert_eq!( + work_oracle(store, time, Some("garden/birch"), usize::MAX), + work_oracle(store, time, Some("agent/garden/birch"), usize::MAX), + "legacy and canonical actor filters preserve the same direct seat-queue order" + ); + let (legacy, _) = publication.rows.actor_rows("garden/birch"); + let (canonical, built) = publication.rows.actor_rows("agent/garden/birch"); + assert!(!built, "a canonical actor reuses its legacy filter's order"); + assert!(Arc::ptr_eq(&legacy, &canonical), "both spellings share one actor order"); (publication, changed) } +#[test] +fn published_work_keeps_persisted_legacy_assignments_and_available_seats_visible() { + let _clock = Clock::at(start_time()); + for available in [false, true] { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("graph.db"); + let store = Store::open(&path, "cedar").unwrap(); + mission(&store, "garden/alpha"); + let run = start(&store, "garden/alpha", "legacy-work-list"); + let (subject, _) = step(&run, "plant"); + store.set_step_state(&subject, "ready", None).unwrap(); + // Retain an actual older member's projection spelling, rather than testing two empty + // actor filters against a row already canonicalized by the current writer. + let connection = rusqlite::Connection::open(&path).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); + let assignee = (!available).then_some("garden/ash"); + let candidates = if available { r#"["garden/ash"]"# } else { "[]" }; + connection.execute( + /* sqlite-large-value: allow-for-now lv-h16928b5eb896 owner=fractal_api_core::api::published_lists_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1", + rusqlite::params![subject, assignee, candidates], + ).unwrap(); + drop(connection); + let (publication, _) = fold_work_checked(&store); + for actor in ["garden/ash", "agent/garden/ash"] { + let (indexes, _) = publication.rows.actor_rows(actor); + assert_eq!(indexes.len(), 1, "{actor}, available={available}"); + assert_eq!(publication.rows.order[indexes[0] as usize], subject); + assert_eq!( + work_oracle(&store, publication.rows.time_unix_ms, Some(actor), usize::MAX).0.len(), + 1, + "the persisted legacy row is visible in the direct read too" + ); + } + } +} + +#[test] +fn mixed_spelling_ready_rows_keep_queue_order_in_direct_and_published_work() { + for (legacy, canonical) in [ + ("node/worker", "agent/node/worker"), + ("node.builder", "agent/node.builder"), + ] { + let _clock = Clock::at(start_time()); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("graph.db"); + let store = Store::open(&path, "cedar").unwrap(); + mission(&store, "garden/alpha"); + let first = start(&store, "garden/alpha", "mixed-first"); + let second = start(&store, "garden/alpha", "mixed-second"); + let (first_subject, _) = step(&first, "plant"); + let (second_subject, _) = step(&second, "plant"); + for subject in [&first_subject, &second_subject] { + store.set_step_state(subject, "ready", None).unwrap(); + } + let connection = rusqlite::Connection::open(&path).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); + for (subject, assignee, created) in [ + (&first_subject, if legacy.contains('/') { legacy } else { canonical }, "10"), + (&second_subject, canonical, "20"), + ] { + connection.execute( + /* sqlite-large-value: allow-for-now lv-hb7cf1dc01667 owner=fractal_api_core::api::published_lists_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1", + rusqlite::params![subject, assignee, created], + ).unwrap(); + } + drop(connection); + store.move_seat_queue_run(&crate::model::SeatQueueMoveRequest { + agent: canonical.into(), + run: second.id.clone(), + placement: "top".into(), + anchor: None, + reason: Some("reverse readiness order".into()), + actor: "person/operator".into(), + idempotency_key: "reverse-ready-order".into(), + }).unwrap(); + let (publication, _) = fold_work_checked(&store); + for actor in [legacy, canonical] { + let order = store.seat_run_order(actor).unwrap(); + let work = store.client_work_at_snapshot(Some(actor), false, publication.rows.time_unix_ms).unwrap(); + let steps = work.iter().map(crate::seat_queue::SeatStep::from).collect::>(); + let selected = crate::seat_queue::select(canonical, &steps, &order); + assert_eq!(selected.ready, [&*second_subject, &*first_subject], + "both spellings honor the explicit queue order, opposite readiness order"); + let direct = work_oracle(&store, publication.rows.time_unix_ms, Some(actor), usize::MAX).0; + let (indexes, _) = publication.rows.actor_rows(actor); + assert_eq!(indexes.iter().map(|&index| publication.rows.order[index as usize].as_str()) + .collect::>(), [&*second_subject, &*first_subject]); + assert_eq!(publication.rows.page_of(Some(&indexes), 0, usize::MAX).0, direct); + } + } +} + #[test] fn published_work_matches_the_direct_read_for_every_actor_as_time_passes() { let started = start_time(); diff --git a/crates/fractal-api-core/src/api/ready_work_tests.rs b/crates/fractal-api-core/src/api/ready_work_tests.rs index ed46676dc90..f58376b5978 100644 --- a/crates/fractal-api-core/src/api/ready_work_tests.rs +++ b/crates/fractal-api-core/src/api/ready_work_tests.rs @@ -397,28 +397,29 @@ async fn ready_work_refuses_authenticated_assistant_and_external_service_grants( let (_root, mut state, _) = fixture(); state.owning_person = Some("person/operator".into()); let id = "111111111111111111111111"; + let principal = if profile == "assistant" { "assistant/chatgpt" } else { "plugin/test/reader" }; + let scopes = if profile == "assistant" { &["read.assistant.summary"][..] } else { external_grants::SERVICE_SCOPES }; grant_claim(&state, &external_grants::subject(id).unwrap(), GRANTED, json!({ - "profile": profile, "principal": "assistant/chatgpt", "grant_id": id, - "session_actor": format!("assistant/chatgpt/session/{id}"), "issuer": "person/operator", + "profile": profile, "principal": principal, "grant_id": id, + "session_actor": format!("{principal}/session/{id}"), "issuer": "person/operator", "credential_hash": client_v0::credential_digest("ready-demand-external"), "expires_at_unix_ms": u64::MAX, "minimal_read_mode": "approval-once", - "disclosure_consent": true, "scopes": ["read.assistant.summary"] + "disclosure_consent": true, "scopes": scopes })); - if profile == "assistant" { - // Prove this bearer authenticates: refusal is the explicit route allowlist, - // not an absent, malformed, expired, or otherwise unusable grant. - let session = external_grants::authenticate(&state, - &client_v0::credential_digest("ready-demand-external"), "unix").unwrap().unwrap(); - assert_eq!(session.authority_actor, "assistant/chatgpt"); - } + // Both bearers authenticate: refusal must come from their closed route + // registries, not malformed principals, scopes or unusable grants. + let session = external_grants::authenticate(&state, + &client_v0::credential_digest("ready-demand-external"), "unix").unwrap().unwrap(); + assert_eq!(session.authority_actor, principal); + assert_eq!(external_grants::is_service(&session), profile == "service"); let response = router(state).oneshot(Request::builder().uri(PATH) .header("authorization", "Bearer ready-demand-external") .body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let error: Value = serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); assert_eq!(error["code"], "forbidden"); - assert!(error["message"].as_str().unwrap().contains(if profile == "assistant" { - "explicit proof-bearing read projections" - } else { "service grant issuance is disabled" }), "{error}"); + assert_eq!(error["message"], if profile == "assistant" { + "assistants may access only explicit proof-bearing read projections" + } else { "service requires a registered pure GET and its exact read scope" }, "{error}"); } } diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index fe8fc0150cc..9dcb55fc08a 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -4,6 +4,8 @@ use crate::LanesStore; use crate::PersonWorkStore; use crate::EvidenceStore; use super::*; +use super::client_v0::{ClientSession, require_scope}; +use rusqlite::OptionalExtension as _; pub(super) fn generic_claim_visible(claim: &ClaimRecord) -> bool { !fractal_schema::notice::is_reserved_subject(&claim.subject) @@ -11,6 +13,7 @@ pub(super) fn generic_claim_visible(claim: &ClaimRecord) -> bool { } /// Fill a serving page with visible claims, leaving raw replication/projector reads intact. +#[allow(clippy::too_many_arguments)] pub(super) fn generic_claims_page( store: &Store, filter: fractal_store::store::ClaimsFilter<'_>, @@ -19,10 +22,11 @@ pub(super) fn generic_claims_page( mut before: Option, descending: bool, limit: usize, + kinds: Option<&[&str]>, ) -> anyhow::Result { let mut claims = Vec::with_capacity(limit.saturating_add(1)); loop { - let page = store.claims_page_matching(filter, owner_run, after, before, descending, limit.saturating_add(1))?; + let page = store.claims_page_matching_with_kinds(filter, owner_run, after, before, descending, limit.saturating_add(1), kinds)?; let next = page.next_cursor; for claim in page.claims.into_iter().filter(generic_claim_visible) { claims.push(claim); @@ -286,17 +290,56 @@ pub async fn post_claim( pub async fn get_claim( State(state): State, AxumPath(id): AxumPath, -) -> Result, ApiError> { + session: Option>, +) -> Result, ApiError> { + let service = session.as_ref().is_some_and(|session| + client_v0::external_grants::is_service(&session.0)); + if let Some(session) = &session + && client_v0::external_grants::is_service(&session.0) { + require_scope(&session.0, "read.claims")?; + } let store = state.store.clone(); let id_for_read = id.clone(); - blocking_store(move || store.claim_by_id(&id_for_read)) + blocking_store(move || { + if service { + // The id index resolves only kind. Hidden and absent rows take the + // same path without loading or parsing any potentially large body. + let kind: Option = store.readers.get() + .query_row("SELECT kind FROM claims WHERE id=?1", [&id_for_read], + |row| row.get(0)) + .optional()?; + if !kind.as_deref().is_some_and(|kind| service_claim_fields(kind).is_some()) { + return Ok(None); + } + } + store.claim_by_id(&id_for_read) + }) .await? .filter(|claim| !claim.subject.starts_with("glass/")) .filter(generic_claim_visible) - .map(Json) + .map(|claim| Json(if service { + service_claim(&claim) + } else { json!(claim) })) .ok_or_else(|| ApiError::not_found(format!("claim `{id}` does not exist"))) } +/// These aliases are a service surface, not a new projection capability for devices. +pub async fn client_get_claim( + state: State, + id: AxumPath, + session: Extension, +) -> Result, ApiError> { + require_service_claims(&session.0)?; + get_claim(state, id, Some(session)).await +} + +fn require_service_claims(session: &ClientSession) -> Result<(), ApiError> { + if !client_v0::external_grants::is_service(session) { + return Err(client_v0::forbidden("client claim reads require a service principal")); + } + require_scope(session, "read.claims") +} + #[derive(Deserialize)] pub struct ClaimsQuery { subject: Option, @@ -313,6 +356,14 @@ pub struct ClaimsQuery { limit: usize, } +impl ClaimsQuery { + fn has_selector(&self) -> bool { + // Every new query field requires an explicit selector-admission decision. + let Self { subject, subject_prefix, kind, owner_run, after_index: _, before_index: _, order: _, limit: _ } = self; + subject.is_some() || subject_prefix.is_some() || kind.is_some() || owner_run.is_some() + } +} + #[derive(Clone, Copy, Default, Deserialize)] #[serde(rename_all = "lowercase")] enum ClaimsOrder { @@ -324,18 +375,30 @@ enum ClaimsOrder { pub async fn list_claims( State(state): State, Query(query): Query, -) -> Result, ApiError> { + session: Option>, +) -> Result, ApiError> { + if let Some(session) = &session + && client_v0::external_grants::is_service(&session.0) { + require_scope(&session.0, "read.claims")?; + } if query.limit == 0 || query.limit > 500 { return Err(ApiError::bad(FractalError::new( "invalid-page-limit", "a claim page limit must be between 1 and 500", ))); } + let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); let store = state.store.clone(); - blocking_store(move || { - let subject = query.subject.as_deref().map(|subject| { - crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) - }).transpose()?; + let page = blocking_store(move || { + let kinds = (service && query.has_selector()).then_some(SERVICE_CLAIM_KINDS); + let subject = if service { + // Services select literal canonical subjects, without alias resolution. + query.subject + } else { + query.subject.as_deref().map(|subject| { + crate::store::identities::resolve_reference_conn(&store.readers.get(), subject) + }).transpose()? + }; generic_claims_page( &store, fractal_store::store::ClaimsFilter { @@ -348,10 +411,62 @@ pub async fn list_claims( query.before_index, matches!(query.order, ClaimsOrder::Desc), query.limit, + kinds, ) }) - .await - .map(Json) + .await?; + Ok(Json(if service { + json!({"claims":page.claims.iter().map(service_claim).collect::>(), + "next_cursor":page.next_cursor}) + } else { json!(page) })) +} + +pub async fn client_list_claims( + state: State, + query: Query, + session: Extension, +) -> Result, ApiError> { + require_service_claims(&session.0)?; + list_claims(state, query, Some(session)).await +} + +/// Closed R29 receipt/state schema. Unknown rows reveal only a paging position. +fn service_claim(claim: &ClaimRecord) -> Value { + let Some(fields) = service_claim_fields(&claim.kind) else { + return json!({"store_index":claim.store_index, "withheld":true}); + }; + let mut value = json!({ + "id":claim.id, "kind":claim.kind, "subject":claim.subject, "origin":claim.origin, + "store_index":claim.store_index, "accepted_at_unix_ms":claim.accepted_at_unix_ms, + "body_digest":client_v0::credential_digest(&claim.body.to_string()), + }); + value["actor"] = json!(claim.actor); + let source = claim.body.get("fields").unwrap_or(&claim.body); + let projected = fields.iter().filter_map(|field| { + source.get(*field).filter(|value| value.is_string() || value.is_number() || value.is_boolean()) + .map(|value| ((*field).to_owned(), value.clone())) + }).collect::>(); + value["body"] = json!({"fields":projected}); + value +} + +const SERVICE_CLAIM_KINDS: &[&str] = &[ + "gate.result", "work.claimed", "work.renewed", "work.progress", "work.submitted", + "work.failed", "work.released", "work.extended", +]; + +fn service_claim_fields(kind: &str) -> Option<&'static [&'static str]> { + if kind == "gate.result" { + Some(&["verdict", "operation", "request", "baseline", "token_usage", "stage"]) + } else if SERVICE_CLAIM_KINDS.contains(&kind) { + Some(&[ + "attempt", "status", "worker_reported", "claimant", "claim_incarnation", + "claim_expires_at_unix_ms", "readiness_epoch", "extend_ms", "clock", + "wait_until_unix_ms", "submitted_incarnation", + ]) + } else { + None + } } #[derive(Default, Deserialize)] diff --git a/crates/fractal-api-core/src/api/request_latency.rs b/crates/fractal-api-core/src/api/request_latency.rs index 9de20cfc29d..33c4cf44174 100644 --- a/crates/fractal-api-core/src/api/request_latency.rs +++ b/crates/fractal-api-core/src/api/request_latency.rs @@ -1,7 +1,7 @@ //! Completed response-envelope timings. Action buckets are subsets of route totals. //! -//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each target in -//! `slo/targets.toml` keeps the same windows over all its paths. +//! Each path also keeps rolling 1-minute, 5-minute and 1-hour windows, and each latency objective +//! in `slo/objectives.kdl` keeps the same windows over all its paths. use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; @@ -246,7 +246,7 @@ pub struct Meter { work_stages: BTreeMap, /// Windows by path: `GET /route`, `stream COLLECTION`, or a long poll's route. paths: BTreeMap, - /// Windows by target position in `slo/targets.toml`: served here, then from another machine. + /// Windows by target position in `slo/objectives.kdl`: served here, then from another machine. targets: Vec<[smallclaims::windows::Series; 2]>, /// The member's CPU by closed minute: the daemon's and its replication workers'. cpu: smallclaims::windows::cpu_minutes::CpuMinutes, diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index a2c1b5bc70f..187df9c5c98 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -15,6 +15,43 @@ use axum::body::to_bytes; use axum::http::Request; use std::path::PathBuf; +#[tokio::test] +async fn opted_in_pairing_advertisement_matches_the_canonical_schema() { + let root = tempfile::tempdir().unwrap(); + let state = super::tests_shared::state(root.path()); + let app = Router::new() + .fallback(|| async { StatusCode::IM_A_TEAPOT }) + .layer(axum::middleware::from_fn_with_state( + (state, ClientTransportBoundary::FabricLoopback), response_envelope, + )); + let contract: Value = serde_json::from_str(include_str!( + "../../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let schema = json!({"$ref":"#/$defs/PairingAdvertisement","$defs":contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + for query in [ + "", + "?accepts=pairing-key-binding", + "?accepts=pairing-key-binding&accepts=fabric-gateway-grant", + ] { + let response = app.clone().oneshot(Request::builder() + .uri(format!("/v1/client/capabilities{query}")) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX).await.unwrap() + ).unwrap(); + assert_eq!(body["capabilities"].as_array().unwrap().iter().any(|capability| { + capability["id"] == "pairing-key-binding" && capability["version"] == 2 + }), !query.is_empty()); + assert!(validator.is_valid(&body), "{body}"); + let mut duplicated = body.clone(); + let capabilities = duplicated["capabilities"].as_array_mut().unwrap(); + capabilities.push(capabilities[0].clone()); + assert!(!validator.is_valid(&duplicated), "duplicate capability IDs must be refused"); + } +} + #[cfg(target_os = "linux")] #[test] fn incomplete_linux_environment_is_unavailable() { diff --git a/crates/fractal-api-core/src/api/work.rs b/crates/fractal-api-core/src/api/work.rs index 75a2e357a28..3839e4d3ef8 100644 --- a/crates/fractal-api-core/src/api/work.rs +++ b/crates/fractal-api-core/src/api/work.rs @@ -4,6 +4,7 @@ use crate::MissionsStoreCore; use crate::PersonWorkStore; use crate::SeatsStoreCore; use super::*; +use super::client_v0::{ClientSession, require_scope}; /// Only a locally bound harness may recover a missing work fence from its own runtime. /// Explicit fences are retained so the action's validation can refuse stale callers. @@ -177,10 +178,72 @@ pub async fn list_work( Ok(Json(work)) } +/// Deliberately independent of the rich native work view: adding a native field +/// cannot grant service disclosure rights. +#[derive(serde::Serialize)] +struct ServiceWork<'a> { + subject: &'a str, + run: &'a str, + generation: &'a str, + step: &'a str, + attempt: u32, + status: &'a str, + claimant: &'a Option, + claim_incarnation: &'a Option, + submitted_incarnation: &'a Option, + claim_expires_at_unix_ms: Option, + readiness_epoch: u32, + worker_reported: bool, + created_at_unix_ms: u128, + updated_at_unix_ms: u128, + clock: ServiceWorkClock, +} + +#[derive(serde::Serialize)] +struct ServiceWorkClock { + started_at_unix_ms: Option, + paused_until_unix_ms: Option, + waited_ms: u128, + elapsed_ms: u128, +} + +impl<'a> From<&'a StepRunView> for ServiceWork<'a> { + fn from(step: &'a StepRunView) -> Self { + Self { + subject: &step.subject, + run: &step.run, + generation: &step.generation, + step: &step.step, + attempt: step.attempt, + status: &step.status, + claimant: &step.claimant, + claim_incarnation: &step.claim_incarnation, + submitted_incarnation: &step.submitted_incarnation, + claim_expires_at_unix_ms: step.claim_expires_at_unix_ms, + readiness_epoch: step.readiness_epoch, + worker_reported: step.worker_reported, + created_at_unix_ms: step.created_at_unix_ms, + updated_at_unix_ms: step.updated_at_unix_ms, + clock: ServiceWorkClock { + started_at_unix_ms: step.execution_started_at_unix_ms, + paused_until_unix_ms: step.execution_paused_until_unix_ms, + waited_ms: step.execution_waited_ms, + elapsed_ms: step.execution_elapsed_ms, + }, + } + } +} + pub async fn get_work( State(state): State, AxumPath(subject): AxumPath, -) -> Result, ApiError> { + session: Option>, +) -> Result { + let service = session.as_ref().is_some_and(|session| client_v0::external_grants::is_service(&session.0)); + if let Some(session) = &session + && service { + require_scope(&session.0, "read.work")?; + } let subject = if subject.starts_with("step-run/") { subject } else { @@ -191,11 +254,28 @@ pub async fn get_work( .step_run(&subject) .map_err(ApiError::internal)? .ok_or_else(|| ApiError::not_found(format!("step run `{subject}` does not exist")))?; - // A seat whose host keeps its receipts acknowledges a wake on that host. - super::message_reads::read_work_wakes(&state, std::iter::once(&mut step)) - .await - .map_err(ApiError::internal)?; - Ok(Json(step)) + // Services observe work without acknowledging wakes or changing delivery state. + if !service { + super::message_reads::read_work_wakes(&state, std::iter::once(&mut step)) + .await + .map_err(ApiError::internal)?; + } + Ok(if service { + Json(ServiceWork::from(&step)).into_response() + } else { + Json(step).into_response() + }) +} + +pub async fn client_get_work( + state: State, + subject: AxumPath, + session: Extension, +) -> Result { + if !client_v0::external_grants::is_service(&session.0) { + return Err(client_v0::forbidden("client work reads require a service principal")); + } + get_work(state, subject, Some(session)).await } pub async fn wake_work( diff --git a/crates/fractal-api-core/src/creation.rs b/crates/fractal-api-core/src/creation.rs index ba33485db07..c26ccb5cd8c 100644 --- a/crates/fractal-api-core/src/creation.rs +++ b/crates/fractal-api-core/src/creation.rs @@ -154,8 +154,12 @@ pub fn terminal_document( creation_key: &str, ) -> String { let identity = format!("{person}/{id}"); - // With no directory named, the shell starts in the host user's home: a service's own working - // directory is `/` (Nathan, 2026-10-05). A home that is unset or missing leaves it where it is. + // The launching runtime resolves this intent, not a potentially remote API client. + let mut tags = creation_tags(creation_key); + if cwd == "." { + tags.entries_mut().push(KdlEntry::new_prop("st3.cwd", "home")); + } + // Older launching hosts ignore the home intent, so keep the shell-side cd during rollout. let command = if cwd == "." { "cd \"${HOME:-.}\" 2>/dev/null; exec \"${SHELL:-/bin/sh}\" -i" } else { @@ -169,7 +173,7 @@ pub fn terminal_document( kdl_node("workspace", [cwd]), kdl_node("command", [command]), kdl_node("restart", ["never"]), - creation_tags(creation_key), + tags, ]); terminal.set_children(body); publication_document(terminal) @@ -275,16 +279,25 @@ mod tests { ..Default::default() }; let plain = terminal_document("person/example", "one", &args, "host", ".", "key"); - // The command is quoted for KDL, so its own quotes are escaped. assert!( plain.contains("cd \\\"${HOME:-.}\\\" 2>/dev/null; exec"), "{plain}" ); assert!(plain.contains("${SHELL:-/bin/sh}"), "{plain}"); + let document = plain.parse::().unwrap(); + let body = document.nodes()[1].children().unwrap(); + let tags = body.get("tags").unwrap(); + assert_eq!(tags.get("st3.cwd").unwrap().as_string(), Some("home")); let placed = terminal_document("person/example", "two", &args, "host", "/srv/work", "key"); assert!(!placed.contains("HOME"), "{placed}"); - assert!(placed.contains("workspace \"/srv/work\""), "{placed}"); - // The command really lands in HOME, and falls back when HOME is unset or missing. + let document = placed.parse::().unwrap(); + let body = document.nodes()[1].children().unwrap(); + assert!(body.get("tags").unwrap().get("st3.cwd").is_none()); + assert_eq!( + body.get("workspace").unwrap().get(0).unwrap().as_string(), + Some("/srv/work") + ); + // The command also lands in HOME when an older runtime launches it. for (home, expected) in [(Some("/tmp"), "/tmp"), (Some("/no/such/dir"), "/")] { let mut shell = std::process::Command::new("sh"); shell.current_dir("/"); diff --git a/crates/fractal-api-core/src/declared_objectives.rs b/crates/fractal-api-core/src/declared_objectives.rs new file mode 100644 index 00000000000..357c71f43ab --- /dev/null +++ b/crates/fractal-api-core/src/declared_objectives.rs @@ -0,0 +1,626 @@ +//! The objectives a fleet declares (docs/st3/objectives.md): each `objective/NAME` is an interval +//! over claims, measured here from the replicated claims every member holds. +//! +//! Each objective keeps its samples in memory, one per subject under its prefix, with the store +//! index of the newest claim it has read; an evaluation reads only the claims after it, through +//! `claims_kind_index`. A new revision of the declaration starts again from the claims. A sample +//! that stopped or was cancelled before the window is dropped from memory. +//! +//! An open sample is censored, not dropped: for each target it enters the percentile at its age +//! once that age is over the target's `within`, since it is certain to be over. +//! +//! A missed row opens one `operational.failure` for the declaration's owner, on the member that +//! owner is declared on: every member evaluates the same claims, and one delivers. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use serde_json::{Map, Value, json}; + +use crate::model::{ClaimRecord, FractalError}; +use crate::objective::{Breach, Indicator, Interval, Objective, Window, percentile_row}; +use crate::objective_faults::{ObjectiveFaults, ObjectiveResponse, check_with_response}; +use crate::objectives::{clamp_percent, duration, nearest_rank, plural}; +use crate::store::Store; +use crate::store::declared_objectives::DeclaredObjective; +use smallclaims::error::internal; + +/// Claims read per page while catching up. +const PAGE: usize = 1_000; +/// Open samples over target an explanation names; the rest are counted. +const LISTED_OPEN: usize = 5; + +/// Every declared objective this store evaluates, by subject. +#[derive(Default)] +struct Evaluations(Mutex>); + +struct Tracked { + revision: String, + claim_id: String, + objective: Result, + /// The store index of the newest claim read. + cursor: u64, + samples: BTreeMap, + /// Claims of the interval's kinds whose time, set or member could not be read. + unreadable: u64, + /// The rows the last evaluation produced, for the breach response. + ids: BTreeSet, +} + +#[derive(Debug, Default)] +struct Sample { + /// The earliest start, and the label and exclude fields of its claim. + start: Option<(u64, BTreeMap)>, + expect: Option, + /// Each member's stop times; without `member=`, under "". + stops: BTreeMap>, + /// The earliest cancel. + cancel: Option, + /// Pauses (true) and resumes (false), in time order. + pauses: Vec<(u64, bool)>, + /// The latest time any of its claims carries. + last_seen: u64, +} + +/// The newest expect claim, by its time and then its claim id, so every member picks the same one. +#[derive(Debug)] +struct Expect { + at: u64, + id: String, + set: Vec, + fields: BTreeMap, +} + +/// What one sample (or one member of it) is at `now`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Closed { value: u64, stop: u64 }, + Open { age: u64 }, + Cancelled { at: u64 }, + Excluded { last_seen: u64 }, + /// Claims under the prefix with no start claim yet. + Unstarted, +} + +/// One point a row counts: a sample, or one member of it under `label "member"`. +struct Point<'a> { + subject: &'a str, + member: Option<&'a str>, + class: Class, +} + +/// The declared objectives' rows, evaluated at `now`. +pub fn rows(store: &Store, now: u64) -> anyhow::Result> { + let declared = store.declared_objectives()?; + let evaluations = store.fractal.extensions.get::(); + let mut tracked = evaluations.0.lock().unwrap_or_else(PoisonError::into_inner); + tracked.retain(|subject, _| declared.iter().any(|declaration| &declaration.subject == subject)); + let mut rows = Vec::new(); + for declaration in declared { + let entry = tracked + .entry(declaration.subject.clone()) + .or_insert_with(|| Tracked::new(&declaration)); + if entry.revision != declaration.revision { + *entry = Tracked::new(&declaration); + } + let name = declaration.name(); + let interval = match &entry.objective { + Ok(objective) => match (&objective.indicator, objective.window) { + (Indicator::Interval(interval), Some(window)) if objective.declarable() => Some((interval.clone(), window)), + _ => None, + }, + Err(_) => None, + }; + let Some((interval, window)) = interval else { + let row = entry.unreadable_row(&declaration.subject, name); + entry.ids = BTreeSet::from([name.to_owned()]); + rows.push(row); + continue; + }; + entry.read(store, &interval)?; + entry.prune(&interval, window, now); + let response = response(store, entry.objective.as_ref().ok().and_then(|objective| objective.on_breach.as_ref())); + let produced = entry.rows(&declaration.subject, &interval, window, now, &response); + entry.ids = produced.iter().filter_map(|row| row["id"].as_str().map(str::to_owned)).collect(); + rows.extend(produced); + } + Ok(rows) +} + +/// Respond to `rows`, the rows just evaluated, and report a failure once until a check succeeds +/// again. +pub fn check_and_report(store: &Store, rows: &[Value]) { + static FAILING: AtomicBool = AtomicBool::new(false); + match check(store, rows) { + Ok(_) => FAILING.store(false, Ordering::Relaxed), + Err(error) => { + if !FAILING.swap(true, Ordering::Relaxed) { + eprintln!("st3: responding to declared objectives failed: {error:?}"); + } + } + } +} + +/// For each declared objective with `on-breach` whose owner is declared on this member, open a +/// fault for each of its missed rows and end it when the row is met again. +pub fn check(store: &Store, rows: &[Value]) -> Result { + let responders: Vec<(String, Breach, BTreeSet)> = { + let evaluations = store.fractal.extensions.get::(); + let tracked = evaluations.0.lock().unwrap_or_else(PoisonError::into_inner); + tracked + .values() + .filter_map(|tracked| { + let objective = tracked.objective.as_ref().ok()?; + Some((objective.name.clone(), objective.on_breach.clone()?, tracked.ids.clone())) + }) + .collect() + }; + let mut total = ObjectiveFaults::default(); + for (name, breach, ids) in responders { + if store.agent_host(&breach.owner).map_err(internal)?.as_deref() != Some(store.origin()) { + continue; + } + let check = check_with_response( + store, + rows, + &ids, + Some(ObjectiveResponse { reviewer: &breach.owner, condition: &breach.condition, title: &name }), + )?; + total.opened += check.opened; + total.recovered += check.recovered; + total.unowned += check.unowned; + } + Ok(total) +} + +impl Tracked { + fn new(declaration: &DeclaredObjective) -> Self { + Self { + revision: declaration.revision.clone(), + claim_id: declaration.claim_id.clone(), + objective: declaration.objective.clone(), + cursor: 0, + samples: BTreeMap::new(), + unreadable: 0, + ids: BTreeSet::new(), + } + } + + /// Read the claims after the cursor. + fn read(&mut self, store: &Store, interval: &Interval) -> anyhow::Result<()> { + let kinds = interval.kinds(); + loop { + let page = store.claims_by_kinds_after(&kinds, Some(&interval.subject_prefix), self.cursor, PAGE)?; + for claim in &page.claims { + self.cursor = self.cursor.max(claim.store_index); + self.observe(interval, claim); + } + if page.next_cursor.is_none() { + return Ok(()); + } + } + } + + fn observe(&mut self, interval: &Interval, claim: &ClaimRecord) { + let sample = self.samples.entry(claim.subject.clone()).or_default(); + if sample.observe(interval, claim).is_none() { + self.unreadable += 1; + } + } + + /// Drop the samples that stopped, were cancelled or were excluded before the window. + fn prune(&mut self, interval: &Interval, window: Window, now: u64) { + let horizon = now.saturating_sub(window.ms); + self.samples.retain(|_, sample| match sample.class(interval, None, now) { + Class::Closed { stop, .. } => stop >= horizon, + Class::Cancelled { at } => at >= horizon, + Class::Excluded { last_seen } => last_seen >= horizon, + Class::Open { .. } | Class::Unstarted => true, + }); + } + + fn short_revision(&self) -> &str { + &self.revision[..self.revision.len().min(12)] + } + + /// The row of a declaration this build cannot evaluate. + fn unreadable_row(&self, subject: &str, name: &str) -> Value { + let why = match &self.objective { + Err(error) => format!("does not decode on this build: {error}"), + Ok(objective) => format!( + "is a `{}` indicator named `{}`, and a fleet declares only an interval named NAMESPACE/NAME", + objective.indicator.kind(), + objective.name + ), + }; + json!({ + "id": name, "kind": "objective", "about": format!("{subject} cannot be evaluated"), + "target": "unreadable", "current": "unreadable declaration", "window": "now", + "remaining_percent": null, "status": "no-data", + "explain": format!( + "{subject} at revision {} (claim {}) {why}. Nothing is measured until it is published again.", + self.short_revision(), self.claim_id, + ), + }) + } + + fn rows(&self, subject: &str, interval: &Interval, window: Window, now: u64, response: &str) -> Vec { + let Ok(objective) = &self.objective else { + return Vec::new(); + }; + let points: Vec> = self + .samples + .iter() + .map(|(subject, sample)| Point { subject, member: None, class: sample.class(interval, None, now) }) + .collect(); + let unstarted = points.iter().filter(|point| point.class == Class::Unstarted).count() as u64; + // Each label's rows, by value. + let mut groups: Vec<(String, Vec>)> = Vec::new(); + for label in &interval.labels { + let mut by_value: BTreeMap>> = BTreeMap::new(); + for (sample_subject, sample) in &self.samples { + if label == "member" { + for member in sample.members() { + by_value.entry(member.to_owned()).or_default().push(Point { + subject: sample_subject, + member: Some(member), + class: sample.class(interval, Some(member), now), + }); + } + } else if let Some(value) = sample.label(label) { + by_value.entry(value.to_owned()).or_default().push(Point { + subject: sample_subject, + member: None, + class: sample.class(interval, None, now), + }); + } + } + groups.extend(by_value.into_iter().map(|(value, points)| (format!("{{{label}={value}}}"), points))); + } + let context = Context { + objective, + subject, + revision: self.short_revision(), + window, + now, + unreadable: self.unreadable, + unstarted, + response, + }; + let mut rows = Vec::new(); + for target in &objective.targets { + let (Some(p), Some(within)) = (target.percentile(), target.within_ms()) else { + continue; + }; + let id = percentile_row(&objective.name, p); + rows.push(context.row(&id, p, within, &points)); + for (suffix, points) in &groups { + rows.push(context.row(&format!("{id}{suffix}"), p, within, points)); + } + } + rows + } +} + +impl Sample { + /// Fold one claim in; None when its time, set or member cannot be read. + fn observe(&mut self, interval: &Interval, claim: &ClaimRecord) -> Option<()> { + let fields = claim.body.get("fields").and_then(Value::as_object); + let accepted = u64::try_from(claim.accepted_at_unix_ms).unwrap_or(u64::MAX); + let time = |at: &Option| match at { + Some(field) => fields.and_then(|fields| fields.get(field)).and_then(time_ms), + None => Some(accepted), + }; + let kind = claim.kind.as_str(); + let at = if kind == interval.start.kind { + let at = time(&interval.start.at)?; + if self.start.as_ref().is_none_or(|(start, _)| at < *start) { + self.start = Some((at, kept_fields(interval, fields))); + } + at + } else if let Some((event, set)) = interval.expect.as_ref().filter(|(event, _)| event.kind == kind) { + let at = time(&event.at)?; + let set = fields.and_then(|fields| fields.get(set)).and_then(string_list)?; + if self.expect.as_ref().is_none_or(|expect| (at, claim.id.as_str()) > (expect.at, expect.id.as_str())) { + self.expect = Some(Expect { at, id: claim.id.clone(), set, fields: kept_fields(interval, fields) }); + } + at + } else if kind == interval.stop.0.kind { + let at = time(&interval.stop.0.at)?; + let member = match &interval.stop.1 { + Some(field) => fields.and_then(|fields| fields.get(field)).and_then(text)?, + None => String::new(), + }; + self.stops.entry(member).or_default().insert(at); + at + } else if interval.cancel.as_deref() == Some(kind) { + self.cancel = Some(self.cancel.map_or(accepted, |cancel| cancel.min(accepted))); + accepted + } else if let Some((pause, _)) = interval.pause.as_ref().filter(|(pause, resume)| pause == kind || resume == kind) { + let event = (accepted, pause == kind); + let at = self.pauses.partition_point(|existing| *existing <= event); + self.pauses.insert(at, event); + accepted + } else { + return None; + }; + self.last_seen = self.last_seen.max(at); + Some(()) + } + + /// The members a sample stops on: its expected set, else the members that stopped. + fn members(&self) -> Vec<&str> { + match &self.expect { + Some(expect) => expect.set.iter().map(String::as_str).collect(), + None => self.stops.keys().map(String::as_str).filter(|member| !member.is_empty()).collect(), + } + } + + fn label(&self, field: &str) -> Option<&str> { + let start = self.start.as_ref().and_then(|(_, fields)| fields.get(field)); + start.or_else(|| self.expect.as_ref().and_then(|expect| expect.fields.get(field))).map(String::as_str) + } + + fn excluded(&self, interval: &Interval) -> bool { + interval.excludes.iter().any(|exclude| { + let start = self.start.as_ref().and_then(|(_, fields)| fields.get(&exclude.field)); + let expect = self.expect.as_ref().and_then(|expect| expect.fields.get(&exclude.field)); + [start, expect].into_iter().flatten().any(|value| exclude.values.contains(value)) + }) + } + + /// The earliest stop of `member` at or after `start`. + fn stop_of(&self, member: &str, start: u64) -> Option { + self.stops.get(member)?.range(start..).next().copied() + } + + /// The sample at `now`, or with `member`, that member's part of it. + fn class(&self, interval: &Interval, member: Option<&str>, now: u64) -> Class { + let Some((start, _)) = &self.start else { + return Class::Unstarted; + }; + let start = *start; + if self.excluded(interval) { + return Class::Excluded { last_seen: self.last_seen }; + } + let close = match (member, &interval.expect, &self.expect) { + (Some(member), ..) => self.stop_of(member, start), + (None, Some(_), None) => None, + (None, Some(_), Some(expect)) if expect.set.is_empty() => Some(expect.at.max(start)), + (None, Some(_), Some(expect)) => expect + .set + .iter() + .map(|member| self.stop_of(member, start)) + .collect::>>() + .and_then(|stops| stops.into_iter().max()), + (None, None, _) => self.stops.keys().filter_map(|member| self.stop_of(member, start)).min(), + }; + if let Some(cancel) = self.cancel + && close.is_none_or(|close| cancel < close) + { + return Class::Cancelled { at: cancel }; + } + match close { + Some(stop) => Class::Closed { value: (stop - start).saturating_sub(self.paused(start, stop)), stop }, + None => Class::Open { age: now.saturating_sub(start).saturating_sub(self.paused(start, now.max(start))) }, + } + } + + /// The time paused between `start` and `end`; a pause with no resume runs to `end`. + fn paused(&self, start: u64, end: u64) -> u64 { + let mut total = 0; + let mut since = None; + for &(at, pause) in &self.pauses { + let at = at.clamp(start, end); + match (pause, since) { + (true, None) => since = Some(at), + (false, Some(from)) => { + total += at - from; + since = None; + } + _ => {} + } + } + total + since.map_or(0, |from| end - from) + } +} + +/// The start or expect fields a sample keeps: its labels' and its excludes'. +fn kept_fields(interval: &Interval, fields: Option<&Map>) -> BTreeMap { + let Some(fields) = fields else { + return BTreeMap::new(); + }; + interval + .labels + .iter() + .chain(interval.excludes.iter().map(|exclude| &exclude.field)) + .filter_map(|name| Some((name.clone(), text(fields.get(name)?)?))) + .collect() +} + +/// A time from an RFC 3339 string or Unix milliseconds, either possibly JSON-encoded in a string. +fn time_ms(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_u64(), + Value::String(text) => chrono::DateTime::parse_from_rfc3339(text) + .ok() + .and_then(|at| u64::try_from(at.timestamp_millis()).ok()) + .or_else(|| text.parse().ok()) + .or_else(|| serde_json::from_str::(text).ok().filter(Value::is_string).and_then(|value| time_ms(&value))), + _ => None, + } +} + +/// A field's text: a string (a JSON-encoded one decoded), a number or a boolean. +fn text(value: &Value) -> Option { + match value { + Value::String(text) => Some(serde_json::from_str::(text).unwrap_or_else(|_| text.clone())), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +/// A list of strings, or a string holding one as JSON. +fn string_list(value: &Value) -> Option> { + match value { + Value::Array(items) => items.iter().map(|item| item.as_str().map(str::to_owned)).collect(), + Value::String(text) => serde_json::from_str::(text).ok().filter(Value::is_array).and_then(|value| string_list(&value)), + _ => None, + } +} + +/// A span as its largest whole unit: `7d`, `90m`. +fn span(ms: u64) -> String { + for (unit, size) in [("d", 86_400_000), ("h", 3_600_000), ("m", 60_000), ("s", 1_000)] { + if ms >= size && ms.is_multiple_of(size) { + return format!("{}{unit}", ms / size); + } + } + format!("{ms}ms") +} + +/// What a miss does, as the explanation says it. +fn response(store: &Store, breach: Option<&Breach>) -> String { + let Some(breach) = breach else { + return "No response is declared: a miss is shown and nothing is delivered.".to_owned(); + }; + let backup = breach + .backup + .as_ref() + .map(|backup| format!(" {backup} is the held backup: recorded and shown, never the reviewer.")) + .unwrap_or_default(); + let owner = &breach.owner; + let live = store.fault_reviewer_is_declared(owner); + let host = store.agent_host(owner); + match (live, host) { + (Ok(true), Ok(Some(host))) => format!( + "A missed row opens one operational.failure `{}` on daemon/{host} addressed to {owner}, delivered by {host}, the member {owner} is declared on{}.{backup}", + breach.condition, + if host == store.origin() { " (this member)" } else { "" }, + ), + (Ok(_), Ok(_)) => format!( + "A miss would open an operational.failure `{}` for {owner}, but {owner} is not a live agent declaration, so nothing is delivered.{backup}", + breach.condition, + ), + (Err(error), _) | (_, Err(error)) => format!("The response to {owner} could not be read: {error}.{backup}"), + } +} + +struct Context<'a> { + objective: &'a Objective, + subject: &'a str, + revision: &'a str, + window: Window, + now: u64, + unreadable: u64, + unstarted: u64, + response: &'a str, +} + +impl Context<'_> { + /// The row of percentile `p` within `within` over `points`. + fn row(&self, id: &str, p: u64, within: u64, points: &[Point<'_>]) -> Value { + let horizon = self.now.saturating_sub(self.window.ms); + let (mut closed, mut open_within, mut cancelled, mut excluded) = (0u64, 0u64, 0u64, 0u64); + let mut population = Vec::new(); + let mut open_over: Vec<(&Point<'_>, u64)> = Vec::new(); + for point in points { + match point.class { + Class::Closed { value, stop } if stop >= horizon => { + closed += 1; + population.push(value); + } + Class::Open { age } if age > within => { + open_over.push((point, age)); + population.push(age); + } + Class::Open { .. } => open_within += 1, + Class::Cancelled { at } if at >= horizon => cancelled += 1, + Class::Excluded { last_seen } if last_seen >= horizon => excluded += 1, + _ => {} + } + } + open_over.sort_by_key(|point| std::cmp::Reverse(point.1)); + let window = span(self.window.ms); + let goal_within = duration(within); + let target = format!("p{p} ≤ {goal_within}"); + let about = &self.objective.about; + let goal = format!("Goal: {p} of every 100 stop within {goal_within}, so {} in 100 may run over.", 100 - p); + let mut counted = format!( + "In the last {window}: {closed} closed, {} open over {goal_within}, {open_within} open within it, {cancelled} cancelled, {excluded} excluded, {} unreadable {}.", + open_over.len(), + self.unreadable, + if self.unreadable == 1 { "claim" } else { "claims" }, + ); + if self.unstarted > 0 { + counted.push_str(&format!(" {} {} claims but no start yet.", plural(self.unstarted, "subject"), if self.unstarted == 1 { "has" } else { "have" })); + } + let declared = format!("Declared as {} at revision {}.", self.subject, self.revision); + let listed = if open_over.is_empty() { + String::new() + } else { + let names = open_over + .iter() + .take(LISTED_OPEN) + .map(|(point, age)| match point.member { + Some(member) => format!("{} on {member} (open {})", point.subject, duration(*age)), + None => format!("{} (open {})", point.subject, duration(*age)), + }) + .collect::>() + .join(", "); + let more = open_over.len().saturating_sub(LISTED_OPEN); + format!( + " Open over target, counted at their age: {names}{}.", + if more > 0 { format!(", and {more} more") } else { String::new() } + ) + }; + let n = population.len() as u64; + let base = json!({ + "id": id, "kind": "objective", "about": about, "target": target, "window": window, + }); + let mut row = base; + let Some(value) = (n > 0).then(|| nearest_rank(&population, p)) else { + row["current"] = json!("no samples"); + row["remaining_percent"] = Value::Null; + row["status"] = json!("no-data"); + row["explain"] = json!(format!( + "{about}. {goal} {declared} {counted} No sample stopped in the window and none is open over target, so there is no budget to spend or save. {}", + self.response, + )); + return row; + }; + let over = population.iter().filter(|value| **value > within).count() as u64; + let share = over as f64 / n as f64; + let allowed = (100 - p) as f64 / 100.0; + let remaining = clamp_percent(100.0 * (1.0 - share / allowed)); + let value = duration(value); + let open = if open_over.is_empty() { String::new() } else { format!(" ({} open over)", open_over.len()) }; + let enough = n >= self.window.min_samples; + row["current"] = json!(if enough { + format!("p{p} {value} over {}{open}", plural(n, "sample")) + } else { + format!("p{p} {value} over {}{open}, needs {}", plural(n, "sample"), self.window.min_samples) + }); + row["remaining_percent"] = if enough { json!(remaining) } else { Value::Null }; + let (status, verdict) = match (enough, share <= allowed) { + (false, _) => ("no-data", format!("It needs {} before it is met or missed.", plural(self.window.min_samples, "sample"))), + (true, true) => ("met", "The goal is met.".to_owned()), + (true, false) => ("missed", format!("The goal is missed in the last {window}.")), + }; + row["status"] = json!(status); + row["explain"] = json!(format!( + "{about}. {goal} {declared} {counted} An open sample over {goal_within} counts at its age, since it is certain to be over; a younger one is left out until it stops. Over {goal_within}: {over} of {} ({:.1}%), against an allowance of {}%, so {remaining:.0}% of the error budget is left. The p{p} is {value}. {verdict}{listed} {}", + plural(n, "sample"), + share * 100.0, + 100 - p, + self.response, + )); + row + } +} + +#[cfg(test)] +#[path = "declared_objectives_tests.rs"] +mod tests; diff --git a/crates/fractal-api-core/src/declared_objectives_tests.rs b/crates/fractal-api-core/src/declared_objectives_tests.rs new file mode 100644 index 00000000000..b6472cd8252 --- /dev/null +++ b/crates/fractal-api-core/src/declared_objectives_tests.rs @@ -0,0 +1,387 @@ +use super::*; +use crate::model::{ClaimInput, IntentInput}; + +const MINUTE: u64 = 60_000; +/// The evaluation time; claim times are given relative to it in their fields. It is the clock's, +/// since a cancel or pause is timed by its acceptance. +static NOW: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64 +}); +const READY: &str = "custom.acme.change-ready"; +const MERGED: &str = "custom.acme.change-merged"; +const APPLIED: &str = "custom.acme.change-applied"; +const ABANDONED: &str = "custom.acme.change-abandoned"; + +fn declaration(extra_interval: &str, window: &str, breach: &str) -> String { + format!( + r#"version 2 +objective "ops/change-rollout" {{ + about "A change, from ready to every member that serves it" + indicator "interval" {{ + subject-prefix "custom/acme/change/" + start "{READY}" at="ready_at" + expect "{MERGED}" set="hosts" at="merged_at" + stop "{APPLIED}" member="host" at="applied_at" + cancel "{ABANDONED}" +{extra_interval} }} + window "{window}" + target "p50" within="60s" + target "p95" within="5m" +{breach}}} +"# + ) +} + +fn apply(store: &Store, source: &str) { + let intent = crate::graph::parse_intent(source, "node").unwrap(); + let preview = store.mission(&intent, IntentInput { kdl: source.into(), source_name: None }).unwrap(); + // One key per publication: a repeated key returns the first publication's result. + store.apply(&intent, &preview.subject_tokens, &intent.source_hash).unwrap(); +} + +fn store_with(source: &str) -> Store { + let store = Store::open_memory("node").unwrap(); + apply(&store, source); + store +} + +fn claim(store: &Store, change: &str, kind: &str, fields: Value) -> u64 { + store + .append_claim(&ClaimInput { + subject: format!("custom/acme/change/{change}"), + kind: kind.into(), + actor: None, + fields: fields.as_object().unwrap().clone().into_iter().collect(), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) + .unwrap() + .store_index +} + +/// A change ready `ago` minutes before NOW, merged for `hosts`, and applied on each `(host, after)` +/// `after` seconds past ready. +fn change(store: &Store, name: &str, ago_ms: u64, hosts: &[&str], applied: &[(&str, u64)], class: &str) { + let ready = *NOW - ago_ms; + claim(store, name, READY, json!({"ready_at": ready, "class": class})); + claim(store, name, MERGED, json!({"merged_at": ready + 1_000, "hosts": hosts})); + for (host, after_s) in applied { + claim(store, name, APPLIED, json!({"host": host, "applied_at": ready + after_s * 1_000})); + } +} + +fn evaluate(store: &Store) -> BTreeMap { + rows(store, *NOW).unwrap().into_iter().map(|row| (row["id"].as_str().unwrap().to_owned(), row)).collect() +} + +fn s(text: &str) -> String { + text.to_owned() +} + +#[test] +fn a_change_closes_at_its_last_expected_member_and_rows_have_the_base_keys() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + let rows = evaluate(&store); + assert_eq!(rows.keys().cloned().collect::>(), [s("ops/change-rollout/p50"), s("ops/change-rollout/p95")]); + let p50 = &rows["ops/change-rollout/p50"]; + let mut keys = p50.as_object().unwrap().keys().cloned().collect::>(); + keys.sort(); + assert_eq!(keys, ["about", "current", "explain", "id", "kind", "remaining_percent", "status", "target", "window"]); + assert_eq!(p50["kind"], "objective"); + assert_eq!(p50["target"], "p50 ≤ 1 min"); + assert_eq!(p50["current"], "p50 55 s over 1 sample", "the latest member's stop, not the first"); + assert_eq!(p50["window"], "7d"); + assert_eq!((p50["status"].as_str(), p50["remaining_percent"].as_f64()), (Some("met"), Some(100.0))); + let explain = p50["explain"].as_str().unwrap(); + assert!(explain.contains("Declared as objective/ops/change-rollout at revision "), "{explain}"); + assert!(explain.contains("1 closed, 0 open over 1 min, 0 open within it, 0 cancelled, 0 excluded, 0 unreadable claims."), "{explain}"); + assert!(explain.ends_with("No response is declared: a miss is shown and nothing is delivered."), "{explain}"); +} + +#[test] +fn an_open_change_over_target_counts_at_its_age_and_a_younger_one_is_left_out() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 130)], "routine"); + // Merged for two hosts, applied on one, ready 20 minutes ago: open and overdue. + change(&store, "c", 20 * MINUTE, &["hosta", "hostb"], &[("hosta", 30)], "routine"); + // Ready 30 seconds ago: open, within both targets. + change(&store, "d", 30_000, &["hosta"], &[], "routine"); + let rows = evaluate(&store); + let p50 = &rows["ops/change-rollout/p50"]; + // p50 population: 55 s, 130 s, and c at 20 min (over 60 s); d (30 s) is within, left out. + assert_eq!(p50["current"], "p50 2.2 min over 3 samples (1 open over)"); + assert_eq!(p50["status"], "missed", "2 of 3 over 60 s against 50 in 100"); + let explain = p50["explain"].as_str().unwrap(); + assert!(explain.contains("2 closed, 1 open over 1 min, 1 open within it"), "{explain}"); + assert!(explain.contains("custom/acme/change/c (open 20 min)"), "{explain}"); + let p95 = &rows["ops/change-rollout/p95"]; + // p95 population: 55 s, 130 s and c at 20 min (over 5 min); its nearest rank is c's age. + assert_eq!(p95["current"], "p95 20 min over 3 samples (1 open over)"); + assert_eq!((p95["status"].as_str(), p95["remaining_percent"].as_f64()), (Some("missed"), Some(0.0))); +} + +#[test] +fn a_cancel_ends_an_open_change_but_not_one_that_stopped_first() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "open", 20 * MINUTE, &["hosta"], &[], "routine"); + claim(&store, "open", ABANDONED, json!({})); + // Stopped long before the cancel's acceptance (now, on the real clock). + change(&store, "closed", 20 * MINUTE, &["hosta"], &[("hosta", 45)], "routine"); + claim(&store, "closed", ABANDONED, json!({})); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 45 s over 1 sample"); + assert!(p50["explain"].as_str().unwrap().contains("1 closed, 0 open over 1 min, 0 open within it, 1 cancelled")); +} + +#[test] +fn an_empty_expected_set_stops_the_change_at_the_expect_claim() { + let store = store_with(&declaration("", "7d", "")); + let ready = *NOW - 10 * MINUTE; + claim(&store, "noop", READY, json!({"ready_at": ready})); + claim(&store, "noop", MERGED, json!({"merged_at": ready + 20_000, "hosts": "[]"})); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 20 s over 1 sample", "a JSON-encoded empty list"); +} + +#[test] +fn an_excluded_change_is_counted_apart_and_never_in_a_percentile() { + let store = store_with(&declaration(" exclude \"exclusion\" \"deferred-launch\"\n", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + let ready = *NOW - 20 * MINUTE; + claim(&store, "x", READY, json!({"ready_at": ready})); + claim(&store, "x", MERGED, json!({"merged_at": ready, "hosts": ["hosta"], "exclusion": "deferred-launch"})); + let p95 = &evaluate(&store)["ops/change-rollout/p95"]; + assert_eq!(p95["current"], "p95 40 s over 1 sample"); + assert!(p95["explain"].as_str().unwrap().contains("0 cancelled, 1 excluded")); +} + +#[test] +fn labels_split_rows_by_a_field_and_by_member() { + let store = store_with(&declaration(" label \"class\"\n label \"member\"\n", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta", "hostb"], &[("hosta", 40), ("hostb", 55)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 130)], "urgent"); + change(&store, "c", 20 * MINUTE, &["hosta", "hostb"], &[("hosta", 30)], "routine"); + let rows = evaluate(&store); + let current = |id: &str| rows[id]["current"].as_str().unwrap().to_owned(); + // a (55 s) and c (open 20 minutes): the nearest rank of p50 over two is the first. + assert_eq!(current("ops/change-rollout/p50{class=routine}"), "p50 55 s over 2 samples (1 open over)"); + assert_eq!(current("ops/change-rollout/p50{class=urgent}"), "p50 2.2 min over 1 sample"); + // hosta stopped in 40 s, 130 s and 30 s; hostb in 55 s and is open on c for 20 minutes. + assert_eq!(current("ops/change-rollout/p95{member=hosta}"), "p95 2.2 min over 3 samples"); + assert_eq!(current("ops/change-rollout/p95{member=hostb}"), "p95 20 min over 2 samples (1 open over)"); + assert!(rows["ops/change-rollout/p95{member=hostb}"]["explain"].as_str().unwrap().contains("custom/acme/change/c on hostb (open 20 min)")); + assert_eq!(rows.len(), 2 * 5); +} + +#[test] +fn a_closed_change_belongs_to_the_window_by_its_stop_time_and_older_ones_are_dropped() { + let store = store_with(&declaration("", "1h", "")); + // Ready two hours ago, stopped 30 minutes ago: in the window. + change(&store, "late", 120 * MINUTE, &["hosta"], &[("hosta", 90 * 60)], "routine"); + // Stopped 100 minutes ago: out of it. + change(&store, "old", 120 * MINUTE, &["hosta"], &[("hosta", 20 * 60)], "routine"); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 90 min over 1 sample"); + let evaluations = store.fractal.extensions.get::(); + let tracked = evaluations.0.lock().unwrap(); + assert_eq!(tracked["objective/ops/change-rollout"].samples.keys().collect::>(), ["custom/acme/change/late"]); +} + +#[test] +fn fewer_samples_than_the_minimum_is_no_data_and_none_at_all_says_so() { + let source = declaration("", "7d", "").replace("window \"7d\"", "window \"7d\" min-samples=3"); + let store = store_with(&source); + let empty = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!((empty["current"].as_str(), empty["status"].as_str()), (Some("no samples"), Some("no-data"))); + assert!(empty["remaining_percent"].is_null()); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 400)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 500)], "routine"); + let p50 = &evaluate(&store)["ops/change-rollout/p50"]; + assert_eq!(p50["current"], "p50 6.7 min over 2 samples, needs 3"); + assert_eq!(p50["status"], "no-data", "both over, but too few to judge"); + assert!(p50["remaining_percent"].is_null()); + change(&store, "c", 10 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + assert_eq!(evaluate(&store)["ops/change-rollout/p50"]["status"], "missed"); +} + +#[test] +fn a_new_revision_reads_the_claims_again() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 50)], "urgent"); + assert_eq!(evaluate(&store).len(), 2); + // The first revision kept no `class` field; the label needs the claims read again. + apply(&store, &declaration(" label \"class\"\n", "7d", "")); + let rows = evaluate(&store); + assert_eq!(rows["ops/change-rollout/p50{class=routine}"]["current"], "p50 40 s over 1 sample"); + assert_eq!(rows["ops/change-rollout/p50{class=urgent}"]["current"], "p50 50 s over 1 sample"); + assert_eq!(rows["ops/change-rollout/p50"]["current"], "p50 40 s over 2 samples"); +} + +#[test] +fn an_evaluation_reads_only_the_claims_after_its_cursor() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + evaluate(&store); + let evaluations = store.fractal.extensions.get::(); + let cursor = { + let mut tracked = evaluations.0.lock().unwrap(); + let tracked = tracked.get_mut("objective/ops/change-rollout").unwrap(); + // Forget what was read: a reread would bring `a` back. + tracked.samples.clear(); + tracked.cursor + }; + // Claims of other kinds or under another prefix do not move it. + store + .append_claim(&ClaimInput { + subject: "custom/acme/other/z".into(), + kind: READY.into(), + actor: None, + fields: BTreeMap::from([("ready_at".into(), json!(*NOW))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) + .unwrap(); + claim(&store, "a", "custom.acme.note", json!({})); + change(&store, "b", 10 * MINUTE, &["hosta"], &[("hosta", 50)], "routine"); + assert_eq!(evaluate(&store)["ops/change-rollout/p50"]["current"], "p50 50 s over 1 sample"); + let tracked = evaluations.0.lock().unwrap(); + let tracked = &tracked["objective/ops/change-rollout"]; + assert!(tracked.cursor > cursor); + assert_eq!(tracked.samples.keys().collect::>(), ["custom/acme/change/b"]); +} + +#[test] +fn a_claim_without_a_readable_time_or_member_is_counted_unreadable() { + let store = store_with(&declaration("", "7d", "")); + change(&store, "a", 10 * MINUTE, &["hosta"], &[("hosta", 40)], "routine"); + claim(&store, "a", APPLIED, json!({"applied_at": "yesterday", "host": "hostb"})); + claim(&store, "a", APPLIED, json!({"applied_at": *NOW})); + let explain = evaluate(&store)["ops/change-rollout/p50"]["explain"].as_str().unwrap().to_owned(); + assert!(explain.contains("2 unreadable claims"), "{explain}"); +} + +fn record(kind: &str, accepted: u64, fields: Value) -> ClaimRecord { + ClaimRecord { + id: format!("{kind}-{accepted}"), + store_index: 0, + batch_id: String::new(), + subject: "custom/acme/change/p".into(), + kind: kind.into(), + origin: "node".into(), + actor: None, + operation_id: None, + request_digest: None, + body: json!({ "fields": fields }), + predecessors: Vec::new(), + accepted_at_unix_ms: u128::from(accepted), + } +} + +#[test] +fn paused_time_is_not_counted_and_a_paused_change_does_not_age() { + let source = declaration(" pause \"custom.acme.change-held\" resume=\"custom.acme.change-released\"\n", "7d", ""); + let document = crate::objective::parse_document(&source).unwrap(); + let Indicator::Interval(interval) = &document[0].indicator else { panic!("an interval") }; + let start = *NOW - 30 * MINUTE; + let mut sample = Sample::default(); + sample.observe(interval, &record(READY, start, json!({"ready_at": start}))).unwrap(); + sample.observe(interval, &record(MERGED, start, json!({"merged_at": start, "hosts": ["hosta"]}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-held", start + 5 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-released", start + 15 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record("custom.acme.change-held", start + 20 * MINUTE, json!({}))).unwrap(); + // Thirty minutes old, ten paused and still paused for the last ten: it aged ten minutes. + assert_eq!(sample.class(interval, None, *NOW), Class::Open { age: 10 * MINUTE }); + assert_eq!(sample.class(interval, None, *NOW + 60 * MINUTE), Class::Open { age: 10 * MINUTE }); + sample.observe(interval, &record("custom.acme.change-released", start + 25 * MINUTE, json!({}))).unwrap(); + sample.observe(interval, &record(APPLIED, start + 28 * MINUTE, json!({"host": "hosta", "applied_at": start + 28 * MINUTE}))).unwrap(); + assert_eq!(sample.class(interval, None, *NOW), Class::Closed { value: 13 * MINUTE, stop: start + 28 * MINUTE }); +} + +#[test] +fn a_declaration_this_build_cannot_read_is_a_row_that_says_so() { + // As a replicated body from a newer build reads here. + let error = crate::objective::parse(&json!({"name": "objective", "arguments": ["ops/next"]})).unwrap_err(); + let declaration = DeclaredObjective { + subject: "objective/ops/next".into(), + revision: "0123456789abcdef".into(), + claim_id: "claim-1".into(), + objective: Err(format!("{}: {}", error.code, error.message)), + }; + let row = Tracked::new(&declaration).unreadable_row(&declaration.subject, declaration.name()); + assert_eq!((row["id"].as_str(), row["status"].as_str()), (Some("ops/next"), Some("no-data"))); + assert_eq!(row["current"], "unreadable declaration"); + let explain = row["explain"].as_str().unwrap(); + assert!(explain.starts_with("objective/ops/next at revision 0123456789ab (claim claim-1) does not decode on this build: invalid-objective"), "{explain}"); +} + +fn with_owner(store: &Store, host: &str) { + let source = format!("version 2\nagent \"{host}.ops\" {{ command \"true\"; host \"{host}\" }}\n"); + apply(store, &source); + let owner = format!("agent/{host}.ops"); + assert_eq!(store.agent_host(&owner).unwrap().as_deref(), Some(host)); + assert!(store.fault_reviewer_is_declared(&owner).unwrap()); +} + +const BREACH: &str = " on-breach \"fault\" owner=\"agent/node.ops\" backup=\"agent/node.backup\" condition=\"change-rollout-slo-missed\"\n"; + +#[test] +fn a_missed_row_is_a_fault_for_its_owner_on_the_member_the_owner_is_declared_on() { + let store = store_with(&declaration("", "7d", BREACH)); + with_owner(&store, "node"); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + let explain = rows[0]["explain"].as_str().unwrap(); + assert!(explain.contains("addressed to agent/node.ops, delivered by node, the member agent/node.ops is declared on (this member). agent/node.backup is the held backup"), "{explain}"); + let opened = check(&store, &rows).unwrap(); + assert_eq!(opened.opened, 2, "p50 and p95 are both missed"); + let failure = store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().unwrap(); + assert_eq!((failure.status.as_str(), failure.reviewer.as_str()), ("pending", "agent/node.ops"), "the owner, never the backup"); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default(), "one episode while missed"); + + // Twenty fast changes put the slow one within both allowances: met again, the fault ends. + for name in ["b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u"] { + change(&store, name, 10 * MINUTE, &["hosta"], &[("hosta", 10)], "routine"); + } + let rows = super::rows(&store, *NOW).unwrap(); + assert_eq!(rows.iter().map(|row| row["status"].as_str().unwrap()).collect::>(), ["met", "met"]); + assert_eq!(check(&store, &rows).unwrap().recovered, 2); + assert_eq!(store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().unwrap().status, "resolved"); +} + +#[test] +fn an_owner_declared_on_another_member_is_delivered_there_not_here() { + let store = store_with(&declaration("", "7d", &BREACH.replace("node.ops", "other.ops"))); + with_owner(&store, "other"); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + assert_eq!(rows[0]["status"], "missed"); + assert!(rows[0]["explain"].as_str().unwrap().contains("delivered by other, the member agent/other.ops is declared on.")); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default()); + assert!(store.operational_failure("slo-objective/node/ops/change-rollout/p95").unwrap().is_none()); +} + +#[test] +fn an_owner_that_is_not_declared_gets_nothing_and_the_explanation_says_so() { + let store = store_with(&declaration("", "7d", BREACH)); + change(&store, "a", 30 * MINUTE, &["hosta"], &[("hosta", 600)], "routine"); + let rows = rows(&store, *NOW).unwrap(); + assert!(rows[0]["explain"].as_str().unwrap().contains("agent/node.ops is not a live agent declaration, so nothing is delivered")); + assert_eq!(check(&store, &rows).unwrap(), ObjectiveFaults::default()); +} + +#[test] +fn spans_and_field_values_read_the_ways_claims_write_them() { + assert_eq!([span(7 * 86_400_000), span(90 * MINUTE), span(1_500)], ["7d", "90m", "1500ms"]); + assert_eq!(time_ms(&json!("2026-11-09T10:00:00Z")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("1794218400000")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("\"2026-11-09T10:00:00Z\"")), Some(1_794_218_400_000)); + assert_eq!(time_ms(&json!("soon")), None); + assert_eq!(string_list(&json!("[\"a\",\"b\"]")), Some(vec![s("a"), s("b")])); + assert_eq!(string_list(&json!(["a", 1])), None); + assert_eq!(text(&json!("\"routine\"")).as_deref(), Some("routine")); +} diff --git a/crates/fractal-api-core/src/lib.rs b/crates/fractal-api-core/src/lib.rs index 47aef1f6bc0..bff5f21ca70 100644 --- a/crates/fractal-api-core/src/lib.rs +++ b/crates/fractal-api-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod api; pub mod claude_channel; pub mod creation; pub mod credit_objectives; +pub mod declared_objectives; pub mod delivery_hold; pub mod file_inventory; pub mod gate_check; diff --git a/crates/fractal-api-core/src/objective_faults.rs b/crates/fractal-api-core/src/objective_faults.rs index b82bea81ff6..dab1e41529d 100644 --- a/crates/fractal-api-core/src/objective_faults.rs +++ b/crates/fractal-api-core/src/objective_faults.rs @@ -1,6 +1,6 @@ //! A missed CI objective is a fault for the fleet's fault agent. //! -//! The objectives `slo/targets.toml` declares for CI (each `[[duration]]` and `[[count]]`) are +//! The objectives `slo/objectives.kdl` ships for CI (each `ci-duration` and `ci-count`) are //! read from the rows the SLO history samples once a minute. An objective whose row is missed //! opens one `operational.failure` on this member's `daemon/NODE` subject, addressed to the fleet //! fault agent and never to a person; it stays one episode until the row is met again, when an diff --git a/crates/fractal-api-core/src/objectives.rs b/crates/fractal-api-core/src/objectives.rs index ec7fb92c018..1e4a10eaf35 100644 --- a/crates/fractal-api-core/src/objectives.rs +++ b/crates/fractal-api-core/src/objectives.rs @@ -2,8 +2,8 @@ //! //! An objective is a target with windows and an error budget: the daemon's latency targets, its //! store and CPU targets, the database's size and the paired phone's reported targets, all from -//! `slo/targets.toml` and the same report as `GET /v1/client/request-latency`, and CI's durations -//! and counts, which `slo/targets.toml` declares and the Workspace CI runs a repository observer +//! `slo/objectives.kdl` and the same report as `GET /v1/client/request-latency`, and CI's durations +//! and counts, which `slo/objectives.kdl` ships and the Workspace CI runs a repository observer //! read feed, held in memory on the member that observes. A budget is an //! allowance that resets: each account's weekly and 5-hour limits, from the readings `st usage` //! shows. Nothing here measures anything. It reads those sources and says, for each row, its @@ -111,11 +111,11 @@ pub fn ms(value: f64) -> String { if value >= 10.0 { format!("{} ms", value.round()) } else { format!("{:.1} ms", value) } } -fn clamp_percent(value: f64) -> f64 { +pub(crate) fn clamp_percent(value: f64) -> f64 { (value.clamp(0.0, 100.0) * 10.0).round() / 10.0 } -fn plural(n: u64, word: &str) -> String { +pub(crate) fn plural(n: u64, word: &str) -> String { format!("{n} {word}{}", if n == 1 { "" } else { "s" }) } @@ -329,18 +329,23 @@ pub fn divergence(open: u64) -> Value { }) } -/// A duration in minutes once it is a minute or more: CI's targets are minutes, not milliseconds. -fn duration(target_ms: u64) -> String { - if target_ms < 60_000 { +/// A duration in seconds under a minute and in minutes from a minute on: CI's targets are +/// minutes, a declared interval's may be seconds. +pub(crate) fn duration(target_ms: u64) -> String { + if target_ms < 1_000 { return ms(target_ms as f64); } + if target_ms < 60_000 { + let seconds = target_ms as f64 / 1_000.0; + return if seconds.fract() == 0.0 { format!("{seconds} s") } else { format!("{seconds:.1} s") }; + } let minutes = target_ms as f64 / 60_000.0; if minutes.fract() == 0.0 { format!("{minutes} min") } else { format!("{minutes:.1} min") } } /// The value at `percentile` of `samples`, nearest rank: the smallest sample that at least /// `percentile` in 100 are at or under. -fn nearest_rank(samples: &[u64], percentile: u64) -> u64 { +pub(crate) fn nearest_rank(samples: &[u64], percentile: u64) -> u64 { let mut sorted = samples.to_vec(); sorted.sort_unstable(); let rank = (percentile as usize * sorted.len()).div_ceil(100).max(1); @@ -1198,10 +1203,12 @@ mod tests { } #[test] - fn a_ci_duration_reads_in_minutes() { + fn a_duration_reads_in_minutes_from_a_minute_and_seconds_below() { assert_eq!(duration(600_000), "10 min"); assert_eq!(duration(780_000), "13 min"); assert_eq!(duration(90_000), "1.5 min"); - assert_eq!(duration(1_500), "1500 ms"); + assert_eq!(duration(55_000), "55 s"); + assert_eq!(duration(1_500), "1.5 s"); + assert_eq!(duration(400), "400 ms"); } } diff --git a/crates/fractal-client-codegen/src/rich.rs b/crates/fractal-client-codegen/src/rich.rs index 1f2d47788d9..48b4568aecf 100644 --- a/crates/fractal-client-codegen/src/rich.rs +++ b/crates/fractal-client-codegen/src/rich.rs @@ -876,6 +876,15 @@ fn validate_condition_schema(value: &Value, cx: &Cx, depth: usize) -> Result<()> validate_condition_schema(schema, cx, depth + 1)?; } } + "anyOf" => { + for schema in part.as_array().context("conditional presence alternatives")? { + let object = schema.as_object().context("conditional presence alternative")?; + if object.len() != 1 || !object.contains_key("required") { + bail!("conditional anyOf only supports presence predicates"); + } + validate_condition_schema(schema, cx, depth + 1)?; + } + } "items" => validate_condition_schema(part, cx, depth + 1)?, "oneOf" | "allOf" => { for schema in part.as_array().context("conditional alternatives")? { @@ -907,7 +916,6 @@ fn validate_condition_fields( let name = name.as_str().context("conditional required name")?; let field = fields.get(name) .with_context(|| format!("conditional field `{name}` is undeclared"))?; - validate_condition_schema(field, cx, depth + 1)?; if nullable_inner(field).is_some() { bail!("conditional presence of nullable field `{name}` is not translated"); } @@ -923,7 +931,7 @@ fn validate_condition_fields( validate_condition_value(assertion, field, cx, depth + 1)?; } } - "allOf" | "oneOf" => { + "allOf" | "oneOf" | "anyOf" => { for part in value.as_array().context("conditional alternatives")? { validate_condition_fields(part, fields, cx, depth + 1)?; } @@ -944,6 +952,10 @@ fn validate_condition_value(assertion: &Value, field: &Value, cx: &Cx, depth: us if depth > 64 { bail!("recursive conditional fields are not translated"); } + // Boolean field assertions inspect only presence, not the decoded field value. + if assertion.is_boolean() { + return Ok(()); + } validate_condition_schema(field, cx, depth)?; if let Some(reference) = field.get("$ref").and_then(Value::as_str) { let name = reference.strip_prefix("#/$defs/").context("only #/$defs/ refs")?; @@ -1034,7 +1046,7 @@ fn condition_predicate(value: &Value, target: &str, cx: &mut Cx) -> Result=" } )); } - "allOf" | "oneOf" => { + "allOf" | "oneOf" | "anyOf" => { let parts = value .as_array() .context("conditional alternatives")? @@ -1051,7 +1063,7 @@ fn condition_predicate(value: &Value, target: &str, cx: &mut Cx) -> Result>() - .join(" && ") + .join(if key == "anyOf" { " || " } else { " && " }) }); } _ => bail!("unsupported conditional assertion `{key}`"), @@ -1810,6 +1822,28 @@ fn emit_models( mod tests { use super::*; + #[test] + fn object_presence_conditions_do_not_validate_or_transform_field_values() { + let schema = json!({"x-st-integers":"json-safe", "$defs":{"Entry":{ + "type":"object", "additionalProperties":false, + "properties":{"state":{"type":"string"},"refused":{"const":true},"receipt":{"type":"object","additionalProperties":true},"grant":{"type":"string"}}, + "allOf":[ + {"if":{"properties":{"state":{"const":"closed"}},"anyOf":[{"required":["receipt"]},{"required":["grant"]}]},"then":{"required":["receipt"]}}, + {"if":{"required":["refused"],"properties":{"refused":{"const":true}}},"then":{"properties":{"receipt":false}}} + ] + }}}); + let generated = models(&schema).unwrap(); + assert!(generated.contains("Object.prototype.hasOwnProperty.call(o, \"receipt\")")); + assert!(generated.contains(" || ")); + assert_eq!(generated.matches("conditional object relationship").count(), 2); + let mut invalid = schema.clone(); + invalid["$defs"]["Entry"]["allOf"][0]["if"]["anyOf"][0] = json!({"properties":{"grant":{"const":"x"}}}); + assert!(format!("{:#}", models(&invalid).unwrap_err()).contains("only supports presence predicates")); + let mut undeclared = schema; + undeclared["$defs"]["Entry"]["allOf"][0]["if"]["anyOf"][0] = json!({"required":["unknown"]}); + assert!(format!("{:#}", models(&undeclared).unwrap_err()).contains("undeclared")); + } + #[test] fn subject_references_keep_string_length_constraints() -> Result<()> { let schema = json!({ diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index bd57ca15bca..1e9eefb115a 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -163,6 +163,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case pairingKeyBindingRequired case pairingAlreadyClaimed case pairingClaimInvalid + case pairingRouteNotAllowed + case pairingResultPending + case pairingResultExpired + case pairingRequestConflict + case pairingWaitBusy + case pairingRejected + case pairingExpired case suspendAuthoredSessionSelection case legacyActionReceiptUntrusted case arrangementExists @@ -257,6 +264,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case "pairing-key-binding-required": .pairingKeyBindingRequired case "pairing-already-claimed": .pairingAlreadyClaimed case "pairing-claim-invalid": .pairingClaimInvalid + case "pairing-route-not-allowed": .pairingRouteNotAllowed + case "pairing-result-pending": .pairingResultPending + case "pairing-result-expired": .pairingResultExpired + case "pairing-request-conflict": .pairingRequestConflict + case "pairing-wait-busy": .pairingWaitBusy + case "pairing-rejected": .pairingRejected + case "pairing-expired": .pairingExpired case "invalid-answer": .invalidAnswer; case "not-answerable": .notAnswerable case "suspend-authored-session-selection": .suspendAuthoredSessionSelection case "legacy-action-receipt-untrusted": .legacyActionReceiptUntrusted @@ -353,6 +367,13 @@ public enum ErrorCode: Codable, Sendable, Equatable { case .pairingKeyBindingRequired: "pairing-key-binding-required" case .pairingAlreadyClaimed: "pairing-already-claimed" case .pairingClaimInvalid: "pairing-claim-invalid" + case .pairingRouteNotAllowed: "pairing-route-not-allowed" + case .pairingResultPending: "pairing-result-pending" + case .pairingResultExpired: "pairing-result-expired" + case .pairingRequestConflict: "pairing-request-conflict" + case .pairingWaitBusy: "pairing-wait-busy" + case .pairingRejected: "pairing-rejected" + case .pairingExpired: "pairing-expired" case .invalidAnswer: "invalid-answer"; case .notAnswerable: "not-answerable" case .suspendAuthoredSessionSelection: "suspend-authored-session-selection" case .legacyActionReceiptUntrusted: "legacy-action-receipt-untrusted" @@ -1495,12 +1516,307 @@ public struct PairingKeyStatus: Codable, Sendable { public let deviceKeyFingerprint: String? enum CodingKeys: String, CodingKey { case kind, status, expiresAt = "expires_at", deviceKeyFingerprint = "device_key_fingerprint" } } +public struct PairingExchangeEmpty: Codable, Sendable { + public init() {} + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.isEmpty else { throw pairingWireError(decoder, "Expected an empty pairing body") } + } + public func encode(to encoder: Encoder) throws { _ = encoder.container(keyedBy: PairingWireKey.self) } +} + +private struct PairingWireKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +private func pairingWireError(_ decoder: Decoder, _ message: String) -> DecodingError { + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: message)) +} + +private func pairingWireFields(_ decoder: Decoder, allowed: Set) throws { + let fields = try decoder.container(keyedBy: PairingWireKey.self) + guard fields.allKeys.allSatisfy({ allowed.contains($0.stringValue) }) else { + throw pairingWireError(decoder, "Unexpected pairing field") + } +} +/// Keep secret in memory only; do not log or persist a permit or its QR payload. +public struct PairingFabricPermit: Codable, Sendable { + public let nodeID, attemptID, secret: String + public init(nodeID: String, attemptID: String, secret: String) { self.nodeID = nodeID; self.attemptID = attemptID; self.secret = secret } + enum CodingKeys: String, CodingKey { case nodeID = "node_id", attemptID = "attempt_id", secret } +} +public enum PairingQrTag: String, Codable, Sendable { case pairing = "fractal.pairing" } +public enum PairingQrVersion: Sendable { + case v1 + case v2(PairingFabricPermit) +} + +/// Version two carries a fabric permit; version one cannot carry one. +public struct PairingQrPayload: Codable, Sendable { + public var tag: PairingQrTag { .pairing } + public let version: PairingQrVersion + public let gateway, id, code: String + public let expiresAtUnixMS: Int64 + public init(version: PairingQrVersion, gateway: String, id: String, code: String, expiresAtUnixMS: Int64) { + self.version = version; self.gateway = gateway; self.id = id; self.code = code; self.expiresAtUnixMS = expiresAtUnixMS + } + enum CodingKeys: String, CodingKey { case tag = "_tag", version, gateway, id, code, expiresAtUnixMS = "expires_at_unix_ms", fabric } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms", "fabric"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + _ = try fields.decode(PairingQrTag.self, forKey: .tag) + switch try fields.decode(UInt8.self, forKey: .version) { + case 1: + guard !fields.contains(.fabric) else { throw pairingWireError(decoder, "Version one must not carry fabric") } + version = .v1 + case 2: version = .v2(try fields.decode(PairingFabricPermit.self, forKey: .fabric)) + default: throw pairingWireError(decoder, "Unsupported pairing QR version") + } + gateway = try fields.decode(String.self, forKey: .gateway) + id = try fields.decode(String.self, forKey: .id) + code = try fields.decode(String.self, forKey: .code) + expiresAtUnixMS = try fields.decode(Int64.self, forKey: .expiresAtUnixMS) + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(tag, forKey: .tag) + switch version { + case .v1: try fields.encode(1, forKey: .version) + case .v2(let permit): + try fields.encode(2, forKey: .version) + try fields.encode(permit, forKey: .fabric) + } + try fields.encode(gateway, forKey: .gateway) + try fields.encode(id, forKey: .id) + try fields.encode(code, forKey: .code) + try fields.encode(expiresAtUnixMS, forKey: .expiresAtUnixMS) + } +} + +public enum PairingCapabilityState: String, Codable, Sendable { case granted } +public enum PairingAdvertisedCapability: String, Sendable, CaseIterable, Hashable, Codable { + case deviceKeyProofs = "device-key-proofs" + case fabricGatewayGrant = "fabric-gateway-grant" + case pairingKeyBinding = "pairing-key-binding" + public var id: String { rawValue } + public var version: UInt32 { self == .pairingKeyBinding ? 2 : 1 } + public var state: PairingCapabilityState { .granted } + enum CodingKeys: String, CodingKey { case id, version, state } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["id", "version", "state"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + let id = try fields.decode(String.self, forKey: .id) + guard let capability = Self(rawValue: id), + try fields.decode(UInt32.self, forKey: .version) == capability.version else { + throw pairingWireError(decoder, "Unsupported pairing capability or version") + } + _ = try fields.decode(PairingCapabilityState.self, forKey: .state) + self = capability + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(id, forKey: .id) + try fields.encode(version, forKey: .version) + try fields.encode(state, forKey: .state) + } +} + +public struct PairingAdvertisement: Codable, Sendable { + public let apiVersion: String + public let capabilities: [PairingAdvertisedCapability] + public init(apiVersion: String, capabilities: [PairingAdvertisedCapability]) throws { + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw EncodingError.invalidValue(capabilities, .init(codingPath: [], debugDescription: "Expected one to three unique pairing capabilities")) + } + self.apiVersion = apiVersion; self.capabilities = capabilities + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", capabilities } + public init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + let capabilities = try fields.decode([PairingAdvertisedCapability].self, forKey: .capabilities) + guard !capabilities.isEmpty, capabilities.count <= 3, Set(capabilities).count == capabilities.count else { + throw pairingWireError(decoder, "Expected one to three unique pairing capabilities") + } + self.apiVersion = try fields.decode(String.self, forKey: .apiVersion) + self.capabilities = capabilities + } +} + +public enum PairingExchangeOperation: String, Codable, Sendable { + case capabilities, claim, approvalWait = "approval-wait", complete, ack +} + +/// The operation is derived from the canonical typed body, never independently supplied. +public enum PairingExchangeRequestBody: Sendable { + case capabilities + case claim(PairingKeyClaim) + case approvalWait(PairingApprovalWaitRequest) + case complete(PairingComplete) + case ack(PairingExchangeAck) + public var op: PairingExchangeOperation { + switch self { + case .capabilities: .capabilities + case .claim: .claim + case .approvalWait: .approvalWait + case .complete: .complete + case .ack: .ack + } + } +} + +public struct PairingExchangeRequest: Codable, Sendable { + public var v: UInt8 { 1 } + public let requestID: String + public var op: PairingExchangeOperation { body.op } + public let body: PairingExchangeRequestBody + public init(requestID: String, body: PairingExchangeRequestBody) { self.requestID = requestID; self.body = body } + public static func capabilities(requestID: String) -> Self { .init(requestID: requestID, body: .capabilities) } + public static func claim(requestID: String, body: PairingKeyClaim) -> Self { .init(requestID: requestID, body: .claim(body)) } + public static func approvalWait(requestID: String, body: PairingApprovalWaitRequest) -> Self { .init(requestID: requestID, body: .approvalWait(body)) } + public static func complete(requestID: String, body: PairingComplete) -> Self { .init(requestID: requestID, body: .complete(body)) } + public static func ack(requestID: String, body: PairingExchangeAck) -> Self { .init(requestID: requestID, body: .ack(body)) } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", op, body } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "op", "body"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + let bodyDecoder = try fields.superDecoder(forKey: .body) + switch try fields.decode(PairingExchangeOperation.self, forKey: .op) { + case .capabilities: + _ = try PairingExchangeEmpty(from: bodyDecoder) + body = .capabilities + case .claim: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof"]) + body = .claim(try PairingKeyClaim(from: bodyDecoder)) + case .approvalWait: + try pairingWireFields(bodyDecoder, allowed: ["pairing_id", "key_fingerprint"]) + body = .approvalWait(try PairingApprovalWaitRequest(from: bodyDecoder)) + case .complete: + try pairingWireFields(bodyDecoder, allowed: ["api_version", "code", "device_public_key", "proof", "key_storage", "fabric_node_id"]) + body = .complete(try PairingComplete(from: bodyDecoder)) + case .ack: + try pairingWireFields(bodyDecoder, allowed: ["request_id"]) + body = .ack(try PairingExchangeAck(from: bodyDecoder)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + try fields.encode(op, forKey: .op) + switch body { + case .capabilities: try fields.encode(PairingExchangeEmpty(), forKey: .body) + case .claim(let body): try fields.encode(body, forKey: .body) + case .approvalWait(let body): try fields.encode(body, forKey: .body) + case .complete(let body): try fields.encode(body, forKey: .body) + case .ack(let body): try fields.encode(body, forKey: .body) + } + } +} +public struct PairingExchangeRefusal: Codable, Sendable { + public let code: ErrorCode + public let message: String + public init(code: ErrorCode, message: String) { self.code = code; self.message = message } +} +/// A success and a refusal cannot coexist or both be absent. +public enum PairingExchangeOutcome: Sendable { + case ok(Body) + case refused(PairingExchangeRefusal) +} + +/// One reply envelope and one common refusal for every operation. +public struct PairingExchangeReply: Codable, Sendable { + public var v: UInt8 { 1 } + public let requestID: String + public let outcome: PairingExchangeOutcome + public init(requestID: String, outcome: PairingExchangeOutcome) { self.requestID = requestID; self.outcome = outcome } + enum CodingKeys: String, CodingKey { case v, requestID = "request_id", ok, body, refusal } + public init(from decoder: Decoder) throws { + try pairingWireFields(decoder, allowed: ["v", "request_id", "ok", "body", "refusal"]) + let fields = try decoder.container(keyedBy: CodingKeys.self) + guard try fields.decode(UInt8.self, forKey: .v) == 1 else { throw pairingWireError(decoder, "Unsupported pairing exchange version") } + requestID = try fields.decode(String.self, forKey: .requestID) + if try fields.decode(Bool.self, forKey: .ok) { + guard fields.contains(.body), !fields.contains(.refusal), !(try fields.decodeNil(forKey: .body)) else { + throw pairingWireError(decoder, "Successful pairing reply requires only a body") + } + outcome = .ok(try fields.decode(Body.self, forKey: .body)) + } else { + guard fields.contains(.refusal), !fields.contains(.body) else { + throw pairingWireError(decoder, "Refused pairing reply requires only a refusal") + } + outcome = .refused(try fields.decode(PairingExchangeRefusal.self, forKey: .refusal)) + } + } + public func encode(to encoder: Encoder) throws { + var fields = encoder.container(keyedBy: CodingKeys.self) + try fields.encode(v, forKey: .v) + try fields.encode(requestID, forKey: .requestID) + switch outcome { + case .ok(let body): + try fields.encode(true, forKey: .ok) + try fields.encode(body, forKey: .body) + case .refused(let refusal): + try fields.encode(false, forKey: .ok) + try fields.encode(refusal, forKey: .refusal) + } + } +} +public struct PairingApprovalWaitRequest: Codable, Sendable { + public let pairingID, keyFingerprint: String + public init(pairingID: String, keyFingerprint: String) { self.pairingID = pairingID; self.keyFingerprint = keyFingerprint } + enum CodingKeys: String, CodingKey { case pairingID = "pairing_id", keyFingerprint = "key_fingerprint" } +} +public enum PairingApprovalStatus: String, Codable, Sendable { case approved } +public struct PairingApprovalWaitApproved: Codable, Sendable { + public let status: PairingApprovalStatus + public let keyFingerprint, approvedAt: String + public init(keyFingerprint: String, approvedAt: String) { self.status = .approved; self.keyFingerprint = keyFingerprint; self.approvedAt = approvedAt } + enum CodingKeys: String, CodingKey { case status, keyFingerprint = "key_fingerprint", approvedAt = "approved_at" } +} +public struct PairingExchangeAck: Codable, Sendable { + public let requestID: String + public init(requestID: String) { self.requestID = requestID } + enum CodingKeys: String, CodingKey { case requestID = "request_id" } +} +public enum PairingAdmissionState: String, Codable, Sendable { case pending = "admission-pending" } +public struct PairingAdmissionPending: Codable, Sendable { + public let state: PairingAdmissionState + public let pairingID: String + public init(pairingID: String) { self.state = .pending; self.pairingID = pairingID } + enum CodingKeys: String, CodingKey { case state, pairingID = "pairing_id" } +} +public struct PairingResultRequest: Codable, Sendable { + public let apiVersion, attemptID, devicePublicKey, proof: String + public init(apiVersion: String, attemptID: String, devicePublicKey: String, proof: String) { self.apiVersion = apiVersion; self.attemptID = attemptID; self.devicePublicKey = devicePublicKey; self.proof = proof } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", attemptID = "attempt_id", devicePublicKey = "device_public_key", proof } +} +public struct PairingResultPending: Codable, Sendable { + public let code: ErrorCode + public let retryable: Bool + public let deadline: String + public init(deadline: String) { self.code = .pairingResultPending; self.retryable = true; self.deadline = deadline } +} public struct PairingChallenge: Codable, Sendable { public let kind, pairingID, code, expiresAt: String; public let personRootFingerprint: String? public let principal: AssistantPrincipal?; public let grantKind, grantID, grantExpiresAt: String?; public let policyRevision: UInt64?; public let minimalReadMode: AssistantMinimalReadMode? enum CodingKeys: String, CodingKey { case kind, pairingID = "pairing_id", code, expiresAt = "expires_at", personRootFingerprint = "person_root_fingerprint", principal, grantKind = "grant_kind", grantID = "grant_id", grantExpiresAt = "grant_expires_at", policyRevision = "policy_revision", minimalReadMode = "minimal_read_mode" } } -public struct PairingComplete: Codable, Sendable { public let apiVersion: String; public let code: String; public let devicePublicKey: String; public var proof: String? = nil; public var keyStorage: String? = nil; public var fabricNodeID: String? = nil; enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } } +public struct PairingComplete: Codable, Sendable { + public let apiVersion, code, devicePublicKey: String + public var proof: String? + public var keyStorage: String? + public var fabricNodeID: String? + public init(apiVersion: String, code: String, devicePublicKey: String, proof: String? = nil, keyStorage: String? = nil, fabricNodeID: String? = nil) { + self.apiVersion = apiVersion; self.code = code; self.devicePublicKey = devicePublicKey + self.proof = proof; self.keyStorage = keyStorage; self.fabricNodeID = fabricNodeID + } + enum CodingKeys: String, CodingKey { case apiVersion = "api_version", code, devicePublicKey = "device_public_key", proof, keyStorage = "key_storage", fabricNodeID = "fabric_node_id" } +} public enum PairedSession: Codable, Sendable { case person(PersonPairedSession), assistant(AssistantPairedSession) public init(from decoder: Decoder) throws { diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index e326b5845fb..29e7081ec2c 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -142,6 +142,13 @@ pub enum ErrorCode { PairingKeyBindingRequired, PairingAlreadyClaimed, PairingClaimInvalid, + PairingRouteNotAllowed, + PairingResultPending, + PairingResultExpired, + PairingRequestConflict, + PairingWaitBusy, + PairingRejected, + PairingExpired, SuspendAuthoredSessionSelection, StaleFence, CursorGap, @@ -4321,6 +4328,353 @@ pub struct PairingKeyStatus { pub device_key_fingerprint: Option, pub expires_at: String, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeEmpty {} +/// The permit secret is deliberately excluded from Debug output. +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingFabricPermit { + pub node_id: String, + pub attempt_id: String, + pub secret: String, +} +/// A numeric wire constant: callers cannot construct a different value. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PairingWireVersion; +impl Serialize for PairingWireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(V) + } +} +impl<'de, const V: u8> Deserialize<'de> for PairingWireVersion { + fn deserialize>(deserializer: D) -> Result { + if u8::deserialize(deserializer)? == V { + Ok(Self) + } else { + Err(serde::de::Error::custom("incorrect pairing wire version")) + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingQrTag { + #[serde(rename = "fractal.pairing")] + Pairing, +} +/// Each variant enforces its version and presence (or absence) of a Fabric permit. +#[derive(Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum PairingQrPayload { + V1(PairingQrV1), + V2(PairingQrV2), +} +impl<'de> Deserialize<'de> for PairingQrPayload { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + #[serde(rename = "_tag")] + tag: PairingQrTag, + version: u8, + gateway: String, + id: String, + code: String, + expires_at_unix_ms: i64, + #[serde(default)] + fabric: PairingExchangeField, + } + let wire = Wire::deserialize(deserializer)?; + match (wire.version, wire.fabric) { + (1, PairingExchangeField::Missing) => Ok(Self::V1(PairingQrV1 { + tag: wire.tag, version: PairingWireVersion, gateway: wire.gateway, + id: wire.id, code: wire.code, expires_at_unix_ms: wire.expires_at_unix_ms, + })), + (2, PairingExchangeField::Present(fabric)) => Ok(Self::V2(PairingQrV2 { + tag: wire.tag, version: PairingWireVersion, gateway: wire.gateway, + id: wire.id, code: wire.code, expires_at_unix_ms: wire.expires_at_unix_ms, fabric, + })), + _ => Err(serde::de::Error::custom("pairing QR version does not match Fabric permit presence")), + } + } +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrV1 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<1>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrV2 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<2>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + pub fabric: PairingFabricPermit, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(try_from = "PairingAdvertisementWire")] +pub struct PairingAdvertisement { + pub api_version: String, + capabilities: Vec, +} +impl PairingAdvertisement { + pub fn new(api_version: String, capabilities: Vec) -> Result { + if capabilities.is_empty() || capabilities.len() > 3 { + return Err("pairing advertisement requires one to three capabilities"); + } + for (index, capability) in capabilities.iter().enumerate() { + if capabilities[..index].iter().any(|other| other.id() == capability.id()) { + return Err("duplicate pairing capability ID"); + } + } + Ok(Self { api_version, capabilities }) + } + + pub fn capabilities(&self) -> &[PairingAdvertisedCapability] { + &self.capabilities + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PairingAdvertisementWire { + api_version: String, + capabilities: Vec, +} +impl TryFrom for PairingAdvertisement { + type Error = &'static str; + fn try_from(wire: PairingAdvertisementWire) -> Result { + Self::new(wire.api_version, wire.capabilities) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingCapabilityState { + #[serde(rename = "granted")] + Granted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "id", deny_unknown_fields)] +pub enum PairingAdvertisedCapability { + #[serde(rename = "device-key-proofs")] + DeviceKeyProofs { version: PairingWireVersion<1>, state: PairingCapabilityState }, + #[serde(rename = "fabric-gateway-grant")] + FabricGatewayGrant { version: PairingWireVersion<1>, state: PairingCapabilityState }, + #[serde(rename = "pairing-key-binding")] + PairingKeyBinding { version: PairingWireVersion<2>, state: PairingCapabilityState }, +} +impl PairingAdvertisedCapability { + pub fn id(&self) -> &'static str { + match self { + Self::DeviceKeyProofs { .. } => "device-key-proofs", + Self::FabricGatewayGrant { .. } => "fabric-gateway-grant", + Self::PairingKeyBinding { .. } => "pairing-key-binding", + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum PairingExchangeOperation { + Capabilities, + Claim, + ApprovalWait, + Complete, + Ack, +} +mod pairing_request_body { + pub trait Sealed {} +} +/// Sealed to the five canonical operation bodies; downstream types cannot choose an opcode. +pub trait PairingExchangeRequestBody: pairing_request_body::Sealed { + const OP: PairingExchangeOperation; +} +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeRequest { + pub request_id: String, + pub body: T, +} +macro_rules! pairing_request_body { + ($body:ty, $op:ident, $constructor:ident) => { + impl pairing_request_body::Sealed for $body {} + impl PairingExchangeRequestBody for $body { + const OP: PairingExchangeOperation = PairingExchangeOperation::$op; + } + impl PairingExchangeRequest<$body> { + pub fn $constructor(request_id: String, body: $body) -> Self { + Self { request_id, body } + } + } + }; +} +pairing_request_body!(PairingExchangeEmpty, Capabilities, capabilities); +pairing_request_body!(PairingKeyClaim, Claim, claim); +pairing_request_body!(PairingApprovalWaitRequest, ApprovalWait, approval_wait); +pairing_request_body!(PairingComplete, Complete, complete); +pairing_request_body!(PairingExchangeAck, Ack, ack); +impl PairingExchangeRequest { + pub fn op(&self) -> PairingExchangeOperation { + T::OP + } +} +impl Serialize for PairingExchangeRequest { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeRequest", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + wire.serialize_field("op", &T::OP)?; + wire.serialize_field("body", &self.body)?; + wire.end() + } +} +impl<'de, T: PairingExchangeRequestBody + Deserialize<'de>> Deserialize<'de> for PairingExchangeRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + op: PairingExchangeOperation, + body: T, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + if wire.op != T::OP { + return Err(serde::de::Error::custom("pairing operation does not match body type")); + } + Ok(Self { request_id: wire.request_id, body: wire.body }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRefusal { + pub code: ErrorCode, + pub message: String, +} +/// Native outcomes cannot represent contradictory success and refusal fields. +#[derive(Clone, Debug, PartialEq)] +pub enum PairingExchangeOutcome { + Ok(T), + Refused(PairingExchangeRefusal), +} +/// One reply envelope and refusal type for every operation. +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeReply { + pub request_id: String, + pub outcome: PairingExchangeOutcome, +} +impl Serialize for PairingExchangeReply { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeReply", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + match &self.outcome { + PairingExchangeOutcome::Ok(body) => { + wire.serialize_field("ok", &true)?; + wire.serialize_field("body", body)?; + } + PairingExchangeOutcome::Refused(refusal) => { + wire.serialize_field("ok", &false)?; + wire.serialize_field("refusal", refusal)?; + } + } + wire.end() + } +} +// Unlike Option, this retains the distinction between a missing field and an +// explicitly present null. Forbidden fields are rejected even when null. +enum PairingExchangeField { + Missing, + Present(T), +} +impl Default for PairingExchangeField { + fn default() -> Self { Self::Missing } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeField { + fn deserialize>(deserializer: D) -> Result { + T::deserialize(deserializer).map(Self::Present) + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeReply { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, bound(deserialize = "T: Deserialize<'de>"))] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + ok: bool, + #[serde(default)] + body: PairingExchangeField, + #[serde(default)] + refusal: PairingExchangeField, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + let outcome = match (wire.ok, wire.body, wire.refusal) { + (true, PairingExchangeField::Present(body), PairingExchangeField::Missing) => PairingExchangeOutcome::Ok(body), + (false, PairingExchangeField::Missing, PairingExchangeField::Present(refusal)) => PairingExchangeOutcome::Refused(refusal), + _ => return Err(serde::de::Error::custom("inconsistent pairing reply outcome")), + }; + Ok(Self { request_id: wire.request_id, outcome }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitRequest { + pub pairing_id: String, + pub key_fingerprint: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitApproved { + pub status: PairingApprovalStatus, + pub key_fingerprint: String, + pub approved_at: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingApprovalStatus { + #[serde(rename = "approved")] + Approved, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeAck { + pub request_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdmissionPending { + pub state: PairingAdmissionState, + pub pairing_id: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingAdmissionState { + #[serde(rename = "admission-pending")] + AdmissionPending, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultRequest { + pub api_version: String, + pub attempt_id: String, + pub device_public_key: String, + pub proof: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultPending { + pub code: ErrorCode, + pub retryable: bool, + pub deadline: String, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PairingChallenge { pub kind: String, diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index 8cf3dc2e6c5..8c9657ab854 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -3495,6 +3495,220 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_native_exchange_models_match_approval_wait_wire_fields() { + use serde::de::DeserializeOwned; + use serde::Serialize; + + fn roundtrip(value: &serde_json::Value) { + let decoded: T = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), *value); + } + fn rejects(value: serde_json::Value) { + assert!(serde_json::from_value::(value.clone()).is_err(), "{value}"); + } + fn request_roundtrip(request: PairingExchangeRequest) + where + T: PairingExchangeRequestBody + DeserializeOwned + Serialize, + { + let value = serde_json::to_value(request).unwrap(); + roundtrip::>(&value); + for op in ["capabilities", "claim", "approval-wait", "complete", "ack"] { + if value["op"] != op { + let mut wrong = value.clone(); + wrong["op"] = serde_json::json!(op); + rejects::>(wrong); + } + } + let mut wrong = value; + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + } + + let request = PairingExchangeRequest::approval_wait( + "wait-1".into(), + PairingApprovalWaitRequest { + pairing_id: "pairing/example".into(), + key_fingerprint: format!("sha256:{}", "A".repeat(43)), + }, + ); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), fixture["cases"][0]["value"]); + request_roundtrip(request); + request_roundtrip(PairingExchangeRequest::capabilities("cap-1".into(), PairingExchangeEmpty {})); + request_roundtrip(PairingExchangeRequest::claim("claim-1".into(), PairingKeyClaim { + api_version: "fractal.client.v0".into(), code: "ABCDEFGH".into(), + device_public_key: "key".into(), proof: "proof".into(), + })); + request_roundtrip(PairingExchangeRequest::complete("complete-1".into(), PairingComplete { + api_version: "fractal.client.v0".into(), code: "ABCDEFGH".into(), + device_public_key: "key".into(), proof: None, key_storage: None, fabric_node_id: None, + })); + request_roundtrip(PairingExchangeRequest::ack("ack-1".into(), PairingExchangeAck { request_id: "complete-1".into() })); + rejects::>(serde_json::json!({ + "v": 1, "request_id": "wait-1", "op": "approval-wait", "body": {} + })); + let approved_value = fixture["cases"][6]["value"].clone(); + let refused_value = fixture["cases"][9]["value"].clone(); + let approved: PairingExchangeReply = + serde_json::from_value(approved_value.clone()).unwrap(); + assert!(matches!(approved.outcome, PairingExchangeOutcome::Ok(PairingApprovalWaitApproved { + status: PairingApprovalStatus::Approved, .. + }))); + let refused: PairingExchangeReply = + serde_json::from_value(refused_value.clone()).unwrap(); + assert!(matches!(refused.outcome, PairingExchangeOutcome::Refused(PairingExchangeRefusal { + code: ErrorCode::PairingRejected, .. + }))); + roundtrip::>(&approved_value); + roundtrip::>(&refused_value); + // Exercise every body/refusal presence combination with both ok values. + for ok in [false, true] { + for has_body in [false, true] { + for has_refusal in [false, true] { + let mut value = serde_json::json!({"v": 1, "request_id": "reply-1", "ok": ok}); + if has_body { value["body"] = approved_value["body"].clone(); } + if has_refusal { value["refusal"] = refused_value["refusal"].clone(); } + if (ok && has_body && !has_refusal) || (!ok && !has_body && has_refusal) { + roundtrip::>(&value); + } else { + rejects::>(value); + } + } + } + } + for field in ["body", "refusal"] { + for base in [&approved_value, &refused_value] { + let mut value = base.clone(); + value[field] = serde_json::Value::Null; + rejects::>(value); + } + } + let mut wrong = approved_value.clone(); + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + let mut wrong = approved_value["body"].clone(); + wrong["status"] = serde_json::json!("pending"); + rejects::(wrong); + + let pending = serde_json::json!({"state": "admission-pending", "pairing_id": "pairing/example"}); + roundtrip::(&pending); + let mut wrong = pending.clone(); + wrong["state"] = serde_json::json!("granted"); + rejects::(wrong); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "complete-1", "ok": true, "body": pending + })); + let qr = serde_json::json!({ + "_tag": "fractal.pairing", "version": 1, "gateway": "https://example.test", + "id": "pairing/example", "code": "ABCDEFGH", "expires_at_unix_ms": 1000 + }); + let permit = serde_json::json!({"node_id": "node", "attempt_id": "attempt", "secret": "secret"}); + roundtrip::(&qr); + let mut qr_v2 = qr.clone(); + qr_v2["version"] = serde_json::json!(2); + qr_v2["fabric"] = permit.clone(); + roundtrip::(&qr_v2); + for (field, value) in [ + ("_tag", serde_json::json!("other.pairing")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("fabric", permit), + ("fabric", serde_json::Value::Null), + ] { + let mut wrong = qr.clone(); + wrong[field] = value; + rejects::(wrong); + } + let mut wrong = qr.clone(); + wrong["version"] = serde_json::json!(2); + rejects::(wrong); + let mut wrong = qr_v2; + wrong["fabric"] = serde_json::Value::Null; + rejects::(wrong); + + let capabilities = serde_json::json!([ + {"id": "device-key-proofs", "version": 1, "state": "granted"}, + {"id": "fabric-gateway-grant", "version": 1, "state": "granted"}, + {"id": "pairing-key-binding", "version": 2, "state": "granted"} + ]); + let advertisement = serde_json::json!({"api_version": "fractal.client.v0", "capabilities": capabilities}); + roundtrip::(&advertisement); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "cap-1", "ok": true, "body": advertisement + })); + for capability in capabilities.as_array().unwrap() { + roundtrip::(capability); + for (field, value) in [ + ("id", serde_json::json!("unknown")), + ("state", serde_json::json!("pending")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("version", serde_json::json!(if capability["version"] == 1 { 2 } else { 1 })), + ] { + let mut wrong = capability.clone(); + wrong[field] = value; + rejects::(wrong); + } + } + for invalid in [ + serde_json::json!([]), + serde_json::json!([capabilities[0], capabilities[0]]), + serde_json::json!([capabilities[0], capabilities[1], capabilities[2], capabilities[0]]), + ] { + rejects::(serde_json::json!({ + "api_version": "fractal.client.v0", "capabilities": invalid + })); + } + } + + #[test] + fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let schema = serde_json::json!({"$ref": format!("#/$defs/{}", case["schema"].as_str().unwrap()), "$defs": contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + assert_eq!(validator.is_valid(&case["value"]), case["valid"].as_bool().unwrap(), "{}", case["name"]); + } + } + + #[test] + fn pairing_admission_state_schema_validates_every_durable_branch() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )).unwrap(); + let schema = serde_json::json!({"$ref": "#/$defs/PairingAdmissionRecord", "$defs": contract["$defs"]}); + let validator = jsonschema::options().should_validate_formats(true).build(&schema).unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-admission-states.json" + )).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut record = fixture["base"].as_object().unwrap().clone(); + record.insert("state".into(), case["state"].clone()); + for group in case["groups"].as_array().unwrap() { + record.extend(fixture[group.as_str().unwrap()].as_object().unwrap().clone()); + } + if let Some(fields) = case["set"].as_object() { + record.extend(fields.clone()); + } + if let Some(fields) = case["remove"].as_array() { + for field in fields { record.remove(field.as_str().unwrap()); } + } + if let Some(fields) = case["remove_receipt"].as_array() { + let receipt = record.get_mut("confirm_receipt").unwrap().as_object_mut().unwrap(); + for field in fields { receipt.remove(field.as_str().unwrap()); } + } + assert_eq!(validator.is_valid(&serde_json::Value::Object(record)), case["valid"].as_bool().unwrap(), "{}", case["name"]); + } + } + #[test] fn phone_documents_without_params_matches_the_schema() { let value = serde_json::to_value(PhoneNavigateArgs::Documents(None)).unwrap(); diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index 3a7b5e5e870..a4a3aafbe1a 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -142,6 +142,13 @@ pub enum ErrorCode { PairingKeyBindingRequired, PairingAlreadyClaimed, PairingClaimInvalid, + PairingRouteNotAllowed, + PairingResultPending, + PairingResultExpired, + PairingRequestConflict, + PairingWaitBusy, + PairingRejected, + PairingExpired, SuspendAuthoredSessionSelection, StaleFence, CursorGap, @@ -5152,6 +5159,402 @@ pub struct PairingKeyStatus { pub device_key_fingerprint: Option, pub expires_at: String, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeEmpty {} +/// The permit secret is deliberately excluded from Debug output. +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingFabricPermit { + pub node_id: String, + pub attempt_id: String, + pub secret: String, +} +/// A numeric wire constant: callers cannot construct a different value. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PairingWireVersion; +impl Serialize for PairingWireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(V) + } +} +impl<'de, const V: u8> Deserialize<'de> for PairingWireVersion { + fn deserialize>(deserializer: D) -> Result { + if u8::deserialize(deserializer)? == V { + Ok(Self) + } else { + Err(serde::de::Error::custom("incorrect pairing wire version")) + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingQrTag { + #[serde(rename = "fractal.pairing")] + Pairing, +} +/// Each variant enforces its version and presence (or absence) of a Fabric permit. +#[derive(Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum PairingQrPayload { + V1(PairingQrV1), + V2(PairingQrV2), +} +impl<'de> Deserialize<'de> for PairingQrPayload { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + #[serde(rename = "_tag")] + tag: PairingQrTag, + version: u8, + gateway: String, + id: String, + code: String, + expires_at_unix_ms: i64, + #[serde(default)] + fabric: PairingExchangeField, + } + let wire = Wire::deserialize(deserializer)?; + match (wire.version, wire.fabric) { + (1, PairingExchangeField::Missing) => Ok(Self::V1(PairingQrV1 { + tag: wire.tag, + version: PairingWireVersion, + gateway: wire.gateway, + id: wire.id, + code: wire.code, + expires_at_unix_ms: wire.expires_at_unix_ms, + })), + (2, PairingExchangeField::Present(fabric)) => Ok(Self::V2(PairingQrV2 { + tag: wire.tag, + version: PairingWireVersion, + gateway: wire.gateway, + id: wire.id, + code: wire.code, + expires_at_unix_ms: wire.expires_at_unix_ms, + fabric, + })), + _ => Err(serde::de::Error::custom( + "pairing QR version does not match Fabric permit presence", + )), + } + } +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrV1 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<1>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, +} +#[derive(Clone, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingQrV2 { + #[serde(rename = "_tag")] + pub tag: PairingQrTag, + pub version: PairingWireVersion<2>, + pub gateway: String, + pub id: String, + pub code: String, + pub expires_at_unix_ms: i64, + pub fabric: PairingFabricPermit, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(try_from = "PairingAdvertisementWire")] +pub struct PairingAdvertisement { + pub api_version: String, + capabilities: Vec, +} +impl PairingAdvertisement { + pub fn new( + api_version: String, + capabilities: Vec, + ) -> Result { + if capabilities.is_empty() || capabilities.len() > 3 { + return Err("pairing advertisement requires one to three capabilities"); + } + for (index, capability) in capabilities.iter().enumerate() { + if capabilities[..index] + .iter() + .any(|other| other.id() == capability.id()) + { + return Err("duplicate pairing capability ID"); + } + } + Ok(Self { + api_version, + capabilities, + }) + } + + pub fn capabilities(&self) -> &[PairingAdvertisedCapability] { + &self.capabilities + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PairingAdvertisementWire { + api_version: String, + capabilities: Vec, +} +impl TryFrom for PairingAdvertisement { + type Error = &'static str; + fn try_from(wire: PairingAdvertisementWire) -> Result { + Self::new(wire.api_version, wire.capabilities) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingCapabilityState { + #[serde(rename = "granted")] + Granted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "id", deny_unknown_fields)] +pub enum PairingAdvertisedCapability { + #[serde(rename = "device-key-proofs")] + DeviceKeyProofs { + version: PairingWireVersion<1>, + state: PairingCapabilityState, + }, + #[serde(rename = "fabric-gateway-grant")] + FabricGatewayGrant { + version: PairingWireVersion<1>, + state: PairingCapabilityState, + }, + #[serde(rename = "pairing-key-binding")] + PairingKeyBinding { + version: PairingWireVersion<2>, + state: PairingCapabilityState, + }, +} +impl PairingAdvertisedCapability { + pub fn id(&self) -> &'static str { + match self { + Self::DeviceKeyProofs { .. } => "device-key-proofs", + Self::FabricGatewayGrant { .. } => "fabric-gateway-grant", + Self::PairingKeyBinding { .. } => "pairing-key-binding", + } + } +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum PairingExchangeOperation { + Capabilities, + Claim, + ApprovalWait, + Complete, + Ack, +} +mod pairing_request_body { + pub trait Sealed {} +} +/// Sealed to the five canonical operation bodies; downstream types cannot choose an opcode. +pub trait PairingExchangeRequestBody: pairing_request_body::Sealed { + const OP: PairingExchangeOperation; +} +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeRequest { + pub request_id: String, + pub body: T, +} +macro_rules! pairing_request_body { + ($body:ty, $op:ident, $constructor:ident) => { + impl pairing_request_body::Sealed for $body {} + impl PairingExchangeRequestBody for $body { + const OP: PairingExchangeOperation = PairingExchangeOperation::$op; + } + impl PairingExchangeRequest<$body> { + pub fn $constructor(request_id: String, body: $body) -> Self { + Self { request_id, body } + } + } + }; +} +pairing_request_body!(PairingExchangeEmpty, Capabilities, capabilities); +pairing_request_body!(PairingKeyClaim, Claim, claim); +pairing_request_body!(PairingApprovalWaitRequest, ApprovalWait, approval_wait); +pairing_request_body!(PairingComplete, Complete, complete); +pairing_request_body!(PairingExchangeAck, Ack, ack); +impl PairingExchangeRequest { + pub fn op(&self) -> PairingExchangeOperation { + T::OP + } +} +impl Serialize for PairingExchangeRequest { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeRequest", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + wire.serialize_field("op", &T::OP)?; + wire.serialize_field("body", &self.body)?; + wire.end() + } +} +impl<'de, T: PairingExchangeRequestBody + Deserialize<'de>> Deserialize<'de> + for PairingExchangeRequest +{ + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + op: PairingExchangeOperation, + body: T, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + if wire.op != T::OP { + return Err(serde::de::Error::custom( + "pairing operation does not match body type", + )); + } + Ok(Self { + request_id: wire.request_id, + body: wire.body, + }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeRefusal { + pub code: ErrorCode, + pub message: String, +} +/// Native outcomes cannot represent contradictory success and refusal fields. +#[derive(Clone, Debug, PartialEq)] +pub enum PairingExchangeOutcome { + Ok(T), + Refused(PairingExchangeRefusal), +} +/// One reply envelope and refusal type for every operation. +#[derive(Clone, Debug, PartialEq)] +pub struct PairingExchangeReply { + pub request_id: String, + pub outcome: PairingExchangeOutcome, +} +impl Serialize for PairingExchangeReply { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut wire = serializer.serialize_struct("PairingExchangeReply", 4)?; + wire.serialize_field("v", &1_u8)?; + wire.serialize_field("request_id", &self.request_id)?; + match &self.outcome { + PairingExchangeOutcome::Ok(body) => { + wire.serialize_field("ok", &true)?; + wire.serialize_field("body", body)?; + } + PairingExchangeOutcome::Refused(refusal) => { + wire.serialize_field("ok", &false)?; + wire.serialize_field("refusal", refusal)?; + } + } + wire.end() + } +} +// Unlike Option, this retains the distinction between a missing field and an +// explicitly present null. Forbidden fields are rejected even when null. +enum PairingExchangeField { + Missing, + Present(T), +} +impl Default for PairingExchangeField { + fn default() -> Self { + Self::Missing + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeField { + fn deserialize>(deserializer: D) -> Result { + T::deserialize(deserializer).map(Self::Present) + } +} +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PairingExchangeReply { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, bound(deserialize = "T: Deserialize<'de>"))] + struct Wire { + v: PairingWireVersion<1>, + request_id: String, + ok: bool, + #[serde(default)] + body: PairingExchangeField, + #[serde(default)] + refusal: PairingExchangeField, + } + let wire = Wire::::deserialize(deserializer)?; + let _ = wire.v; + let outcome = match (wire.ok, wire.body, wire.refusal) { + (true, PairingExchangeField::Present(body), PairingExchangeField::Missing) => { + PairingExchangeOutcome::Ok(body) + } + (false, PairingExchangeField::Missing, PairingExchangeField::Present(refusal)) => { + PairingExchangeOutcome::Refused(refusal) + } + _ => { + return Err(serde::de::Error::custom( + "inconsistent pairing reply outcome", + )); + } + }; + Ok(Self { + request_id: wire.request_id, + outcome, + }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitRequest { + pub pairing_id: String, + pub key_fingerprint: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingApprovalWaitApproved { + pub status: PairingApprovalStatus, + pub key_fingerprint: String, + pub approved_at: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingApprovalStatus { + #[serde(rename = "approved")] + Approved, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingExchangeAck { + pub request_id: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingAdmissionPending { + pub state: PairingAdmissionState, + pub pairing_id: String, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)] +pub enum PairingAdmissionState { + #[serde(rename = "admission-pending")] + AdmissionPending, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultRequest { + pub api_version: String, + pub attempt_id: String, + pub device_public_key: String, + pub proof: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct PairingResultPending { + pub code: ErrorCode, + pub retryable: bool, + pub deadline: String, +} #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct PairingChallenge { pub kind: String, diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index 16ce1966558..ad50a904b62 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -5370,6 +5370,303 @@ fn percent_encode_segment(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn pairing_native_exchange_models_match_approval_wait_wire_fields() { + use serde::Serialize; + use serde::de::DeserializeOwned; + + fn roundtrip(value: &serde_json::Value) { + let decoded: T = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), *value); + } + fn rejects(value: serde_json::Value) { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{value}" + ); + } + fn request_roundtrip(request: PairingExchangeRequest) + where + T: PairingExchangeRequestBody + DeserializeOwned + Serialize, + { + let value = serde_json::to_value(request).unwrap(); + roundtrip::>(&value); + for op in ["capabilities", "claim", "approval-wait", "complete", "ack"] { + if value["op"] != op { + let mut wrong = value.clone(); + wrong["op"] = serde_json::json!(op); + rejects::>(wrong); + } + } + let mut wrong = value; + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + } + + let request = PairingExchangeRequest::approval_wait( + "wait-1".into(), + PairingApprovalWaitRequest { + pairing_id: "pairing/example".into(), + key_fingerprint: format!("sha256:{}", "A".repeat(43)), + }, + ); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )) + .unwrap(); + assert_eq!( + serde_json::to_value(&request).unwrap(), + fixture["cases"][0]["value"] + ); + request_roundtrip(request); + request_roundtrip(PairingExchangeRequest::capabilities( + "cap-1".into(), + PairingExchangeEmpty {}, + )); + request_roundtrip(PairingExchangeRequest::claim( + "claim-1".into(), + PairingKeyClaim { + api_version: "fractal.client.v0".into(), + code: "ABCDEFGH".into(), + device_public_key: "key".into(), + proof: "proof".into(), + }, + )); + request_roundtrip(PairingExchangeRequest::complete( + "complete-1".into(), + PairingComplete { + api_version: "fractal.client.v0".into(), + code: "ABCDEFGH".into(), + device_public_key: "key".into(), + proof: None, + key_storage: None, + fabric_node_id: None, + }, + )); + request_roundtrip(PairingExchangeRequest::ack( + "ack-1".into(), + PairingExchangeAck { + request_id: "complete-1".into(), + }, + )); + rejects::>(serde_json::json!({ + "v": 1, "request_id": "wait-1", "op": "approval-wait", "body": {} + })); + let approved_value = fixture["cases"][6]["value"].clone(); + let refused_value = fixture["cases"][9]["value"].clone(); + let approved: PairingExchangeReply = + serde_json::from_value(approved_value.clone()).unwrap(); + assert!(matches!( + approved.outcome, + PairingExchangeOutcome::Ok(PairingApprovalWaitApproved { + status: PairingApprovalStatus::Approved, + .. + }) + )); + let refused: PairingExchangeReply = + serde_json::from_value(refused_value.clone()).unwrap(); + assert!(matches!( + refused.outcome, + PairingExchangeOutcome::Refused(PairingExchangeRefusal { + code: ErrorCode::PairingRejected, + .. + }) + )); + roundtrip::>(&approved_value); + roundtrip::>(&refused_value); + // Exercise every body/refusal presence combination with both ok values. + for ok in [false, true] { + for has_body in [false, true] { + for has_refusal in [false, true] { + let mut value = serde_json::json!({"v": 1, "request_id": "reply-1", "ok": ok}); + if has_body { + value["body"] = approved_value["body"].clone(); + } + if has_refusal { + value["refusal"] = refused_value["refusal"].clone(); + } + if (ok && has_body && !has_refusal) || (!ok && !has_body && has_refusal) { + roundtrip::>(&value); + } else { + rejects::>(value); + } + } + } + } + for field in ["body", "refusal"] { + for base in [&approved_value, &refused_value] { + let mut value = base.clone(); + value[field] = serde_json::Value::Null; + rejects::>(value); + } + } + let mut wrong = approved_value.clone(); + wrong["v"] = serde_json::json!(2); + rejects::>(wrong); + let mut wrong = approved_value["body"].clone(); + wrong["status"] = serde_json::json!("pending"); + rejects::(wrong); + + let pending = + serde_json::json!({"state": "admission-pending", "pairing_id": "pairing/example"}); + roundtrip::(&pending); + let mut wrong = pending.clone(); + wrong["state"] = serde_json::json!("granted"); + rejects::(wrong); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "complete-1", "ok": true, "body": pending + })); + let qr = serde_json::json!({ + "_tag": "fractal.pairing", "version": 1, "gateway": "https://example.test", + "id": "pairing/example", "code": "ABCDEFGH", "expires_at_unix_ms": 1000 + }); + let permit = + serde_json::json!({"node_id": "node", "attempt_id": "attempt", "secret": "secret"}); + roundtrip::(&qr); + let mut qr_v2 = qr.clone(); + qr_v2["version"] = serde_json::json!(2); + qr_v2["fabric"] = permit.clone(); + roundtrip::(&qr_v2); + for (field, value) in [ + ("_tag", serde_json::json!("other.pairing")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ("fabric", permit), + ("fabric", serde_json::Value::Null), + ] { + let mut wrong = qr.clone(); + wrong[field] = value; + rejects::(wrong); + } + let mut wrong = qr.clone(); + wrong["version"] = serde_json::json!(2); + rejects::(wrong); + let mut wrong = qr_v2; + wrong["fabric"] = serde_json::Value::Null; + rejects::(wrong); + + let capabilities = serde_json::json!([ + {"id": "device-key-proofs", "version": 1, "state": "granted"}, + {"id": "fabric-gateway-grant", "version": 1, "state": "granted"}, + {"id": "pairing-key-binding", "version": 2, "state": "granted"} + ]); + let advertisement = + serde_json::json!({"api_version": "fractal.client.v0", "capabilities": capabilities}); + roundtrip::(&advertisement); + roundtrip::>(&serde_json::json!({ + "v": 1, "request_id": "cap-1", "ok": true, "body": advertisement + })); + for capability in capabilities.as_array().unwrap() { + roundtrip::(capability); + for (field, value) in [ + ("id", serde_json::json!("unknown")), + ("state", serde_json::json!("pending")), + ("version", serde_json::json!(0)), + ("version", serde_json::json!(3)), + ( + "version", + serde_json::json!(if capability["version"] == 1 { 2 } else { 1 }), + ), + ] { + let mut wrong = capability.clone(); + wrong[field] = value; + rejects::(wrong); + } + } + for invalid in [ + serde_json::json!([]), + serde_json::json!([capabilities[0], capabilities[0]]), + serde_json::json!([ + capabilities[0], + capabilities[1], + capabilities[2], + capabilities[0] + ]), + ] { + rejects::(serde_json::json!({ + "api_version": "fractal.client.v0", "capabilities": invalid + })); + } + } + + #[test] + fn pairing_approval_wait_schema_validates_all_reply_and_request_fixtures() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )) + .unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-approval-wait.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let schema = serde_json::json!({"$ref": format!("#/$defs/{}", case["schema"].as_str().unwrap()), "$defs": contract["$defs"]}); + let validator = jsonschema::options() + .should_validate_formats(true) + .build(&schema) + .unwrap(); + assert_eq!( + validator.is_valid(&case["value"]), + case["valid"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + } + + #[test] + fn pairing_admission_state_schema_validates_every_durable_branch() { + let contract: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/schemas/client-v0.schema.json" + )) + .unwrap(); + let schema = serde_json::json!({"$ref": "#/$defs/PairingAdmissionRecord", "$defs": contract["$defs"]}); + let validator = jsonschema::options() + .should_validate_formats(true) + .build(&schema) + .unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../docs/st3/client-v0/fixtures/pairing-admission-states.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut record = fixture["base"].as_object().unwrap().clone(); + record.insert("state".into(), case["state"].clone()); + for group in case["groups"].as_array().unwrap() { + record.extend( + fixture[group.as_str().unwrap()] + .as_object() + .unwrap() + .clone(), + ); + } + if let Some(fields) = case["set"].as_object() { + record.extend(fields.clone()); + } + if let Some(fields) = case["remove"].as_array() { + for field in fields { + record.remove(field.as_str().unwrap()); + } + } + if let Some(fields) = case["remove_receipt"].as_array() { + let receipt = record + .get_mut("confirm_receipt") + .unwrap() + .as_object_mut() + .unwrap(); + for field in fields { + receipt.remove(field.as_str().unwrap()); + } + } + assert_eq!( + validator.is_valid(&serde_json::Value::Object(record)), + case["valid"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + } + #[test] fn phone_documents_without_params_matches_the_schema() { let value = serde_json::to_value(PhoneNavigateArgs::Documents(None)).unwrap(); diff --git a/crates/fractal-daemon/src/api.rs b/crates/fractal-daemon/src/api.rs index 72fe8f65b00..1c6a5574d41 100644 --- a/crates/fractal-daemon/src/api.rs +++ b/crates/fractal-daemon/src/api.rs @@ -29,3 +29,6 @@ pub fn router(state: AppState) -> Router { pub fn fabric_router(state: AppState) -> Router { fabric_router_with(state, ROUTE_FAMILIES) } + +#[cfg(test)] +mod service_read_tests; diff --git a/crates/fractal-daemon/src/api/service_read_tests.rs b/crates/fractal-daemon/src/api/service_read_tests.rs new file mode 100644 index 00000000000..48211859e12 --- /dev/null +++ b/crates/fractal-daemon/src/api/service_read_tests.rs @@ -0,0 +1,221 @@ +//! Real producers above api-core prove that service projections fail closed. +use super::*; +use axum::{body::{Body, to_bytes}, http::{Request, StatusCode}}; +use serde_json::{Value, json}; +use tower::ServiceExt as _; +use fractal_api_core::api::client_v0::{credential_digest, external_grants}; +use crate::model::ClaimInput; + +const TOKEN: &str = "private-service-read-fixture"; +const OWNER: &str = "person/owner"; + +async fn request(app: &Router, method: &str, path: &str, token: Option<&str>, body: Value) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(path) + .header("x-st3-person",OWNER).header("content-type","application/json"); + if let Some(token) = token { builder = builder.header("authorization",format!("Bearer {token}")); } + let response = app.clone().oneshot(builder.body(Body::from(body.to_string())).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(),usize::MAX).await.unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status,if status.is_success() { value.get("value").cloned().unwrap_or(value) } else { value }) +} + +// cfg(test) module only: no production route or feature enables service issuance. +fn service_fixture(state: &AppState) { + state.store.append_claim(&ClaimInput { + subject:external_grants::subject("565656565656565656565656").unwrap(), + kind:external_grants::GRANTED.into(),actor:Some(OWNER.into()), + fields:serde_json::from_value(json!({"profile":"service","principal":"plugin/test/reader", + "grant_id":"565656565656565656565656", "session_actor":"plugin/test/reader/session/565656565656565656565656", + "issuer":OWNER,"expires_at_unix_ms":u64::MAX,"credential_hash":credential_digest(TOKEN), + "scopes":external_grants::SERVICE_SCOPES})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); +} + +#[tokio::test] +async fn real_mission_planning_and_pending_pairings_are_withheld_or_refused() { + let root = tempfile::tempdir().unwrap(); + let mut state = tests_shared::state(root.path()); + state.owning_person = Some(OWNER.into()); + let source = r#"version 2 +mission "service-sensitive" state="ready" { + goal "Protect embedded declarations." + agent "builder" { command "true"; env { TOKEN "mission-env-secret-sentinel" } } + step "work" { + assigned-to "agent/${ST_MISSION_RUN}/builder" + agent "checker" { command "true"; env { TOKEN "step-env-secret-sentinel" } } + } +} +"#; + let intent = crate::graph::parse_test_intent(source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source.into(),source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"service-real-mission").unwrap(); + let native = router(state.clone()); + let (status,launch) = request(&native,"POST","/v1/launches",None,json!({ + "mission":"service-planned","request":b"Plan a harmless mission.".to_vec(), + "workspace":root.path().to_string_lossy(),"requester":OWNER,"idempotency_key":"service-real-planning" + })).await; + assert_eq!(status,StatusCode::OK,"{launch}"); + let candidate = br#"version 2 +mission "service-planned" state="ready" { + goal "Protect a real planning candidate." + agent "builder" { command "true"; env { TOKEN "planning-env-secret-sentinel" } } + step "work" { assigned-to "agent/${ST_MISSION_RUN}/builder" } +} +"#; + let id = launch["id"].as_str().unwrap(); + let (status,submitted) = request(&native,"POST",&format!("/v1/launches/{}/variants/secret/submit",urlencoding::encode(id)),None,json!({ + "actor":launch["planner"],"markdown":b"# Planning candidate".to_vec(), + "kdl":candidate.to_vec(),"idempotency_key":"service-real-variant" + })).await; + assert_eq!(status,StatusCode::OK,"{submitted}"); + for body in [ + json!({"api_version":"st3.client.v0","device_name":"Pending device","person_id":OWNER,"scopes":["read.projections"]}), + json!({"api_version":"st3.client.v0","device_name":"Pending assistant","assistant_id":"assistant/chatgpt", + "scopes":&external_grants::SCOPES[..4],"disclosure_consent":true,"consent_evidence":"Recorded owner consent"}), + ] { + let (status,challenge) = request(&native,"POST","/v1/client/pairings",None,body).await; + assert_eq!(status,StatusCode::OK,"{challenge}"); + assert!(challenge["code"].as_str().is_some()); + } + service_fixture(&state); + let claims = state.store.claims_page(None,None,0,None,false,500).unwrap().claims; + let mission = claims.iter().find(|claim| claim.kind == "mission.published").unwrap(); + assert!(mission.body.to_string().contains("mission-env-secret-sentinel")); + assert!(mission.body.to_string().contains("step-env-secret-sentinel")); + let challenges: Vec<_> = claims.iter().filter(|claim| matches!(claim.kind.as_str(), + "custom.client.pairing-begun" | external_grants::BEGUN)).collect(); + assert_eq!(challenges.len(),2); + let verifiers: Vec<_> = challenges.iter().map(|claim| claim.body["fields"]["code_hash"].as_str().unwrap()).collect(); + let documents = state.store.list_documents_page(None,Some("doc/planning/"),true,None,500).unwrap(); + assert!(!documents.is_empty()); + assert!(documents.iter().any(|document| state.store.get_document(&document.name,&document.hash).unwrap() + .is_some_and(|bytes| String::from_utf8_lossy(&bytes).contains("planning-env-secret-sentinel")))); + for app in [router(state.clone()),fabric_router(state.clone())] { + let mut after = 0; + let mut seen = Vec::new(); + loop { + let (status,page) = request(&app,"GET",&format!("/v1/client/claims?limit=2&after={after}"),Some(TOKEN),Value::Null).await; + assert_eq!(status,StatusCode::OK,"{page}"); + let wire = page.to_string(); + for sentinel in ["mission-env-secret-sentinel","step-env-secret-sentinel","planning-env-secret-sentinel"] { + assert!(!wire.contains(sentinel)); + } + for verifier in &verifiers { assert!(!wire.contains(verifier)); } + for challenge in &challenges { + assert!(!wire.contains(&challenge.subject)); + assert!(!wire.contains(&challenge.id)); + assert!(!wire.contains(&credential_digest(&challenge.body.to_string()))); + let pairing_id = challenge.subject.rsplit('/').next().unwrap(); + assert!(!wire.contains(pairing_id)); + } + assert!(!wire.contains("code_hash") && !wire.contains("credential_hash")); + seen.extend(page["claims"].as_array().unwrap().iter().map(|claim| { + if claim["withheld"] == true { + assert_eq!(claim,&json!({"store_index":claim["store_index"],"withheld":true})); + } + claim["store_index"].as_u64().unwrap() + })); + let Some(next) = page["next_cursor"].as_u64() else { break }; + assert!(next > after); after = next; + } + for claim in claims.iter().filter(|claim| matches!(claim.kind.as_str(),"mission.published" | "intent.desired" + | "custom.client.pairing-begun" | external_grants::BEGUN | external_grants::GRANTED)) { + assert!(seen.contains(&claim.store_index)); + for prefix in ["/v1/client/claims/by-id/","/v1/claims/by-id/"] { + let (status,value) = request(&app,"GET",&format!("{prefix}{}",claim.id),Some(TOKEN),Value::Null).await; + assert!(matches!(status,StatusCode::NOT_FOUND | StatusCode::FORBIDDEN),"{value}"); + let (missing_status,_) = request(&app,"GET",&format!("{prefix}nonexistent"),Some(TOKEN),Value::Null).await; + assert_eq!(status,missing_status); + } + } + for prefix in ["/v1/client/documents","/v1/documents"] { + for query in ["","?history=true","?prefix=doc/planning/","?prefix=doc/planning/&history=true"] { + assert_eq!(request(&app,"GET",&format!("{prefix}{query}"),Some(TOKEN),Value::Null).await.0,StatusCode::FORBIDDEN); + } + } + for document in &documents { + for path in [format!("/v1/client/documents/content?name={}@{}",document.name,document.hash), + format!("/v1/client/documents/content?name={}",document.name), + format!("/v1/documents/content?reference={}@{}",document.name,document.hash)] { + assert_eq!(request(&app,"GET",&path,Some(TOKEN),Value::Null).await.0,StatusCode::FORBIDDEN); + } + } + } +} + +#[tokio::test] +async fn paired_devices_and_browser_sessions_cannot_use_service_claim_routes() { + let root = tempfile::tempdir().unwrap(); + let state = tests_shared::state(root.path()); + state.store.append_client_operation_claim(&ClaimInput { + subject:"custom/client/service-route-device".into(),kind:"custom.client.pairing-completed".into(), + actor:Some(OWNER.into()),fields:serde_json::from_value(json!({ + "credential_hash":credential_digest("device-only"),"session_actor":"person/owner/session/device", + "person_id":OWNER,"device_id":"device/phone","scopes":["read.projections"],"expires_at_unix_ms":u64::MAX + })).unwrap(),evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + for app in [router(state.clone()),fabric_router(state.clone())] { + assert_eq!(request(&app,"GET","/v1/client/work",Some("device-only"),Value::Null).await.0,StatusCode::OK); + for path in ["/v1/client/claims","/v1/client/claims/by-id/absent"] { + assert_eq!(request(&app,"GET",path,Some("device-only"),Value::Null).await.0,StatusCode::FORBIDDEN); + assert_eq!(request(&app,"GET",path,None,Value::Null).await.0,StatusCode::FORBIDDEN); + let browser = Request::builder().uri(path) + .extension(fractal_api_core::api::client_v0::LocalWebSession { + person:OWNER.into(),id:credential_digest("browser-session"), + expires_at:u128::from(u64::MAX), + }).body(Body::empty()).unwrap(); + assert_eq!(app.clone().oneshot(browser).await.unwrap().status(),StatusCode::FORBIDDEN); + } + } +} + +#[tokio::test] +async fn real_predicate_gate_receipt_withholds_gate_name() { + use fractal_reconcile::reconcile::{Reconciler, tests::FakeRuntime}; + use fractal_api_core::MissionsStore; + let root = tempfile::tempdir().unwrap(); + let mut state = tests_shared::state(root.path()); + state.owning_person = Some(OWNER.into()); + let document = state.store.put_document_as("doc/service-gate-input",b"present",&None,"service-gate-input",None).unwrap(); + let source = r#"version 2 +mission "service-gate" state="ready" { + goal "Protect literal gate names." + step "check" { + agentless + gate "gate-name-secret-sentinel" { exists "doc/service-gate-input" } + } +} +"#.replace("doc/service-gate-input",&format!("doc/service-gate-input@{}",document.hash)); + let intent = crate::graph::parse_test_intent(&source,"node").unwrap(); + let plan = state.store.mission(&intent,crate::model::IntentInput { kdl:source,source_name:None }).unwrap(); + state.store.apply(&intent,&plan.subject_tokens,"service-gate-publish").unwrap(); + state.store.create_mission_run(&crate::model::MissionRunRequest { + mission:"service-gate".into(),revision:None,workspace:root.path().display().to_string(), + requester:Some(OWNER.into()),mode:Some("run".into()),inputs:std::collections::BTreeMap::new(), + idempotency_key:"service-gate-run".into(), + }).unwrap(); + let reconciler = Reconciler::new(state.store.clone(),std::sync::Arc::new(FakeRuntime::default()), + "node".into(),std::sync::Arc::new(tokio::sync::Notify::new())); + reconciler.reconcile_once().unwrap(); + // Pending work first becomes ready; the next pass admits agentless work and evaluates its gate. + reconciler.reconcile_once().unwrap(); + let receipt = state.store.claims_page(None,None,0,None,false,500).unwrap().claims.into_iter() + .find(|claim| claim.kind == "gate.result" && claim.body["fields"]["gate"] == "gate-name-secret-sentinel") + .expect("the real reconciler writes the literal gate name"); + service_fixture(&state); + for (gateway,app) in [(false,router(state.clone())),(true,fabric_router(state.clone()))] { + for prefix in ["/v1/client/claims/by-id/","/v1/claims/by-id/"] { + let (status,value) = request(&app,"GET",&format!("{prefix}{}",receipt.id),Some(TOKEN),Value::Null).await; + if gateway && prefix == "/v1/claims/by-id/" { + assert_eq!(status,StatusCode::FORBIDDEN); + } else { + assert_eq!(status,StatusCode::OK,"{value}"); + assert_eq!(value["body"]["fields"]["verdict"],"pass"); + assert!(value["body"]["fields"].get("gate").is_none()); + assert!(!value.to_string().contains("gate-name-secret-sentinel")); + } + } + } +} diff --git a/crates/fractal-daemon/src/lib.rs b/crates/fractal-daemon/src/lib.rs index 6119b3c72b8..6e357dc5368 100644 --- a/crates/fractal-daemon/src/lib.rs +++ b/crates/fractal-daemon/src/lib.rs @@ -71,6 +71,7 @@ pub use fractal_store::native_resume; /// Durable seat-scoped first native launch outcomes. pub use fractal_reconcile::native_seed; pub use fractal_store::node_identity; +pub use fractal_api_mailbox::declared_objectives; pub use fractal_api_mailbox::objective_faults; pub use fractal_api_mailbox::objectives; pub use fractal_api_mailbox::readiness_slo; diff --git a/crates/fractal-daemon/src/web.rs b/crates/fractal-daemon/src/web.rs index 4e2454d7222..a3f845972b2 100644 --- a/crates/fractal-daemon/src/web.rs +++ b/crates/fractal-daemon/src/web.rs @@ -131,6 +131,10 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { let cookie = request.headers().get(header::COOKIE).and_then(|value| value.to_str().ok()) .and_then(|value| value.split(';').find_map(|part| part.trim().strip_prefix("fractal_local="))) .map(str::to_owned); + if (cookie.is_some() || token.is_some()) && request.headers().contains_key(header::AUTHORIZATION) { + // Browser admission requires a single credential source. + return StatusCode::FORBIDDEN.into_response(); + } let mut expires_at = None; let result = { let mut credentials = web.credentials.lock().expect("web credentials poisoned"); @@ -163,7 +167,6 @@ async fn admit(web: &Web, mut request: Request, next: Next) -> Response { } }; if let Some(response) = result { response } else { - request.headers_mut().remove(header::AUTHORIZATION); request.headers_mut().remove("x-fractal-person"); request.headers_mut().remove("x-st3-person"); request.extensions_mut().insert(crate::api::client_v0::LocalWebSession { @@ -243,7 +246,7 @@ mod tests { }).layer(middleware::from_fn_with_state(web.clone(), admission)); let call = |uri: &str, method: &str, host: &str, cookie: Option<&str>, origin: Option<&str>, upgrade: bool| { let mut request = Request::builder().uri(uri).method(method).header(header::HOST, host) - .header("x-st3-person", "person/other").header(header::AUTHORIZATION, "Bearer forged"); + .header("x-st3-person", "person/other"); if let Some(cookie) = cookie { request = request.header(header::COOKIE, cookie); } if let Some(origin) = origin { request = request.header(header::ORIGIN, origin); } if upgrade { request = request.header(header::UPGRADE, "websocket"); } @@ -265,4 +268,48 @@ mod tests { let bytes = axum::body::to_bytes(accepted.into_body(), 1024).await.unwrap(); assert_eq!(&bytes[..], b"person/robin"); } + + #[tokio::test] + async fn admitted_browser_cookie_and_bearer_are_refused_before_dispatch() { + let root = tempfile::tempdir().unwrap(); + let web = Web { + port:7410, person:"person/robin".into(), dist:root.path().into(), + policy:policy("",7410), credential_path:root.path().join("web-launch-url"), + credentials:Arc::new(Mutex::new(Credentials { + token:"once".into(), sessions:VecDeque::from([("admitted".into(),Instant::now())]), + })), + }; + let dispatched = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = dispatched.clone(); + let app = Router::new().fallback(move || { + let observed = observed.clone(); + async move { + observed.fetch_add(1,std::sync::atomic::Ordering::SeqCst); + StatusCode::OK + } + }).layer(middleware::from_fn_with_state(web.clone(),admission)); + for method in ["GET","POST","PUT","PATCH","DELETE"] { + let response = app.clone().oneshot(Request::builder() + .method(method).uri("/v1/client/actions") + .header(header::HOST,"127.0.0.1:7410") + .header(header::ORIGIN,"http://127.0.0.1:7410") + .header(header::COOKIE,"fractal_local=admitted") + .header(header::AUTHORIZATION,"Bearer service-credential") + .body(Body::from("{}")).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::FORBIDDEN,"{method}"); + } + for cookie in [Some("fractal_local=admitted"),None] { + let mut login = Request::builder().uri("/?token=once") + .header(header::HOST,"127.0.0.1:7410") + .header(header::AUTHORIZATION,"Bearer service-credential"); + if let Some(cookie) = cookie { login = login.header(header::COOKIE,cookie); } + let response = app.clone().oneshot(login.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(),StatusCode::FORBIDDEN); + let credentials = web.credentials.lock().unwrap(); + assert_eq!(credentials.token,"once"); + assert_eq!(credentials.sessions.len(),1); + assert!(!web.credential_path.exists()); + } + assert_eq!(dispatched.load(std::sync::atomic::Ordering::SeqCst),0); + } } diff --git a/crates/fractal-delivery/src/maintenance.rs b/crates/fractal-delivery/src/maintenance.rs index 3056390198b..9df0c1a79d4 100644 --- a/crates/fractal-delivery/src/maintenance.rs +++ b/crates/fractal-delivery/src/maintenance.rs @@ -18,14 +18,14 @@ const DIVERGENCE_BUSY_BACKOFF: Duration = Duration::from_secs(60); pub fn daemon_busy(store: &Store) -> bool { use smallclaims::windows::{StoreWork, store_series}; let now = Instant::now(); - let max_ms = crate::slo::targets().transaction.max_ms as f64; - let over = |kind| { + let targets = crate::slo::targets(); + let over = |kind, max_ms: u64| { let minute = &store_series(kind).snapshot(now)["1m"]; minute["over_target"].as_u64().unwrap_or(0) > 0 - || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms + || minute["max_ms"].as_f64().unwrap_or(0.0) > max_ms as f64 }; - over(StoreWork::WriterHold) - || over(StoreWork::WriteTransaction) + over(StoreWork::WriterHold, targets.writer_hold.max_ms) + || over(StoreWork::WriteTransaction, targets.transaction.max_ms) || store.replication_catching_up() || store.replication_projection_deferred() } diff --git a/crates/fractal-drivers/src/codex_app_server.rs b/crates/fractal-drivers/src/codex_app_server.rs index 71e46ac2264..6ceac29ef05 100644 --- a/crates/fractal-drivers/src/codex_app_server.rs +++ b/crates/fractal-drivers/src/codex_app_server.rs @@ -3262,7 +3262,7 @@ fn spawn_controlled_app_server( socket_path: &Path, log: &File, ) -> Result { - let mut command = Command::new(codex); + let mut command = crate::managed_launch::provider_command(codex)?; command .args(args) .stdin(Stdio::null()) @@ -3276,14 +3276,14 @@ fn spawn_controlled_app_server( /// startup screen would hold it before it creates a thread; the update notice is one. const CONTROLLED_TUI_OVERRIDES: [&str; 2] = ["-c", "check_for_update_on_startup=false"]; -fn controlled_tui_command(codex: &str, args: &[String]) -> Command { - let mut command = Command::new(codex); +fn controlled_tui_command(codex: &str, args: &[String]) -> std::io::Result { + let mut command = crate::managed_launch::provider_command(codex)?; command.args(CONTROLLED_TUI_OVERRIDES).args(args); - command + Ok(command) } fn spawn_controlled_tui(codex: &str, args: &[String]) -> std::io::Result { - controlled_tui_command(codex, args) + controlled_tui_command(codex, args)? .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -4416,7 +4416,7 @@ fn preflight_hook_trust( diagnostics: &mut WrapperDiagnostics, ) -> Result> { diagnostics.record("hookTrustPreflightStarting", json!({}))?; - let mut server_command = Command::new(codex); + let mut server_command = crate::managed_launch::provider_command(codex)?; server_command .args(server_args) .stdin(Stdio::null()) diff --git a/crates/fractal-drivers/src/codex_app_server/protocol.rs b/crates/fractal-drivers/src/codex_app_server/protocol.rs index 60d89b71460..8b408ace2fd 100644 --- a/crates/fractal-drivers/src/codex_app_server/protocol.rs +++ b/crates/fractal-drivers/src/codex_app_server/protocol.rs @@ -5,7 +5,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::process::Command; use std::thread; use std::time::Duration; @@ -32,7 +31,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { .prefix("st-codex-protocol-") .tempdir() .context("creating a temporary Codex protocol schema directory")?; - let output = Command::new(codex) + let output = crate::managed_launch::provider_command(codex)? .args([ "app-server", "generate-json-schema", @@ -70,7 +69,7 @@ pub(super) fn ensure_supported_protocol(codex: &str) -> Result { fn codex_version(codex: &str) -> Result { let mut attempt_index = 0; let output = loop { - let attempt = Command::new(codex).arg("--version").output(); + let attempt = crate::managed_launch::provider_command(codex)?.arg("--version").output(); match attempt { Ok(output) => break output, Err(error) if error.raw_os_error() == Some(libc::ETXTBSY) && attempt_index + 1 < 5 => { diff --git a/crates/fractal-drivers/src/codex_app_server/tests.rs b/crates/fractal-drivers/src/codex_app_server/tests.rs index f0a1c2dce8e..3d165674af5 100644 --- a/crates/fractal-drivers/src/codex_app_server/tests.rs +++ b/crates/fractal-drivers/src/codex_app_server/tests.rs @@ -6904,7 +6904,7 @@ fn a_controlled_tui_starts_without_the_update_notice() { "resume".to_string(), "thread-prior".to_string(), ]; - let command = controlled_tui_command("codex", &args); + let command = controlled_tui_command("codex", &args).unwrap(); assert_eq!(command.get_program(), "codex"); assert_eq!( command.get_args().collect::>(), @@ -6927,7 +6927,7 @@ fn a_controlled_tui_starts_without_the_update_notice() { "gpt-test".into(), ], ] { - let command = controlled_tui_command("codex", &args); + let command = controlled_tui_command("codex", &args).unwrap(); assert_eq!( command.get_args().take(2).collect::>(), ["-c", "check_for_update_on_startup=false"] @@ -9356,6 +9356,7 @@ fn managed_launch_argv_carries_access_mode_flags() { prepare_controlled_launch_args("unix:///control.sock", &managed[1..], resume).unwrap(); let server = prepared.server_args; let tui: Vec = controlled_tui_command("codex", &prepared.tui_args) + .unwrap() .get_args() .map(|arg| arg.to_str().unwrap().to_owned()) .collect(); diff --git a/crates/fractal-drivers/src/lib.rs b/crates/fractal-drivers/src/lib.rs index 060de96fd8b..e0b0228a1df 100644 --- a/crates/fractal-drivers/src/lib.rs +++ b/crates/fractal-drivers/src/lib.rs @@ -61,6 +61,7 @@ pub mod hooks; pub mod host; pub mod host_lock; pub mod identity; +pub mod managed_launch; pub mod materialize; pub mod message; pub mod metrics; diff --git a/crates/fractal-drivers/src/managed_launch.rs b/crates/fractal-drivers/src/managed_launch.rs new file mode 100644 index 00000000000..ad46db01055 --- /dev/null +++ b/crates/fractal-drivers/src/managed_launch.rs @@ -0,0 +1,412 @@ +//! Resolve managed harnesses before admission, resume helpers, or provider startup. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; + +// Runtime-profile contract owned by the wrapper publisher. +const WRAPPER_DIRECTORY_KEY: &str = "harnessBin"; + +// A driver owns one seat. Keep launch-only environment separate from the driver's environment. +struct ProviderEnvironment { + path: std::ffi::OsString, + subject: String, +} +static PROVIDER_ENVIRONMENT: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Select the managed executable, including when argv names a raw provider by absolute path. +/// Other harnesses retain their own launch contracts. +pub fn resolve(harness: &str, argv: &mut [String], spec: &serde_json::Value, subject: &str) -> Result<()> { + if is_person_bound(spec)? { + return Ok(()); + } + ensure!(subject.starts_with("agent/"), "managed harness launch requires a fenced agent subject"); + let path = resolve_from_source(harness, argv, spec, &|name| std::env::var_os(name), &|_| Ok(()))?; + if let Some(existing) = PROVIDER_ENVIRONMENT.get() { + ensure!(existing.path == path && existing.subject == subject, + "driver already selected a different managed provider environment"); + } else { + PROVIDER_ENVIRONMENT.set(ProviderEnvironment { path, subject: subject.into() }) + .map_err(|_| anyhow::anyhow!("managed provider environment already selected"))?; + } + Ok(()) +} + +/// Resolve a daemon seat using the effective captured launch environment, without global state. +/// Register profile and wrapper inputs before reading or validating them, including on failure. +pub fn resolve_with_environment( + harness: &str, + argv: &mut [String], + spec: &serde_json::Value, + environment: &std::collections::BTreeMap, + input: &dyn Fn(&Path) -> Result<()>, +) -> Result { + resolve_from_source(harness, argv, spec, &|name| environment.get(name).map(std::ffi::OsString::from), input) +} + +/// Person-owned terminal bindings retain their native harness launch contract. +/// Callers supply the declaration read through the driver's authenticated client. +pub fn is_person_bound(spec: &serde_json::Value) -> Result { + let Some(binding) = spec.get("children").and_then(serde_json::Value::as_array) + .and_then(|nodes| nodes.iter().find(|node| node["name"] == "bind-terminal")) else { + return Ok(false); + }; + let subject = binding.get("arguments").and_then(serde_json::Value::as_array) + .and_then(|arguments| arguments.first()).and_then(serde_json::Value::as_str) + .context("terminal binding is missing its subject")?; + let owner = fractal_schema::owned_terminals::owner(subject) + .map_err(|error| anyhow::anyhow!("{}", error.message))?; + Ok(owner.is_some_and(|owner| owner.starts_with("person/"))) +} + +fn resolve_from_source( + harness: &str, + argv: &mut [String], + spec: &serde_json::Value, + env: &impl Fn(&str) -> Option, + input: &dyn Fn(&Path) -> Result<()>, +) -> Result { + ensure!(matches!(harness, "omp" | "claude" | "codex"), "unsupported managed harness {harness}"); + if is_person_bound(spec)? { + return Ok(env("PATH").unwrap_or_default()); + } + let profile = profile_path_from(env)?; + let environment_persona = env("AGENT_PERSONA") + .map(|value| value.into_string().map_err(|_| anyhow::anyhow!("AGENT_PERSONA is not UTF-8"))) + .transpose()?; + let persona = environment_persona.as_deref().unwrap_or("generalist"); + resolve_for_persona(harness, argv, &profile, persona, input)?; + let bin = Path::new(&argv[0]).parent().context("managed wrapper has no parent")?; + prefixed_path(bin, env("PATH").as_deref()) +} + +fn profile_path_from(env: &impl Fn(&str) -> Option) -> Result { + if let Some(profile) = env("AGENT_RUNTIME_PROFILE") { + ensure!(!profile.is_empty(), "AGENT_RUNTIME_PROFILE is empty; refusing raw provider"); + let profile = PathBuf::from(profile); + ensure!(profile.is_absolute(), "AGENT_RUNTIME_PROFILE must be absolute: {}", profile.display()); + return Ok(profile); + } + let home = env("HOME").context("HOME is required to discover the managed runtime profile")?; + let home = PathBuf::from(home); + ensure!(home.is_absolute(), "HOME must be absolute to discover the managed runtime profile"); + Ok(home.join(".local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json")) +} + +fn resolve_for_persona( + harness: &str, + argv: &mut [String], + profile: &Path, + persona: &str, + input: &dyn Fn(&Path) -> Result<()>, +) -> Result<()> { + let program = argv.first_mut().context("managed provider argv is empty")?; + input(profile)?; + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(profile) + .with_context(|| format!("read managed runtime profile {}", profile.display()))?, + ).with_context(|| format!("parse managed runtime profile {}", profile.display()))?; + let bin = manifest.get("personas") + .and_then(|personas| personas.get("launch")) + .and_then(|launch| launch.get(persona)) + .and_then(|launch| launch.get(WRAPPER_DIRECTORY_KEY)) + .and_then(serde_json::Value::as_str) + .filter(|bin| !bin.is_empty()) + .map(Path::new) + .with_context(|| format!( + "managed runtime profile {} is missing personas.launch.{persona}.{WRAPPER_DIRECTORY_KEY}; refusing raw {harness}", + profile.display(), + ))?; + ensure!(bin.is_absolute(), "managed wrapper directory must be absolute: {}", bin.display()); + let wrapper = bin.join(harness); + input(&wrapper)?; + let metadata = std::fs::metadata(&wrapper) + .with_context(|| format!("managed {harness} wrapper is missing: {}; refusing raw provider", wrapper.display()))?; + ensure!(metadata.is_file(), "managed {harness} wrapper is not a file: {}", wrapper.display()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + ensure!(metadata.permissions().mode() & 0o111 != 0, + "managed {harness} wrapper is not executable: {}", wrapper.display()); + } + // An absolute executable gives the same wrapper precedence as the kit's PATH without + // changing the driver's environment or letting an explicit raw argv bypass the wrapper. + *program = wrapper.to_str().context("managed wrapper path is not UTF-8")?.to_owned(); + Ok(()) +} + +/// Apply the managed launch environment only to a provider child. +pub fn apply_provider_environment(command: &mut std::process::Command) -> std::io::Result<()> { + if let Some(environment) = PROVIDER_ENVIRONMENT.get() { + apply_agent_identity(command, &environment.subject, &|name| std::env::var_os(name))?; + apply_path(command, Some(&environment.path)); + } + Ok(()) +} + +fn apply_agent_identity( + command: &mut std::process::Command, + subject: &str, + inherited: &impl Fn(&str) -> Option, +) -> std::io::Result<()> { + for name in ["ST_AGENT", "FRACTAL_AGENT"] { + let value = command.get_envs().find(|(key, _)| *key == std::ffi::OsStr::new(name)) + .map(|(_, value)| value.map(std::ffi::OsStr::to_owned)) + .unwrap_or_else(|| inherited(name)); + if let Some(value) = value + && value != std::ffi::OsStr::new(subject) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, + format!("managed provider {name}={value:?} does not match fenced subject {subject}; refusing launch"))); + } + } + command.env("ST_AGENT", subject).env("FRACTAL_AGENT", subject); + Ok(()) +} + +fn apply_path(command: &mut std::process::Command, path: Option<&std::ffi::OsStr>) { + if let Some(path) = path { + command.env("PATH", path); + } +} + +pub fn provider_command(program: impl AsRef) -> std::io::Result { + let mut command = std::process::Command::new(program); + apply_provider_environment(&mut command)?; + Ok(command) +} + +fn prefixed_path(bin: &Path, inherited: Option<&std::ffi::OsStr>) -> Result { + let mut paths = vec![bin.to_path_buf()]; + if let Some(inherited) = inherited { + paths.extend(std::env::split_paths(inherited).filter(|path| path.as_path() != bin)); + } + std::env::join_paths(paths).context("prepend managed harness directory to provider PATH") +} + + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {(WRAPPER_DIRECTORY_KEY): bin}}}, + })).unwrap()).unwrap(); + (root, bin, profile) + } + + #[test] + fn managed_wrapper_takes_precedence_over_login_path_and_raw_argv() { + let (_root, bin, profile) = fixture(); + for harness in ["omp", "claude", "codex"] { + let wrapper = bin.join(harness); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for program in [harness.to_owned(), format!("/raw/bin/{harness}")] { + let mut argv = vec![program, "--model".into(), "example".into()]; + resolve_for_persona(harness, &mut argv, &profile, "generalist", &|_| Ok(())).unwrap(); + assert_eq!(Path::new(&argv[0]), wrapper); + assert_eq!(&argv[1..], ["--model", "example"]); + } + } + } + + #[test] + fn missing_wrapper_refuses_instead_of_searching_path() { + let (_root, bin, profile) = fixture(); + for program in ["omp", "/raw/bin/omp"] { + let mut argv = vec![program.to_owned()]; + let error = resolve_for_persona("omp", &mut argv, &profile, "generalist", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); + assert_eq!(argv[0], program); + } + } + + #[test] + fn missing_wrapper_directory_refuses() { + let (_root, bin, profile) = fixture(); + std::fs::remove_dir(&bin).unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains(bin.join("omp").to_str().unwrap())); + } + + #[test] + fn missing_profile_key_refuses_raw_provider() { + let (_root, _bin, profile) = fixture(); + std::fs::write(&profile, "{}").unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains(WRAPPER_DIRECTORY_KEY)); + } + + #[test] + fn missing_persona_does_not_fall_back_to_generalist() { + let (_root, _bin, profile) = fixture(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "unknown", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains("personas.launch.unknown.harnessBin")); + } + + #[test] + fn profile_discovery_prefers_explicit_path_and_keeps_stable_link() { + let explicit = profile_path_from(&|name| match name { + "AGENT_RUNTIME_PROFILE" => Some("/custom/profile.json".into()), + "HOME" => Some("/home/example".into()), + _ => None, + }).unwrap(); + assert_eq!(explicit, Path::new("/custom/profile.json")); + let stable = profile_path_from(&|name| (name == "HOME").then(|| "/home/example".into())).unwrap(); + assert_eq!(stable, Path::new( + "/home/example/.local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json", + )); + assert!(profile_path_from(&|_| None).is_err()); + assert!(profile_path_from(&|name| (name == "AGENT_RUNTIME_PROFILE").then(|| "".into())).is_err()); + } + + #[test] + fn environment_steward_selects_its_wrapper_without_a_spec_persona() { + let (_root, bin, profile) = fixture(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let wrapper = bin.join("omp"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let environment = std::collections::BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()), + ("AGENT_PERSONA".into(), "steward".into()), + ]); + let mut argv = ["omp".into()]; + resolve_with_environment("omp", &mut argv, &serde_json::json!({}), &environment, &|_| Ok(())).unwrap(); + assert_eq!(Path::new(&argv[0]), wrapper); + } + + #[test] + fn provider_path_starts_with_managed_bin_without_changing_driver_path() { + let before = std::env::var_os("PATH"); + let path = prefixed_path(Path::new("/managed/bin"), Some(std::ffi::OsStr::new("/native/bin:/managed/bin:/usr/bin:/managed/bin"))).unwrap(); + let mut command = std::process::Command::new("/managed/bin/omp"); + apply_path(&mut command, Some(&path)); + let provider_path = command.get_envs().find(|(name, _)| *name == std::ffi::OsStr::new("PATH")) + .and_then(|(_, value)| value).unwrap(); + assert_eq!(std::env::split_paths(provider_path).collect::>(), + [PathBuf::from("/managed/bin"), PathBuf::from("/native/bin"), PathBuf::from("/usr/bin")]); + assert_eq!(std::env::var_os("PATH"), before); + } + + #[test] + fn explicit_relative_profile_is_refused() { + let error = profile_path_from(&|name| (name == "AGENT_RUNTIME_PROFILE") + .then(|| "relative/profile.json".into())).unwrap_err(); + assert!(format!("{error:#}").contains("AGENT_RUNTIME_PROFILE must be absolute")); + } + + #[test] + fn malformed_profile_and_relative_wrapper_directory_refuse() { + let (_root, _bin, profile) = fixture(); + std::fs::write(&profile, "{").unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains("parse managed runtime profile")); + std::fs::write(&profile, r#"{"personas":{"launch":{"generalist":{"harnessBin":"relative/bin"}}}}"#).unwrap(); + let error = resolve_for_persona("omp", &mut ["omp".into()], &profile, "generalist", &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains("managed wrapper directory must be absolute")); + } + + #[cfg(unix)] + #[test] + fn profile_link_is_read_again_for_each_launch_and_broken_link_refuses() { + use std::os::unix::fs::{PermissionsExt as _, symlink}; + let (root, first_bin, first_profile) = fixture(); + let second_bin = root.path().join("second/bin"); + std::fs::create_dir_all(&second_bin).unwrap(); + for bin in [&first_bin, &second_bin] { + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nprintf managed-helper\n").unwrap(); + std::fs::set_permissions(wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let second_profile = root.path().join("second-profile.json"); + std::fs::write(&second_profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": second_bin}}}, + })).unwrap()).unwrap(); + let link = root.path().join("current-profile.json"); + let environment = std::collections::BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), link.display().to_string()), + ("PATH".into(), "/missing/native/bin".into()), + ]); + for (profile, bin) in [(&first_profile, &first_bin), (&second_profile, &second_bin)] { + symlink(profile, &link).unwrap(); + let mut argv = ["codex".into(), "--version".into()]; + let path = resolve_with_environment("codex", &mut argv, &serde_json::json!({}), &environment, &|_| Ok(())).unwrap(); + assert_eq!(Path::new(&argv[0]), bin.join("codex")); + assert_eq!(std::env::split_paths(&path).next().unwrap(), *bin); + let output = std::process::Command::new(&argv[0]).arg(&argv[1]).env("PATH", path).output().unwrap(); + assert_eq!(output.stdout, b"managed-helper"); + std::fs::remove_file(&link).unwrap(); + } + symlink(root.path().join("missing-profile.json"), &link).unwrap(); + let error = resolve_with_environment("codex", &mut ["codex".into()], &serde_json::json!({}), &environment, &|_| Ok(())).unwrap_err(); + assert!(format!("{error:#}").contains("read managed runtime profile")); + } + + #[test] + fn person_bound_driver_keeps_native_argv_without_a_managed_profile() { + let spec = serde_json::json!({"children": [{ + "name": "bind-terminal", + "arguments": ["pty/person/avery/019a0000-0000-7000-8000-000000000001"], + }]}); + let environment = std::collections::BTreeMap::from([("PATH".into(), "/native/bin".into())]); + for harness in ["omp", "claude", "codex"] { + let mut argv = [format!("/native/{harness}")]; + let path = resolve_with_environment(harness, &mut argv, &spec, &environment, &|_| Ok(())).unwrap(); + assert_eq!(path, std::ffi::OsStr::new("/native/bin")); + assert_eq!(argv[0], format!("/native/{harness}")); + } + let agent_binding = serde_json::json!({"children": [{ + "name": "bind-terminal", + "arguments": ["pty/agent/worker/019a0000-0000-7000-8000-000000000001"], + }]}); + assert!(!is_person_bound(&agent_binding).unwrap()); + assert!(resolve_with_environment("omp", &mut ["omp".into()], &agent_binding, &environment, &|_| Ok(())).is_err()); + } + + #[test] + fn provider_identity_refuses_mismatches_and_sets_matching_aliases() { + let subject = "agent/worker"; + for (st, fractal) in [ + (Some("agent/wrong"), None), + (None, Some("agent/wrong")), + (Some("agent/wrong"), Some("agent/wrong")), + (Some(subject), Some("agent/other")), + (Some(""), None), + ] { + let mut command = std::process::Command::new("omp"); + let error = apply_agent_identity(&mut command, subject, &|name| { + match name { "ST_AGENT" => st, "FRACTAL_AGENT" => fractal, _ => None } + .map(std::ffi::OsString::from) + }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("does not match fenced subject")); + } + for inherited in [None, Some(subject)] { + let mut command = std::process::Command::new("omp"); + apply_agent_identity(&mut command, subject, &|_| inherited.map(Into::into)).unwrap(); + let values = command.get_envs().collect::>(); + for name in ["ST_AGENT", "FRACTAL_AGENT"] { + assert_eq!(values[std::ffi::OsStr::new(name)], Some(std::ffi::OsStr::new(subject))); + } + } + let mut overridden = std::process::Command::new("omp"); + overridden.env("ST_AGENT", "agent/wrong"); + assert!(apply_agent_identity(&mut overridden, subject, &|_| Some(subject.into())).is_err()); + } +} diff --git a/crates/fractal-drivers/src/provider_session.rs b/crates/fractal-drivers/src/provider_session.rs index 42d84821b05..75f3b46460e 100644 --- a/crates/fractal-drivers/src/provider_session.rs +++ b/crates/fractal-drivers/src/provider_session.rs @@ -590,6 +590,7 @@ pub(crate) fn run_provider_observed_with_env_removals( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); apply_provider_environment(&mut command, env, removed_env); + crate::managed_launch::apply_provider_environment(&mut command)?; unsafe { command.pre_exec(|| { libc::signal(libc::SIGINT, libc::SIG_DFL); diff --git a/crates/fractal-github-base/src/resource/github_workflows.rs b/crates/fractal-github-base/src/resource/github_workflows.rs index a01c4ae8a85..15f08b532e3 100644 --- a/crates/fractal-github-base/src/resource/github_workflows.rs +++ b/crates/fractal-github-base/src/resource/github_workflows.rs @@ -157,7 +157,7 @@ const WORKSPACE_WORKFLOW: &str = "Workspace CI"; const WORKSPACE_WORKFLOW_PATH: &str = ".github/workflows/fleet.yml"; /// A merge group's head branch: `gh-readonly-queue/BASE/pr-N-SHA`. const MERGE_GROUP_BRANCH_PREFIX: &str = "gh-readonly-queue/"; -/// The series these runs feed, as `slo/targets.toml` names them. +/// The series these runs feed, as `slo/objectives.kdl` names them. pub const CRITICAL_PATH_SERIES: &str = "ci/workspace-critical-path"; pub const MERGE_QUEUE_SERIES: &str = "ci/merge-queue"; pub const FLAKES_SERIES: &str = "ci/flakes"; diff --git a/crates/fractal-kernel/Cargo.toml b/crates/fractal-kernel/Cargo.toml index cd590be037f..a6b9d0c94bf 100644 --- a/crates/fractal-kernel/Cargo.toml +++ b/crates/fractal-kernel/Cargo.toml @@ -12,6 +12,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" clap = { version = "4", features = ["derive", "env"] } getrandom = "0.3" hex = "0.4" +kdl = "6" libc = "0.2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rusqlite = { version = "0.32", features = ["bundled", "trace", "functions"] } diff --git a/crates/fractal-kernel/src/declaration_kdl.rs b/crates/fractal-kernel/src/declaration_kdl.rs new file mode 100644 index 00000000000..f2746f86175 --- /dev/null +++ b/crates/fractal-kernel/src/declaration_kdl.rs @@ -0,0 +1,109 @@ +//! The values of declaration KDL every declaration kind shares: a node as the canonical JSON +//! each declaration is stored, hashed and read as (`{name, arguments?, properties?, children?}`, +//! properties sorted by name), and durations (`300ms`, `5m`, `7d`). + +use std::collections::BTreeMap; + +use kdl::{KdlNode, KdlValue}; +use serde_json::{Map, Value}; + +use crate::model::FractalError; + +pub fn json_value(value: &KdlValue) -> Result { + Ok(match value { + KdlValue::String(value) => Value::String(value.clone()), + KdlValue::Integer(value) => { + let integer = i64::try_from(*value).map_err(|_| { + FractalError::new( + "integer-range", + "a KDL integer must fit a signed 64-bit value", + ) + })?; + Value::Number(integer.into()) + } + KdlValue::Float(value) => Value::Number( + serde_json::Number::from_f64(*value) + .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, + ), + KdlValue::Bool(value) => Value::Bool(*value), + KdlValue::Null => Value::Null, + }) +} + +pub fn canonical_node(node: &KdlNode) -> Result { + let mut properties = BTreeMap::::new(); + let mut arguments = Vec::new(); + for entry in node.entries() { + let value = json_value(entry.value())?; + if let Some(name) = entry.name() { + if properties.insert(name.value().into(), value).is_some() { + return Err(FractalError::new( + "duplicate-property", + format!( + "node `{}` repeats property `{}`", + node.name().value(), + name.value() + ), + )); + } + } else { + arguments.push(value); + } + } + let children = node + .children() + .map(|document| { + document + .nodes() + .iter() + .map(canonical_node) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + let mut output = Map::new(); + output.insert("name".into(), Value::String(node.name().value().into())); + if !arguments.is_empty() { + output.insert("arguments".into(), Value::Array(arguments)); + } + if !properties.is_empty() { + output.insert( + "properties".into(), + serde_json::to_value(properties).expect("BTreeMap serializes"), + ); + } + if !children.is_empty() { + output.insert("children".into(), Value::Array(children)); + } + Ok(Value::Object(output)) +} + +/// A duration in milliseconds: an integer with `ms`, `s`, `m`, `h` or `d`, seconds when bare. +pub fn parse_duration(value: &str, positive: bool) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { + (value, 1) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1_000) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60_000) + } else if let Some(value) = value.strip_suffix('h') { + (value, 3_600_000) + } else if let Some(value) = value.strip_suffix('d') { + (value, 86_400_000) + } else { + (value, 1_000) + }; + let number = number + .parse::() + .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; + let duration = number + .checked_mul(multiplier) + .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; + if positive && duration == 0 { + return Err(FractalError::new( + "invalid-duration", + "a required duration must be positive", + )); + } + Ok(duration) +} diff --git a/crates/fractal-kernel/src/instance.rs b/crates/fractal-kernel/src/instance.rs index 153f155f263..c5fa8f68cd7 100644 --- a/crates/fractal-kernel/src/instance.rs +++ b/crates/fractal-kernel/src/instance.rs @@ -16,6 +16,7 @@ //! bin/ the st3, st and pty the installer puts there //! claude/ the instance's Claude account (CLAUDE_CONFIG_DIR) //! codex/ the instance's Codex account (CODEX_HOME) +//! pi/ the instance's shared pi/omp profile (PI_CODING_AGENT_DIR) //! work/ the default work root: workspaces of new agents, in work/agents/NAME //! app/ where a macOS app bundle for the instance goes //! ``` @@ -140,6 +141,41 @@ impl Instance { self.root.join("codex") } + /// The shared pi/omp profile; HOME remains the login home for ordinary shell tools. + pub fn pi_agent_dir(&self) -> PathBuf { + self.root.join("pi") + } + + /// HOME-shaped view for discovery only, never exported as a process HOME. + pub fn native_session_home(&self) -> PathBuf { + self.state_home().join("native-sessions") + } + + /// Keep readers on the same provider directories as launched harnesses without copying data. + pub fn prepare_native_session_home(&self) -> Result { + let home = self.native_session_home(); + std::fs::create_dir_all(home.join(".omp"))?; + for (alias, target) in [ + (home.join(".claude"), self.claude_config_dir()), + (home.join(".codex"), self.codex_home()), + // Both tools share PI_CODING_AGENT_DIR. Expose this profile once, as omp. + (home.join(".omp/agent"), self.pi_agent_dir()), + ] { + if let Err(error) = std::os::unix::fs::symlink(&target, &alias) { + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(error).with_context(|| format!("create native session alias {}", alias.display())); + } + anyhow::ensure!( + std::fs::read_link(&alias) + .with_context(|| format!("read native session alias {}", alias.display()))? == target, + "native session alias {} does not point to its selected instance directory", + alias.display() + ); + } + } + Ok(home) + } + /// The instance's default work root: where its agents work, beside the rest of the instance. pub fn work_dir(&self) -> PathBuf { // Workspaces are a separate migration; do not move them with config/state. @@ -183,6 +219,7 @@ impl Instance { ("XDG_RUNTIME_DIR", self.run_dir()), ("CLAUDE_CONFIG_DIR", self.claude_config_dir()), ("CODEX_HOME", self.codex_home()), + ("PI_CODING_AGENT_DIR", self.pi_agent_dir()), ] } } @@ -201,6 +238,14 @@ pub fn current() -> Option { } } +/// Select the discovery root without ever falling back to the login home inside an instance. +pub fn native_session_home() -> Result> { + match current() { + Some(instance) => instance.prepare_native_session_home().map(Some), + None => Ok(env::var_os("HOME").map(PathBuf::from)), + } +} + fn fail(message: &str) -> ! { eprintln!("st: {message}"); std::process::exit(2); @@ -378,6 +423,7 @@ mod tests { one.bin_dir(), one.claude_config_dir(), one.codex_home(), + one.pi_agent_dir(), one.work_dir(), one.app_dir(), ] { @@ -387,6 +433,8 @@ mod tests { for (variable, value) in one.environment() { assert!(value.starts_with(one.root()), "{variable}"); } + assert!(one.native_session_home().starts_with(one.state_home())); + assert!(!one.environment().iter().any(|(variable, _)| *variable == "HOME")); } #[test] diff --git a/crates/fractal-kernel/src/lib.rs b/crates/fractal-kernel/src/lib.rs index eaf1517928a..0673fba7e1c 100644 --- a/crates/fractal-kernel/src/lib.rs +++ b/crates/fractal-kernel/src/lib.rs @@ -3,6 +3,10 @@ pub mod cli_output; pub mod accounts; +/// The values every declaration kind's KDL shares: canonical nodes and durations. +pub mod declaration_kdl; +/// An `objective` declaration: an indicator, its targets and the response to a miss. +pub mod objective; pub mod archive; pub mod boot; pub mod config; diff --git a/crates/fractal-kernel/src/model.rs b/crates/fractal-kernel/src/model.rs index 9ba505849f9..1fb3089a4d9 100644 --- a/crates/fractal-kernel/src/model.rs +++ b/crates/fractal-kernel/src/model.rs @@ -899,6 +899,39 @@ pub struct DeclaredResourceReference { pub reason: Option, } +/// One rule of a mission's `authority` block: what its runs may do as `mission-run/RUN` +/// (docs/st3/program-engine.md §3). Mission verbs take a mission ID pattern, `write` a subject +/// pattern; either is exact or a terminal `/*` namespace. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct AuthorityGrant { + pub verb: AuthorityVerb, + pub pattern: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityVerb { + Publish, + Start, + Revise, + Cancel, + Write, +} + +impl AuthorityVerb { + pub const ALL: [Self; 5] = [Self::Publish, Self::Start, Self::Revise, Self::Cancel, Self::Write]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Publish => "publish", + Self::Start => "start", + Self::Revise => "revise", + Self::Cancel => "cancel", + Self::Write => "write", + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MissionSpec { pub id: String, @@ -911,6 +944,10 @@ pub struct MissionSpec { pub system: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub resources: Vec, + /// What runs of this mission may do as `mission-run/RUN`, in canonical order; empty grants + /// nothing. Omitted when empty, so an undeclared mission hashes as it did before the field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authority: Vec, #[serde(default)] pub inputs: BTreeMap, #[serde(default = "default_mission_run_limit")] diff --git a/crates/fractal-kernel/src/objective.rs b/crates/fractal-kernel/src/objective.rs new file mode 100644 index 00000000000..db233efa125 --- /dev/null +++ b/crates/fractal-kernel/src/objective.rs @@ -0,0 +1,732 @@ +//! What an `objective` declaration says (docs/st3/objectives.md): an indicator that measures +//! samples, the targets that bound them, the window an interval is judged over, and who a miss +//! is delivered to. One strict reader serves the objectives Fractal ships (`slo/objectives.kdl`) +//! and the ones a fleet declares: a missing, unknown, repeated or nonsensical child is an error, +//! never a default. + +use std::collections::BTreeSet; + +use kdl::KdlDocument; +use serde_json::Value; + +use crate::declaration_kdl::{canonical_node, parse_duration}; +use crate::model::FractalError; + +/// The declaration kind and subject family. +pub const KIND: &str = "objective"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Objective { + /// The objective's id, the name after `objective/`. + pub name: String, + pub about: String, + pub indicator: Indicator, + /// The span an interval's budget is measured over; native telemetry keeps its own windows. + pub window: Option, + pub targets: Vec, + pub on_breach: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Indicator { + /// Request, stream, publication or paired-phone latency over the native 1m/5m/1h windows. + Latency { paths: Vec }, + /// A phone's live foreground time over its foreground time. + Share { path: String }, + /// One SQL statement. + Statement, + /// Read and write transactions and writer holds. + Transaction, + /// Each writer hold and long read, by holder. + WriterHold, + /// The member's daemon and replication workers over closed UTC minutes. + Cpu { idle_min_minutes: u64, load_max_cores: f64 }, + /// The store's pages in use and their daily growth. + Database, + /// Open projection divergence faults. + Divergence, + /// A percentile of a CI series' durations. + CiDuration { series: String }, + /// Events in a CI series per hour. + CiCount { series: String }, + /// Work that starts and stops in claims, one sample per subject. + Interval(Box), +} + +impl Indicator { + pub fn kind(&self) -> &'static str { + match self { + Self::Latency { .. } => "latency", + Self::Share { .. } => "share", + Self::Statement => "statement", + Self::Transaction => "transaction", + Self::WriterHold => "writer-hold", + Self::Cpu { .. } => "cpu", + Self::Database => "database", + Self::Divergence => "divergence", + Self::CiDuration { .. } => "ci-duration", + Self::CiCount { .. } => "ci-count", + Self::Interval(_) => "interval", + } + } +} + +/// A claim that marks a moment of a sample, and the field its time is read from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Event { + pub kind: String, + /// A field holding an RFC 3339 time or Unix milliseconds; without one, the claim's accepted time. + pub at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Interval { + pub subject_prefix: String, + pub start: Event, + /// The members a sample must stop on, a list of strings in field `set` of the newest claim. + pub expect: Option<(Event, String)>, + /// One member stopping, named by field `member` (required with `expect`). + pub stop: (Event, Option), + pub cancel: Option, + /// The pause kind and the kind that resumes it. + pub pause: Option<(String, String)>, + /// Fields of the start or expect claim, or `member`, that split the rows. + pub labels: Vec, + pub excludes: Vec, +} + +/// Leave out a sample whose start or expect claim has one of `values` in `field`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Exclude { + pub field: String, + pub values: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Window { + pub ms: u64, + /// The samples the window needs before it is met or missed. + pub min_samples: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + /// What the indicator reports: `p99`, `max`, `cores`, `live-gb`, ... + pub stat: String, + pub bound: Bound, + /// A latency target for reads served through this member from another that owns them. + pub remote: bool, +} + +impl Target { + /// The percentile a `pNN` target bounds. + pub fn percentile(&self) -> Option { + percentile(&self.stat) + } + + pub fn within_ms(&self) -> Option { + match self.bound { + Bound::Within(ms) => Some(ms), + _ => None, + } + } + + pub fn max(&self) -> Option { + match self.bound { + Bound::Max(max) => Some(max), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Bound { + Within(u64), + Max(f64), + MinPercent(f64), +} + +/// A miss opens a fault for `owner`; `backup` is held, recorded and never the reviewer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Breach { + pub owner: String, + pub backup: Option, + pub condition: String, +} + +impl Objective { + pub fn subject(&self) -> String { + format!("{KIND}/{}", self.name) + } + + /// The target with `stat` (and `remote`). + pub fn target(&self, stat: &str, remote: bool) -> Option<&Target> { + self.targets.iter().find(|target| target.stat == stat && target.remote == remote) + } + + /// Whether a fleet may declare it: an interval over claims, named `NAMESPACE/NAME` or deeper so + /// it never takes the name of one Fractal ships, which are one word each. Native telemetry + /// counts each path, series and reading toward the one objective the daemon was built with. + pub fn declarable(&self) -> bool { + matches!(self.indicator, Indicator::Interval(_)) && self.name.contains('/') + } +} + +/// The row of percentile `p` of objective `name`: `merge-latency/p95`. +pub fn percentile_row(name: &str, p: u64) -> String { + format!("{name}/p{p}") +} + +fn percentile(stat: &str) -> Option { + let digits = stat.strip_prefix('p')?; + (matches!(digits.len(), 1 | 2) && !digits.starts_with('0') && digits.bytes().all(|b| b.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + .filter(|p| (1..=99).contains(p)) +} + +/// Lowercase words joined by `-`, as a name segment and a fault condition are. +pub fn valid_word(word: &str) -> bool { + word.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && word.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !word.ends_with('-') + && !word.contains("--") +} + +/// One or more words joined by `/`: `cpu`, `ops/change-rollout`. +pub fn valid_name(name: &str) -> bool { + name.len() <= 128 && name.split('/').all(valid_word) +} + +fn error(name: &str, message: impl std::fmt::Display) -> FractalError { + FractalError::new("invalid-objective", format!("objective `{name}`: {message}")) +} + +/// The objectives in a KDL document of `objective` nodes, after an optional `version 2`. +pub fn parse_document(text: &str) -> Result, FractalError> { + let document: KdlDocument = text + .parse() + .map_err(|error| FractalError::new("invalid-kdl", format!("{error}")))?; + let mut names = BTreeSet::new(); + let mut objectives = Vec::new(); + for (index, node) in document.nodes().iter().enumerate() { + let canonical = canonical_node(node)?; + if index == 0 && node.name().value() == "version" { + if canonical.get("arguments") != Some(&serde_json::json!([2])) || canonical.get("children").is_some() { + return Err(FractalError::new("invalid-version", "objectives are `version 2` KDL")); + } + continue; + } + let objective = parse(&canonical)?; + if !names.insert(objective.name.clone()) { + return Err(error(&objective.name, "is declared twice")); + } + objectives.push(objective); + } + Ok(objectives) +} + +/// An `objective` node, in its canonical JSON. +pub fn parse(node: &Value) -> Result { + if node["name"] != KIND { + return Err(FractalError::new("invalid-objective", format!("`{}` is not an objective", node["name"]))); + } + let name = match arguments(node) { + [Value::String(name)] if valid_name(name) => name.clone(), + _ => return Err(FractalError::new( + "invalid-objective", + "an objective has one name: lowercase words joined by `-`, segments by `/`", + )), + }; + let fail = |message: String| error(&name, message); + if properties(node).next().is_some() { + return Err(fail("takes no properties".into())); + } + let mut about = None; + let mut indicator = None; + let mut window = None; + let mut targets = Vec::new(); + let mut on_breach = None; + for child in children(node) { + let child_name = child["name"].as_str().unwrap_or_default(); + let once = |slot: bool| if slot { Err(fail(format!("repeats `{child_name}`"))) } else { Ok(()) }; + match child_name { + "about" => { + once(about.is_some())?; + no_children(child, &fail)?; + only_properties(child, &[], &fail)?; + about = Some(one_string(child, &fail)?.to_owned()).filter(|about| !about.trim().is_empty()); + if about.is_none() { + return Err(fail("`about` is empty".into())); + } + } + "indicator" => { + once(indicator.is_some())?; + indicator = Some(parse_indicator(child, &fail)?); + } + "window" => { + once(window.is_some())?; + no_children(child, &fail)?; + only_properties(child, &["min-samples"], &fail)?; + let ms = duration(one_string(child, &fail)?, &fail)?; + let min_samples = property(child, "min-samples") + .map(|value| positive_integer(value, "min-samples", &fail)) + .transpose()? + .unwrap_or(1); + window = Some(Window { ms, min_samples }); + } + "target" => { + no_children(child, &fail)?; + targets.push(parse_target(child, &fail)?); + } + "on-breach" => { + once(on_breach.is_some())?; + on_breach = Some(parse_breach(child, &fail)?); + } + other => return Err(fail(format!("has an unknown child `{other}`"))), + } + } + let objective = Objective { + about: about.ok_or_else(|| fail("has no `about`".into()))?, + indicator: indicator.ok_or_else(|| fail("has no `indicator`".into()))?, + name: name.clone(), + window, + targets, + on_breach, + }; + check_targets(&objective, &fail)?; + Ok(objective) +} + +fn parse_indicator(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &[], fail)?; + let kind = one_string(node, fail)?; + let kids = children(node).collect::>(); + let allow = |names: &[&str]| -> Result<(), FractalError> { + match kids.iter().find(|kid| !names.contains(&kid["name"].as_str().unwrap_or_default())) { + Some(kid) => Err(fail(format!("indicator `{kind}` has an unknown child `{}`", kid["name"].as_str().unwrap_or_default()))), + None => Ok(()), + } + }; + let strings = |child: &str| -> Result, FractalError> { + kids.iter() + .filter(|kid| kid["name"] == child) + .map(|kid| { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + Ok(one_string(kid, fail)?.to_owned()) + }) + .collect() + }; + let single = |child: &str| -> Result { + match strings(child)?.as_slice() { + [value] => Ok(value.clone()), + _ => Err(fail(format!("indicator `{kind}` needs one `{child}`"))), + } + }; + Ok(match kind { + "latency" => { + allow(&["path"])?; + let paths = strings("path")?; + if paths.is_empty() { + return Err(fail("a latency indicator needs a `path`".into())); + } + Indicator::Latency { paths } + } + "share" => { + allow(&["path"])?; + Indicator::Share { path: single("path")? } + } + "statement" | "transaction" | "writer-hold" | "database" | "divergence" => { + allow(&[])?; + match kind { + "statement" => Indicator::Statement, + "transaction" => Indicator::Transaction, + "writer-hold" => Indicator::WriterHold, + "database" => Indicator::Database, + _ => Indicator::Divergence, + } + } + "cpu" => { + allow(&["idle-min-minutes", "load-max-cores"])?; + let number = |child: &str| -> Result<&Value, FractalError> { + let mut found = kids.iter().filter(|kid| kid["name"] == child); + match (found.next(), found.next()) { + (Some(kid), None) => { + no_children(kid, fail)?; + only_properties(kid, &[], fail)?; + match arguments(kid) { + [value] => Ok(value), + _ => Err(fail(format!("`{child}` needs one number"))), + } + } + _ => Err(fail(format!("a cpu indicator needs one `{child}`"))), + } + }; + Indicator::Cpu { + idle_min_minutes: positive_integer(number("idle-min-minutes")?, "idle-min-minutes", fail)?, + load_max_cores: positive_number(number("load-max-cores")?, "load-max-cores", fail)?, + } + } + "ci-duration" | "ci-count" => { + allow(&["series"])?; + let series = single("series")?; + if !is_ci_series(&series) { + return Err(fail(format!("series {series:?} is not `ci/` and lowercase words"))); + } + if kind == "ci-duration" { Indicator::CiDuration { series } } else { Indicator::CiCount { series } } + } + "interval" => Indicator::Interval(Box::new(parse_interval(&kids, fail)?)), + other => return Err(fail(format!("has an unknown indicator `{other}`"))), + }) +} + +fn parse_interval(kids: &[&Value], fail: &dyn Fn(String) -> FractalError) -> Result { + let mut prefix = None; + let mut start = None; + let mut expect = None; + let mut stop = None; + let mut cancel = None; + let mut pause = None; + let mut labels = Vec::new(); + let mut excludes = Vec::new(); + for kid in kids { + let name = kid["name"].as_str().unwrap_or_default(); + no_children(kid, fail)?; + let repeated = || fail(format!("the interval repeats `{name}`")); + let event = |extra: &[&str]| -> Result { + let mut allowed = vec!["at"]; + allowed.extend_from_slice(extra); + only_properties(kid, &allowed, fail)?; + let kind = one_string(kid, fail)?; + if kind.is_empty() { + return Err(fail(format!("`{name}` names no claim kind"))); + } + Ok(Event { kind: kind.to_owned(), at: field_property(kid, "at", fail)? }) + }; + match name { + "subject-prefix" => { + only_properties(kid, &[], fail)?; + let value = one_string(kid, fail)?; + if !value.ends_with('/') || value.len() < 2 || value.starts_with('/') { + return Err(fail(format!("subject-prefix {value:?} must name subjects and end with `/`"))); + } + if prefix.replace(value.to_owned()).is_some() { + return Err(repeated()); + } + } + "start" => { + if start.replace(event(&[])?).is_some() { + return Err(repeated()); + } + } + "expect" => { + let event = event(&["set"])?; + let set = field_property(kid, "set", fail)?.ok_or_else(|| fail("`expect` needs `set=FIELD`".into()))?; + if expect.replace((event, set)).is_some() { + return Err(repeated()); + } + } + "stop" => { + let event = event(&["member"])?; + if stop.replace((event, field_property(kid, "member", fail)?)).is_some() { + return Err(repeated()); + } + } + "cancel" => { + only_properties(kid, &[], fail)?; + if cancel.replace(one_string(kid, fail)?.to_owned()).is_some() { + return Err(repeated()); + } + } + "pause" => { + only_properties(kid, &["resume"], fail)?; + let resume = property(kid, "resume") + .and_then(Value::as_str) + .filter(|resume| !resume.is_empty()) + .ok_or_else(|| fail("`pause` needs `resume=KIND`".into()))?; + if pause.replace((one_string(kid, fail)?.to_owned(), resume.to_owned())).is_some() { + return Err(repeated()); + } + } + "label" => { + only_properties(kid, &[], fail)?; + let label = one_string(kid, fail)?; + if !is_field(label) || labels.contains(&label.to_owned()) { + return Err(fail(format!("label {label:?} is not a field, or is repeated"))); + } + labels.push(label.to_owned()); + } + "exclude" => { + only_properties(kid, &[], fail)?; + let values = arguments(kid) + .iter() + .map(|value| value.as_str().filter(|value| !value.is_empty()).map(str::to_owned)) + .collect::>>() + .filter(|values| values.len() >= 2 && is_field(&values[0])) + .ok_or_else(|| fail("`exclude` needs a field and at least one value".into()))?; + excludes.push(Exclude { field: values[0].clone(), values: values[1..].to_vec() }); + } + other => return Err(fail(format!("indicator `interval` has an unknown child `{other}`"))), + } + } + let stop = stop.ok_or_else(|| fail("an interval needs `stop`".into()))?; + if expect.is_some() && stop.1.is_none() { + return Err(fail("with `expect`, `stop` needs `member=FIELD`".into())); + } + if labels.iter().any(|label| label == "member") && stop.1.is_none() { + return Err(fail("`label \"member\"` needs `stop member=FIELD`".into())); + } + let interval = Interval { + subject_prefix: prefix.ok_or_else(|| fail("an interval needs `subject-prefix`".into()))?, + start: start.ok_or_else(|| fail("an interval needs `start`".into()))?, + expect, + stop, + cancel, + pause, + labels, + excludes, + }; + let mut kinds = BTreeSet::new(); + for kind in interval.kinds() { + if !kinds.insert(kind) { + return Err(fail(format!("claim kind {kind:?} has two roles"))); + } + } + Ok(interval) +} + +impl Interval { + /// Every claim kind the interval reads, each with one role. + pub fn kinds(&self) -> Vec<&str> { + let mut kinds = vec![self.start.kind.as_str()]; + kinds.extend(self.expect.as_ref().map(|(event, _)| event.kind.as_str())); + kinds.push(self.stop.0.kind.as_str()); + kinds.extend(self.cancel.as_deref()); + if let Some((pause, resume)) = &self.pause { + kinds.extend([pause.as_str(), resume.as_str()]); + } + kinds + } +} + +fn parse_target(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + only_properties(node, &["within", "max", "min-percent", "scope"], fail)?; + let stat = one_string(node, fail)?.to_owned(); + let bounds = ["within", "max", "min-percent"] + .into_iter() + .filter_map(|name| property(node, name).map(|value| (name, value))) + .collect::>(); + let bound = match bounds.as_slice() { + [("within", value)] => Bound::Within(duration( + value.as_str().ok_or_else(|| fail(format!("target `{stat}`'s `within` is not a duration")))?, + fail, + )?), + [("max", value)] => Bound::Max(number(value, &format!("target `{stat}`'s `max`"), fail)?), + [("min-percent", value)] => { + let min = number(value, &format!("target `{stat}`'s `min-percent`"), fail)?; + if !(min > 0.0 && min <= 100.0) { + return Err(fail(format!("target `{stat}`'s `min-percent` is not between 0 and 100"))); + } + Bound::MinPercent(min) + } + _ => return Err(fail(format!("target `{stat}` needs one bound: `within`, `max` or `min-percent`"))), + }; + let remote = match property(node, "scope") { + None => false, + Some(Value::String(scope)) if scope == "remote" => true, + Some(other) => return Err(fail(format!("target `{stat}` has an unknown scope {other}"))), + }; + Ok(Target { stat, bound, remote }) +} + +fn parse_breach(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result { + no_children(node, fail)?; + only_properties(node, &["owner", "backup", "condition"], fail)?; + if one_string(node, fail)? != "fault" { + return Err(fail("`on-breach` is `fault`, the only response".into())); + } + let string = |name: &str| property(node, name).map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| fail(format!("`on-breach`'s `{name}` is not a string"))) + }).transpose(); + let agent = |value: &str| value.strip_prefix("agent/").is_some_and(|id| !id.is_empty()); + let owner = string("owner")?.filter(|owner| agent(owner)).ok_or_else(|| fail("`on-breach` needs `owner=\"agent/...\"`".into()))?; + let backup = string("backup")?; + if backup.as_deref().is_some_and(|backup| !agent(backup) || backup == owner) { + return Err(fail("`on-breach`'s `backup` is an agent other than the owner".into())); + } + let condition = string("condition")? + .filter(|condition| valid_word(condition)) + .ok_or_else(|| fail("`on-breach` needs `condition=NAME`, lowercase words joined by `-`".into()))?; + Ok(Breach { owner, backup, condition }) +} + +/// Each indicator's targets: which stats it reports, which bounds they take and how many. +fn check_targets(objective: &Objective, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + let mut seen = BTreeSet::new(); + for target in &objective.targets { + if !seen.insert((target.stat.as_str(), target.remote)) { + return Err(fail(format!("repeats target `{}`", target.stat))); + } + if target.remote && !matches!(objective.indicator, Indicator::Latency { .. }) { + return Err(fail("only a latency target has a remote scope".into())); + } + } + let kind = objective.indicator.kind(); + let stat_bound = |target: &Target| -> bool { + let within = matches!(target.bound, Bound::Within(ms) if ms > 0); + let max = |integer: bool, zero: bool| match target.bound { + Bound::Max(max) => max.is_finite() && (max > 0.0 || (zero && max == 0.0)) && (!integer || max.fract() == 0.0), + _ => false, + }; + match (&objective.indicator, target.stat.as_str()) { + (Indicator::Latency { .. }, "p99") => within, + (Indicator::Share { .. }, "live-share") => matches!(target.bound, Bound::MinPercent(_)), + (Indicator::Statement, "p99" | "max") => within, + (Indicator::Transaction | Indicator::WriterHold, "max") => within, + (Indicator::Cpu { .. }, "cores" | "idle-cores") => max(false, false), + (Indicator::Database, "live-gb" | "growth-mb-per-day") => max(false, false), + (Indicator::Divergence, "open") => max(true, true), + (Indicator::CiCount { .. }, "per-hour") => max(true, true), + (Indicator::CiDuration { .. } | Indicator::Interval(_), stat) => percentile(stat).is_some() && within, + _ => false, + } + }; + if let Some(target) = objective.targets.iter().find(|target| !stat_bound(target)) { + return Err(fail(format!("a `{kind}` indicator has no target `{}` with that bound", target.stat))); + } + let required: &[&str] = match objective.indicator { + Indicator::Latency { .. } => &["p99"], + Indicator::Statement => &["p99", "max"], + Indicator::Share { .. } => &["live-share"], + Indicator::Transaction | Indicator::WriterHold => &["max"], + Indicator::Cpu { .. } => &["cores", "idle-cores"], + Indicator::Database => &["live-gb", "growth-mb-per-day"], + Indicator::Divergence => &["open"], + Indicator::CiCount { .. } => &["per-hour"], + _ => &[], + }; + if let Some(missing) = required.iter().find(|stat| objective.target(stat, false).is_none()) { + return Err(fail(format!("a `{kind}` indicator needs target `{missing}`"))); + } + if objective.targets.is_empty() { + return Err(fail("has no `target`".into())); + } + if let (Some(local), Some(remote)) = (objective.target("p99", false), objective.target("p99", true)) + && remote.within_ms() < local.within_ms() + { + return Err(fail("its remote latency is below its own".into())); + } + if let (Some(p99), Some(max)) = (objective.target("p99", false), objective.target("max", false)) + && matches!(objective.indicator, Indicator::Statement) + && max.within_ms() < p99.within_ms() + { + return Err(fail("its max is below its p99".into())); + } + if let (Some(cores), Some(idle)) = (objective.target("cores", false), objective.target("idle-cores", false)) + && idle.max() > cores.max() + { + return Err(fail("its idle CPU target is above the member's".into())); + } + if let Indicator::Cpu { idle_min_minutes, .. } = objective.indicator + && idle_min_minutes > 60 + { + return Err(fail("its idle minimum is not between 1 and 60 minutes".into())); + } + match (&objective.indicator, objective.window) { + (Indicator::Interval(_), None) => return Err(fail("an interval needs a `window`".into())), + (Indicator::Interval(_), Some(_)) => {} + (_, Some(_)) => return Err(fail("only an interval takes a `window`; native telemetry keeps its own".into())), + (_, None) => {} + } + if objective.on_breach.is_some() && !matches!(objective.indicator, Indicator::Interval(_)) { + return Err(fail("only an interval takes `on-breach`; native objectives respond as they are built".into())); + } + Ok(()) +} + +fn is_ci_series(path: &str) -> bool { + path.strip_prefix("ci/").is_some_and(|rest| { + rest.split('/').all(|part| { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + }) +} + +fn is_field(name: &str) -> bool { + !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn arguments(node: &Value) -> &[Value] { + node.get("arguments").and_then(Value::as_array).map_or(&[], Vec::as_slice) +} + +fn properties(node: &Value) -> impl Iterator { + node.get("properties").and_then(Value::as_object).into_iter().flatten() +} + +fn property<'a>(node: &'a Value, name: &str) -> Option<&'a Value> { + node.get("properties")?.get(name) +} + +fn children(node: &Value) -> impl Iterator { + node.get("children").and_then(Value::as_array).into_iter().flatten() +} + +fn no_children(node: &Value, fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match node.get("children") { + None => Ok(()), + Some(_) => Err(fail(format!("`{}` takes no children", node["name"].as_str().unwrap_or_default()))), + } +} + +fn only_properties(node: &Value, allowed: &[&str], fail: &dyn Fn(String) -> FractalError) -> Result<(), FractalError> { + match properties(node).find(|(name, _)| !allowed.contains(&name.as_str())) { + Some((name, _)) => Err(fail(format!( + "`{}` has an unknown property `{name}`", + node["name"].as_str().unwrap_or_default() + ))), + None => Ok(()), + } +} + +fn one_string<'a>(node: &'a Value, fail: &dyn Fn(String) -> FractalError) -> Result<&'a str, FractalError> { + match arguments(node) { + [Value::String(value)] => Ok(value), + _ => Err(fail(format!("`{}` needs one string", node["name"].as_str().unwrap_or_default()))), + } +} + +fn field_property(node: &Value, name: &str, fail: &dyn Fn(String) -> FractalError) -> Result, FractalError> { + property(node, name) + .map(|value| { + value + .as_str() + .filter(|field| is_field(field)) + .map(str::to_owned) + .ok_or_else(|| fail(format!("`{name}` is not a field name"))) + }) + .transpose() +} + +fn duration(value: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + parse_duration(value, true).map_err(|error| fail(error.message)) +} + +fn number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_f64().filter(|number| number.is_finite()).ok_or_else(|| fail(format!("{what} is not a number"))) +} + +fn positive_number(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + number(value, what, fail).and_then(|number| { + if number > 0.0 { Ok(number) } else { Err(fail(format!("{what} is not positive"))) } + }) +} + +fn positive_integer(value: &Value, what: &str, fail: &dyn Fn(String) -> FractalError) -> Result { + value.as_u64().filter(|n| *n > 0).ok_or_else(|| fail(format!("{what} is not a positive integer"))) +} + +#[cfg(test)] +#[path = "objective_tests.rs"] +mod tests; diff --git a/crates/fractal-kernel/src/objective_tests.rs b/crates/fractal-kernel/src/objective_tests.rs new file mode 100644 index 00000000000..6bb2504565f --- /dev/null +++ b/crates/fractal-kernel/src/objective_tests.rs @@ -0,0 +1,94 @@ +use super::*; + +const SHIPPED: &str = /* sqlite-large-value: allow-for-now lv-ha01213f3c4b5 owner=fractal_kernel::objective_tests TODO=trace-statement-and-value-representation */ include_str!("../../../slo/objectives.kdl"); + +fn one(text: &str) -> Result { + parse_document(&format!("version 2\n{text}")).map(|mut objectives| objectives.remove(0)) +} + +const INTERVAL: &str = r#"objective "ops/change-rollout" { + about "A change, from ready to served by every member" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + pause "custom.acme.change-paused" resume="custom.acme.change-resumed" + label "class" + label "member" + exclude "exclusion" "deferred-launch" "deferred-runtime" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +}"#; + +#[test] +fn the_shipped_objectives_parse_and_none_is_declarable() { + let objectives = parse_document(SHIPPED).unwrap(); + assert_eq!(objectives.len(), 21); + assert!(objectives.iter().all(|objective| !objective.declarable())); + let merge = objectives.iter().find(|objective| objective.name == "merge-latency").unwrap(); + assert_eq!(merge.indicator, Indicator::CiDuration { series: "ci/merge-queue".into() }); + assert_eq!( + merge.targets.iter().map(|target| (target.percentile(), target.within_ms())).collect::>(), + [(Some(50), Some(600_000)), (Some(95), Some(600_000))], + ); + let read = objectives.iter().find(|objective| objective.name == "person-read").unwrap(); + assert_eq!(read.target("p99", true).and_then(Target::within_ms), Some(300)); +} + +#[test] +fn an_interval_reads_every_role() { + let objective = one(INTERVAL).unwrap(); + assert!(objective.declarable()); + assert_eq!(objective.window, Some(Window { ms: 7 * 86_400_000, min_samples: 20 })); + let Indicator::Interval(interval) = &objective.indicator else { panic!() }; + assert_eq!(interval.subject_prefix, "custom/acme/change/"); + assert_eq!(interval.start, Event { kind: "custom.acme.change-ready".into(), at: Some("ready_at".into()) }); + assert_eq!(interval.expect.as_ref().map(|(_, set)| set.as_str()), Some("hosts")); + assert_eq!(interval.stop.1.as_deref(), Some("host")); + assert_eq!(interval.labels, ["class", "member"]); + assert_eq!(interval.excludes, [Exclude { field: "exclusion".into(), values: vec!["deferred-launch".into(), "deferred-runtime".into()] }]); + assert_eq!(interval.kinds().len(), 6); + assert_eq!( + objective.on_breach, + Some(Breach { + owner: "agent/ops/release-manager".into(), + backup: Some("agent/ops/observability".into()), + condition: "change-rollout-slo-missed".into(), + }) + ); + assert_eq!(percentile_row(&objective.name, 95), "ops/change-rollout/p95"); +} + +#[test] +fn a_nonsensical_objective_is_refused_never_defaulted() { + let refused = |text: &str| one(text).unwrap_err().message; + let interval = |replace: &str, with: &str| INTERVAL.replace(replace, with); + for (text, says) in [ + (interval(" window \"7d\" min-samples=20\n", ""), "needs a `window`"), + (interval("target \"p95\" within=\"5m\"", "target \"p95\" within=\"5m\"\n target \"p95\" within=\"9m\""), "repeats target"), + (interval("target \"p95\"", "target \"p100\""), "no target `p100`"), + (interval("target \"p95\"", "target \"p05\""), "no target `p05`"), + (interval(" member=\"host\"", ""), "needs `member=FIELD`"), + (interval("cancel \"custom.acme.change-abandoned\"", "cancel \"custom.acme.change-ready\""), "two roles"), + (interval("backup=\"agent/ops/observability\"", "backup=\"agent/ops/release-manager\""), "other than the owner"), + (interval("owner=\"agent/ops/release-manager\"", "owner=\"person/ada\""), "needs `owner="), + (interval("subject-prefix \"custom/acme/change/\"", "subject-prefix \"custom/acme/change\""), "end with `/`"), + (interval(" label \"class\"\n", " label \"class\"\n color \"red\"\n"), "unknown child `color`"), + (interval("about \"A change, from ready to served by every member\"", "about \" \""), "`about` is empty"), + ("objective \"cpu\" { about \"x\"; indicator \"cpu\" { idle-min-minutes 10; load-max-cores 2.0 }; target \"cores\" max=1.0 }".into(), "needs target `idle-cores`"), + ("objective \"db\" { about \"x\"; indicator \"database\"; target \"live-gb\" max=1.0; target \"growth-mb-per-day\" max=1; window \"1d\" }".into(), "only an interval takes a `window`"), + ("objective \"r\" { about \"x\"; indicator \"latency\" { path \"GET /v1/x\" }; target \"p99\" within=\"300ms\"; target \"p99\" within=\"200ms\" scope=\"remote\" }".into(), "remote latency is below"), + ("objective \"Bad\" { about \"x\" }".into(), "lowercase words"), + ("objective \"ops//x\" { about \"x\" }".into(), "lowercase words"), + ] { + let message = refused(&text); + assert!(message.contains(says), "{says:?} not in {message:?}"); + } + assert!(parse_document(&format!("version 2\n{INTERVAL}\n{INTERVAL}")).unwrap_err().message.contains("declared twice")); + assert!(!one(&INTERVAL.replace("ops/change-rollout", "change-rollout")).unwrap().declarable()); +} diff --git a/crates/fractal-kernel/src/seat_queue.rs b/crates/fractal-kernel/src/seat_queue.rs index 32a4534c3b2..aa3c21c7fa3 100644 --- a/crates/fractal-kernel/src/seat_queue.rs +++ b/crates/fractal-kernel/src/seat_queue.rs @@ -13,6 +13,37 @@ use serde::{Deserialize, Serialize}; pub const MOVED_CLAIM: &str = "agent.queue.moved"; +/// Compare work actors without changing persisted rows or conflating recognized actor kinds. +pub fn work_actor_matches(candidate: Option<&str>, actor: &str) -> bool { + let Some(candidate) = candidate.and_then(fractal_schema::canonical_agent_actor) else { + return false; + }; + fractal_schema::canonical_agent_actor(actor).as_deref() == Some(candidate.as_ref()) +} + +#[derive(Clone, Copy)] +enum ActorMatching { + Literal, + Aliases, +} + +impl ActorMatching { + fn matches(self, candidate: Option<&str>, actor: &str) -> bool { + match self { + Self::Literal => candidate == Some(actor), + Self::Aliases => work_actor_matches(candidate, actor), + } + } + + fn same(self, left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (Some(left), Some(right)) => self.matches(Some(left), right), + (None, None) => true, + _ => false, + } + } +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Placement { @@ -218,12 +249,26 @@ impl<'a> SeatSelection<'a> { /// place either: a ready step whose claim by this seat a revision dropped comes /// first until the seat claims it again. pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> SeatSelection<'a> { + select_with_matching(agent, steps, run_order, ActorMatching::Aliases) +} + +/// Select mutation-admission work using the exact persisted actor spellings. +pub fn select_literal<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> SeatSelection<'a> { + select_with_matching(agent, steps, run_order, ActorMatching::Literal) +} + +fn select_with_matching<'a>( + agent: &str, + steps: &[SeatStep<'a>], + run_order: &[String], + matching: ActorMatching, +) -> SeatSelection<'a> { let held = steps .iter() .filter(|step| { matches!(step.status, "claimed" | "working" | "verifying") - && (step.claimant == Some(agent) || step.assignee == Some(agent)) - && !submitted_with_ready_nested_work(step, steps) + && (matching.matches(step.claimant, agent) || matching.matches(step.assignee, agent)) + && !submitted_with_ready_nested_work_matching(step, steps, matching) }) .map(|step| step.subject) .collect(); @@ -237,11 +282,13 @@ pub fn select<'a>(agent: &str, steps: &[SeatStep<'a>], run_order: &[String]) -> .iter() .filter(|step| { step.status == "ready" - && step.assignee == Some(agent) - && !reached_through_parent(step, steps) + && matching.matches(step.assignee, agent) + && !steps.iter().any(|candidate| { + nests_under_matching(candidate, step, matching) && candidate.status != "verifying" + }) }) .collect::>(); - let waits_its_turn = |step: &SeatStep<'_>| step.carried_claimant != Some(agent); + let waits_its_turn = |step: &SeatStep<'_>| !matching.matches(step.carried_claimant, agent); ready.sort_by(|left, right| { waits_its_turn(left) .cmp(&waits_its_turn(right)) @@ -267,9 +314,21 @@ pub fn reached_through_parent(step: &SeatStep<'_>, steps: &[SeatStep<'_>]) -> bo /// True when `step` is nested under the listed `ancestor` in the same run and /// both have the same selector. pub fn nests_under(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { + nests_under_matching(ancestor, step, ActorMatching::Aliases) +} + +/// Compare nested selectors for mutation admission without merging actor aliases. +pub fn nests_under_literal(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { + nests_under_matching(ancestor, step, ActorMatching::Literal) +} + +fn nests_under_matching(ancestor: &SeatStep<'_>, step: &SeatStep<'_>, matching: ActorMatching) -> bool { ancestor.run == step.run - && ancestor.assignee == step.assignee - && ancestor.available_to == step.available_to + && matching.same(ancestor.assignee, step.assignee) + && ancestor.available_to.len() == step.available_to.len() + && ancestor.available_to.iter().zip(step.available_to).all(|(left, right)| { + matching.matches(Some(left), right) + }) && ancestor.step.len() < step.step.len() && step.step.starts_with(ancestor.step) && step.step.as_bytes().get(ancestor.step.len()) == Some(&b'/') @@ -280,10 +339,18 @@ pub fn nests_under(ancestor: &SeatStep<'_>, step: &SeatStep<'_>) -> bool { /// inherited parent message was consumed, and the seat looks occupied. Such a /// parent frees the seat so the ready nested step gets its own wake. pub fn submitted_with_ready_nested_work(parent: &SeatStep<'_>, steps: &[SeatStep<'_>]) -> bool { + submitted_with_ready_nested_work_matching(parent, steps, ActorMatching::Aliases) +} + +fn submitted_with_ready_nested_work_matching( + parent: &SeatStep<'_>, + steps: &[SeatStep<'_>], + matching: ActorMatching, +) -> bool { parent.status == "verifying" && steps .iter() - .any(|step| step.status == "ready" && nests_under(parent, step)) + .any(|step| step.status == "ready" && nests_under_matching(parent, step, matching)) } #[cfg(test)] @@ -431,6 +498,49 @@ mod tests { } } + #[test] + fn mixed_actor_spellings_share_ready_held_carried_and_nested_selection() { + let available = vec!["node/worker".to_owned()]; + let canonical_available = vec!["agent/node/worker".to_owned()]; + let first = SeatStep { + subject: "step-run/first/build", run: "mission-run/first", step: "build", + status: "ready", assignee: Some("node/worker"), claimant: None, + available_to: &available, created_at_unix_ms: 10, carried_claimant: None, + }; + let second = SeatStep { + subject: "step-run/second/build", run: "mission-run/second", + assignee: Some("agent/node/worker"), created_at_unix_ms: 20, ..first + }; + let order = vec![first.run.to_owned(), second.run.to_owned()]; + assert_eq!( + select_literal("agent/node/worker", &[second, first], &order).ready, + [second.subject], + ); + for actor in ["node/worker", "agent/node/worker"] { + assert_eq!(select(actor, &[second, first], &order).ready, [first.subject, second.subject]); + let carried = SeatStep { carried_claimant: Some("node/worker"), ..second }; + assert_eq!(select(actor, &[first, carried], &order).next(), Some(second.subject)); + let held = SeatStep { status: "working", claimant: Some("node/worker"), ..second }; + assert_eq!(select(actor, &[first, held], &order).held, [second.subject]); + } + let nested = SeatStep { + subject: "step-run/first/build/check", step: "build/check", + assignee: Some("agent/node/worker"), available_to: &canonical_available, ..first + }; + assert!(reached_through_parent(&nested, &[first, nested])); + assert_eq!( + select_literal("agent/node/worker", &[first, nested], &order).ready, + [nested.subject], + "an alias-assigned parent must not hide literally assigned nested work", + ); + let submitted = SeatStep { status: "verifying", ..first }; + assert!(submitted_with_ready_nested_work(&submitted, &[submitted, nested])); + for other in ["daemon/node/worker", "person/test", "host/node/worker"] { + assert!(select(other, &[first, second], &order).ready.is_empty()); + assert!(!nests_under(&SeatStep { assignee: Some(other), ..first }, &nested)); + } + } + #[test] fn nested_steps_are_reached_through_their_listed_parent_only() { let none = Vec::new(); diff --git a/crates/fractal-missions-base/src/declaration_syntax.rs b/crates/fractal-missions-base/src/declaration_syntax.rs index 48d5140cd7c..39592e50f44 100644 --- a/crates/fractal-missions-base/src/declaration_syntax.rs +++ b/crates/fractal-missions-base/src/declaration_syntax.rs @@ -4,7 +4,6 @@ use std::collections::{BTreeMap, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::Value; use sha2::{Digest as _, Sha256}; use crate::model::FractalError; @@ -204,26 +203,7 @@ pub fn first_string(node: &KdlNode) -> Result { .and_then(|value| value_string(value)) } -pub fn json_value(value: &KdlValue) -> Result { - Ok(match value { - KdlValue::String(value) => Value::String(value.clone()), - KdlValue::Integer(value) => { - let integer = i64::try_from(*value).map_err(|_| { - FractalError::new( - "integer-range", - "a KDL integer must fit a signed 64-bit value", - ) - })?; - Value::Number(integer.into()) - } - KdlValue::Float(value) => Value::Number( - serde_json::Number::from_f64(*value) - .ok_or_else(|| FractalError::new("invalid-number", "a float must be finite"))?, - ), - KdlValue::Bool(value) => Value::Bool(*value), - KdlValue::Null => Value::Null, - }) -} +pub use crate::declaration_kdl::{json_value, parse_duration}; pub fn one_string(node: &KdlNode) -> Result { ensure_no_children(node)?; @@ -247,35 +227,6 @@ pub fn one_string_with_children(node: &KdlNode) -> Result }) } -pub fn parse_duration(value: &str, positive: bool) -> Result { - let (number, multiplier) = if let Some(value) = value.strip_suffix("ms") { - (value, 1) - } else if let Some(value) = value.strip_suffix('s') { - (value, 1_000) - } else if let Some(value) = value.strip_suffix('m') { - (value, 60_000) - } else if let Some(value) = value.strip_suffix('h') { - (value, 3_600_000) - } else if let Some(value) = value.strip_suffix('d') { - (value, 86_400_000) - } else { - (value, 1_000) - }; - let number = number - .parse::() - .map_err(|_| FractalError::new("invalid-duration", format!("invalid duration `{value}`")))?; - let duration = number - .checked_mul(multiplier) - .ok_or_else(|| FractalError::new("duration-range", "duration is too large"))?; - if positive && duration == 0 { - return Err(FractalError::new( - "invalid-duration", - "a required duration must be positive", - )); - } - Ok(duration) -} - pub fn parse_map_child( document: &KdlDocument, name: &str, diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index d8f53486697..f6cc23804f5 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -7,10 +7,10 @@ use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; use crate::model::{ - BaselineSpec, CompletionSpec, DependencySpec, GateSpec, LoopAttentionSpec, LoopExhaustionSpec, - LoopSpec, LoopStopSpec, MetricSource, MetricSpec, MissionInputKind, MissionInputSpec, - MissionSpec, MissionState, ProductSpec, RetrySpec, RevisionCutover, FractalError, StepSpec, - UsedMissionSpec, WorkSelector, + AuthorityGrant, AuthorityVerb, BaselineSpec, CompletionSpec, DependencySpec, GateSpec, + LoopAttentionSpec, LoopExhaustionSpec, LoopSpec, LoopStopSpec, MetricSource, MetricSpec, + MissionInputKind, MissionInputSpec, MissionSpec, MissionState, ProductSpec, RetrySpec, + RevisionCutover, FractalError, StepSpec, UsedMissionSpec, WorkSelector, }; pub const LOOP_ROUND_INPUT: &str = "__st3_loop_round"; @@ -308,6 +308,7 @@ fn parse_mission( let mut revision_owners = Vec::new(); let mut system = false; let mut capacity = None; + let mut authority = None; for child in children.nodes() { match child.name().value() { "declared-priority" | "declared-lane" | "declared-budgets" => {} @@ -353,6 +354,21 @@ fn parse_mission( } resources.push(reference); } + "authority" if top_level => { + if authority.is_some() { + return Err(FractalError::new( + "duplicate-mission-field", + format!("mission `{id}` repeats `authority`"), + )); + } + authority = Some(parse_authority(child, &id)?); + } + "authority" => { + return Err(FractalError::new( + "nested-mission-authority", + format!("mission `{id}` is nested; declare authority on the top-level mission"), + )); + } "concurrent-runs" => { if concurrent_runs_seen { return Err(FractalError::new( @@ -503,6 +519,12 @@ fn parse_mission( format!("mission `{id}` cannot own an account"), )); } + "objective" => { + return Err(FractalError::new( + "objective-inside-mission", + format!("mission `{id}` cannot own an objective"), + )); + } "capacity" => { if capacity.is_some() { return Err(FractalError::new( @@ -565,6 +587,7 @@ fn parse_mission( declared_lane: admission.lane, declared_budgets: admission.budgets, resources, + authority: authority.unwrap_or_default(), inputs, max_active_runs, timeout_ms, @@ -597,6 +620,86 @@ fn parse_mission( Ok(mission) } +/// `authority { VERB "PATTERN" … }`: what runs of a top-level mission may do as +/// `mission-run/RUN` (docs/st3/program-engine.md §3). The shape of an agent's `*-authority` +/// block; `publish`, `start`, `revise` and `cancel` take mission IDs as `mission-authority` does, +/// `write` takes a subject. The rules are kept in canonical order, so reordering is no revision. +fn parse_authority(node: &KdlNode, id: &str) -> Result, FractalError> { + ensure_bare(node)?; + let empty = || { + FractalError::new( + "empty-authority", + format!("mission `{id}` authority needs at least one rule"), + ) + }; + let body = node.children().ok_or_else(empty)?; + let mut grants = BTreeSet::new(); + for rule in body.nodes() { + let name = rule.name().value(); + let verb = AuthorityVerb::ALL + .into_iter() + .find(|verb| verb.as_str() == name) + .ok_or_else(|| { + FractalError::new( + "invalid-authority", + format!( + "mission `{id}` authority cannot contain `{name}`; its rules are publish, start, revise, cancel and write" + ), + ) + })?; + ensure_no_properties(rule)?; + ensure_no_children(rule)?; + let pattern = one_string(rule)?; + validate_authority_pattern(verb, &pattern)?; + if !grants.insert(AuthorityGrant { verb, pattern: pattern.clone() }) { + return Err(FractalError::new( + "duplicate-authority", + format!("mission `{id}` repeats authority `{name} {pattern}`"), + )); + } + } + if grants.is_empty() { + return Err(empty()); + } + Ok(grants.into_iter().collect()) +} + +/// Exact, or a terminal `/*` namespace covering everything under it: a mission ID without +/// `mission/` for the mission verbs, a subject (or a bare registered family before `/*`) for +/// `write`. +fn validate_authority_pattern(verb: AuthorityVerb, pattern: &str) -> Result<(), FractalError> { + let invalid = || { + FractalError::new( + "invalid-authority-pattern", + format!( + "authority `{} {pattern}` needs {} or a terminal `/*` namespace", + verb.as_str(), + if verb == AuthorityVerb::Write { "an exact subject" } else { "an exact mission ID without `mission/`" }, + ), + ) + }; + let namespace = pattern.strip_suffix("/*"); + let exact = namespace.unwrap_or(pattern); + if exact.is_empty() || exact.contains('*') { + return Err(invalid()); + } + if verb != AuthorityVerb::Write { + if exact.starts_with("mission/") { + return Err(invalid()); + } + return validate_mission_id(exact).map_err(|_| invalid()); + } + // A namespace is a registered family, alone or followed by non-empty path segments. + let registry = fractal_schema::registry(); + let Some(prefix) = namespace else { + return registry.validate_subject(exact).map(|_| ()).map_err(|_| invalid()); + }; + let well_formed = registry.subject(&format!("{prefix}/_")).is_some() + && !prefix.split('/').any(str::is_empty) + && !prefix.chars().any(|character| character.is_whitespace() || character.is_control()); + well_formed.then_some(()).ok_or_else(invalid) +} + fn parse_loop_group( node: &KdlNode, parent_mission: &str, @@ -1405,6 +1508,12 @@ fn parse_step( format!("step `{path}` cannot own an account"), )); } + "objective" => { + return Err(FractalError::new( + "objective-inside-mission", + format!("step `{path}` cannot own an objective"), + )); + } "lane" => { return Err(FractalError::new( "lane-inside-step", @@ -3115,6 +3224,12 @@ fn validate_deferred_declaration_in(node: &KdlNode, parent: Option<&str>) -> Res "an account declaration must be at the root", )); } + if node.name().value() == "objective" { + return Err(FractalError::new( + "objective-inside-mission", + "an objective declaration must be at the root", + )); + } if node.name().value() == "env" { validate_string_map(node, true)?; } @@ -3132,6 +3247,54 @@ fn validate_deferred_declaration_in(node: &KdlNode, parent: Option<&str>) -> Res #[cfg(test)] mod tests { + #[test] + fn assignees_retain_main_spelling_during_expansion() { + for (input, expected) in [ + ("example/worker", "example/worker"), + ("agent/example/worker", "agent/example/worker"), + ("person/reviewer", "person/reviewer"), + ("daemon/scheduler", "daemon/scheduler"), + ("worker", "agent/host.worker"), + ("agent/worker", "agent/host.worker"), + ] { + assert_eq!(super::normalize_assignee(input, "host"), expected); + } + } + + #[test] + fn declaration_assignees_qualify_host_relative_agent_names() { + let source = r#"mission "relative" state="ready" { + goal "Resolve host-relative declaration assignees."; + step "prefixed" { assigned-to "agent/worker"; } + step "bare" { assigned-to "worker"; } + }"#; + let missions = super::parse_missions(&source.parse().unwrap(), "host").unwrap(); + for step in ["prefixed", "bare"] { + assert_eq!( + missions["relative"].steps[step].work_selector, + Some(super::WorkSelector::Assigned { + agent: "agent/host.worker".into(), + }) + ); + } + } + + #[test] + fn stored_assignees_preserve_historical_malformed_normalization() { + for (input, expected) in [ + ("agent/", "agent/host."), + ("", "agent/host."), + ("worker name", "agent/host.worker name"), + ("example/worker name", "example/worker name"), + ("agent/example/worker name", "agent/example/worker name"), + ("example/worker.name ", "agent/example/worker.name "), + ("person/reviewer name", "person/reviewer name"), + ("person/", "person/"), + ] { + assert_eq!(super::normalize_assignee(input, "host"), expected); + } + } + #[test] fn nested_loop_paths_follow_their_distinct_owning_steps() { let source = r#"mission "collision" state="ready" { goal "Keep nested histories separate."; @@ -3230,4 +3393,64 @@ mod tests { .unwrap(); assert_eq!(command, format!("printf '%s' '{message}'")); } + + fn authority_error(body: &str) -> String { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {body} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code.to_string() + } + + #[test] + fn mission_authority_parses_in_canonical_order_and_enters_the_revision() { + use crate::model::{AuthorityGrant, AuthorityVerb}; + let parse = |authority: &str| { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {authority} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap().remove("land").unwrap() + }; + let mission = parse(r#"authority { write "resource/factory/*"; publish "scratch/*"; start "factory/publish"; cancel "factory/*"; revise "factory/*"; write "custom/factory/landing"; write "resource/*" }"#); + let grant = |verb, pattern: &str| AuthorityGrant { verb, pattern: pattern.into() }; + assert_eq!(mission.authority, [ + grant(AuthorityVerb::Publish, "scratch/*"), + grant(AuthorityVerb::Start, "factory/publish"), + grant(AuthorityVerb::Revise, "factory/*"), + grant(AuthorityVerb::Cancel, "factory/*"), + grant(AuthorityVerb::Write, "custom/factory/landing"), + grant(AuthorityVerb::Write, "resource/*"), + grant(AuthorityVerb::Write, "resource/factory/*"), + ]); + let reordered = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_eq!(reordered.revision, mission.revision); + let narrower = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_ne!(narrower.revision, mission.revision); + let undeclared = parse(""); + assert!(undeclared.authority.is_empty()); + assert!(serde_json::to_value(&undeclared).unwrap().get("authority").is_none()); + } + + #[test] + fn mission_authority_refusals() { + let mut mismatches = Vec::new(); + for (body, code) in [ + (r#"authority"#, "empty-authority"), + (r#"authority {}"#, "empty-authority"), + (r#"authority "none""#, "invalid-node"), + (r#"authority { write "resource/a"; }; authority { write "resource/b"; }"#, "duplicate-mission-field"), + (r#"authority { write "resource/a"; write "resource/a"; }"#, "duplicate-authority"), + (r#"authority { move "agent/a"; }"#, "invalid-authority"), + (r#"authority { write "resource/a" mode="audit"; }"#, "unknown-property"), + (r#"authority { write "resource/*/x"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource/a*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/a"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource"; }"#, "invalid-authority-pattern"), + (r#"authority { start "mission/factory"; }"#, "invalid-authority-pattern"), + (r#"authority { start "factory/*/land"; }"#, "invalid-authority-pattern"), + (r#"authority { publish "/*"; }"#, "invalid-authority-pattern"), + (r#"step "outer" { mission "inner" { goal "Inner."; authority { write "resource/a"; }; step "x" { agentless; }; }; }"#, "nested-mission-authority"), + ] { + let actual = authority_error(body); + if actual != code { mismatches.push(format!("{body}: {actual}, expected {code}")); } + } + assert!(mismatches.is_empty(), "{mismatches:#?}"); + } } diff --git a/crates/fractal-missions-base/src/mission_render.rs b/crates/fractal-missions-base/src/mission_render.rs index 5e1fb6b1ea5..38575cd9784 100644 --- a/crates/fractal-missions-base/src/mission_render.rs +++ b/crates/fractal-missions-base/src/mission_render.rs @@ -35,7 +35,7 @@ fn mission_node(mission: &MissionSpec) -> Result { fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result, FractalError> { // Exhaustive destructuring makes a new MissionSpec field a renderer coverage decision. - let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, inputs, + let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, authority, inputs, max_active_runs, timeout_ms: _, report_to: _, stalled_after_ms: _, report_stalls: _, report_completed: _, revision_owners: _, revisions_human_only: _, revision_reviewer: _, revision_cutover: _, declarations_kdl: _, capacity, work_selector, completion, goals, @@ -71,6 +71,10 @@ fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result>(); + assert_eq!(rules, ["publish scratch/*", "start factory/publish", "write resource/factory/*"]); + } + #[test] fn canonical_declared_admission_retains_typed_metadata() { let mut spec = parse(r#"mission "test" state="ready" { goal "Keep declaration metadata."; }"#); diff --git a/crates/fractal-missions/src/store.rs b/crates/fractal-missions/src/store.rs index e8a12f92160..168e0c20b55 100644 --- a/crates/fractal-missions/src/store.rs +++ b/crates/fractal-missions/src/store.rs @@ -824,7 +824,7 @@ fn agent_working_since_at( Ok(since.and_then(|time| time.parse().ok())) } -/// The current generation's steps of actor `?1` (every actor when null), with finished and +/// The current generation's steps of actor spelling JSON `?1` (every actor when null), with finished and /// pending ones when `?2`, and agentless ones when `?3` and no actor is named. With `open_only` /// the finished steps are left out in SQL, word for word the predicate of /// `step_runs_open_index`, so the read covers the fleet's open steps and not every step it ran. @@ -850,9 +850,9 @@ fn work_at_snapshot_query_of(open_only: bool, selected: &str) -> String { WHERE {selected} (agentless=0 OR (?3 AND ?1 IS NULL)) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) AND (?1 IS NULL - OR assignee=?1 - OR lease_owner=?1 - OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1)) + OR assignee IN (SELECT value FROM json_each(?1)) + OR lease_owner IN (SELECT value FROM json_each(?1)) + OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1)))) AND (?2 OR status NOT IN ('pending','completed','failed','cancelled')) {open} ORDER BY created_at_unix_ms, step_path" diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index c5c84bfbdc6..c100cdce194 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -3,6 +3,7 @@ use super::*; pub use fractal_delivery::store::missions::*; use fractal_store::store::identities::resolve_reference_conn; +use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; mod run_review; pub mod step_history; @@ -85,6 +86,33 @@ fn physical_actor(connection: &Connection, actor: &str) -> Result Result { + canonical_work_actor(&physical_actor(connection, actor)?) +} + +fn canonical_work_actor(actor: &str) -> Result { + fractal_schema::canonical_agent_actor(actor) + .map(std::borrow::Cow::into_owned) + .ok_or_else(|| { + FractalError::new("invalid-work-actor", format!("invalid agent actor `{actor}`")) + }) +} + +fn validate_work_write_actor(actor: &str) -> Result<(), FractalError> { + let canonical = fractal_schema::canonical_agent_actor(actor).ok_or_else(|| { + FractalError::new("invalid-work-actor", format!("invalid agent actor `{actor}`")) + })?; + if canonical != actor { + return Err(FractalError::new( + "invalid-work-actor", + format!("work writes require the prefixed actor spelling `{canonical}`; retry with that actor"), + )); + } + Ok(()) +} + // One query preserves the native collection's indexed latest/live probes and shallow // key sort. Assistant membership matches reads::ident before pagination/hydration: // mission/ contributes eight allowed ASCII bytes; '-' is literal at the GLOB class end. @@ -929,15 +957,16 @@ fn seat_order_conflict_tx( agent: &str, subject: &str, ) -> Result> { - let order = seat_run_orders_tx(connection, Some(agent))? + let order = fractal_store::store::seat_queue_inputs_literal_tx(connection, Some(agent))? .remove(agent) - .unwrap_or_default(); - let rows = seat_step_rows_tx(connection, Some(agent))?; + .unwrap_or_default() + .live_order(); + let rows = fractal_store::store::missions::seat_step_rows_literal_tx(connection, agent)?; let steps = rows .iter() .map(RosterStepRow::seat_step) .collect::>(); - let selection = seat_queue::select(agent, &steps, &order); + let selection = seat_queue::select_literal(agent, &steps, &order); let Some(next) = selection.next() else { return Ok(None); }; @@ -1805,6 +1834,9 @@ pub trait MissionsStore { fn seat_run_orders(&self) -> Result>>; /// The live mission runs queued for one seat, in seat-queue order. fn seat_run_order(&self, agent: &str) -> Result>; + /// Literal queue order for wake decisions that must agree with claim admission. + fn seat_run_orders_for_mutation(&self) -> Result>>; + fn seat_run_order_for_mutation(&self, agent: &str) -> Result>; /// Record one move in a seat's queue. The move never touches a claim: a /// held step stays held, and the new order applies to the next selection. fn move_seat_queue_run( @@ -5196,25 +5228,26 @@ impl MissionsStore for Store { } fn work_for_renewal(&self, actor: &str) -> Result> { - let actor = normalize_actor(actor, "agent"); let snapshot = now_ms(); let connection = self.readers.get(); - let actor = physical_actor(&connection, &actor)?; + let actor = physical_read_actor(&connection, actor)?; let views = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs INDEXED BY step_runs_lease_index - WHERE lease_owner=?1 AND agentless=0 + WHERE lease_owner IN (SELECT value FROM json_each(?1)) AND agentless=0 AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) ORDER BY subject", )? - .query_map([&actor], step_run_from_row)? + .query_map([work_actor_spellings_json(&actor)], step_run_from_row)? .collect::>>()?; let mut held = Vec::with_capacity(views.len()); for mut view in views { apply_effective_step_state(&connection, &mut view, snapshot)?; - if view.work_claim_is_held_by(&actor) { + if matches!(view.status.as_str(), "claimed" | "working" | "verifying" | "blocked") + && work_actor_matches(view.claimant.as_deref(), &actor) + { held.push(view); } } @@ -5290,6 +5323,25 @@ impl MissionsStore for Store { .unwrap_or_default()) } + fn seat_run_orders_for_mutation(&self) -> Result>> { + let connection = self.readers.get(); + Ok(fractal_store::store::seat_queue_inputs_literal_tx(&connection, None)? + .into_iter() + .map(|(agent, inputs)| (agent, inputs.live_order())) + .collect()) + } + + fn seat_run_order_for_mutation(&self, agent: &str) -> Result> { + smallclaims::touched::note_read(|| format!("kind:{}", seat_queue::MOVED_CLAIM)); + let agent = normalize_seat(agent); + let connection = self.readers.get(); + let agent = physical_actor(&connection, &agent)?; + Ok(fractal_store::store::seat_queue_inputs_literal_tx(&connection, Some(&agent))? + .remove(&agent) + .unwrap_or_default() + .live_order()) + } + fn move_seat_queue_run( &self, request: &SeatQueueMoveRequest, @@ -5426,7 +5478,8 @@ impl MissionsStore for Store { selected: Option<&BTreeSet>, ) -> Result> { let connection = self.readers.get(); - let actor = actor.map(|value| physical_actor(&connection, value)).transpose()?; + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; + let actor_spellings = actor.as_deref().map(work_actor_spellings_json); let views = match selected { None => { let query = if actor.is_some() { @@ -5436,7 +5489,7 @@ impl MissionsStore for Store { }; let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-hc51ec5c7f089 owner=fractal_missions::store::missions TODO=trace-statement-and-value-representation */ &query)?; let rows = statement.query_map( - params![actor.as_deref(), include_terminal, include_agentless], + params![actor_spellings.as_deref(), include_terminal, include_agentless], step_run_from_row, )?; rows.collect::, _>>()? @@ -5447,7 +5500,7 @@ impl MissionsStore for Store { let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-hfb225899064e owner=fractal_missions::store::missions TODO=trace-statement-and-value-representation */ &query)?; let rows = statement.query_map( params![ - actor.as_deref(), + actor_spellings.as_deref(), include_terminal, include_agentless, serde_json::to_string(selected)? @@ -5506,10 +5559,10 @@ impl MissionsStore for Store { continue; } let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) || (matches!(view.status.as_str(), "ready" | "blocked") - && view.available_to.iter().any(|candidate| candidate == actor)) + && view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor))) }); if visible_to_actor && (include_terminal @@ -5562,8 +5615,9 @@ impl MissionsStore for Store { limit: usize, after: Option<&(u128, String)>, ) -> Result<(Vec, bool)> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; + let actor_spellings = actor.as_deref().map(work_actor_spellings_json); // Enrichment can clear a step's claimant, through its effective state alone, but // never changes its assignee, its candidates or its update time. So this finds every // step the actor can see, in the order the history shows them, and a step it matches @@ -5572,17 +5626,17 @@ impl MissionsStore for Store { let Some(actor) = actor.as_deref() else { return Ok(true); }; - if view.assigned_to.as_deref() == Some(actor) - || view.available_to.iter().any(|candidate| candidate == actor) + if work_actor_matches(view.assigned_to.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) { return Ok(true); } - if view.claimant.as_deref() != Some(actor) { + if !work_actor_matches(view.claimant.as_deref(), actor) { return Ok(false); } let mut effective = view.clone(); apply_effective_step_state(&connection, &mut effective, snapshot_unix_ms)?; - Ok(effective.claimant.as_deref() == Some(actor)) + Ok(work_actor_matches(effective.claimant.as_deref(), actor)) }; let mut statement = connection.prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5592,13 +5646,13 @@ impl MissionsStore for Store { OR (length(updated_at_unix_ms)=length(?2) AND updated_at_unix_ms?3)) AND (agentless=0 OR ?1 IS NULL) - AND (?1 IS NULL OR assignee=?1 OR lease_owner=?1 - OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1)) + AND (?1 IS NULL OR assignee IN (SELECT value FROM json_each(?1)) OR lease_owner IN (SELECT value FROM json_each(?1)) + OR EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1)))) ORDER BY length(updated_at_unix_ms) DESC, updated_at_unix_ms DESC, subject", )?; let rows = statement.query_map( params![ - actor.as_deref(), + actor_spellings.as_deref(), after.map(|key| key.0.to_string()), after.map(|key| key.1.as_str()) ], @@ -5630,8 +5684,8 @@ impl MissionsStore for Store { actor: Option<&str>, snapshot_unix_ms: u128, ) -> Result> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let view = connection .prepare_cached( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, @@ -5646,9 +5700,9 @@ impl MissionsStore for Store { }; enrich_step_queue_at(&connection, &mut view, snapshot_unix_ms)?; let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) - || view.available_to.iter().any(|candidate| candidate == actor) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) }); Ok(visible_to_actor.then_some(view)) } @@ -5659,8 +5713,8 @@ impl MissionsStore for Store { snapshot_unix_ms: u128, include_agentless: bool, ) -> Result> { - let actor = actor.map(|value| normalize_actor(value, "agent")); let connection = self.readers.get(); + let actor = actor.map(|value| physical_read_actor(&connection, value)).transpose()?; let mut statement = connection.prepare( "SELECT subject, run_id, step_path, definition_hash, status, attempt, assignee, available_to, agentless, title, goals, worker_reported, lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints @@ -5678,9 +5732,9 @@ impl MissionsStore for Store { let mut view = row?; enrich_step_queue_cached(&connection, &mut view, snapshot_unix_ms, &mut definitions)?; let visible_to_actor = actor.as_ref().is_none_or(|actor| { - view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) - || view.available_to.iter().any(|candidate| candidate == actor) + work_actor_matches(view.assigned_to.as_deref(), actor) + || work_actor_matches(view.claimant.as_deref(), actor) + || view.available_to.iter().any(|candidate| work_actor_matches(Some(candidate), actor)) }); if visible_to_actor { visible.push(view); @@ -5964,12 +6018,9 @@ impl MissionsStore for Store { )); } let subject = normalize_step_run(subject); - let actor = normalize_actor( - request.actor.as_deref().ok_or_else(|| { - FractalError::new("missing-work-actor", "a work action needs an actor") - })?, - "agent", - ); + let actor = request.actor.as_deref().ok_or_else(|| { + FractalError::new("missing-work-actor", "a work action needs an actor") + })?; let requested_incarnation = request .incarnation .as_deref() @@ -5985,7 +6036,8 @@ impl MissionsStore for Store { let committed = self .connection .batched(|transaction| -> Result<(StepRunView, Option), FractalError> { - let actor = physical_actor(transaction, &actor)?; + let actor = physical_actor(transaction, actor)?; + validate_work_write_actor(&actor)?; #[cfg(any(test, feature = "test-support"))] let _writer_work = WorkActionWriterProbe::start(&request.idempotency_key); if let Some(response) = smallclaims::store::idempotency::cached_response(transaction, &request.idempotency_key)? @@ -6109,10 +6161,7 @@ impl MissionsStore for Store { return Err(FractalError::new("handoff-not-acknowledged", "read the handoff note and acknowledge its message before claiming this work")); } let eligible = current.assigned_to.as_deref() == Some(actor.as_str()) - || current - .available_to - .iter() - .any(|candidate| candidate == &actor); + || current.available_to.iter().any(|candidate| candidate == &actor); if current.agentless || !eligible { return Err(FractalError::new( "work-not-available", @@ -6201,6 +6250,7 @@ impl MissionsStore for Store { let lease_valid = current .claim_expires_at_unix_ms .is_some_and(|expiry| expiry > now); + // Expansion preserves main's literal ownership admission for every mutation. if lease_valid && current.claimant.as_deref() != Some(actor.as_str()) { return Err(FractalError::new( "work-already-claimed", @@ -6250,6 +6300,10 @@ impl MissionsStore for Store { )); } } + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + // Expand comparisons and admission only; writes retain main's physical actor + // spelling. A later spelling switch requires a durable one-way fleet fence. + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership let (status, worker_reported, claimant, claim_incarnation, claim_expiry) = match action { "claim" => { if current.status != "ready" && current.status != "claimed" { diff --git a/crates/fractal-missions/src/store/tests.rs b/crates/fractal-missions/src/store/tests.rs index b684cde5e65..7843e3b2ee3 100644 --- a/crates/fractal-missions/src/store/tests.rs +++ b/crates/fractal-missions/src/store/tests.rs @@ -569,7 +569,7 @@ mission "clock-parent" state="ready" { mission "clock-worker" state="ready" { goal "Start the loop clock when work is claimed." concurrent-runs max=20 - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } step "automatic" { agentless } } mission "clock-automatic" state="ready" { @@ -658,7 +658,7 @@ mission "clock-automatic" state="ready" { .append_claim(&ClaimInput { subject: step.subject.clone(), kind: "work.claimed".into(), - actor: Some("agent/worker".into()), + actor: Some("agent/node.worker".into()), fields: BTreeMap::from([("attempt".into(), Value::from(index + 1))]), evidence: Vec::new(), expected_subject: None, @@ -1102,10 +1102,10 @@ version 2 mission "held-work" state="ready" { goal "Hold some steps." concurrent-runs max=2 - step "active" { assigned-to "agent/worker" } - step "expired" { assigned-to "agent/worker" } - step "ready" { assigned-to "agent/worker" } - step "pending" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/node.worker" } + step "expired" { assigned-to "agent/node.worker" } + step "ready" { assigned-to "agent/node.worker" } + step "pending" { assigned-to "agent/node.worker" } step "watch" { agentless } } @@ -5200,7 +5200,7 @@ version 2 mission "execution-timing" state="ready" { goal "Measure only active worker execution." step "work" timeout="1h" { - assigned-to "agent/worker" + assigned-to "agent/source.worker" retry { attempts 2 } } } @@ -5314,7 +5314,7 @@ version 2 mission "external-blocker" state="ready" { goal "Wait truthfully for the external host repair." - step "automated-proof" { assigned-to "agent/ios-owner" } + step "automated-proof" { assigned-to "agent/source.ios-owner" } } "#; let intent = crate::graph::parse_test_intent(source, "source").unwrap(); @@ -5693,11 +5693,11 @@ version 2 mission "terminal-owner" state="ready" { goal "Make terminal ownership authoritative." - step "active" { assigned-to "agent/worker" } - step "expired" { assigned-to "agent/worker" } - step "ready" { assigned-to "agent/worker" } - step "blocked" { assigned-to "agent/worker" } - step "pending" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/source.worker" } + step "expired" { assigned-to "agent/source.worker" } + step "ready" { assigned-to "agent/source.worker" } + step "blocked" { assigned-to "agent/source.worker" } + step "pending" { assigned-to "agent/source.worker" } } "#; @@ -5901,7 +5901,7 @@ fn terminal_roots_reap_nested_orphans_idempotently_across_restart_and_replicatio r#"version 2 mission "tree-child" state="ready" { goal "Expose nested work." - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/source.worker" } }"#, "terminal-tree-child", ); @@ -6435,7 +6435,7 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { mission "bootstrap" state="ready" { goal "Complete mission bootstrap." step "compile" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" produces-mission "project/work" mission "work" { goal "Complete mission work."; step "publish" { } } } @@ -6516,8 +6516,7 @@ fn an_active_nested_lease_can_publish_its_parent_mission_output() { assert_eq!(output.revision, mission.revision); } -#[test] -fn nested_work_renews_the_same_workers_ancestor_lease() { +fn nested_work_ancestor_expiry_after_child_completion(holder: &str) -> (u128, u128) { let store = Store::open_memory("node").unwrap(); let source = r#"version 2 @@ -6526,7 +6525,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "nested-work" state="ready" { goal "Complete nested work." step "outer" { - assigned-to "agent/worker" + assigned-to "agent/node/worker" mission "work" { goal "Complete the child work." step "first" { } @@ -6568,7 +6567,7 @@ mission "nested-work" state="ready" { .unwrap(); let request = |key: &str| WorkRequest { wait_ms: None, - actor: Some("agent/node.worker".into()), + actor: Some("agent/node/worker".into()), incarnation: Some("current".into()), summary: None, reason: None, @@ -6588,13 +6587,14 @@ mission "nested-work" state="ready" { store .work_action(&child.subject, "claim", &request("claim-child")) .unwrap(); + let anchored_expiry = now_ms() + 60_000; store .connection .lock() .unwrap() .execute( - /* sqlite-large-value: allow-for-now lv-h96d7cb75d46b owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1", - [&parent.subject], + /* sqlite-large-value: allow-for-now lv-h1c3e44d7086a owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1", + params![parent.subject, holder, anchored_expiry.to_string()], ) .unwrap(); @@ -6604,9 +6604,21 @@ mission "nested-work" state="ready" { let parent = store.step_run(&parent.subject).unwrap().unwrap(); assert_eq!(parent.status, "working"); - assert_eq!(parent.claimant.as_deref(), Some("agent/node.worker")); + assert_eq!(parent.claimant.as_deref(), Some(holder)); assert_eq!(parent.claim_incarnation.as_deref(), Some("current")); - assert!(parent.claim_expires_at_unix_ms.unwrap() > now_ms()); + (anchored_expiry, parent.claim_expires_at_unix_ms.unwrap()) +} + +#[test] +fn nested_work_renews_the_literal_owners_ancestor_lease() { + let (before, after) = nested_work_ancestor_expiry_after_child_completion("agent/node/worker"); + assert!(after > before); +} + +#[test] +fn nested_work_does_not_renew_an_alias_owned_ancestor_lease() { + let (before, after) = nested_work_ancestor_expiry_after_child_completion("node/worker"); + assert_eq!(after, before); } #[test] @@ -7214,7 +7226,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "retry" state="ready" { goal "Retry failed work." step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" retry { attempts 3 } gate "the result is ready" { exists "resource/result" } } @@ -8619,7 +8631,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "summaries" state="ready" { goal "Show what the worker reported." step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" retry { attempts 2 } gate "the result is ready" { exists "resource/result" } } @@ -8743,7 +8755,7 @@ version 2 mission "revision" state="ready" { goal "Complete mission revision." agent "worker" { workspace "."; command "true" } - step "owned" { assigned-to "agent/worker"; goal "First goal." } + step "owned" { assigned-to "agent/${ST_MISSION_RUN}/worker"; goal "First goal." } step "unrelated" { } step "join" { depends-on { step "owned" completed; step "unrelated" completed } } } @@ -8776,7 +8788,7 @@ version 2 goal "Complete mission revision." agent "worker" { workspace "."; command "true" } step "owned" { - assigned-to "agent/worker" + assigned-to "agent/${ST_MISSION_RUN}/worker" goal "First goal." constraint "Do not push." } @@ -9197,7 +9209,7 @@ fn revise_notes_carry_progress_for_active_and_completed_steps() { let store = Store::open_memory("node").unwrap(); let source = CARRY_SOURCE.replace( "step \"other\"", - "step \"done\" { assigned-to \"agent/worker\"; goal \"Finish the work.\" }\n step \"other\"", + "step \"done\" { assigned-to \"agent/node.worker\"; goal \"Finish the work.\" }\n step \"other\"", ); publish_carry(&store, &source, "notes-one"); let run = store @@ -9513,7 +9525,7 @@ version 2 mission "carry" state="ready" {{ goal "Keep submitted work across a gate revision." step "work" {{ - assigned-to "agent/worker" + assigned-to "agent/node.worker" goal {goal:?} gate "checked" {{ exec {gate:?}; host "node"; workspace "/tmp" }} }} @@ -9712,6 +9724,74 @@ fn a_revision_keeps_the_claim_on_a_carried_step() { assert_eq!(submitted.status, "verifying"); } +#[test] +fn carried_claim_successor_requires_literal_ownership() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + let store = Store::open_memory("node").unwrap(); + publish_carry(&store, CARRY_SOURCE, "alias-carry-one"); + let run = store.create_mission_run(&MissionRunRequest { + mission: "carry".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/requester".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "alias-carry-run".into(), + }).unwrap(); + let old = &run.steps.iter().find(|step| step.step == "work").unwrap().subject; + store.set_step_state(old, "ready", None).unwrap(); + store.work_action(old, "claim", &carry_request("one", "alias-carry-claim")).unwrap(); + let revised = publish_carry( + &store, + &CARRY_SOURCE.replace("Use the first goal.", "Use the second goal."), + "alias-carry-two", + ); + let adopted = store.adopt_mission_revision( + &run.id, &revised, "person/requester", "revise other work", "alias-carry-revision", + ).unwrap(); + let carried = &adopted.steps.iter().find(|step| step.step == "work").unwrap().subject; + assert_ne!(carried, old); + store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h76038fa53ef8 owner=fractal_missions::store::tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1", + params![carried, holder], + ).unwrap(); + let mut renew = carry_request("one", "alias-carry-renew"); + renew.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let error = store.work_action(old, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + assert_eq!( + store.step_run(carried).unwrap().unwrap().claimant.as_deref(), + Some(holder), + ); + continue; + } + // Literal c72ede13 physical_actor rule, independent of the implementation + // under test: normalize_actor(actor, "agent"), then resolve_reference_conn. + let main_actor = fractal_store::store::identities::resolve_reference_conn( + &store.readers.get(), + &smallclaims::store::normalize_actor(actor, "agent"), + ).unwrap(); + if main_actor != holder { + let error = store.work_action(old, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "stale-run-generation"); + assert_eq!( + store.step_run(carried).unwrap().unwrap().claimant.as_deref(), + Some(holder), + ); + continue; + } + let renewed = store.work_action(old, "renew", &renew).unwrap(); + assert_eq!(&renewed.subject, carried); + // The carried subject has no replicated lease anchor. Main publishes this + // renewal and writes the request actor, unlike a quiet in-generation renewal. + assert_eq!(renewed.claimant.as_deref(), Some(main_actor.as_str())); + let anchors = store.claims_for(carried, Some("work.renewed")).unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].actor.as_deref(), Some(main_actor.as_str())); + assert_eq!(anchors[0].body["fields"]["claimant"], json!(main_actor)); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + } + } +} + #[test] fn all_affected_human_reviewers_approve_the_exact_revision_preview() { let store = Store::open_memory("node").unwrap(); @@ -9724,7 +9804,7 @@ version 2 goal "Test protected revision." agent "worker" {{ workspace "."; command "true" }} step "work" revisions="human-only" revision-reviewer="person/step-reviewer" {{ - assigned-to "agent/worker" + assigned-to "agent/${{ST_MISSION_RUN}}/worker" goal {goal:?} }} }} @@ -10037,7 +10117,7 @@ version 2 agent "worker" { workspace "."; command "true" } mission "drain-child" state="ready" { goal "Keep descendant work in the idle boundary." - step "active" { assigned-to "agent/worker" } + step "active" { assigned-to "agent/node.worker" } } "#; diff --git a/crates/fractal-missions/src/store/work_renew_tests.rs b/crates/fractal-missions/src/store/work_renew_tests.rs index c1295f15c04..abcdeaf7828 100644 --- a/crates/fractal-missions/src/store/work_renew_tests.rs +++ b/crates/fractal-missions/src/store/work_renew_tests.rs @@ -1,5 +1,6 @@ #![allow(unused_imports)] use super::*; +use super::adhoc_work::AdhocWorkStore as _; use fractal_delivery::store::shared_missions::*; use fractal_delivery::store::shared_missions::{RUN_TREE_REBUILDS, STEP_TIMING_FOLD_CALLS, STEP_TIMING_ROWS_PARSED}; use fractal_store::store::work_renew_tests_shared_missions::*; @@ -508,6 +509,572 @@ fn ready_step_with_mail(mail: usize) -> Renewal { Renewal { _directory: directory, store, subject } } +#[test] +fn work_claim_requires_literal_assignment_and_main_actor_spelling() { + for (actor, assignee) in [ + ("node/worker", "agent/node/worker"), + ("agent/node/worker", "node/worker"), + ("node/worker", "node/worker"), + ("agent/node/worker", "agent/node/worker"), + ] { + let step = ready_step_with_mail(0); + // Model both current rows and persisted rows predating actor canonicalization. + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h755979e290e9 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, assignee], + ).unwrap(); + assert_eq!( + step.store.step_run(&step.subject).unwrap().unwrap().assigned_to.as_deref(), + Some(assignee), + ); + let mut claim_request = request("canonical-actor-claim", None); + claim_request.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + assert!(error.message.contains("agent/node/worker")); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } + if actor != assignee { + let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); + assert_eq!(error.code, "work-not-available"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } + let claimed = step.store.work_action(&step.subject, "claim", &claim_request).unwrap(); + assert_eq!(claimed.claimant.as_deref(), Some(actor)); + assert_eq!(claimed.assigned_to.as_deref(), Some(assignee)); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.claimant.as_deref(), Some(actor)); + assert_eq!(persisted.assigned_to.as_deref(), Some(assignee)); + let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(actor)); + assert_eq!(claims[0].body["fields"]["claimant"], json!(actor)); + // Main's physical_actor leaves slash-qualified inputs unchanged. Its literal + // capacity predicate sees that exact spelling, not the assignment's spelling. + let held: bool = step.store.readers.get().query_row( + "SELECT EXISTS(SELECT 1 FROM step_runs WHERE subject=?1 AND lease_owner=?2)", + params![step.subject, actor], |row| row.get(0), + ).unwrap(); + assert!(held); + let fields: BTreeMap = + serde_json::from_value(claims[0].body["fields"].clone()).unwrap(); + let strict = fractal_schema::registry() + .validate_claim(&claims[0].subject, &claims[0].kind, &fields); + assert!(strict.is_ok(), "old validators must accept every emitted claim: {strict:?}"); + let mut raw = fields; + raw.insert("claimant".into(), json!("node/worker")); + assert!(fractal_schema::registry() + .validate_claim(&claims[0].subject, &claims[0].kind, &raw).is_err(), + "the pre-bridge validator rejects raw claimants"); + } +} + +#[test] +fn replicated_legacy_claimant_refuses_alias_mutations_without_replay_loss() { + use fractal_store::store::{classify_replicated_claim_with_registry, + project_claim_isolated_tx, project_mission_run_update}; + + let source = Store::open_memory("node").unwrap(); + let kdl = "version 2\nmission \"legacy-replica\" state=\"ready\" { goal \"Work.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(kdl, "node").unwrap(); + let plan = source.mission(&intent, IntentInput { kdl: kdl.into(), source_name: None }).unwrap(); + source.apply(&intent, &plan.subject_tokens, "legacy-replica-definition").unwrap(); + let run = source.create_mission_run(&MissionRunRequest { + mission: "legacy-replica".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "legacy-replica-run".into(), + }).unwrap(); + let subject = &run.steps[0].subject; + source.set_step_state(subject, "ready", None).unwrap(); + let mut claim_request = request("legacy-replica-claim", None); + claim_request.actor = Some("agent/node/worker".into()); + source.work_action(subject, "claim", &claim_request).unwrap(); + // Model an independently produced historical fact, not a locally admitted write. + let mut replication = source.export_replication(0).unwrap(); + let historical = replication.batches.iter_mut().flat_map(|batch| &mut batch.claims) + .find(|claim| claim.subject == *subject && claim.kind == "work.claimed").unwrap(); + historical.body["fields"]["claimant"] = json!("node/worker"); + let anchored_expiry = now_ms() + 540_000; + historical.body["fields"]["claim_expires_at_unix_ms"] = json!(anchored_expiry); + historical.id = claim_hash( + &historical.batch_id, &historical.subject, &historical.kind, &historical.origin, + historical.actor.as_deref(), &historical.body, &historical.predecessors, + ).unwrap(); + let claim = historical.clone(); + assert_eq!(claim.body["fields"]["claimant"], json!("node/worker")); + assert!(classify_replicated_claim_with_registry(&claim, fractal_schema::registry()).is_ok()); + let replica = Store::open_memory("replica").unwrap(); + assert!(replica.step_run(subject).unwrap().is_none()); + replica.import_replication("node", &replication).unwrap(); + let projected = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(projected.claimant.as_deref(), Some("node/worker")); + assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); + assert_eq!(projected.claim_incarnation, claim_request.incarnation); + assert_eq!(projected.claim_expires_at_unix_ms, Some(anchored_expiry)); + assert!(anchored_expiry > now_ms() + 300_000, "the anchor would allow a quiet renewal"); + let before_index = replica.index().unwrap(); + for action in ["renew", "release", "complete"] { + let mut mutation = claim_request.clone(); + mutation.idempotency_key = format!("legacy-owner-alias-{action}"); + let error = replica.work_action(subject, action, &mutation).unwrap_err(); + assert_eq!(error.code, "work-already-claimed", "main's literal ownership fence: {action}"); + assert_eq!(replica.index().unwrap(), before_index); + let after = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(after.status, projected.status); + assert_eq!(after.claimant, projected.claimant); + assert_eq!(after.claim_incarnation, projected.claim_incarnation); + assert_eq!(after.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + // In particular, a refused release must not disappear or clear the lease on replay. + replica.rebuild_claim_projections().unwrap(); + let replayed = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(replayed.status, projected.status, "{action}"); + assert_eq!(replayed.claimant, projected.claimant, "{action}"); + assert_eq!(replayed.claim_incarnation, projected.claim_incarnation, "{action}"); + assert_eq!(replayed.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms, "{action}"); + assert_eq!(replica.index().unwrap(), before_index); + } + let local_renewals: u64 = replica.readers.get().query_row( + "SELECT COUNT(*) FROM local_work_lease_renewals WHERE subject=?1", + [subject], |row| row.get(0), + ).unwrap(); + assert_eq!(local_renewals, 0); + replica.rebuild_claim_projections().unwrap(); + assert_eq!(replica.step_run(subject).unwrap().unwrap().claimant.as_deref(), Some("node/worker")); + let replayed = replica.step_run(subject).unwrap().unwrap(); + assert_eq!(replayed.status, projected.status); + assert_eq!(replayed.claim_incarnation, projected.claim_incarnation); + assert_eq!(replayed.claim_expires_at_unix_ms, projected.claim_expires_at_unix_ms); + let replayed_local_renewals: u64 = replica.readers.get().query_row( + "SELECT COUNT(*) FROM local_work_lease_renewals WHERE subject=?1", + [subject], |row| row.get(0), + ).unwrap(); + assert_eq!(replayed_local_renewals, 0); + assert_eq!(replica.claims_for(subject, Some("work.claimed")).unwrap()[0].body, claim.body); + + let mut connection = replica.connection.write(); + let transaction = connection.transaction().unwrap(); + transaction.execute( + /* sqlite-large-value: allow-for-now lv-hac330e1ef0a3 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', + status='ready', lease_owner=NULL, lease_incarnation=NULL, + lease_expires_at_unix_ms=NULL WHERE subject=?1", [subject], + ).unwrap(); + project_claim_isolated_tx(&transaction, "legacy-ownership-test", &claim, || { + project_mission_run_update(&transaction, &claim) + }).unwrap(); + let owner: Option = transaction.query_row( + "SELECT lease_owner FROM step_runs WHERE subject=?1", [subject], |row| row.get(0), + ).unwrap(); + assert!(owner.is_none()); + let defect: String = transaction.query_row( + "SELECT error_code FROM projection_health WHERE aggregate=?1", + [format!("legacy-ownership-test:{}", claim.id)], |row| row.get(0), + ).unwrap(); + assert_eq!(defect, "invalid-work-claimant"); +} + +#[test] +fn work_claim_requires_literal_candidate_and_main_actor_spelling() { + for actor in ["node/worker", "agent/node/worker"] { + for candidate in ["node/worker", "agent/node/worker"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-hbbb772f82848 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1", + params![step.subject, json!([candidate]).to_string()], + ).unwrap(); + let mut claim = request("candidate-claim", None); + claim.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let error = step.store.work_action(&step.subject, "claim", &claim).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } + if actor != candidate { + let error = step.store.work_action(&step.subject, "claim", &claim).unwrap_err(); + assert_eq!(error.code, "work-not-available"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + continue; + } + let claimed = step.store.work_action(&step.subject, "claim", &claim).unwrap(); + assert_eq!(claimed.claimant.as_deref(), Some(actor)); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.claimant.as_deref(), Some(actor)); + assert!(persisted.assigned_to.is_none()); + assert_eq!(persisted.available_to, vec![candidate.to_owned()]); + let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(actor)); + assert_eq!(claims[0].body["fields"]["claimant"], json!(actor)); + } + } +} + +#[test] +fn work_actor_equivalence_preserves_recognized_kinds() { + use fractal_store::store::{work_actor_matches, work_actor_spellings_json}; + + assert!(work_actor_matches(Some("node/worker"), "agent/node/worker")); + assert!(work_actor_matches(Some("agent/node/worker"), "node/worker")); + for (candidate, actor) in [ + ("person/reviewer", "agent/person/reviewer"), + ("daemon/scheduler", "agent/daemon/scheduler"), + ("agent/node/worker", "agent/agent/node/worker"), + ("node/worker-other", "agent/node/worker"), + ] { + assert!(!work_actor_matches(Some(candidate), actor)); + let spellings: Vec = serde_json::from_str(&work_actor_spellings_json(actor)).unwrap(); + assert!(!spellings.iter().any(|spelling| spelling == candidate)); + } + for invalid in ["", "worker", "node/worker name"] { + assert!(!work_actor_matches(Some(invalid), invalid)); + assert_eq!(work_actor_spellings_json(invalid), "[]"); + } +} + +#[test] +fn work_reads_match_legacy_and_canonical_actor_spellings() { + for actor in ["node/worker", "agent/node/worker"] { + for stored in ["node/worker", "agent/node/worker"] { + for field in ["assignee", "available_to", "lease_owner"] { + let step = ready_step_with_mail(0); + let snapshot = now_ms(); + let assignee = if field == "assignee" { stored } else { "agent/node/other" }; + let available = if field == "available_to" { json!([stored]) } else { json!([]) }; + let owner = (field == "lease_owner").then_some(stored); + let status = if owner.is_some() { "claimed" } else { "ready" }; + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-hd60f12cbb0cd owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, + status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1", + params![step.subject, assignee, available.to_string(), owner, status, + request("read", None).incarnation, (snapshot + 600_000).to_string()], + ).unwrap(); + let selected = BTreeSet::from([step.subject.clone()]); + let lists = [ + step.store.work_at_snapshot(Some(actor), false, snapshot).unwrap(), + step.store.client_work_at_snapshot(Some(actor), false, snapshot).unwrap(), + step.store.work_at_snapshot_selected( + Some(actor), false, snapshot, true, false, false, Some(&selected), + ).unwrap(), + step.store.work_history_at_snapshot(Some(actor), snapshot).unwrap(), + step.store.client_work_history_at_snapshot(Some(actor), snapshot).unwrap(), + step.store.client_work_history_page_at_snapshot( + Some(actor), snapshot, 0, 10, + ).unwrap().0, + ]; + for list in lists { + assert_eq!(list.len(), 1, "{actor} / {stored} / {field}"); + assert_eq!(list[0].subject, step.subject); + } + assert_eq!( + step.store.client_work_item_at_snapshot(&step.subject, Some(actor), snapshot) + .unwrap().unwrap().subject, + step.subject, + ); + assert!(step.store.work_at_snapshot( + Some("agent/node/unrelated"), false, snapshot, + ).unwrap().is_empty()); + assert!(step.store.client_work_item_at_snapshot( + &step.subject, Some("agent/node/unrelated"), snapshot, + ).unwrap().is_none()); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.assigned_to.as_deref(), Some(assignee)); + assert_eq!(persisted.claimant.as_deref(), owner); + assert_eq!(persisted.available_to, if field == "available_to" { + vec![stored.to_owned()] + } else { Vec::::new() }); + } + } + } +} + +#[test] +fn work_renewal_matches_legacy_and_canonical_holders() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-hd38e77dd6432 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [&step.subject], + ).unwrap(); + let mut claim = request("holder-claim", None); + claim.actor = Some("agent/node/worker".into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h2102123ed04c owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + params![step.subject, holder], + ).unwrap(); + for status in ["claimed", "working", "verifying", "blocked"] { + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h28928374ca14 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET status=?2 WHERE subject=?1", + params![step.subject, status], + ).unwrap(); + let held = step.store.work_for_renewal(actor).unwrap(); + assert_eq!(held.len(), 1, "{actor} / {holder} / {status}"); + assert_eq!(held[0].subject, step.subject); + } + let mut renew = claim.clone(); + renew.idempotency_key = "holder-renew".into(); + renew.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let before = step.store.claims_for(&step.subject, None).unwrap().len(); + let error = step.store.work_action(&step.subject, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + assert_eq!(step.store.claims_for(&step.subject, None).unwrap().len(), before); + continue; + } + if actor != holder { + let error = step.store.work_action(&step.subject, "renew", &renew).unwrap_err(); + assert_eq!(error.code, "work-already-claimed"); + continue; + } + let renewed = step.store.work_action(&step.subject, "renew", &renew).unwrap(); + // Main's quiet renewal writes only the operational lease, not a new claim + // or a replacement claimant spelling in the projected step. + assert_eq!(renewed.claimant.as_deref(), Some(holder)); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + } + } +} + +#[test] +fn work_mutations_require_literal_legacy_or_canonical_holders() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + for (action, expected_status) in [("renew", "claimed"), ("release", "ready"), ("complete", "verifying")] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-hd700e0550a21 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, "agent/node/worker"], + ).unwrap(); + let mut claim = request("holder-action-claim", None); + claim.actor = Some("agent/node/worker".into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-ha0cd354483f3 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1", + params![step.subject, holder], + ).unwrap(); + let mut finish = claim.clone(); + finish.idempotency_key = format!("holder-{action}"); + finish.actor = Some(actor.into()); + if !actor.starts_with("agent/") { + let before = step.store.claims_for(&step.subject, None).unwrap().len(); + let error = step.store.work_action(&step.subject, action, &finish).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor"); + assert_eq!(step.store.claims_for(&step.subject, None).unwrap().len(), before); + assert_eq!(step.store.step_run(&step.subject).unwrap().unwrap().claimant.as_deref(), Some(holder)); + continue; + } + if actor != holder { + let before_index = step.store.index().unwrap(); + let before = step.store.step_run(&step.subject).unwrap().unwrap(); + let error = step.store.work_action(&step.subject, action, &finish).unwrap_err(); + assert_eq!(error.code, "work-already-claimed", "{action}"); + assert_eq!(step.store.index().unwrap(), before_index); + let after = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(after.status, before.status); + assert_eq!(after.claimant, before.claimant); + assert_eq!(after.claim_incarnation, before.claim_incarnation); + assert_eq!(after.claim_expires_at_unix_ms, before.claim_expires_at_unix_ms); + assert_eq!(after.worker_reported, before.worker_reported); + continue; + } + let finished = step.store.work_action(&step.subject, action, &finish).unwrap(); + assert_eq!(finished.status, expected_status, "{actor} / {holder} / {action}"); + // A quiet renewal retains the projected claimant; release/complete clear it. + let expected_holder = (action == "renew").then_some(holder); + assert_eq!(finished.claimant.as_deref(), expected_holder); + if action == "renew" { + assert_eq!(finished.claim_incarnation, claim.incarnation); + assert!(finished.claim_expires_at_unix_ms.unwrap() > now_ms()); + let local_holder: String = step.store.connection.write().query_row( + "SELECT lease_owner FROM local_work_lease_renewals WHERE subject=?1", + [&step.subject], |row| row.get(0), + ).unwrap(); + assert_eq!(local_holder, actor); + } else { + assert!(finished.claim_incarnation.is_none()); + assert!(finished.claim_expires_at_unix_ms.is_none()); + } + assert_eq!(finished.worker_reported, action == "complete"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, expected_status); + assert_eq!(persisted.claimant.as_deref(), expected_holder); + assert_eq!(persisted.assigned_to.as_deref(), Some("agent/node/worker")); + } + } + } +} + +#[test] +fn independent_work_capacity_uses_literal_ownership_for_admission() { + for actor in ["node/worker", "agent/node/worker"] { + for holder in ["node/worker", "agent/node/worker"] { + for status in ["claimed", "working", "verifying", "blocked"] { + let step = ready_step_with_mail(0); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h48c2f2d4b497 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [&step.subject], + ).unwrap(); + let mut claim = request("capacity-first", None); + claim.actor = Some("agent/node/worker".into()); + step.store.work_action(&step.subject, "claim", &claim).unwrap(); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h7d7fcc2328ee owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1", + params![step.subject, holder, status], + ).unwrap(); + let declaration = "version 2\nagent \"node/worker\" { workspace \"/tmp\"; command \"true\"; restart \"never\"; }\nmission \"capacity-second\" state=\"ready\" { goal \"Another task.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(declaration, "node").unwrap(); + let plan = step.store.mission(&intent, IntentInput { + kdl: declaration.into(), source_name: None, + }).unwrap(); + step.store.apply(&intent, &plan.subject_tokens, "capacity-agent").unwrap(); + let started = step.store.start_work(&crate::model::WorkStartRequest { + actor: "agent/node/worker".into(), + title: "A second independent task".into(), + idempotency_key: "capacity-spontaneous-start".into(), + }); + if holder == "agent/node/worker" { + assert_eq!(started.unwrap_err().code, "agent-capacity", "{actor} / {holder} / {status}"); + } else { + let started = started.unwrap(); + assert!(started.claimant.is_none()); + step.store.set_step_state(&started.subject, "cancelled", None).unwrap(); + } + let second = step.store.create_mission_run(&MissionRunRequest { + mission: "capacity-second".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "capacity-second-run".into(), + }).unwrap(); + let second = &second.steps[0].subject; + step.store.set_step_state(second, "ready", None).unwrap(); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h78e96a5b9d99 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1", + [second], + ).unwrap(); + claim.idempotency_key = "capacity-second-claim".into(); + claim.actor = Some(actor.into()); + let result = step.store.work_action(second, "claim", &claim); + if !actor.starts_with("agent/") || actor == holder { + let expected = if actor.starts_with("agent/") { "agent-capacity" } else { "invalid-work-actor" }; + assert_eq!(result.unwrap_err().code, expected, "{actor} / {holder} / {status}"); + let refused = step.store.step_run(second).unwrap().unwrap(); + assert_eq!(refused.status, "ready"); + assert!(refused.claimant.is_none()); + assert!(step.store.claims_for(second, Some("work.claimed")).unwrap().is_empty()); + } else { + assert_eq!(result.unwrap().claimant.as_deref(), Some(actor)); + } + } + } + } +} + +#[test] +fn legacy_ready_run_does_not_block_literal_claim_but_remains_visible() { + let first = ready_step_with_mail(0); + first.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h934585192b73 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1", + params![first.subject, "node/worker"], + ).unwrap(); + let source = "version 2\nmission \"literal-queue\" state=\"ready\" { goal \"Literal queue admission.\"; step \"work\" { assigned-to \"agent/node/worker\"; } }"; + let intent = crate::graph::parse_test_intent(source, "node").unwrap(); + let plan = first.store.mission(&intent, IntentInput { + kdl: source.into(), source_name: None, + }).unwrap(); + first.store.apply(&intent, &plan.subject_tokens, "literal-queue-definition").unwrap(); + let second = first.store.create_mission_run(&MissionRunRequest { + mission: "literal-queue".into(), revision: None, workspace: "/tmp".into(), + requester: Some("person/test".into()), mode: Some("run".into()), + inputs: BTreeMap::new(), idempotency_key: "literal-queue-run".into(), + }).unwrap(); + let second = &second.steps[0].subject; + first.store.set_step_state(second, "ready", None).unwrap(); + let visible = first.store.work(Some("agent/node/worker"), false).unwrap(); + assert_eq!(visible.len(), 2); + assert!(visible.iter().any(|step| step.subject == first.subject)); + assert!(visible.iter().any(|step| step.subject == *second)); + let mut claim = request("literal-queue-claim", None); + claim.actor = Some("agent/node/worker".into()); + let claimed = first.store.work_action(second, "claim", &claim).unwrap(); + assert_eq!(claimed.subject, *second); + assert_eq!(claimed.claimant.as_deref(), Some("agent/node/worker")); + let unchanged = first.store.step_run(&first.subject).unwrap().unwrap(); + assert_eq!(unchanged.status, "ready"); + assert!(unchanged.claimant.is_none()); +} + +#[test] +fn bare_work_actors_claim_renew_release_and_complete_with_main_normalization() { + for bare_actor in ["node.worker", "worker"] { + for (action, expected_status) in [("release", "ready"), ("complete", "verifying")] { + let step = ready_step_with_mail(0); + let main_actor = smallclaims::store::normalize_actor(bare_actor, "agent"); + step.store.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h34894d4454a8 owner=fractal_missions::store::work_renew_tests TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET assignee=?2 WHERE subject=?1", + params![step.subject, main_actor], + ).unwrap(); + let mut mutation = request("bare-claim", None); + mutation.actor = Some(bare_actor.into()); + let claimed = step.store.work_action(&step.subject, "claim", &mutation).unwrap(); + assert_eq!(claimed.status, "claimed"); + assert_eq!(claimed.claimant.as_deref(), Some(main_actor.as_str())); + assert_eq!(claimed.claim_incarnation, mutation.incarnation); + let claims = step.store.claims_for(&step.subject, Some("work.claimed")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(main_actor.as_str())); + assert_eq!(claims[0].body["fields"]["claimant"], json!(main_actor)); + + mutation.idempotency_key = "bare-renew".into(); + let renewed = step.store.work_action(&step.subject, "renew", &mutation).unwrap(); + assert_eq!(renewed.status, "claimed"); + assert_eq!(renewed.claimant.as_deref(), Some(main_actor.as_str())); + assert_eq!(renewed.claim_incarnation, mutation.incarnation); + assert!(renewed.claim_expires_at_unix_ms.unwrap() > now_ms()); + + mutation.idempotency_key = format!("bare-{action}"); + let finished = step.store.work_action(&step.subject, action, &mutation).unwrap(); + assert_eq!(finished.status, expected_status); + assert!(finished.claimant.is_none()); + assert!(finished.claim_incarnation.is_none()); + assert!(finished.claim_expires_at_unix_ms.is_none()); + assert_eq!(finished.worker_reported, action == "complete"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, expected_status); + assert!(persisted.claimant.is_none()); + } + } +} + +#[test] +fn work_claim_rejects_malformed_actor_without_claiming_work() { + let step = ready_step_with_mail(0); + for (index, actor) in ["", "agent/", "/worker", "node/worker name"].iter().enumerate() { + let mut claim_request = request(&format!("malformed-actor-claim-{index}"), None); + claim_request.actor = Some((*actor).into()); + let error = step.store.work_action(&step.subject, "claim", &claim_request).unwrap_err(); + assert_eq!(error.code, "invalid-work-actor", "{actor:?}"); + let persisted = step.store.step_run(&step.subject).unwrap().unwrap(); + assert_eq!(persisted.status, "ready"); + assert!(persisted.claimant.is_none()); + assert!(step.store.claims_for(&step.subject, Some("work.claimed")).unwrap().is_empty()); + } +} + #[test] fn a_claim_and_a_completion_hold_the_writer_for_the_same_work_whatever_the_assignees_mail() { let mut costs = Vec::new(); diff --git a/crates/fractal-reconcile/src/contract.rs b/crates/fractal-reconcile/src/contract.rs index 12161fa3364..eb8efd19dd7 100644 --- a/crates/fractal-reconcile/src/contract.rs +++ b/crates/fractal-reconcile/src/contract.rs @@ -1,5 +1,5 @@ //! Host-local launch identity; publication never changes incarnation evidence. -use std::{collections::BTreeMap, path::Path, sync::OnceLock}; +use std::{borrow::Cow, collections::BTreeMap, path::Path, sync::OnceLock}; use anyhow::{Context as _, Result}; use crate::launch_contract::Contract; @@ -68,6 +68,29 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru *path = std::env::join_paths(std::env::split_paths(path).map(|entry| if entry.is_absolute() { entry } else { Path::new(&member.cwd).join(entry) }))?.to_string_lossy().into_owned(); } + let authored = desired.member.as_ref().context("launch contract needs a member")?; + let provider = match &authored.launch { + LaunchSpec::Shell(_) => "sh", + LaunchSpec::Argv(argv) => { + let position = if authored.driver.is_some() { + argv.iter().position(|arg| arg == "--").context("harness launch has no provider")? + 1 + } else { 0 }; + argv.get(position).context("launch has no program")? + } + }; + let mut provider = Cow::Borrowed(provider); + if let Some(harness @ ("omp" | "claude" | "codex")) = member.driver.as_deref() { + // Measure the provider child, not the raw executable in the authored expansion. + // Wrapper selection and its interpreter PATH must agree with driver startup without + // changing the daemon's environment or selecting process-global driver state. + let mut argv = [provider.into_owned()]; + let path = fractal_drivers::managed_launch::resolve_with_environment( + harness, &mut argv, &desired.desired, &environment, input, + )?; + environment.insert("PATH".into(), path.into_string().map_err(|_| anyhow::anyhow!("managed provider PATH is not UTF-8"))?); + let [program] = argv; + provider = Cow::Owned(program); + } let resolve = |program: &str| { let mut program = program.to_owned(); fractal_runtime::expand_path_placeholder(&mut program, &environment); @@ -81,18 +104,8 @@ fn capture_with(store: &Store, desired: &DesiredSubject, member: &MemberSpec, ru let path = fractal_runtime::resolve_executable(&program, &environment)?; Ok(if path.is_absolute() { path } else { Path::new(&member.cwd).join(path) }) }; - let authored = desired.member.as_ref().context("launch contract needs a member")?; - let provider = match &authored.launch { - LaunchSpec::Shell(_) => "sh", - LaunchSpec::Argv(argv) => { - let position = if authored.driver.is_some() { - argv.iter().position(|arg| arg == "--").context("harness launch has no provider")? + 1 - } else { 0 }; - argv.get(position).context("launch has no program")? - } - }; let mut binaries = BTreeMap::new(); - for (role, path) in [("launcher", executable), ("provider", resolve(provider)?)] { + for (role, path) in [("launcher", executable), ("provider", resolve(&provider)?)] { // Start every pending measurement before answering, so one later read finds them all. binaries.insert(role.to_owned(), (measures.identity)(&path, &resolve, input)?); } @@ -139,6 +152,142 @@ pub fn status(store: &Store, desired: &DesiredSubject, incarnation: Option<&str> mod tests { use super::*; use std::os::unix::fs::PermissionsExt as _; + fn isolated_capture(store: &Store, desired: &DesiredSubject, ready: bool, input: &dyn Fn(&Path) -> Result<()>) -> Result> { + let environment = |declared: &BTreeMap, _: &Path| Ok(Some(declared.clone())); + let identity = move |path: &Path, resolve: &dyn Fn(&str) -> Result, input: &dyn Fn(&Path) -> Result<()>| { + if ready { + fractal_drivers::harness_admission::executable_identity_ready_observed(path, resolve, input) + } else { + fractal_drivers::harness_admission::executable_identity_with(path, resolve).map(Some) + } + }; + capture_with(store, desired, desired.member.as_ref().unwrap(), false, &Measures { + environment: &environment, + identity: &identity, + }, input) + } + + fn managed_fixture(harness: &str, stable_profile: bool) -> (tempfile::TempDir, DesiredSubject, std::path::PathBuf) { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let profile = if stable_profile { + root.path().join(".local/state/nix/profiles/coding-agent-runtime/share/coding-agents/profile.json") + } else { + root.path().join("profile.json") + }; + std::fs::create_dir_all(profile.parent().unwrap()).unwrap(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": { + "generalist": {"harnessBin": bin}, + "steward": {"harnessBin": bin}, + }}, + })).unwrap()).unwrap(); + for (program, source) in [(harness, "#!/usr/bin/env node\nmanaged provider\n"), ("node", "#!/bin/sh\nmanaged interpreter\n")] { + let path = bin.join(program); + std::fs::write(&path, source).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let mut desired = crate::graph::parse_test_intent(&format!( + "version 2\nagent \"worker\" {{ workspace {:?}; harness {harness:?} {{ model \"model-a\"; }} }}", + root.path().to_str().unwrap(), + ), "node").unwrap().subjects.into_values().next().unwrap(); + let member = desired.member.as_mut().unwrap(); + member.environment = BTreeMap::from([ + ("HOME".into(), root.path().display().to_string()), + ("PATH".into(), root.path().join("raw/bin").display().to_string()), + ]); + if !stable_profile { + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + } + (root, desired, bin) + } + + #[test] + fn typed_managed_contract_measures_wrapper_without_raw_provider() { + for harness in ["omp", "claude", "codex"] { + for stable_profile in [false, true] { + let (root, mut desired, bin) = managed_fixture(harness, stable_profile); + let store = Store::open_memory("node").unwrap(); + let seen = std::cell::RefCell::new(Vec::new()); + let original = isolated_capture(&store, &desired, false, &|path| { + seen.borrow_mut().push(path.to_owned()); Ok(()) + }).unwrap().unwrap(); + assert!(seen.borrow().contains(&bin.join(harness))); + assert!(!seen.borrow().contains(&root.path().join("raw/bin").join(harness))); + let LaunchSpec::Argv(argv) = &mut desired.member.as_mut().unwrap().launch else { panic!("typed argv") }; + let position = argv.iter().position(|arg| arg == "--").unwrap() + 1; + argv[position] = root.path().join("unavailable").join(harness).display().to_string(); + let explicit = isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + assert_eq!(original.binary, explicit.binary, "an unavailable explicit raw provider cannot bypass the wrapper"); + let ready_inputs = std::cell::RefCell::new(Vec::new()); + if let Some(ready) = isolated_capture(&store, &desired, true, &|path| { + ready_inputs.borrow_mut().push(path.to_owned()); Ok(()) + }).unwrap() { + assert_eq!(ready, explicit); + } + assert!(ready_inputs.borrow().contains(&bin.join(harness))); + assert!(ready_inputs.borrow().contains(&bin.join("node"))); + std::fs::write(bin.join(harness), "#!/usr/bin/env node\nchanged managed provider\n").unwrap(); + let changed = isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + assert_ne!(explicit.binary, changed.binary, "wrapper content is the provider identity"); + std::fs::write(bin.join("node"), "#!/bin/sh\nchanged managed interpreter\n").unwrap(); + assert_ne!(changed.binary, isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap().binary, + "wrapper shebang uses the provider's prefixed PATH"); + } + } + } + + #[test] + fn managed_contract_uses_environment_persona_and_refuses_unknown_persona() { + let (root, mut desired, bin) = managed_fixture("omp", false); + let store = Store::open_memory("node").unwrap(); + std::fs::write(root.path().join("profile.json"), serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + desired.member.as_mut().unwrap().environment.insert("AGENT_PERSONA".into(), "steward".into()); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + desired.member.as_mut().unwrap().environment.insert("AGENT_PERSONA".into(), "generalist".into()); + assert!(format!("{:#}", isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap_err()) + .contains("personas.launch.generalist.harnessBin")); + } + + #[test] + fn untyped_provider_named_like_managed_harness_keeps_native_identity() { + let (root, mut desired, bin) = managed_fixture("omp", false); + let store = Store::open_memory("node").unwrap(); + let member = desired.member.as_mut().unwrap(); + member.driver = None; + member.launch = LaunchSpec::Argv(vec![bin.join("omp").display().to_string()]); + member.environment.remove("AGENT_RUNTIME_PROFILE"); + member.environment.insert("HOME".into(), root.path().join("no-profile").display().to_string()); + // Native env shebang resolution still needs its authored interpreter PATH. + member.environment.insert("PATH".into(), bin.display().to_string()); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + } + + #[test] + fn person_bound_contract_keeps_native_provider_without_a_managed_profile() { + let (root, mut desired, bin) = managed_fixture("claude", false); + let store = Store::open_memory("node").unwrap(); + let binding = crate::model::TerminalBinding { + subject: "pty/person/avery/019a0000-0000-7000-8000-000000000001".into(), + incarnation: "42:created".into(), + id: "019a0000-0000-7000-8000-000000000002".into(), + }; + desired.desired["children"].as_array_mut().unwrap().push(serde_json::json!({ + "name": "bind-terminal", "arguments": [binding.subject], + "properties": {"incarnation": binding.incarnation, "id": binding.id}, "children": [], + })); + let member = desired.member.as_mut().unwrap(); + member.lifecycle = crate::model::MemberLifecycle::TerminalBound; + member.terminal_binding = Some(binding); + member.environment.remove("AGENT_RUNTIME_PROFILE"); + member.environment.insert("HOME".into(), root.path().join("no-profile").display().to_string()); + member.environment.insert("PATH".into(), bin.display().to_string()); + isolated_capture(&store, &desired, false, &|_| Ok(())).unwrap().unwrap(); + } + #[test] fn relative_provider_and_runtime_path_use_the_prepared_member() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-reconcile/src/reconcile.rs b/crates/fractal-reconcile/src/reconcile.rs index a41d7a8e4c2..efd9d4e2d44 100644 --- a/crates/fractal-reconcile/src/reconcile.rs +++ b/crates/fractal-reconcile/src/reconcile.rs @@ -2910,7 +2910,7 @@ impl Reconciler { if choosing.is_empty() { return Ok(BTreeMap::new()); } - let mut orders = self.store.seat_run_orders()?; + let mut orders = self.store.seat_run_orders_for_mutation()?; orders.retain(|agent, _| choosing.contains(agent.as_str())); Ok(orders) } @@ -6136,7 +6136,7 @@ impl Reconciler { } let run_order = if seat_chooses_between_runs(agent, &work) { - self.store.seat_run_order(agent)? + self.store.seat_run_order_for_mutation(agent)? } else { Vec::new() }; @@ -6144,7 +6144,7 @@ impl Reconciler { // The last durable automatic wake records the head already announced; // using its subject in the key also permits returning to an earlier head. let steps = work.iter().map(crate::seat_queue::SeatStep::from).collect::>(); - let selection = crate::seat_queue::select(agent, &steps, &run_order); + let selection = crate::seat_queue::select_literal(agent, &steps, &run_order); let last_wake = messages.iter().filter(|message| { !crate::silent::message_tag_values( message.tags.iter().map(String::as_str), "wake-source:", @@ -20266,11 +20266,6 @@ fn work_wake_deadline( }) .collect::>(); work.iter() - .filter(|step| { - step.assigned_to - .as_ref() - .is_some_and(|assignee| local_agents.contains(assignee)) - }) .filter(|step| wakes.contains(step.subject.as_str())) .filter_map(|step| { let wake = step.wake.as_ref()?; @@ -20336,7 +20331,7 @@ fn seat_chooses_between_runs(agent: &str, work: &[StepRunView]) -> bool { .iter() .map(crate::seat_queue::SeatStep::from) .collect::>(); - let selection = crate::seat_queue::select(agent, &steps, &[]); + let selection = crate::seat_queue::select_literal(agent, &steps, &[]); if !selection.held.is_empty() { return false; } @@ -20359,7 +20354,7 @@ fn next_work_wake_for_agent<'a>( .iter() .map(crate::seat_queue::SeatStep::from) .collect::>(); - crate::seat_queue::select(agent, &steps, run_order).wake() + crate::seat_queue::select_literal(agent, &steps, run_order).wake() } /// Inherited work is woken only after an early parent submission. A turn that is still working can @@ -20375,7 +20370,7 @@ fn defers_inherited_work_wake( fn nested_under_work(step: &StepRunView, work: &[StepRunView]) -> bool { let nested = crate::seat_queue::SeatStep::from(step); work.iter().any(|ancestor| { - crate::seat_queue::nests_under(&crate::seat_queue::SeatStep::from(ancestor), &nested) + crate::seat_queue::nests_under_literal(&crate::seat_queue::SeatStep::from(ancestor), &nested) }) } @@ -24075,7 +24070,7 @@ agent "worker" { workspace "/tmp"; command "true" } mission "nested-wake" state="ready" { goal "Complete nested work." step "outer" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" mission "work" { goal "Complete the child work." step "first" { } @@ -24809,7 +24804,7 @@ version 2 goal "Complete mission product." completion { when "all-steps-exhausted" } step "publish" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" produces { resource "mission-run/${ST_MISSION_RUN}/change" { kind "custom.st3.product-test" @@ -24889,7 +24884,7 @@ version 2 .append_claim(&ClaimInput { subject: format!("resource/mission-run/{}/change", run.id), kind: "resource.observed".into(), - actor: Some("agent/worker".into()), + actor: Some("agent/node.worker".into()), fields: BTreeMap::from([ ( "kind".into(), @@ -24916,6 +24911,80 @@ version 2 ); } + #[test] + fn stored_mission_declarations_preserve_legacy_invalid_assignee_normalization() { + let store = Arc::new(Store::open_memory("node").unwrap()); + let source = r#"version 2 +mission "legacy-declarations" state="ready" { + goal "Materialize declarations stored before canonical agent IDs." + step "wait" { agentless } +} +"#; + let declarations = r#"version 2 +mission "legacy-child" state="ready" { + goal "Preserve historical assignee normalization." + step "empty-agent-name" { assigned-to "agent/" } + step "whitespace" { assigned-to "example/worker name" } +} +"#; + // Persist the historical declaration source without admitting it through today's parser. + let mut intent = parse_intent(source, "node").unwrap(); + intent.missions.get_mut("legacy-declarations").unwrap().declarations_kdl = + Some(declarations.into()); + let planned = store + .mission( + &intent, + crate::model::IntentInput { + kdl: source.into(), + source_name: None, + }, + ) + .unwrap(); + store + .apply(&intent, &planned.subject_tokens, "publish-legacy-declarations") + .unwrap(); + let mission = store + .mission_spec("legacy-declarations", None) + .unwrap() + .unwrap(); + assert_eq!(mission.declarations_kdl.as_deref(), Some(declarations)); + let run = store + .create_mission_run(&crate::model::MissionRunRequest { + mission: "legacy-declarations".into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/test".into()), + mode: Some("run".into()), + inputs: BTreeMap::new(), + idempotency_key: "run-legacy-declarations".into(), + }) + .unwrap(); + let reconciler = Reconciler::new( + store.clone(), + Arc::new(FakeRuntime::default()), + "node".into(), + Arc::new(Notify::new()), + ); + assert!( + reconciler + .materialize_mission_declarations(&run, &mission, None) + .unwrap() + ); + let nested = store.mission_spec("legacy-child", None).unwrap().unwrap(); + assert_eq!( + nested.steps["empty-agent-name"].work_selector, + Some(WorkSelector::Assigned { + agent: "agent/node.".into(), + }) + ); + assert_eq!( + nested.steps["whitespace"].work_selector, + Some(WorkSelector::Assigned { + agent: "example/worker name".into(), + }) + ); + } + #[test] fn a_step_uses_the_exact_mission_revision_produced_by_an_earlier_step() { let store = Arc::new(Store::open_memory("node").unwrap()); @@ -24927,11 +24996,11 @@ version 2 goal "Complete mission bootstrap." completion { when "all-steps-exhausted" } step "compile" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" produces-mission "project/work" } step "execute" { - assigned-to "agent/planner" + assigned-to "agent/node.planner" depends-on { step "compile" completed } uses-mission output-of="compile" } @@ -25168,7 +25237,7 @@ version 2 mission "assignment" state="ready" { goal "Complete mission assignment." - step "work" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } } "#, @@ -27295,22 +27364,80 @@ agent "import/omp/fixture" {{ .expect("a target mutation must still wake reconciliation"); } + #[test] + fn managed_launch_inputs_remain_watched_when_profile_or_wrapper_is_missing() { + use std::os::unix::fs::PermissionsExt as _; + let root = tempfile::tempdir().unwrap(); + let profile = root.path().join("profile.json"); + let desired = crate::graph::parse_test_intent(&format!("version 2\nagent \"seat\" {{ workspace {:?}; harness \"claude\" {{ model \"model-a\"; }} }}", root.path().to_str().unwrap()), "node").unwrap().subjects.into_values().next().unwrap(); + let store = Arc::new(Store::open_memory("node").unwrap()); + let reconciler = Reconciler::new(store.clone(), Arc::new(FakeRuntime::default()), "node".into(), Arc::new(Notify::new())); + let mut member = desired.member.clone().unwrap(); + member.environment.insert("PATH".into(), root.path().display().to_string()); + member.environment.insert("AGENT_PERSONA".into(), "generalist".into()); + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + crate::contract::install(&store, Box::new(move |_| Ok(member.clone()))); + assert!(crate::contract::declared(&store, &desired).is_err()); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err()); + let profile_key = format!("launch-input/{}", profile.display()); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&profile_key)); + assert!(!reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("claude").display())), + "a missing managed profile must not fall back to raw claude"); + + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + reconciler.incremental.touch(&profile_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err()); + let wrapper = bin.join("claude"); + let wrapper_key = format!("launch-input/{}", wrapper.display()); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + reconciler.incremental.touch(&wrapper_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(crate::contract::declared_ready(&store, &desired).is_err(), + "a non-executable wrapper must remain refused and watched"); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + reconciler.incremental.touch(&wrapper_key); + reconciler.watch_launch_inputs(&desired).unwrap(); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&wrapper_key)); + } + #[test] fn launch_input_measurement_failure_is_unknown_and_subscription_faults_target_daemon() { use std::os::unix::fs::PermissionsExt as _; let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("claude"); + // Managed seats measure the selected wrapper and its interpreter, not raw PATH claude. + std::fs::write(&wrapper, "#!/usr/bin/env missing-claude-interpreter\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); let desired = crate::graph::parse_test_intent(&format!("version 2\nagent \"seat\" {{ workspace {:?}; harness \"claude\" {{ model \"model-a\"; }} }}", root.path().to_str().unwrap()), "node").unwrap().subjects.into_values().next().unwrap(); let store = Arc::new(Store::open_memory("node").unwrap()); let reconciler = Reconciler::new(store.clone(), Arc::new(FakeRuntime::default()), "node".into(), Arc::new(Notify::new())); let mut member = desired.member.clone().unwrap(); member.environment.insert("PATH".into(), root.path().display().to_string()); + member.environment.insert("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()); + member.environment.insert("AGENT_PERSONA".into(), "generalist".into()); let member = Arc::new(Mutex::new(member)); let prepared = member.clone(); crate::contract::install(&store, Box::new(move |_| Ok(prepared.lock().unwrap().clone()))); - assert!(crate::contract::declared(&store, &desired).is_err()); // Settle the environment, not the missing provider. + assert!(crate::contract::declared(&store, &desired).is_err()); // Settle the environment, not the missing interpreter. reconciler.watch_launch_inputs(&desired).unwrap(); assert!(crate::contract::declared_ready(&store, &desired).is_err()); - assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("claude").display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", wrapper.display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", root.path().join("missing-claude-interpreter").display()))); let scope = format!("stage/launch-inputs/{}", desired.subject); let denied = root.path().join("denied"); std::fs::create_dir(&denied).unwrap(); @@ -27324,7 +27451,7 @@ agent "import/omp/fixture" {{ } std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(reconciler.isolate(&scope, "daemon/node", || reconciler.watch_launch_inputs(&desired)).is_some()); - assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", denied.join("claude").display()))); + assert!(reconciler.file_watchers.lock().unwrap().contains_key(&format!("launch-input/{}", denied.join("missing-claude-interpreter").display()))); assert!(store.reconcile_fault("daemon/node", &scope).unwrap().is_none()); } @@ -29782,7 +29909,7 @@ mission "proof" state="ready" {{ goal "Land the change." completion {{ when "all-steps-exhausted" }} step "land" {{ - assigned-to "agent/worker" + assigned-to "agent/node.worker" goal "Land the change on main." gate "the suite passes on main" {{ exec {gate:?} @@ -30792,7 +30919,7 @@ mission "publish" state="ready" { r#"version 2 mission "asks" state="ready" { goal "Ask a person for a decision while working." - step "work" { assigned-to "agent/asker" } + step "work" { assigned-to "agent/node.asker" } } "#, "asks-mission", @@ -34983,7 +35110,7 @@ mission "absent-stop" state="ready" { mission "release" state="ready" { goal "Complete mission release." step "publish" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" depends-on { field "decision" "resource/approval" "is" "approve" } @@ -49357,6 +49484,23 @@ mission "ios-proof-blocked" state="ready" { updated_at_unix_ms: 1, person_answers: Vec::new(), }; + let legacy = step("step-run/run-1/legacy", "legacy", "node/worker"); + assert_eq!( + next_work_wake_for_agent("agent/node/worker", std::slice::from_ref(&legacy), &[]), + None, + "main does not wake a canonical seat for a legacy-only assignment" + ); + let canonical = step("step-run/run-1/canonical", "canonical", "agent/node/worker"); + assert_eq!( + next_work_wake_for_agent("agent/node/worker", std::slice::from_ref(&canonical), &[]), + Some(canonical.subject.as_str()), + "a literal assignment still wakes its seat" + ); + assert_eq!( + next_work_wake_for_agent("daemon/node/worker", std::slice::from_ref(&legacy), &[]), + None, + "recognized non-agent kinds remain distinct" + ); let parent = step("step-run/run-1/build", "build", "agent/builder"); let inherited = step( "step-run/run-1/build/work/inspect", @@ -49522,6 +49666,18 @@ mission "ios-proof-blocked" state="ready" { updated_at_unix_ms: 10, person_answers: Vec::new(), }; + let mut legacy = first.clone(); + legacy.assigned_to = Some("node/worker".into()); + assert_eq!( + work_wake_deadline( + &[legacy], + &BTreeSet::from(["agent/node/worker".into()]), + &BTreeMap::new(), + 2_000, + ), + None, + "main gives no canonical wake deadline to a legacy-only assignment" + ); let mut second = first.clone(); second.subject = "step-run/newer/work".into(); second.run = "mission-run/newer".into(); @@ -50382,6 +50538,7 @@ mission "fresh" state="ready" { struct SeatQueueFixture { store: Arc, reconciler: Reconciler, + agent: &'static str, } impl SeatQueueFixture { @@ -50390,12 +50547,23 @@ mission "fresh" state="ready" { } fn with_store(store: Arc) -> Self { - apply_source(&store, SEAT_QUEUE_SOURCE, "seat-queue-missions"); + Self::with_store_for_agent(store, SEAT) + } + + fn with_store_for_agent(store: Arc, agent: &'static str) -> Self { + let source = if agent == SEAT { + SEAT_QUEUE_SOURCE.to_owned() + } else { + SEAT_QUEUE_SOURCE + .replace("agent \"worker\"", &format!("agent \"{}\"", agent.strip_prefix("agent/").unwrap())) + .replace(SEAT, agent) + }; + apply_source(&store, &source, "seat-queue-missions"); let desired = store .desired_subjects() .unwrap() .into_iter() - .find(|subject| subject.subject == SEAT) + .find(|subject| subject.subject == agent) .unwrap(); let runtime = Arc::new(FakeRuntime::default()); runtime.ptys.lock().unwrap().push(RuntimeObservation { @@ -50414,9 +50582,9 @@ mission "fresh" state="ready" { reconciler.reconcile_once().unwrap(); store .append_claim(&ClaimInput { - subject: SEAT.into(), + subject: agent.into(), kind: "harness.observed".into(), - actor: Some(SEAT.into()), + actor: Some(agent.into()), fields: BTreeMap::from([ ("state".into(), Value::String("ready".into())), ("driver".into(), Value::String("codex".into())), @@ -50427,7 +50595,7 @@ mission "fresh" state="ready" { idempotency_key: Some("seat-one-ready".into()), }) .unwrap(); - Self { store, reconciler } + Self { store, reconciler, agent } } /// Start a run a little later than the last so join times differ. @@ -50459,7 +50627,7 @@ mission "fresh" state="ready" { } fn queue(&self) -> crate::model::SeatQueueView { - self.store.seat_queue(SEAT).unwrap() + self.store.seat_queue(self.agent).unwrap() } fn order(&self) -> Vec { @@ -50479,7 +50647,7 @@ mission "fresh" state="ready" { .store .agent_work_queues() .unwrap() - .remove(SEAT) + .remove(self.agent) .and_then(|queue| queue.next_work_id); assert_eq!(queue.next_work_id, roster, "the roster disagrees"); queue.next_work_id @@ -50487,15 +50655,15 @@ mission "fresh" state="ready" { /// The step the reconciler wakes now, or none while the seat is held. fn wake(&self) -> Option { - let work = self.store.work_for_reconcile(SEAT).unwrap(); - let order = self.store.seat_run_order(SEAT).unwrap(); - next_work_wake_for_agent(SEAT, &work, &order).map(str::to_owned) + let work = self.store.work_for_reconcile(self.agent).unwrap(); + let order = self.store.seat_run_order_for_mutation(self.agent).unwrap(); + next_work_wake_for_agent(self.agent, &work, &order).map(str::to_owned) } /// Steps named by open wake messages, oldest first. fn woken(&self) -> Vec { self.store - .messages(Some(SEAT), false) + .messages(Some(self.agent), false) .unwrap() .into_iter() .filter_map(|message| { @@ -50514,7 +50682,7 @@ mission "fresh" state="ready" { ) -> Result { self.store .move_seat_queue_run(&crate::model::SeatQueueMoveRequest { - agent: SEAT.into(), + agent: self.agent.into(), run: run.into(), placement: placement.into(), anchor: anchor.map(str::to_owned), @@ -50531,7 +50699,7 @@ mission "fresh" state="ready" { action, &crate::model::WorkRequest { wait_ms: None, - actor: Some(SEAT.into()), + actor: Some(self.agent.into()), incarnation: Some("seat-one".into()), summary: None, reason: None, @@ -50543,6 +50711,66 @@ mission "fresh" state="ready" { } } + #[test] + fn wake_order_ignores_alias_join_times_and_agrees_with_claim_admission() { + const SEAT: &str = "agent/node/worker"; + struct Clock; + impl Drop for Clock { + fn drop(&mut self) { + smallclaims::store::set_thread_clock(None); + } + } + smallclaims::store::set_thread_clock(Some(1)); + let _clock = Clock; + let store = Arc::new(Store::open_memory("node").unwrap()); + store.set_write_clock_at(1).unwrap(); + let seat = SeatQueueFixture::with_store_for_agent(store, SEAT); + let canonical_source = SEAT_QUEUE_SOURCE + .replace("agent \"worker\"", "agent \"node/worker\"") + .replace("agent/node.worker", SEAT); + let legacy_source = canonical_source.replace(SEAT, "node/worker"); + apply_source(&seat.store, &legacy_source, "literal-wake-legacy-definition"); + let a = seat.start("queued", "literal-wake-a"); + let legacy = a.steps.iter().find(|step| step.step == "work").unwrap(); + assert_eq!(legacy.assigned_to.as_deref(), Some("node/worker")); + assert_eq!(legacy.created_at_unix_ms, 1); + smallclaims::store::set_thread_clock(Some(10)); + seat.store.set_write_clock_at(10).unwrap(); + apply_source(&seat.store, &canonical_source, "literal-wake-canonical-definition"); + let b = seat.start("queued", "literal-wake-b"); + let b_work = SeatQueueFixture::step(&b, "work"); + smallclaims::store::set_thread_clock(Some(20)); + seat.store.set_write_clock_at(20).unwrap(); + // A new generation joins the literal seat at 20, while its old + // generation keeps the alias join at 1 for presentation. + let revised_source = canonical_source.replace("step \"work\"", "step \"literal\""); + apply_source(&seat.store, &revised_source, "literal-wake-revision"); + let revision = seat.store.mission_spec("queued", None).unwrap().unwrap(); + let a = seat.store.adopt_mission_revision( + &a.id, &revision, "person/requester", "join the literal seat", + "literal-wake-adopt", + ).unwrap(); + seat.reconciler.reconcile_once().unwrap(); + let a_work = SeatQueueFixture::step(&a, "literal"); + assert_eq!(a.steps.iter().find(|step| step.subject == a_work).unwrap().created_at_unix_ms, 20); + assert_eq!(b.steps.iter().find(|step| step.subject == b_work).unwrap().created_at_unix_ms, 10); + assert_eq!(seat.order(), SeatQueueFixture::runs(&[&a, &b]), + "presentation still merges the earlier alias join"); + let expected = SeatQueueFixture::runs(&[&b, &a]); + assert_eq!(seat.store.seat_run_order_for_mutation(SEAT).unwrap(), expected); + let work = seat.store.work_for_reconcile_all().unwrap(); + let orders = seat.reconciler.wake_run_orders( + &BTreeSet::from([SEAT.to_owned()]), &work, + ).unwrap(); + assert_eq!(orders.get(SEAT), Some(&expected), + "deadline order uses only literal joins too"); + assert_eq!(seat.wake(), Some(b_work.clone())); + seat.reconciler.reconcile_once().unwrap(); + assert_eq!(seat.woken().last(), Some(&b_work), + "the actual wake agrees with the literal admission order"); + seat.work(&b_work, "claim", "literal-wake-claim-b").unwrap(); + } + /// Step 2b: once every member can ask the seat's host, an unchanged working re-stamp stays in /// the host's local log. A wake into a turn that was already working is still taken up by the /// next working report after it was sent, which the host hears locally. diff --git a/crates/fractal-reconcile/src/reconcile/run_report.rs b/crates/fractal-reconcile/src/reconcile/run_report.rs index ca9dfa9f4e9..c4fd64d4c96 100644 --- a/crates/fractal-reconcile/src/reconcile/run_report.rs +++ b/crates/fractal-reconcile/src/reconcile/run_report.rs @@ -45,24 +45,41 @@ impl RunEvent { } } -/// The last sign of life in a run: its own last change, any step's last change or progress, and -/// the end of any step's lease. +/// The last sign of life in a run: its own last change and each step's last sign of life. pub fn run_activity(run: &MissionRunView) -> u128 { run.steps .iter() - .flat_map(|step| { - [ - Some(step.updated_at_unix_ms), - step.progress_at_unix_ms, - step.claim_expires_at_unix_ms, - ] - }) + .flat_map(step_activity) .flatten() .chain([run.created_at_unix_ms, run.updated_at_unix_ms]) .max() .unwrap_or_default() } +/// A step's signs of life. A step nobody holds changes only when something happens to it, so +/// its last change counts. A held step is different: its holder's driver renews the lease every +/// minute, and each renewal moves both the lease end and the step's last change, so neither says +/// that work is being done. A held step shows life through the start of its current execution +/// interval (its claim, or the end of its last wait), its last progress record, and a declared +/// wait until that wait ends on its own. +fn step_activity(step: &StepRunView) -> [Option; 3] { + let held = is_held(step); + let declared = [ + step.execution_started_at_unix_ms, + step.execution_paused_until_unix_ms, + ]; + if held && declared.iter().any(Option::is_some) { + [declared[0], step.progress_at_unix_ms, declared[1]] + } else { + [Some(step.updated_at_unix_ms), step.progress_at_unix_ms, None] + } +} + +/// Whether a worker holds the step, so that its driver renews the step's lease. +fn is_held(step: &StepRunView) -> bool { + step.claimant.is_some() && matches!(step.status.as_str(), "claimed" | "working") +} + /// Whether a run is still working, so that silence means something. A run in cleanup is ending, /// and a run that waits for the run before it has not started. fn can_stall(run: &MissionRunView) -> bool { @@ -77,7 +94,7 @@ impl Reconciler { /// register every run and step read, including nested work, so progress moves the stall /// deadline on the next incremental evaluation rather than waiting for a scan. fn run_report_activity(&self, run: &MissionRunView) -> Result { - let mut activity = run_activity(run); + let mut activity = run_activity(run).max(self.held_progress(run)?); let mut pending = run .steps .iter() @@ -89,13 +106,35 @@ impl Reconciler { if !visited.insert(child.subject.clone()) { continue; } - activity = activity.max(run_activity(&child)); + activity = activity + .max(run_activity(&child)) + .max(self.held_progress(&child)?); pending.extend(child.steps.into_iter().map(|step| step.subject)); } } Ok(activity) } + /// The last progress record of the steps a worker holds. A run read for evaluation does not + /// carry step progress, and a held step's last change cannot stand in for it because lease + /// renewals move it, so read each held step's own record. + fn held_progress(&self, run: &MissionRunView) -> Result { + let mut progress = 0; + for step in &run.steps { + if step.progress_at_unix_ms.is_some() || !is_held(step) { + continue; + } + if let Some(at) = self + .store + .step_run(&step.subject)? + .and_then(|step| step.progress_at_unix_ms) + { + progress = progress.max(at); + } + } + Ok(progress) + } + /// Who a run reports to: its latest `st missions report-to`, else what it was created with. pub fn run_report(&self, run: &str) -> Result> { let claim = match self diff --git a/crates/fractal-reconcile/src/reconcile/tests/differential.rs b/crates/fractal-reconcile/src/reconcile/tests/differential.rs index 52029007984..6c49ecdf1a5 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/differential.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/differential.rs @@ -48,7 +48,7 @@ mission "diff/flow" state="ready" timeout="2h" { depends-on { step "review" completed } } step "work" { - assigned-to "agent/worker" + assigned-to "agent/node.worker" depends-on { step "prepare" completed } } step "team" { diff --git a/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs b/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs index bd74867b9a2..e6e4fdd27f7 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/final_cancel_missing_assignee.rs @@ -12,7 +12,7 @@ mission "heron/close-out" state="ready" { gate "work waits" { field "state" "resource/heron-work" is "ready" } } finally { - step "close-out" { assigned-to "agent/heron-requester" } + step "close-out" { assigned-to "agent/node.heron-requester" } } }"#; diff --git a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs index eb057e211c2..5903b83d7dc 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/run_report_tests.rs @@ -57,6 +57,21 @@ mission "absent-reporter" state="ready" report-to="agent/node.nobody" { goal "Name an agent that is not declared." step "build" { assigned-to "agent/node.builder" } } +mission "gated" state="ready" report-to="agent/node.reporter" stalled-after="10m" { + concurrent-runs + goal "Wait on a person and on a fact." + step "review" { + agentless + gate "accept" type="human" { + reviewer "person/example" + question "Accept this?" + } + } + step "observe" { + agentless + gate "signal" { field "status" "resource/loop-result" is "ready" } + } +} mission "loop-parent" state="ready" report-to="agent/node.reporter" stalled-after="10m" { concurrent-runs goal "Watch progress in child rounds." @@ -283,10 +298,11 @@ fn a_quiet_parent_counts_child_round_progress_and_then_reports_real_silence() { ); assert_eq!( fixture.reconciler.incremental.next_due(&parent.subject), - Some(progress_at + 20 * MINUTE) + Some(progress_at + 10 * MINUTE), + "the lease that progress renewed for ten minutes is not a sign of life" ); - // The live worker's progress renews its lease for ten minutes. Release it before - // checking true silence, so a later lease-expiry transition cannot count as activity. + // Release the step before checking true silence, so the stall is the parent's own and a + // later lease-expiry transition cannot count as activity. fixture .store .work_action( @@ -577,6 +593,162 @@ fn a_stalled_run_is_reported_once_and_again_only_after_more_progress() { ); } +/// A live seat's driver renews its lease every minute. Renewals move the lease end and the step's +/// last change, and neither is work: a held step that records nothing stalls like any other. +#[test] +fn a_held_step_that_only_renews_its_lease_stalls() { + let (_clock, fixture) = fixture(); + let run = fixture.start("watched", "held-quiet-run"); + let build = Fixture::step(&run, "build"); + fixture.passes(3); + let builder = fixture + .reconciler + .runtime + .started_members + .lock() + .unwrap() + .iter() + .find(|member| member.runtime_id.ends_with("builder")) + .unwrap() + .clone(); + fixture + .reconciler + .runtime + .ptys + .lock() + .unwrap() + .push(RuntimeObservation { + runtime_id: builder.runtime_id, + terminal: builder.terminal, + status: "running".into(), + exit_code: None, + incarnation_id: Some("quiet-worker".into()), + }); + fixture.passes(1); + let act = |action: &str, key: &str, summary: Option<&str>| { + fixture + .store + .work_action( + &build, + action, + &crate::model::WorkRequest { + actor: Some("agent/node.builder".into()), + incarnation: Some("quiet-worker".into()), + summary: summary.map(str::to_owned), + reason: None, + evidence: Vec::new(), + wait_ms: None, + idempotency_key: key.into(), + }, + ) + .unwrap(); + }; + let stalled = || { + fixture + .reports(REPORTER) + .into_iter() + .filter(|message| message.tags.contains(&"st3-run-report:stalled".to_owned())) + .count() + }; + let claimed_at = start() + MINUTE; + { + let _at = Clock::at(&fixture.store, claimed_at); + act("claim", "claim-quiet", None); + fixture.passes(2); + } + for minute in 1..=9 { + let _at = Clock::at(&fixture.store, claimed_at + minute * MINUTE); + act("renew", &format!("renew-quiet-{minute}"), None); + fixture.passes(1); + } + let held = fixture.store.step_run(&build).unwrap().unwrap(); + assert!(held.claim_expires_at_unix_ms.unwrap() > claimed_at + 10 * MINUTE); + assert_eq!(stalled(), 0, "nine minutes into a ten-minute limit"); + assert_eq!( + fixture.reconciler.incremental.next_due(&run.subject), + Some(claimed_at + 10 * MINUTE), + "the limit counts from the claim, not from the renewed lease" + ); + { + let _at = Clock::at(&fixture.store, claimed_at + 11 * MINUTE); + act("renew", "renew-quiet-11", None); + fixture.passes(2); + assert_eq!(stalled(), 1); + } + { + // More renewals are the same silence, so the same event. + let _at = Clock::at(&fixture.store, claimed_at + 12 * MINUTE); + act("renew", "renew-quiet-12", None); + fixture.passes(2); + assert_eq!(stalled(), 1); + } + { + let _at = Clock::at(&fixture.store, claimed_at + 13 * MINUTE); + act("progress", "progress-quiet", Some("Building.")); + fixture.passes(2); + assert_eq!(stalled(), 1, "progress is a sign of life"); + } + // Keep the ten-minute lease alive through the next silence, as a driver does. + for minute in 14..=22 { + let _at = Clock::at(&fixture.store, claimed_at + minute * MINUTE); + act("renew", &format!("renew-quiet-{minute}"), None); + fixture.passes(1); + assert_eq!(stalled(), 1, "minute {minute} is inside the limit after progress"); + } + let _at = Clock::at(&fixture.store, claimed_at + 24 * MINUTE); + act("renew", "renew-quiet-24", None); + fixture.passes(2); + assert_eq!(stalled(), 2, "silence after progress is a new stall"); +} + +/// The runtime holds an agentless gate step without a claimant or a lease, so it is not a held +/// step and nothing renews it: its last change is its sign of life, as before lease renewals +/// stopped counting. A run that only waits on open gates therefore stalls at its limit, once; +/// `report-stalls=#false` is how a run that intends such a wait says so. +#[test] +fn a_gate_step_the_runtime_holds_is_not_a_held_step() { + let (_clock, fixture) = fixture(); + let run = fixture.start("gated", "gated-run"); + fixture.passes(4); + let view = fixture.store.mission_run(&run.id).unwrap().unwrap(); + assert_eq!(view.steps.len(), 2); + for step in &view.steps { + assert!(step.agentless, "{}", step.step); + assert_eq!(step.status, "working", "{}", step.step); + assert!(step.claimant.is_none(), "{}", step.step); + assert!(step.claim_expires_at_unix_ms.is_none(), "{}", step.step); + } + // No held step: the stall clock is the newest change of the run or of a step. + let quiet_since = crate::reconcile::run_report::run_activity(&view); + let last_change = view + .steps + .iter() + .map(|step| step.updated_at_unix_ms) + .chain([view.created_at_unix_ms, view.updated_at_unix_ms]) + .max() + .unwrap(); + assert_eq!(quiet_since, last_change); + assert_eq!( + fixture.reconciler.incremental.next_due(&run.subject), + Some(quiet_since + 10 * MINUTE) + ); + let stalled = || { + fixture + .reports(REPORTER) + .into_iter() + .filter(|message| message.tags.contains(&"st3-run-report:stalled".to_owned())) + .count() + }; + { + let _inside = Clock::at(&fixture.store, quiet_since + 9 * MINUTE); + fixture.passes(2); + assert_eq!(stalled(), 0); + } + let _later = Clock::at(&fixture.store, quiet_since + 3 * 24 * 60 * MINUTE); + fixture.passes(3); + assert_eq!(stalled(), 1, "one report, however long the gates stay open"); +} + #[test] fn a_stall_is_a_deadline_on_the_run_not_a_scan() { let (_clock, fixture) = fixture(); diff --git a/crates/fractal-runtime/src/pty.rs b/crates/fractal-runtime/src/pty.rs index c48ed4e55ee..b4518e127b6 100644 --- a/crates/fractal-runtime/src/pty.rs +++ b/crates/fractal-runtime/src/pty.rs @@ -510,6 +510,28 @@ impl PtyRuntime { guard: Option<&dyn Fn() -> Result<()>>, producer_entered: &mut bool, ) -> Result<()> { + // Only API-created default terminals opt into home resolution. A seat or mission + // workspace of "." retains its ordinary working-directory semantics. + let home = if cwd == Path::new(".") + && tags.get("st3.cwd").is_some_and(|value| value == "home") + { + if let Some(home) = std::env::home_dir() { + anyhow::ensure!( + home.is_absolute(), + "default terminal home directory must be absolute" + ); + Some(home) + } else { + tracing::warn!( + runtime_id = id, + "default terminal home is unavailable; retaining '.' for shell-side home resolution" + ); + None + } + } else { + None + }; + let cwd = home.as_deref().unwrap_or(cwd); // The lane is the scope's, never the harness's environment. let mut env = env.clone(); let capacity = crate::lane::Capacity::take(&mut env); @@ -2205,6 +2227,71 @@ exit 0 } } + #[test] + fn default_terminal_forwards_absolute_host_home_as_cwd() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-home-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + runtime + .spawn( + "work", + &Launch::Argv(vec!["true".into()]), + Path::new("."), + &BTreeMap::new(), + None, + &BTreeMap::from([("st3.cwd".into(), "home".into())]), + None, + ) + .unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); + assert!(Path::new(cwd).is_absolute(), "{arguments}"); + assert_eq!(Path::new(cwd), std::env::home_dir().unwrap()); + } + + #[test] + fn explicit_terminal_cwd_is_forwarded_unchanged() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-explicit-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + spawn_work(&runtime, root.path(), &BTreeMap::new()).unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); + assert_eq!(Path::new(cwd), root.path()); + } + + #[test] + fn untagged_dot_workspace_is_forwarded_unchanged() { + let root = tempfile::tempdir().unwrap(); + let binary = fake_pty(root.path(), "fake-pty-dot-cwd", " publish new"); + let runtime = + PtyRuntime::new(root.path().join("registry")).with_binary(binary.to_string_lossy()); + + spawn_work(&runtime, Path::new("."), &BTreeMap::new()).unwrap(); + + let arguments = fs::read_to_string(binary.with_extension("args")).unwrap(); + let cwd = arguments + .lines() + .skip_while(|arg| *arg != "--cwd") + .nth(1) + .unwrap(); + assert_eq!(cwd, "."); + } + #[test] fn spawn_preserves_an_explicit_terminal_type() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-schema/retention.toml b/crates/fractal-schema/retention.toml index 4300b393d00..0b9e35c7229 100644 --- a/crates/fractal-schema/retention.toml +++ b/crates/fractal-schema/retention.toml @@ -121,6 +121,7 @@ "loop.state" = { class = "transition", window = "7d" } "message.closed" = { class = "durable" } "message.delivered" = { class = "transition", window = "7d", pending = "delivery state reads it until the message closes" } +"message.expired" = { class = "durable" } "message.read" = { class = "transition", window = "7d", pending = "unread state reads it until the message closes" } "message.sent" = { class = "durable" } "message.staged" = { class = "transition", window = "7d", pending = "delivery state reads it until the message closes" } diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 8b822a5387e..a3121e225a5 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -35,6 +35,49 @@ use sha2::{Digest as _, Sha256}; pub const SCHEMA_NAME: &str = "st3.v1"; +/// Canonicalize an actor used for agent work without changing an explicit actor kind. +/// Bare names need a host before admission; legacy slash-qualified names need `agent/`. +pub fn canonical_agent_actor(id: &str) -> Option> { + if id.chars().any(char::is_whitespace) { + return None; + } + let (kind, name) = id.split_once('/')?; + if kind.is_empty() || name.is_empty() { + return None; + } + if matches!(kind, "agent" | "person" | "daemon" | "host" | "plugin") { + Some(std::borrow::Cow::Borrowed(id)) + } else { + Some(std::borrow::Cow::Owned(format!("agent/{id}"))) + } +} + +#[cfg(test)] +mod canonical_agent_actor_tests { + use super::canonical_agent_actor; + + #[test] + fn preserves_actor_kinds_and_prefixes_legacy_agent_ids() { + for id in ["agent/example/worker", "person/reviewer", "daemon/scheduler"] { + assert_eq!(canonical_agent_actor(id).as_deref(), Some(id)); + } + assert_eq!( + canonical_agent_actor("example/worker").as_deref(), + Some("agent/example/worker") + ); + for id in ["", "worker", "agent/", "person/", "/worker", "example/worker name"] { + assert!(canonical_agent_actor(id).is_none(), "{id:?}"); + } + } + + #[test] + fn preserves_host_and_plugin_principals() { + for id in ["host/example", "plugin/example"] { + assert_eq!(canonical_agent_actor(id).as_deref(), Some(id)); + } + } +} + /// Local SQLite compatibility is separate from the replicated claim vocabulary. pub use fractal_storage_version::STORAGE_VERSION; pub const LOCAL_STORAGE_SCHEMA: &str = /* sqlite-large-value: allow-for-now lv-h98d3e9599416 owner=fractal_schema TODO=chunk-or-reference-variable-columns */ r#"## Local checkpoint capture storage @@ -1143,6 +1186,12 @@ fn build_registry() -> Registry { "One bounded loop execution.", false, ), + ( + "objective", + "objective/NAME", + "A declared objective: an interval over claims, its targets, window and breach response.", + false, + ), ( "owned-set", "owned-set/NAME", @@ -1562,6 +1611,7 @@ fn claim_specs() -> BTreeMap { "lane", "message", "observer", + "objective", "mission", "mission-run", "planning-session", @@ -2518,6 +2568,15 @@ fn claim_specs() -> BTreeMap { true, &[], ), + ( + "message.expired", + &["message"], + WritePolicy::SystemOnly, + Cardinality::Once, + Some("messages"), + true, + &[], + ), ( "planning-session.started", &["planning-session"], @@ -4206,6 +4265,10 @@ fn claim_fields(kind: &str) -> BTreeMap { ], "message.read" => &[("status", required_enum(&["read"])), ("acting_for", reference_to(&["agent"]))], "message.closed" => &[("status", required_enum(&["closed"])), ("acting_for", reference_to(&["agent"]))], + "message.expired" => &[ + ("status", required_enum(&["expired"])), + ("reason", required_enum(&["cutoff-never-offered", "cutoff-uncertain-attempt", "no-live-recipient"])), + ], "planning-session.started" => &[ ("mission", reference()), ("request", reference()), @@ -4885,10 +4948,13 @@ mod tests { #[test] fn assistant_principal_registry_digest_transition() { - // Reconstruct the registry at the principal transition, before usage observations. + // Reconstruct the principal-transition registry, excluding subsequently added kinds. let mut current = registry().clone(); current.claims.remove(usage_limits::KIND).unwrap(); current.claims.remove("client.operation-authorized").unwrap(); + current.subjects.remove("objective").unwrap(); + current.claims.get_mut("intent.desired").unwrap().source_kdl.retain(|kind| kind != "objective"); + current.claims.remove("message.expired").unwrap(); assert_eq!(current.subjects["assistant"].pattern, "assistant/{chatgpt|mcp}[/session/assistant-grant/ID]"); let mut previous = current.clone(); previous.subjects.get_mut("assistant").unwrap().pattern = diff --git a/crates/fractal-seats/src/external_sessions.rs b/crates/fractal-seats/src/external_sessions.rs index 0854e7e872d..ebe2aeb9fed 100644 --- a/crates/fractal-seats/src/external_sessions.rs +++ b/crates/fractal-seats/src/external_sessions.rs @@ -2817,6 +2817,101 @@ mod tests { #[cfg(target_os = "linux")] use fractal_store::external_sessions::shared_tests_seats::path_executable; + #[test] + fn native_session_discovery_is_instance_scoped() { + use fractal_kernel::instance; + const CHILD: &str = "FRACTAL_TEST_NATIVE_INSTANCE_HOME"; + if let Ok(case) = std::env::var(CHILD) { + let login_home = PathBuf::from(std::env::var_os("HOME").unwrap()); + let write_session = |home: &Path, id: &str| { + let directory = home.join(".codex/sessions/2026/11/10"); + fs::create_dir_all(&directory).unwrap(); + fs::write( + directory.join(format!("rollout-{id}.jsonl")), + format!("{}\n", json!({"type":"session_meta", "payload":{"id":id, "cwd":home}})), + ).unwrap(); + }; + write_session(&login_home, "login-only"); + let write_omp = |profile: &Path, id: &str| { + let directory = profile.join("sessions/project"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join(format!("{id}.jsonl")), + format!("{}\n", json!({"type":"session", "id":id, "cwd":login_home}))).unwrap(); + }; + write_omp(&login_home.join(".omp/agent"), "login-omp"); + if case == "instance" { + // SAFETY: isolated single-test subprocess; no runtime or other readers exist. + let selected = unsafe { instance::activate("trial") }.unwrap(); + let native = instance::native_session_home().unwrap().unwrap(); + assert_eq!(native, selected.native_session_home()); + fs::create_dir_all(selected.codex_home()).unwrap(); + write_session(&native, "instance-only"); + let home = Some(native.as_path()); + assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home.clone())); + let profile = PathBuf::from(std::env::var_os("PI_CODING_AGENT_DIR").unwrap()); + assert_eq!(profile, selected.pi_agent_dir()); + write_omp(&profile, "instance-omp"); + assert_eq!(instance::current(), Some(selected.clone())); + assert_eq!(PathBuf::from(std::env::var_os("CODEX_HOME").unwrap()), selected.codex_home()); + assert_eq!(fs::canonicalize(native.join(".codex")).unwrap(), fs::canonicalize(selected.codex_home()).unwrap()); + assert_eq!(fs::read_link(native.join(".claude")).unwrap(), selected.claude_config_dir()); + assert_eq!(fs::read_link(native.join(".omp/agent")).unwrap(), selected.pi_agent_dir()); + assert!(!native.join(".local/share").exists()); + // Repeated activation leaves shell-tool HOME and profile selection unchanged. + unsafe { instance::activate("trial") }.unwrap(); + assert_eq!(instance::current(), Some(selected)); + assert_eq!(std::env::var_os("HOME").map(PathBuf::from), Some(login_home)); + // Inventory and search use these discovery entrypoints, including the cached history. + for discovery in [ + crate::external_sessions::discover_fresh(home, true).unwrap(), + crate::external_sessions::discover(home, true).unwrap(), + ] { + assert_eq!(discovery.sessions.len(), 2); + assert!(discovery.sessions.iter().any(|session| session.native_id == "instance-only")); + assert!(discovery.sessions.iter().any(|session| session.native_id == "instance-omp" + && session.driver == ExternalDriver::Omp)); + } + let login_id = crate::external_sessions::external_session_id( + crate::external_sessions::ExternalDriver::Codex, "login-only", + ); + assert!(crate::external_sessions::find_conversation(home, &login_id).unwrap().is_none()); + } else { + assert!(instance::current().is_none()); + let native = instance::native_session_home().unwrap().unwrap(); + assert_eq!(native, login_home); + assert_eq!(std::env::var_os("PI_CODING_AGENT_DIR"), + Some(login_home.join("ambient-profile").into_os_string())); + let discovery = crate::external_sessions::discover_fresh(Some(&native), true).unwrap(); + assert_eq!(discovery.sessions.len(), 2); + assert!(discovery.sessions.iter().any(|session| session.native_id == "login-only")); + assert!(discovery.sessions.iter().any(|session| session.native_id == "login-omp")); + } + return; + } + for case in ["default", "instance"] { + let home = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "external_sessions::tests::native_session_discovery_is_instance_scoped", "--nocapture", "--test-threads=1"]) + .env(CHILD, case) + .env("HOME", home.path()) + .env("PI_CODING_AGENT_DIR", home.path().join("ambient-profile")) + .env_remove("ST_INSTANCE") + .env_remove("FRACTAL_INSTANCE") + .output().unwrap(); + assert!(output.status.success(), "{case}: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + } + } + + #[test] + fn instance_native_discovery_rejects_conflicting_alias() { + let home = tempfile::tempdir().unwrap(); + let instance = fractal_kernel::instance::Instance::new(home.path(), "trial").unwrap(); + let conflicting = instance.native_session_home().join(".claude"); + fs::create_dir_all(&conflicting).unwrap(); + let error = instance.prepare_native_session_home().unwrap_err(); + assert!(format!("{error:#}").contains(&format!("read native session alias {}", conflicting.display()))); + } + #[cfg(target_os = "linux")] #[test] fn omp_old_hook_recovery_requires_exact_live_lineage_account_and_header() { diff --git a/crates/fractal-seats/src/store/tests.rs b/crates/fractal-seats/src/store/tests.rs index fdd48784a04..df5c755f9d8 100644 --- a/crates/fractal-seats/src/store/tests.rs +++ b/crates/fractal-seats/src/store/tests.rs @@ -1605,8 +1605,8 @@ version 2 mission "lease" state="ready" { goal "Complete mission lease." - step "work" { assigned-to "agent/worker" } - step "other" { assigned-to "agent/worker" } + step "work" { assigned-to "agent/node.worker" } + step "other" { assigned-to "agent/node.worker" } } "#, diff --git a/crates/fractal-store/src/graph.rs b/crates/fractal-store/src/graph.rs index 1d96f20fc65..533e878436a 100644 --- a/crates/fractal-store/src/graph.rs +++ b/crates/fractal-store/src/graph.rs @@ -1,10 +1,11 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use kdl::{KdlDocument, KdlNode, KdlValue}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; +use crate::declaration_kdl::canonical_node; pub use crate::declaration_syntax::parse_duration; #[cfg(test)] use crate::model::GateSpec; @@ -26,6 +27,7 @@ const ROOT_NODES: &[&str] = &[ "mission-run", "planning-session", "message", + "objective", "repair", "schedule", "stop", @@ -380,6 +382,12 @@ fn parse_desired_node( "an account declaration must be at the root", )); } + if kind == "objective" && context.owner_run.is_some() { + return Err(FractalError::new( + "objective-inside-mission", + "an objective declaration must be at the root", + )); + } match kind { "host" => parse_host(node, context), "agent" => parse_agent(node, enclosing_host, context), @@ -1688,6 +1696,7 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re validate_name(&name, false)?; match kind { "account" => validate_account(node)?, + "objective" => validate_objective(node)?, "doc" => validate_doc(node)?, "resource" => validate_resource(node)?, "observer" => validate_observer(node)?, @@ -1703,6 +1712,8 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re } let subject = match kind { "doc" => format!("doc/{name}"), + // The kernel reader names the subject, so `objective "objective/x"` is not `objective/x`. + "objective" => format!("objective/{name}"), "lane" | "observer" | "subscription" | "schedule" if context.owner_run.is_some() => { format!( "{kind}/{}/{}", @@ -1742,6 +1753,23 @@ fn parse_structure(node: &KdlNode, kind: &str, context: &mut ParseContext) -> Re Ok(()) } +/// An objective a fleet declares: the kernel's strict reader, and only an interval over claims +/// named `NAMESPACE/NAME`, since native telemetry counts toward the objectives Fractal ships. +fn validate_objective(node: &KdlNode) -> Result<(), FractalError> { + let objective = crate::objective::parse(&canonical_node(node)?)?; + if !objective.declarable() { + return Err(FractalError::new( + "objective-not-declarable", + format!( + "objective `{}`: a fleet declares an `interval` objective named NAMESPACE/NAME; `{}` indicators and one-segment names are the objectives Fractal ships", + objective.name, + objective.indicator.kind() + ), + )); + } + Ok(()) +} + fn validate_account(node: &KdlNode) -> Result<(), FractalError> { ensure_no_properties(node)?; one_string_with_children(node)?; @@ -4428,54 +4456,6 @@ fn insert_subject(context: &mut ParseContext, subject: DesiredSubject) -> Result Ok(()) } -fn canonical_node(node: &KdlNode) -> Result { - let mut properties = BTreeMap::::new(); - let mut arguments = Vec::new(); - for entry in node.entries() { - let value = json_value(entry.value())?; - if let Some(name) = entry.name() { - if properties.insert(name.value().into(), value).is_some() { - return Err(FractalError::new( - "duplicate-property", - format!( - "node `{}` repeats property `{}`", - node.name().value(), - name.value() - ), - )); - } - } else { - arguments.push(value); - } - } - let children = node - .children() - .map(|document| { - document - .nodes() - .iter() - .map(canonical_node) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - let mut output = Map::new(); - output.insert("name".into(), Value::String(node.name().value().into())); - if !arguments.is_empty() { - output.insert("arguments".into(), Value::Array(arguments)); - } - if !properties.is_empty() { - output.insert( - "properties".into(), - serde_json::to_value(properties).expect("BTreeMap serializes"), - ); - } - if !children.is_empty() { - output.insert("children".into(), Value::Array(children)); - } - Ok(Value::Object(output)) -} - fn hash_json(value: &Value) -> String { let bytes = serde_json::to_vec(value).expect("normalized JSON serializes"); hex::encode(Sha256::digest(bytes)) @@ -4801,6 +4781,72 @@ version 2 ); } + const OBJECTIVE: &str = r#" +version 2 + +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + label "class" + } + window "7d" min-samples=3 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +} +"#; + + #[test] + fn an_objective_is_a_root_declaration_the_kernel_reads() { + let intent = parse_intent(OBJECTIVE, "node").unwrap(); + let desired = &intent.subjects["objective/ops/change-rollout"]; + assert_eq!((desired.kind.as_str(), desired.member.is_none()), ("objective", true)); + let objective = crate::objective::parse(&desired.desired).unwrap(); + assert_eq!(objective.name, "ops/change-rollout"); + assert!(objective.declarable()); + + let nested = parse_execution_intent(OBJECTIVE, "node", "run-1").expect_err("a run cannot own an objective"); + assert_eq!(nested.code, "objective-inside-mission"); + let body = OBJECTIVE.trim_start().trim_start_matches("version 2").trim(); + for inside in [ + format!("version 2\nmission \"bad\" state=\"ready\" {{\n goal \"No.\"\n {body}\n step \"work\" {{ agentless }}\n}}\n"), + format!("version 2\nmission \"bad\" state=\"ready\" {{\n goal \"No.\"\n step \"work\" {{\n {body}\n }}\n}}\n"), + ] { + assert_eq!(parse_intent(&inside, "node").unwrap_err().code, "objective-inside-mission", "{inside}"); + } + } + + #[test] + fn only_an_interval_named_namespace_slash_name_is_declarable() { + let one_segment = OBJECTIVE.replace("\"ops/change-rollout\"", "\"change-rollout\""); + assert_eq!(parse_intent(&one_segment, "node").unwrap_err().code, "objective-not-declarable"); + + let native = r#" +version 2 +objective "ops/statement" { + about "One SQL statement" + indicator "statement" + target "p99" within="25ms" + target "max" within="250ms" +} +"#; + assert_eq!(parse_intent(native, "node").unwrap_err().code, "objective-not-declarable"); + + for broken in [ + OBJECTIVE.replace(" window \"7d\" min-samples=3\n", ""), + OBJECTIVE.replace("target \"p95\" within=\"5m\"", "target \"p95\" max=5"), + OBJECTIVE.replace(" label \"class\"\n", " label \"class\"\n retry \"x\"\n"), + OBJECTIVE.replace("backup=\"agent/ops/observability\"", "backup=\"agent/ops/release-manager\""), + ] { + assert_eq!(parse_intent(&broken, "node").unwrap_err().code, "invalid-objective", "{broken}"); + } + } + #[test] fn a_missions_agent_names_an_account_the_way_a_top_level_seat_does() { let mission = |harness_body: &str| { diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 27cfcb22876..ccc4fa8ba5d 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -16,6 +16,7 @@ macro_rules! message_desired_recipient_match { // Store methods by domain (the crate plan's domain rules); core stays in this file. pub mod attention; pub mod delivery; +pub mod message_expiry; pub mod deploy; mod device_renewal; pub mod evidence; @@ -227,6 +228,7 @@ use smallclaims::windows::holds; pub mod accounts; +pub mod declared_objectives; pub mod runtime_starts; pub use runtime_starts::RuntimeStartOperation; pub mod roster_admission; @@ -4316,10 +4318,28 @@ impl Store { before_index: Option, descending: bool, limit: usize, + ) -> Result { + self.claims_page_matching_with_kinds( + filter, owner_run, after_index, before_index, descending, limit, None, + ) + } + + /// Match `filter` and restrict allowed kinds in SQL before selecting or decoding page rows. + /// `None` leaves kinds unrestricted; an empty allowlist selects no claims. + #[allow(clippy::too_many_arguments)] + pub fn claims_page_matching_with_kinds( + &self, + filter: ClaimsFilter<'_>, + owner_run: Option<&str>, + after_index: u64, + before_index: Option, + descending: bool, + limit: usize, + allowed_kinds: Option<&[&str]>, ) -> Result { let (subject, lower, upper) = claim_pages::subject_match(filter)?; let connection = self.readers.get(); - let query = claims_page_query(subject, filter.kind.is_some(), descending); + let query = claims_page_query(subject, filter.kind.is_some(), descending, allowed_kinds.is_some()); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-h2080eb040134 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &query)?; // The owner filter is evaluated per subject below. Other pages can stop // in SQLite at one row past the requested bound, before decoding bodies. @@ -4328,8 +4348,9 @@ impl Store { } else { i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX) }; - let values: [&dyn rusqlite::ToSql; 6] = - [&after_index, &before_index, &lower, &scan_limit, &filter.kind, &upper]; + let allowed_kinds_json = allowed_kinds.map(serde_json::to_string).transpose()?; + let values: [&dyn rusqlite::ToSql; 7] = + [&after_index, &before_index, &lower, &scan_limit, &filter.kind, &upper, &allowed_kinds_json]; // Each query names its highest parameter; the unused ones below it stay NULL. let bound = statement.parameter_count(); let rows = statement.query_map( @@ -11247,6 +11268,10 @@ fn validate_message_transition( transaction: &Transaction<'_>, input: &ClaimInput, ) -> Result { + if input.kind == message_expiry::KIND { + message_expiry::validate_actor(&input.kind, input.actor.as_deref())?; + return message_expiry::validate_transition(transaction, &input.subject); + } let requested = match input.kind.as_str() { "message.sent" => "sent", "message.staged" => "staged", @@ -11313,6 +11338,8 @@ fn validate_message_transition( .filter(|kind| *kind == "message") .map(|_| "sent") }; + let late_consumption = matches!(requested, "delivered" | "read") + && message_expiry::exists(transaction, &input.subject).map_err(internal)?; if requested != "sent" && current == Some(requested) && !(requested == "read" && input.fields.contains_key("acting_for")) { if input.fields.contains_key("delegation") { @@ -11328,7 +11355,7 @@ fn validate_message_transition( (Some("delivered" | "read" | "closed"), "staged") | (Some("read" | "closed"), "delivered") | (Some("closed"), "read") - ) && input.actor.is_some() + ) && input.actor.is_some() && !late_consumption { let index: Option = transaction .query_row("SELECT created_index FROM message_index WHERE subject=?1", [&input.subject], |row| row.get(0)) @@ -11351,7 +11378,7 @@ fn validate_message_transition( | (Some("sent"), "delivered") | (Some("delivered"), "read") | (Some("read"), "closed") - ); + ) || (late_consumption && matches!(current, Some("sent" | "staged" | "read" | "closed"))); let custody_read = requested == "read" && input.fields.contains_key("acting_for") && matches!(current, Some("sent" | "staged" | "read")); @@ -11481,6 +11508,10 @@ pub fn append_claim_tx_with_assistant_answer( } // Daemon notifications already inside a larger transaction use the same admission seam // as API sends. Replication inserts authenticated history through its separate import path. + if kind == message_expiry::KIND { + return Err(anyhow::Error::new(FractalError::new( + "invalid-message-expiry", "expiry requires runtime receipt admission"))); + } if kind == retirements::KIND { return Err(anyhow::Error::new(retirements::writer_required())); } @@ -11513,6 +11544,33 @@ pub fn append_claim_tx_with_assistant_answer( ) } +// LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 +// Expansion preserves main's writer spelling. A later spelling switch needs a durable +// one-way fence and a historical reader floor before this bridge can be contracted. +fn legacy_work_claimant<'a>(kind: &str, fields: &'a BTreeMap) -> Option<&'a str> { + if !matches!(kind, "work.claimed" | "work.renewed" | "work.progress" | "work.extended") { + return None; + } + fields.get("claimant").and_then(Value::as_str).filter(|claimant| { + fractal_schema::canonical_agent_actor(claimant).is_some_and(|canonical| { + canonical.starts_with("agent/") && canonical.as_ref() != *claimant + }) + }) +} + +fn replicated_work_validation_fields<'a>( + kind: &str, + fields: &'a BTreeMap, +) -> std::borrow::Cow<'a, BTreeMap> { + let Some(claimant) = legacy_work_claimant(kind, fields) else { + return std::borrow::Cow::Borrowed(fields); + }; + let mut validation = fields.clone(); + validation.insert("claimant".into(), json!(fractal_schema::canonical_agent_actor(claimant))); + std::borrow::Cow::Owned(validation) +} +// LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership + #[allow(clippy::too_many_arguments)] /* sqlite-large-value-flow: allow-for-now flow-h1f241b496964 owner=fractal_store::store TODO=prepare-body-and-predecessor-values */ fn append_admitted_claim_tx( transaction: &Transaction<'_>, @@ -16106,6 +16164,9 @@ pub fn classify_replicated_claim_with_registry( claim: &ClaimRecord, registry: &fractal_schema::Registry, ) -> Result { + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + // Signed legacy ownership can arrive before its row. Projection checks eligibility. + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership if matches!(claim.kind.as_str(), "agent.launcher-bound" | "agent.helper-launched" | "client.action-result") { return Err(FractalError::new("invalid-replicated-claim", "helper launcher authority, launch history, and client action receipts cannot be admitted from peers")); @@ -16123,7 +16184,8 @@ pub fn classify_replicated_claim_with_registry( ), ) })?; - if let Err(error) = registry.validate_replicated_claim(&claim.subject, &claim.kind, &fields) { + let validation_fields = replicated_work_validation_fields(&claim.kind, &fields); + if let Err(error) = registry.validate_replicated_claim(&claim.subject, &claim.kind, &validation_fields) { // A peer may already publish a field introduced by a newer schema. Keep // that authenticated record retryable so an upgrade can admit the // original claim without replacing or rewriting it. @@ -16138,6 +16200,7 @@ pub fn classify_replicated_claim_with_registry( ), )); } + message_expiry::validate_actor(&claim.kind, claim.actor.as_deref())?; identities::validate_replicated(claim)?; if claim.kind == retirements::KIND && (fields.get("owner_host").and_then(Value::as_str) != Some(claim.origin.as_str()) @@ -18593,6 +18656,22 @@ pub fn project_mission_run_update( if current_attempt != Some(claim_attempt) { return Ok(()); } + // LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership — DELETE at contraction — https://github.com/compoundingtech/fractal/pull/5582 + let validation_fields = schema_fields_for_body(&claim.kind, &claim.body).map_err(internal)?; + if let Some(claimant) = legacy_work_claimant(&claim.kind, &validation_fields) { + let (assignee, available, agentless): (Option, String, bool) = transaction.query_row( + "SELECT assignee, available_to, agentless FROM step_runs WHERE subject=?1", + [&claim.subject], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ).map_err(internal)?; + let available: Vec = serde_json::from_str(&available).map_err(internal)?; + if agentless || !(work_actor_matches(assignee.as_deref(), claimant) + || available.iter().any(|candidate| work_actor_matches(Some(candidate), claimant))) + { + return Err(FractalError::new("invalid-work-claimant", + "legacy work claimant is not eligible for the target step")); + } + } + // LIVE-MIGRATION END arn:lmig:fractal:2026-11-07-agent-actor-ownership if claim.kind == "work.handoff-returned" { transaction.prepare_cached( /* sqlite-large-value: allow-for-now lv-h26512c176d95 owner=fractal_store::store TODO=bound-or-reference-values-and-reclamation */ @@ -19208,7 +19287,7 @@ impl RosterStepRow { } } -/// The held and ready agent steps of seat `?1`, or of every seat when null. The unfinished-step +/// The held and ready agent steps of seat spellings `?1`, or of every seat when null. The unfinished-step /// predicate, word for word that of `step_runs_open_index`, reads the fleet's open steps rather /// than every step the store has run. const SEAT_STEP_ROWS: &str = "SELECT subject, run_id, step_path, status, assignee, lease_owner, @@ -19217,7 +19296,8 @@ const SEAT_STEP_ROWS: &str = "SELECT subject, run_id, step_path, status, assigne WHERE agentless=0 AND status IN ('ready', 'claimed', 'working', 'verifying') AND status NOT IN ('completed','failed','cancelled') - AND (?1 IS NULL OR assignee=?1 OR lease_owner=?1) + AND (?1 IS NULL OR assignee IN (SELECT value FROM json_each(?1)) + OR lease_owner IN (SELECT value FROM json_each(?1))) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) AND NOT ((status='ready' OR (status IN ('claimed','working','verifying') AND lease_expires_at_unix_ms IS NOT NULL @@ -19255,15 +19335,15 @@ impl SeatQueueInputs { } } -/// When each seat `?1` (every seat when null) joined each open run it has a step in, and the +/// When each seat spelling in `?1` (every seat when null) joined each open run it has a step in, and the /// run's status. It reads from the open runs to their steps, in that order: from the steps, /// SQLite reads every step any seat was ever assigned before it finds the open runs among them. -/// Every seat's queue moves (`?2` null), or one seat's, that no repair replaced, in graph order. +/// Every seat's queue moves (`?2` null), or one seat's spelling array, that no repair replaced, in graph order. pub fn seat_queue_moves_query() -> String { canonical_sql( "SELECT claims.id, claims.subject, claims.actor, claims.body, claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id - WHERE claims.kind=?1 AND (?2 IS NULL OR claims.subject=?2) + WHERE claims.kind=?1 AND (?2 IS NULL OR claims.subject IN (SELECT value FROM json_each(?2))) AND NOT EXISTS ( SELECT 1 FROM replica_records WHERE replica_records.claim_id=claims.id @@ -19276,30 +19356,49 @@ pub fn seat_queue_moves_query() -> String { pub fn seat_queue_joins_query() -> String { format!( "SELECT step_runs.assignee, step_runs.run_id, - MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status + MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status, + MAX(step_runs.generation_id=mission_runs.current_generation_id) FROM mission_runs CROSS JOIN step_runs ON step_runs.run_id=mission_runs.id WHERE {OPEN_MISSION_RUN} AND mission_runs.phase<>'terminal' AND step_runs.agentless=0 AND step_runs.assignee IS NOT NULL - AND (?1 IS NULL OR step_runs.assignee=?1) + AND (?1 IS NULL OR step_runs.assignee IN (SELECT value FROM json_each(?1))) GROUP BY step_runs.assignee, step_runs.run_id - HAVING MAX(step_runs.generation_id=mission_runs.current_generation_id)=1 ORDER BY step_runs.assignee, step_runs.run_id" ) } -/// When seat `?1` joined run `?2`, a run its queue moves name that is no longer queued, and the +/// When a seat spelling in `?1` joined run `?2`, a run its queue moves name that is no longer queued, and the /// run's status. `+step_runs.assignee` keeps SQLite on the run's own steps (`step_runs_run_index`): /// through the assignee index it read every step the seat was ever assigned, once per named run, /// and a seat that has moved its queue often names a hundred runs on every claim. pub const SEAT_QUEUE_NAMED_RUN_QUERY: &str = "SELECT MIN(CAST(step_runs.created_at_unix_ms AS INTEGER)), mission_runs.status FROM step_runs JOIN mission_runs ON mission_runs.id=step_runs.run_id - WHERE +step_runs.assignee=?1 AND step_runs.run_id=?2 AND step_runs.agentless=0"; + WHERE +step_runs.assignee IN (SELECT value FROM json_each(?1)) + AND step_runs.run_id=?2 AND step_runs.agentless=0"; + +pub use crate::seat_queue::work_actor_matches; + +/// Exact persisted spellings of one logical work actor, as a JSON array for indexed SQL reads. +/// Only a prefixless name that canonicalizes back to the same actor is an alias. +pub fn work_actor_spellings_json(actor: &str) -> String { + let Some(canonical) = fractal_schema::canonical_agent_actor(actor) else { + return "[]".into(); + }; + let legacy = canonical.strip_prefix("agent/").filter(|legacy| { + fractal_schema::canonical_agent_actor(legacy).as_deref() == Some(canonical.as_ref()) + }); + match legacy { + Some(legacy) => json!([canonical, legacy]).to_string(), + None => json!([canonical]).to_string(), + } +} -/// [`work_at_snapshot_query`] for one named actor `?1`. The reconciler reads each running seat's -/// steps on every pass; the actor's steps are found through the assignee and lease indexes, and +/// [`work_at_snapshot_query`] for one actor's JSON spelling array `?1`. +/// The reconciler reads each running seat's steps on every pass; the actor's steps are found +/// through the assignee and lease indexes, and /// the few steps offered to several seats through their own index, instead of scanning every /// step the store ever ran. pub fn seat_work_at_snapshot_query(open_only: bool) -> String { @@ -19313,10 +19412,10 @@ pub fn seat_work_at_snapshot_query(open_only: bool) -> String { lease_owner, lease_incarnation, lease_expires_at_unix_ms, blocked_reason, not_before_unix_ms, created_at_unix_ms, updated_at_unix_ms, readiness_epoch, constraints FROM step_runs WHERE rowid IN ( - SELECT rowid FROM step_runs WHERE assignee=?1 - UNION SELECT rowid FROM step_runs WHERE lease_owner=?1 + SELECT rowid FROM step_runs WHERE assignee IN (SELECT value FROM json_each(?1)) + UNION SELECT rowid FROM step_runs WHERE lease_owner IN (SELECT value FROM json_each(?1)) UNION SELECT rowid FROM step_runs WHERE available_to!='[]' - AND EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value=?1) + AND EXISTS (SELECT 1 FROM json_each(step_runs.available_to) WHERE value IN (SELECT value FROM json_each(?1))) ) AND (agentless=0 OR (?3 AND ?1 IS NULL)) AND generation_id=(SELECT current_generation_id FROM mission_runs WHERE id=step_runs.run_id) @@ -19339,6 +19438,14 @@ pub fn seat_queue_inputs_tx( seat_queue_inputs_of_tx(connection, agent, None) } +/// Queue inputs for mutation admission, without merging actor aliases. +pub fn seat_queue_inputs_literal_tx( + connection: &Connection, + agent: Option<&str>, +) -> Result> { + seat_queue_inputs_matching_tx(connection, agent, None, false) +} + #[cfg(any(test, feature = "test-support"))] thread_local! { /// Named-run probes `seat_queue_inputs_of_tx` made on this thread, for tests. @@ -19358,9 +19465,22 @@ pub fn seat_queue_inputs_of_tx( agent: Option<&str>, only: Option<&BTreeSet>, ) -> Result> { + seat_queue_inputs_matching_tx(connection, agent, only, true) +} + +fn seat_queue_inputs_matching_tx( + connection: &Connection, + agent: Option<&str>, + only: Option<&BTreeSet>, + aliases: bool, +) -> Result> { + let actor_spellings = |actor: &str| { + if aliases { work_actor_spellings_json(actor) } else { json!([actor]).to_string() } + }; + let spellings = agent.map(actor_spellings); let mut seats = BTreeMap::::new(); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-hd2995294a599 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &seat_queue_moves_query())?; - let rows = statement.query_map(params![seat_queue::MOVED_CLAIM, agent], |row| { + let rows = statement.query_map(params![seat_queue::MOVED_CLAIM, spellings], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -19371,6 +19491,12 @@ pub fn seat_queue_inputs_of_tx( })?; for row in rows { let (claim_id, subject, actor, body, accepted_at) = row?; + let subject = if aliases { + fractal_schema::canonical_agent_actor(&subject) + .map(std::borrow::Cow::into_owned).unwrap_or(subject) + } else { + subject + }; let body = serde_json::from_str::(&body).unwrap_or(Value::Null); let fields = body.get("fields").unwrap_or(&body); let text = |name: &str| fields.get(name).and_then(Value::as_str).map(str::to_owned); @@ -19407,24 +19533,31 @@ pub fn seat_queue_inputs_of_tx( drop(statement); let mut statement = connection.prepare(/* sqlite-large-value: allow-for-now lv-h9270afe17ad4 owner=fractal_store::store TODO=trace-statement-and-value-representation */ &seat_queue_joins_query())?; - let rows = statement.query_map(params![agent], |row| { + let rows = statement.query_map(params![spellings], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?, + row.get::<_, bool>(4)?, )) })?; for row in rows { - let (seat, run_id, joined_at, status) = row?; + let (seat, run_id, joined_at, status, current) = row?; + let seat = if aliases { + fractal_schema::canonical_agent_actor(&seat) + .map(std::borrow::Cow::into_owned).unwrap_or(seat) + } else { + seat + }; let run = format!("mission-run/{run_id}"); let inputs = seats.entry(seat).or_default(); - inputs.joins.push(QueueJoin { - run: run.clone(), - at_unix_ms: u128::try_from(joined_at).unwrap_or_default(), - }); + let at_unix_ms = u128::try_from(joined_at).unwrap_or_default(); + inputs.joins.push(QueueJoin { run: run.clone(), at_unix_ms }); inputs.statuses.insert(run.clone(), status); - inputs.live.insert(run); + if current { + inputs.live.insert(run); + } } drop(statement); @@ -19432,7 +19565,17 @@ pub fn seat_queue_inputs_of_tx( if let Some(only) = only { seats.retain(|seat, _| only.contains(seat)); } + seats.retain(|_, inputs| !inputs.live.is_empty() || !inputs.moves.is_empty()); for (seat, inputs) in &mut seats { + inputs.joins.retain(|join| inputs.live.contains(&join.run)); + // Mixed spellings can contribute different generations of the same run. + // Keep their earliest join, but only current-generation membership makes it live. + inputs.joins.sort_unstable_by(|left, right| { + left.run.cmp(&right.run).then(left.at_unix_ms.cmp(&right.at_unix_ms)) + }); + inputs.joins.dedup_by(|left, right| left.run == right.run); + inputs.statuses.retain(|run, _| inputs.live.contains(run)); + let spellings = actor_spellings(seat); let moves = inputs .moves .iter() @@ -19445,7 +19588,7 @@ pub fn seat_queue_inputs_of_tx( #[cfg(any(test, feature = "test-support"))] NAMED_RUN_PROBES.with(|probes| probes.set(probes.get() + 1)); let joined = statement - .query_row(/* sqlite-large-value: allow-for-now lv-h79c5fdb57d22 owner=fractal_store::store TODO=trace-statement-and-value-representation */ params![seat, run_id], |row| { + .query_row(/* sqlite-large-value: allow-for-now lv-hf01b6d5cecf5 owner=fractal_store::store TODO=trace-statement-and-value-representation */ params![spellings, run_id], |row| { Ok(( row.get::<_, Option>(0)?, row.get::<_, Option>(1)?, @@ -22721,8 +22864,9 @@ pub fn verify_replica_batch(batch: &ReplicaBatch) -> Result<(), FractalError> { ), ) })?; + let validation_fields = replicated_work_validation_fields(&claim.kind, &fields); fractal_schema::registry() - .validate_claim(&claim.subject, &claim.kind, &fields) + .validate_claim(&claim.subject, &claim.kind, &validation_fields) .map_err(|error| { FractalError::new( "invalid-replicated-claim", @@ -22796,6 +22940,8 @@ pub struct ClaimCommitContext<'a> { pub retirement_writer: bool, /// Only rename_identity supplies naming CAS, owner and fleet registry admission. pub identity_writer: bool, + /// Runtime append owns cross-file expiry/receipt serialization. + pub expiry_writer: bool, pub admission: Option<&'a ClaimAdmission<'a>>, pub assistant: Option<&'a assistant_write::AssistantWriteAuthorization>, pub native_receipt: Option<&'a crate::conversation_delivery::NativeReceipt>, @@ -22852,6 +22998,10 @@ pub fn append_claim_with_commit_context( context: ClaimCommitContext<'_>, ) -> Result<(ClaimRecord, bool), FractalError> { let admission = |connection: &Connection| { + message_expiry::validate_actor(&input.kind, input.actor.as_deref())?; + if input.kind == message_expiry::KIND && !context.expiry_writer { + return Err(FractalError::new("invalid-message-expiry", "expiry requires runtime receipt admission")); + } if input.kind == retirements::KIND && !context.retirement_writer { return Err(retirements::writer_required()); } @@ -22970,6 +23120,8 @@ pub fn append_claim_with_commit_context( } matches!((input.kind.as_str(), message.status.as_str()), ("message.staged", "delivered" | "read" | "closed") | ("message.delivered", "read" | "closed") | ("message.read", "closed")) + && !(matches!(input.kind.as_str(), "message.delivered" | "message.read") + && message_expiry::exists(transaction, &input.subject).map_err(internal)?) } else { false }; if let Some(claim) = claim_operation_outcome_tx(transaction, operation.as_ref())? { return Ok((claim, false)); diff --git a/crates/fractal-store/src/store/claim_pages.rs b/crates/fractal-store/src/store/claim_pages.rs index f756c73238a..13bbd54c81d 100644 --- a/crates/fractal-store/src/store/claim_pages.rs +++ b/crates/fractal-store/src/store/claim_pages.rs @@ -159,6 +159,52 @@ mod tests { } } + #[test] + fn claim_page_allowlists_intersect_filters_before_pagination() { + let store = Store::open_memory("node").unwrap(); + let denied = "custom.acme.private"; + append(&store, "custom/acme/change/1", denied); + let a = append(&store, "custom/acme/change/1", CHANGE); + append(&store, "custom/acme/change/1", denied); + let b = append(&store, "custom/acme/change/2", NOTE); + append(&store, "custom/acme/changes/outside", CHANGE); + let c = append(&store, "custom/acme/change/1", CHANGE); + append(&store, "custom/acme/change/1", denied); + let prefix = ClaimsFilter { + subject_prefix: Some("custom/acme/change/"), + ..ClaimsFilter::default() + }; + let allowed = [CHANGE, NOTE]; + let page = |filter, after, before, descending, limit, kinds| { + store.claims_page_matching_with_kinds( + filter, None, after, before, descending, limit, kinds, + ).unwrap() + }; + let first = page(prefix, 0, None, false, 1, Some(allowed.as_slice())); + assert_eq!((indexes(&first), first.next_cursor), (vec![a], Some(a))); + let rest = page(prefix, a, None, false, 10, Some(allowed.as_slice())); + assert_eq!((indexes(&rest), rest.next_cursor), (vec![b, c], None)); + let newest = page(prefix, 0, None, true, 1, Some(allowed.as_slice())); + assert_eq!((indexes(&newest), newest.next_cursor), (vec![c], Some(c))); + let older = page(prefix, 0, Some(c), true, 10, Some(allowed.as_slice())); + assert_eq!((indexes(&older), older.next_cursor), (vec![b, a], None)); + let exact = ClaimsFilter { subject: Some("custom/acme/change/1"), ..ClaimsFilter::default() }; + assert_eq!(indexes(&page(exact, 0, None, false, 10, Some(allowed.as_slice()))), vec![a, c]); + let kind = ClaimsFilter { kind: Some(CHANGE), ..prefix }; + assert_eq!(indexes(&page(kind, 0, None, false, 10, Some(allowed.as_slice()))), vec![a, c]); + let excluded = ClaimsFilter { kind: Some(denied), ..prefix }; + let empty = page(excluded, 0, None, false, 10, Some(allowed.as_slice())); + assert!(empty.claims.is_empty()); + assert_eq!(empty.next_cursor, None); + let empty = page(prefix, 0, None, false, 10, Some([].as_slice())); + assert!(empty.claims.is_empty()); + assert_eq!(empty.next_cursor, None); + assert_eq!( + indexes(&page(prefix, 0, None, false, 10, None)), + indexes(&store.claims_page_matching(prefix, None, 0, None, false, 10).unwrap()), + ); + } + #[test] fn claims_by_kinds_after_reads_newer_claims_under_a_prefix() { let store = Store::open_memory("node").unwrap(); @@ -203,7 +249,7 @@ mod tests { (ClaimsSubject::Range, false, "USING INDEX claims_subject_index (subject>? AND subject?)"), ] { - let plan = plan(&store, &claims_page_query(subject, kind, descending)); + let plan = plan(&store, &claims_page_query(subject, kind, descending, false)); assert!(plan.contains(expected), "{subject:?} kind={kind}: {plan}"); assert!(!plan.contains("SCAN claims"), "{subject:?} kind={kind}: {plan}"); } diff --git a/crates/fractal-store/src/store/database_size.rs b/crates/fractal-store/src/store/database_size.rs index a976bbe6f6f..9146b173646 100644 --- a/crates/fractal-store/src/store/database_size.rs +++ b/crates/fractal-store/src/store/database_size.rs @@ -1,5 +1,5 @@ -//! How big this member's store is, and how much it grows a day: the database target in -//! `slo/targets.toml`, and the physical growth that disk-filling conditions read. The daemon +//! How big this member's store is, and how much it grows a day: the database objective in +//! `slo/objectives.kdl`, and the physical growth that disk-filling conditions read. The daemon //! samples the store hourly and keeps two days of samples in `meta`, so the daily growth survives //! restarts. A sample reads three pragmas and the WAL file's length. It is the one sampler: the //! newest sample is cached for every reader (`fractal_daemon::slo::database_size`). diff --git a/crates/fractal-store/src/store/declared_objectives.rs b/crates/fractal-store/src/store/declared_objectives.rs new file mode 100644 index 00000000000..ec8cd094a6f --- /dev/null +++ b/crates/fractal-store/src/store/declared_objectives.rs @@ -0,0 +1,57 @@ +//! Declared objectives (docs/st3/objectives.md): each `objective/NAME` declaration, and the member +//! an objective's owner is declared on. + +use super::*; +use crate::objective::Objective; + +/// One current `objective/NAME` declaration. A body the kernel reader refuses is kept with its +/// error, so the objective's row says so instead of disappearing. +#[derive(Clone, Debug)] +pub struct DeclaredObjective { + pub subject: String, + pub revision: String, + pub claim_id: String, + pub objective: std::result::Result, +} + +impl DeclaredObjective { + /// The objective's name: its subject after `objective/`. + pub fn name(&self) -> &str { + self.subject.strip_prefix("objective/").unwrap_or(&self.subject) + } +} + +pub fn declared_objectives_at(connection: &Connection) -> Result> { + let mut statement = connection.prepare_cached( + "SELECT subject, revision, claim_id, body FROM desired WHERE kind='objective' ORDER BY subject", + )?; + let rows = statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)) + })?; + let mut objectives = Vec::new(); + for row in rows { + let (subject, revision, claim_id, body) = row?; + let objective = serde_json::from_str::(&body) + .map_err(|error| format!("its body is not JSON: {error}")) + .and_then(|desired| crate::objective::parse(&desired).map_err(|error| format!("{}: {}", error.code, error.message))); + objectives.push(DeclaredObjective { subject, revision, claim_id, objective }); + } + Ok(objectives) +} + +impl Store { + pub fn declared_objectives(&self) -> Result> { + declared_objectives_at(&self.readers.get()) + } + + /// The member `agent` is declared on, when it is a current agent declaration. + pub fn agent_host(&self, agent: &str) -> Result> { + Ok(self + .readers + .get() + .prepare_cached("SELECT json_extract(member,'$.host') FROM desired WHERE subject=?1 AND kind='agent'")? + .query_row(/* sqlite-large-value: allow-for-now lv-hbb373c1f9535 owner=fractal_store::store::declared_objectives TODO=trace-statement-and-value-representation */ [agent], |row| row.get::<_, Option>(0)) + .optional()? + .flatten()) + } +} diff --git a/crates/fractal-store/src/store/message_expiry.rs b/crates/fractal-store/src/store/message_expiry.rs new file mode 100644 index 00000000000..6561630e878 --- /dev/null +++ b/crates/fractal-store/src/store/message_expiry.rs @@ -0,0 +1,207 @@ +//! Durable product outcomes, separate from member-local transport receipts. No expiry writer +//! runs here. Readers retain expiry history even when later consumption evidence supersedes it. +use super::*; + +pub const KIND: &str = "message.expired"; + +/// Cross-file ordering: take this before either writer and retain it through commit. +#[derive(Default)] +pub(super) struct ReceiptGate(pub Mutex<()>); + +#[derive(Clone, Debug)] +pub struct MessageExpiry { + pub claim: ClaimRecord, + /// Actual delivery/read evidence wins; expiry never asserts that consumption was impossible. + pub superseded: bool, +} + +impl Store { + pub fn message_expiry(&self, subject: &str) -> Result> { + let Some(claim) = self.latest_claim(subject, Some(KIND))? else { return Ok(None); }; + let mut superseded = message_marks::recorded_at(&self.readers.get(), subject, + &["message.delivered", "message.read"], None)?.is_some(); + for kind in ["message.delivered", "message.read"] { + if superseded { break; } + if self.latest_claim(subject, Some(kind))?.is_some() + || messages_shard::mailbox_reads::lifecycle_step(self, subject, kind)?.is_some() + { + superseded = true; + break; + } + } + Ok(Some(MessageExpiry { claim, superseded })) + } +} + +pub(super) fn validate_actor(kind: &str, actor: Option<&str>) -> Result<(), FractalError> { + if kind == KIND && actor != Some("daemon/runtime") { + return Err(FractalError::new("invalid-message-expiry", "only daemon/runtime may expire mail")); + } + Ok(()) +} + +pub(super) fn exists(connection: &Connection, subject: &str) -> Result { + Ok(connection.query_row("SELECT EXISTS(SELECT 1 FROM claims WHERE subject=?1 AND kind='message.expired')", + [subject], |row| row.get(0))?) +} + +/// Runtime admission holds ReceiptGate before entering the graph writer. +pub(super) fn validate_shard(runtime: &FractalRuntime, connection: &Connection, subject: &str) -> Result<(), FractalError> { + if exists(connection, subject).map_err(internal)? { return Ok(()); } + let slot = runtime.extensions.get::(); + if let Some(shard) = slot.get() { + let terminal = shard.readers.get().query_row( + "SELECT EXISTS(SELECT 1 FROM stream_lifecycle WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed'))", + [subject], |row| row.get::<_, bool>(0), + ).map_err(internal)?; + if terminal { return Err(FractalError::new("invalid-message-transition", "shard mail already has a terminal receipt")); } + } + Ok(()) +} + +pub(super) fn validate_transition(connection: &Connection, subject: &str) -> Result { + if exists(connection, subject).map_err(internal)? { return Ok(true); } + let terminal: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM claims WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed')) + OR EXISTS(SELECT 1 FROM local_message_marks WHERE message=?1) + OR EXISTS(SELECT 1 FROM local_stream_lifecycle WHERE subject=?1 AND kind IN ('message.delivered','message.read','message.closed'))", + [subject], |row| row.get(0), + ).map_err(internal)?; + if !message_sent::message_sent_tx(connection, subject).map_err(internal)? || terminal { + return Err(FractalError::new("invalid-message-transition", "expiry requires sent or uncertain staged mail")); + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(kind: &str, actor: &str) -> ClaimInput { + ClaimInput { subject: "message/expiry-contract".into(), kind: kind.into(), actor: Some(actor.into()), + fields: BTreeMap::from([("status".into(), json!(kind.trim_start_matches("message.")))]), + evidence: vec![], expected_subject: None, idempotency_key: None } + } + + fn fixture(storage: &str) -> (tempfile::TempDir, Arc, ClaimInput) { + let directory = tempfile::tempdir().unwrap(); + let store = Arc::new(Store::open(&directory.path().join("claims.sqlite3"), "fixture").unwrap()); + let mut sent = input("message.sent", "agent/sender"); + sent.fields.extend([("from".into(), json!("agent/sender")), ("to".into(), json!("agent/fixture.recipient")), + ("content".into(), json!("Expiry contract fixture"))]); + if storage == "shard" { + store.bind_fleet("expiry-fixture").unwrap(); + store.load_member_key(Some(Arc::new(smallclaims::fleet::MemberKey::generate().unwrap().0))).unwrap(); + store.use_key_directory(&directory.path().join("keys")).unwrap(); + messages_shard::open(&store, directory.path()).unwrap().append_claim(&sent).unwrap(); + stream_mirror::mirror(&store).unwrap(); + } else { + store.append_claim(&sent).unwrap(); + if storage == "marks" { + let intent = crate::graph::parse_intent("version 2\nagent \"recipient\" { host \"fixture\"; command \"true\"; }", "fixture").unwrap(); + crate::store::card_fold_tests_shared_missions::declare(&store, &intent, "expiry-local-seat"); + assert!(store.refresh_marks_switch(&[]).unwrap().0); + } + } + let mut expiry = input(KIND, "daemon/runtime"); + expiry.fields.insert("reason".into(), json!("cutoff-uncertain-attempt")); + (directory, store, expiry) + } + + fn receipt(store: &Store, storage: &str, kind: &str, actor: &str) { + let input = input(kind, actor); + if storage == "shard" { + messages_shard::record_lifecycle(store, &input.subject, kind, input.actor.as_deref(), &input.fields).unwrap(); + assert!(messages_shard::mailbox_reads::lifecycle_step(store, &input.subject, kind).unwrap().is_some()); + } else { store.append_claim(&input).unwrap(); } + } + + #[test] + fn expiry_contract_rejects_transactional_bypass() { + let (_directory, store, expiry) = fixture("graph"); + let mut connection = store.connection.write(); + let transaction = connection.transaction().unwrap(); + let body = json!({"fields": expiry.fields}); + assert!(runtime().append_claim_tx(&transaction, "fixture", &expiry.subject, KIND, + expiry.actor.as_deref(), &body, &[], None).is_err()); + assert!(append_claim_tx_with_assistant_answer(&transaction, "fixture", &expiry.subject, KIND, + expiry.actor.as_deref(), &body, &[], None, None).is_err()); + assert!(!exists(&transaction, &expiry.subject).unwrap()); + transaction.commit().unwrap(); + } + + #[test] + fn expiry_contract_read_then_delivered_is_handled() { + for storage in ["graph", "marks", "shard"] { + let (_directory, store, expiry) = fixture(storage); + store.append_claim(&expiry).unwrap(); + receipt(&store, storage, "message.read", "agent/fixture.recipient"); + receipt(&store, storage, "message.delivered", "agent/fixture.recipient"); + assert!(store.message_expiry(&expiry.subject).unwrap().unwrap().superseded, "{storage}"); + } + } + + #[test] + fn expiry_contract_history_marks_and_late_receipts() { + for storage in ["graph", "marks", "shard"] { + for step in ["message.delivered", "message.read"] { + for withdrawn in [false, true] { + let (directory, store, mut expiry) = fixture(storage); + for actor in ["agent/fixture.recipient", "daemon/other"] { + expiry.actor = Some(actor.into()); + assert!(store.append_claim(&expiry).is_err()); + } + expiry.actor = Some("daemon/runtime".into()); + if withdrawn { receipt(&store, storage, "message.staged", "agent/fixture.recipient"); } + let claim = store.append_claim(&expiry).unwrap(); + assert_eq!(store.append_claim(&expiry).unwrap().id, claim.id); + if withdrawn { receipt(&store, storage, "message.closed", "daemon/runtime"); } + assert!(!store.message_expiry(&expiry.subject).unwrap().unwrap().superseded); + receipt(&store, storage, step, "agent/fixture.recipient"); + assert!(store.message_expiry(&expiry.subject).unwrap().unwrap().superseded, "{storage}/{step}/{withdrawn}"); + if storage == "marks" { + assert!(message_marks::recorded_at(&store.readers.get(), &expiry.subject, &[step], None).unwrap().is_some()); + } + drop(store); + let reopened = Store::open(&directory.path().join("claims.sqlite3"), "fixture").unwrap(); + assert_eq!(reopened.message_expiry(&expiry.subject).unwrap().unwrap().claim.id, claim.id); + } + } + } + } + + #[test] + fn expiry_contract_rejects_terminal_receipts_without_a_mirror_refresh() { + for storage in ["graph", "marks", "shard"] { + for terminal in ["message.delivered", "message.read", "message.closed"] { + let (_directory, store, mut expiry) = fixture(storage); + if terminal == "message.read" { receipt(&store, storage, "message.delivered", "agent/fixture.recipient"); } + receipt(&store, storage, terminal, if terminal == "message.closed" { "daemon/runtime" } else { "agent/fixture.recipient" }); + assert!(store.append_claim(&expiry).is_err(), "{storage}/{terminal}"); + expiry.subject = "message/missing-expiry".into(); + assert!(store.append_claim(&expiry).is_err()); + } + } + } + + #[test] + fn expiry_contract_replication_and_old_registry_are_compatible() { + let registry = fractal_schema::registry(); + let mut old = registry.clone(); + old.claims.remove(KIND); + assert_ne!(old.digest(), registry.digest()); + for reason in ["cutoff-never-offered", "cutoff-uncertain-attempt", "no-live-recipient"] { + let fields = BTreeMap::from([("status".into(), json!("expired")), ("reason".into(), json!(reason))]); + registry.validate_claim("message/expiry-contract", KIND, &fields).unwrap(); + let mut claim = messages_shard::lifecycle_receipt("fixture", "message/expiry-contract", KIND, + Some("daemon/runtime"), &fields); + assert!(matches!(classify_replicated_claim_with_registry(&claim, &old).unwrap(), ReplicatedClaimAdmission::UnknownKind)); + assert!(matches!(classify_replicated_claim_with_registry(&claim, registry).unwrap(), ReplicatedClaimAdmission::Valid)); + claim.actor = Some("agent/recipient".into()); + assert!(classify_replicated_claim_with_registry(&claim, registry).is_err()); + assert!(messages_shard::classify_stream_item(&claim).is_err()); + } + let fields = BTreeMap::from([("status".into(), json!("expired")), ("reason".into(), json!("other"))]); + assert!(registry.validate_claim("message/expiry-contract", KIND, &fields).is_err()); + } +} diff --git a/crates/fractal-store/src/store/messages_shard.rs b/crates/fractal-store/src/store/messages_shard.rs index 32b016d9b35..b5f4983a867 100644 --- a/crates/fractal-store/src/store/messages_shard.rs +++ b/crates/fractal-store/src/store/messages_shard.rs @@ -369,6 +369,8 @@ fn record_lifecycle_checked( let Some((sent, _)) = stream_item(&shard, subject).map_err(internal)? else { return Err(FractalError::new("missing-message", format!("no stream item `{subject}`"))); }; + let gate = graph.extension::(); + let _held = gate.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // The step is decided and recorded under the shard's writer, in one transaction, so two // steps at once can't both pass a check made before either was recorded. let mut writer = shard.connection.write(); @@ -383,10 +385,12 @@ fn record_lifecycle_checked( .unwrap_or(0); // A successor records its own custody read of mail its predecessor already read, once. let custody_reread = checked && rank == 3 && current == 3 && fields.contains_key("acting_for"); + let late_consumption = checked && matches!(rank, 2 | 3) + && super::message_expiry::exists(&graph.readers.get(), subject).map_err(internal)?; // A replayed or earlier step settles to what is recorded, as a graph receipt does. An // admitted step is recorded wherever the item stands, as the graph appends its claim, so the // receipt it names exists. - if checked && rank <= current && !custody_reread { + if checked && rank <= current && !custody_reread && !late_consumption { return Ok(false); } // Forward steps follow the graph's message transitions, so a stream item moves as the same @@ -394,6 +398,7 @@ fn record_lifecycle_checked( let recipient = sent.body.pointer("/fields/to").and_then(Value::as_str); let valid = !checked || custody_reread + || late_consumption || matches!((current, rank), (0, 1) | (1, 2) | (0, 2) | (2, 3) | (3, 4)) // A person's displayed message has no harness handoff; their read is its delivery. || (rank == 3 && current <= 1 && actor.is_some_and(|actor| actor.starts_with("person/")) && actor == recipient) diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index e59e8f3aa00..9b20f0a39ae 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -1,12 +1,39 @@ -//! A mission revision that declares `capacity`, on the mission or on a step, is published only -//! once every current member reads the field. A member whose build does not checks a replicated -//! revision by hashing the mission as it reads it, without the field, and quarantines the claim -//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises the field on its own +//! A mission revision that declares a field an older build cannot read (`capacity` on the +//! mission or a step, or the mission's run `authority`) is published only once every current +//! member reads the field. A member whose build does not checks a replicated revision by hashing +//! the mission as it reads it, without the field, and quarantines the claim +//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises each field on its own //! `daemon.started`, as the standby barrier reads `schedule_standby`. use super::*; /// The `daemon.started` feature of a build that reads mission and step capacity. pub const FEATURE: &str = "mission_capacity"; +/// The `daemon.started` feature of a build that reads a mission's run `authority`. +pub const AUTHORITY_FEATURE: &str = "mission_authority"; + +/// One mission field behind a fleet barrier: the feature that says a member reads it, the +/// refusal code, and what to do once every member is upgraded. +struct Barrier { + feature: &'static str, + code: &'static str, + declares: fn(&MissionSpec) -> bool, + later: &'static str, +} + +const BARRIERS: [Barrier; 2] = [ + Barrier { + feature: FEATURE, + code: "mission-capacity-unavailable", + declares: declares_capacity, + later: "declare capacity on a mission or step", + }, + Barrier { + feature: AUTHORITY_FEATURE, + code: "mission-authority-unavailable", + declares: |mission| !mission.authority.is_empty(), + later: "declare authority on a mission", + }, +]; const WRITER_TABLES: [(&str, &str); 3] = [ ("replica_envelopes", "writer"), ("batches", "origin"), ("checkpoint_envelopes", "writer"), @@ -15,10 +42,6 @@ fn writer_seek(table: &str, column: &str) -> String { format!("SELECT {column} FROM {table} WHERE {column}>?1 ORDER BY {column} LIMIT 1") } -fn refused(reason: impl Into) -> FractalError { - FractalError::new("mission-capacity-unavailable", reason) -} - fn declares_capacity(mission: &MissionSpec) -> bool { mission.capacity.is_some() || mission.steps.values().any(|step| { @@ -26,15 +49,17 @@ fn declares_capacity(mission: &MissionSpec) -> bool { }) } -/// Refuses each new mission revision in `intent` that declares capacity while a current member -/// has not advertised [`FEATURE`]. A revision already published passes, and so does a host with -/// no other member. +/// Refuses each new mission revision in `intent` that declares a field behind a barrier while a +/// current member has not advertised its feature. A revision already published passes, and so +/// does a host with no other member. pub(super) fn validate_publication( connection: &Connection, origin: &str, intent: &NormalizedIntent, ) -> Result<(), FractalError> { - for mission in intent.missions.values().filter(|mission| declares_capacity(mission)) { + for mission in intent.missions.values() + .filter(|mission| BARRIERS.iter().any(|barrier| (barrier.declares)(mission))) + { let published: Option = connection .query_row( "SELECT revision FROM mission_definitions WHERE mission_id=?1", @@ -55,9 +80,15 @@ pub(super) fn validate_mission( origin: &str, mission: &MissionSpec, ) -> Result<(), FractalError> { - if !declares_capacity(mission) { - return Ok(()); + for barrier in BARRIERS.iter().filter(|barrier| (barrier.declares)(mission)) { + validate_barrier(connection, origin, barrier)?; } + Ok(()) +} + +fn validate_barrier(connection: &Connection, origin: &str, barrier: &Barrier) -> Result<(), FractalError> { + let Barrier { feature, code, later, .. } = barrier; + let refused = |reason: String| FractalError::new(code, reason); let membership = fleet_membership_tx(connection).map_err(internal)?; for member in membership.incarnations().filter(|member| member.end.is_none()) { // This host runs this build. @@ -66,14 +97,14 @@ pub(super) fn validate_mission( } if !matches!(membership.state(&member.name), crate::fleet::MemberState::Current(_)) { return Err(refused(format!( - "the fleet membership of host/{} is ambiguous, so its build cannot be checked; declare capacity on a mission or step once it is settled", + "the fleet membership of host/{} is ambiguous, so its build cannot be checked; {later} once it is settled", member.name ))); } - let advertised: Option> = connection + let features: Option> = connection .query_row( - /* sqlite-large-value: allow-for-now lv-h20cb3d2e4d9e owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( - "SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 + /* sqlite-large-value: allow-for-now lv-h88ff6fefeb9f owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origin=?2 AND batches.replica_sequence>=?3 ORDER BY CANONICAL_DESC(claims) LIMIT 1", @@ -83,9 +114,12 @@ pub(super) fn validate_mission( ) .optional() .map_err(internal)?; - if advertised.flatten() != Some(true) { + let advertised = features.flatten() + .and_then(|features| serde_json::from_str::(&features).ok()) + .is_some_and(|features| features.get(*feature) == Some(&json!(1))); + if !advertised { return Err(refused(format!( - "host/{} runs an older build that would refuse this revision (it has not advertised {FEATURE}=1); declare capacity on a mission or step once every member is upgraded", + "host/{} runs an older build that would refuse this revision (it has not advertised {feature}=1); {later} once every member is upgraded", member.name ))); } @@ -102,7 +136,7 @@ pub(super) fn validate_mission( { if writer != origin && !membership.is_keyed_writer(&writer) && !fenced.contains(&writer) { return Err(refused(format!( - "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; declare capacity on a mission or step once it is a member or fenced" + "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; {later} once it is a member or fenced" ))); } previous = writer; @@ -150,14 +184,8 @@ mission "plain" state="ready" { json!({"status": "running", "features": {FEATURE: u8::from(reads_capacity)}})); } - #[test] - fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { - // A host alone publishes at once, with or without a fleet. - publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); - let anchored = Store::open_memory("alder").unwrap(); - enable(&anchored); - publish(&anchored, LANED, "anchored-alone").unwrap(); - + /// `alder` with `birch` admitted, synced and started on a build that reads no barrier field. + fn two_members() -> (Store, Store) { let store = Store::open_memory("alder").unwrap(); let anchor = enable(&store); let peer_key = Arc::new(MemberKey::generate().unwrap().0); @@ -171,6 +199,18 @@ mission "plain" state="ready" { sync(&store, &peer); started(&peer, false); sync(&peer, &store); + (store, peer) + } + + #[test] + fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { + // A host alone publishes at once, with or without a fleet. + publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); + let anchored = Store::open_memory("alder").unwrap(); + enable(&anchored); + publish(&anchored, LANED, "anchored-alone").unwrap(); + + let (store, peer) = two_members(); // A member on an older build would quarantine the revision: the plan shows why and the // publication is refused. A mission without capacity is unaffected. @@ -202,4 +242,31 @@ mission "plain" state="ready" { assert!(publish(&store, LANED, "upgraded").unwrap().is_empty()); assert_eq!(store.mission_spec("laned", None).unwrap().unwrap().capacity, mission.capacity); } + + #[test] + fn a_revision_that_declares_run_authority_waits_until_every_member_reads_it() { + const AUTHORIZED: &str = r#"version 2 +mission "authorized" state="ready" { + goal "Work." + authority { write "resource/example/*"; } + step "work" { agentless; } +} +"#; + publish(&Store::open_memory("alder").unwrap(), AUTHORIZED, "alone").unwrap(); + let (store, peer) = two_members(); + // Reading capacity is not reading authority. + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1}})); + sync(&peer, &store); + let refusal = publish(&store, AUTHORIZED, "older").unwrap_err(); + assert_eq!(refusal.code, "mission-authority-unavailable"); + assert!(refusal.message.contains("has not advertised mission_authority=1); declare authority on a mission once every member is upgraded"), + "{}", refusal.message); + assert!(store.mission_spec("authorized", None).unwrap().is_none()); + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1, AUTHORITY_FEATURE: 1}})); + sync(&peer, &store); + assert!(publish(&store, AUTHORIZED, "upgraded").unwrap().is_empty()); + assert_eq!(store.mission_spec("authorized", None).unwrap().unwrap().authority.len(), 1); + } } diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index e121db31e25..ba0d92dd522 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -636,15 +636,16 @@ impl Store { let connection = self.readers.get(); let orders = seat_run_orders_tx(&connection, selected_agent)?; let rows = seat_step_rows_tx(&connection, selected_agent)?; - let mut seats = BTreeMap::<&str, Vec>>::new(); + let mut seats = BTreeMap::, Vec>>::new(); for row in &rows { let step = row.seat_step(); for agent in [row.assignee.as_deref(), row.claimant.as_deref()] .into_iter() .flatten() + .filter_map(fractal_schema::canonical_agent_actor) .collect::>() { - if selected_agent.is_some_and(|selected| selected != agent) { + if selected_agent.is_some_and(|selected| !work_actor_matches(Some(&agent), selected)) { continue; } seats.entry(agent).or_default().push(step); @@ -652,13 +653,13 @@ impl Store { } let mut queues = BTreeMap::::new(); for (agent, steps) in seats { - let order = orders.get(agent).map(Vec::as_slice).unwrap_or_default(); - let selection = seat_queue::select(agent, &steps, order); + let order = orders.get(agent.as_ref()).map(Vec::as_slice).unwrap_or_default(); + let selection = seat_queue::select(&agent, &steps, order); if selection.held.is_empty() && selection.ready.is_empty() { continue; } queues.insert( - agent.to_owned(), + agent.into_owned(), AgentWorkQueue { current_work_ids: selection .held @@ -1771,10 +1772,19 @@ pub fn note_run_view_reads(view: &MissionRunView) { /// Current held and ready agent steps, for every seat or for one seat. pub fn seat_step_rows_tx(connection: &Connection, agent: Option<&str>) -> Result> { + seat_step_rows_with_spellings_tx(connection, agent.map(work_actor_spellings_json)) +} + +/// Current held and ready steps eligible for literal mutation admission. +pub fn seat_step_rows_literal_tx(connection: &Connection, agent: &str) -> Result> { + seat_step_rows_with_spellings_tx(connection, Some(serde_json::to_string(&[agent])?)) +} + +fn seat_step_rows_with_spellings_tx(connection: &Connection, spellings: Option) -> Result> { let snapshot_unix_ms = now_ms(); let mut statement = connection.prepare_cached(/* sqlite-large-value: allow-for-now lv-h0096cec23ed6 owner=fractal_store::store::missions TODO=trace-statement-and-value-representation */ SEAT_STEP_ROWS)?; let mut rows = statement - .query_map(params![agent, snapshot_unix_ms.to_string()], |row| { + .query_map(params![spellings, snapshot_unix_ms.to_string()], |row| { let mut status: String = row.get(3)?; let mut claimant = row.get(5)?; let expires: Option = row.get(8)?; diff --git a/crates/fractal-store/src/store/reconcile_work_context.rs b/crates/fractal-store/src/store/reconcile_work_context.rs index 6f53a866966..6f39fd75595 100644 --- a/crates/fractal-store/src/store/reconcile_work_context.rs +++ b/crates/fractal-store/src/store/reconcile_work_context.rs @@ -175,13 +175,13 @@ pub fn read(store: &Store, actor: &str, at: u128) -> Result> { .get(view.run.strip_prefix("mission-run/").unwrap_or(&view.run)) .is_some_and(|phase| phase == "revision-draining"); (!draining || matches!(view.status.as_str(), "claimed" | "working" | "verifying")) - && (view.assigned_to.as_deref() == Some(actor.as_str()) - || view.claimant.as_deref() == Some(actor.as_str()) + && (work_actor_matches(view.assigned_to.as_deref(), &actor) + || work_actor_matches(view.claimant.as_deref(), &actor) || (matches!(view.status.as_str(), "ready" | "blocked") && view .available_to .iter() - .any(|candidate| candidate == &actor))) + .any(|candidate| work_actor_matches(Some(candidate), &actor)))) }); Ok(inputs.views) } @@ -190,7 +190,7 @@ pub fn inputs(store: &Store, actor: &str, at: u128) -> Result { let connection = store.readers.get(); let mut views = connection .prepare_cached(/* sqlite-large-value: allow-for-now lv-ha3063a1f5575 owner=fractal_store::store::reconcile_work_context TODO=trace-statement-and-value-representation */ &seat_work_at_snapshot_query(false))? - .query_map(params![actor, true, false], step_run_from_row)? + .query_map(params![work_actor_spellings_json(actor), true, false], step_run_from_row)? .collect::>>()?; let carried = carried_claimants_tx( &connection, diff --git a/crates/fractal-store/src/store/runtime.rs b/crates/fractal-store/src/store/runtime.rs index 153580fb301..1268c9d1e80 100644 --- a/crates/fractal-store/src/store/runtime.rs +++ b/crates/fractal-store/src/store/runtime.rs @@ -464,6 +464,13 @@ impl Runtime for FractalRuntime { input: &ClaimInput, ) -> Result<(ClaimRecord, bool), FractalError> { self.validate_usage_limits_writer(&input.kind, input.actor.as_deref())?; + if input.kind == message_expiry::KIND { + let gate = self.extensions.get::(); + let _held = gate.0.lock().unwrap_or_else(PoisonError::into_inner); + let admission = |connection: &Connection| message_expiry::validate_shard(self, connection, &input.subject); + return append_claim_with_commit_context(store, input, None, None, None, None, + ClaimCommitContext { expiry_writer: true, admission: Some(&admission), ..Default::default() }); + } append_claim_fenced_outcome(store, input, None) } diff --git a/crates/fractal-store/src/store/seat_queue_plan_tests.rs b/crates/fractal-store/src/store/seat_queue_plan_tests.rs index c2b9edbe9af..687cc1e5708 100644 --- a/crates/fractal-store/src/store/seat_queue_plan_tests.rs +++ b/crates/fractal-store/src/store/seat_queue_plan_tests.rs @@ -7,7 +7,7 @@ fn a_named_run_probe_reads_the_runs_steps_not_every_step_of_the_seat() { let plan = writer .prepare(/* sqlite-large-value: allow-for-now lv-hf932ad4cefc8 owner=fractal_store::store::seat_queue_plan_tests TODO=trace-statement-and-value-representation */ &format!("EXPLAIN QUERY PLAN {SEAT_QUEUE_NAMED_RUN_QUERY}")) .unwrap() - .query_map(params!["agent/garden/seat", "garden/tend"], |row| row.get::<_, String>(3)) + .query_map(params![work_actor_spellings_json("agent/garden/seat"), "garden/tend"], |row| row.get::<_, String>(3)) .unwrap() .collect::>>() .unwrap(); diff --git a/crates/fractal-store/src/store/tests.rs b/crates/fractal-store/src/store/tests.rs index 630d759b62c..83c2bace5eb 100644 --- a/crates/fractal-store/src/store/tests.rs +++ b/crates/fractal-store/src/store/tests.rs @@ -442,7 +442,7 @@ fn subject_claim_pages_seek_the_subject_index() { .unwrap(); let connection = store.readers.get(); for descending in [false, true] { - let query = format!("EXPLAIN QUERY PLAN {}", claims_page_query(ClaimsSubject::Exact, false, descending)); + let query = format!("EXPLAIN QUERY PLAN {}", claims_page_query(ClaimsSubject::Exact, false, descending, false)); let plan = connection .prepare(/* sqlite-large-value: allow-for-now lv-h8911c1538a9c owner=fractal_store::store::tests TODO=trace-statement-and-value-representation */ &query) .unwrap() @@ -3363,7 +3363,8 @@ fn a_prefixs_subjects_are_read_one_seek_per_subject() { fn seat_steps_are_read_from_the_open_steps() { let store = Store::open_memory("node").unwrap(); for agent in [None, Some("agent/seat")] { - let steps = query_plan(&store, SEAT_STEP_ROWS, params![agent, now_ms().to_string()]); + let spellings = agent.map(work_actor_spellings_json); + let steps = query_plan(&store, SEAT_STEP_ROWS, params![spellings, now_ms().to_string()]); assert!( steps .iter() diff --git a/crates/fractal-store/src/store/tests_shared_missions.rs b/crates/fractal-store/src/store/tests_shared_missions.rs index 4e78d45b4b3..b0c14a64853 100644 --- a/crates/fractal-store/src/store/tests_shared_missions.rs +++ b/crates/fractal-store/src/store/tests_shared_missions.rs @@ -49,8 +49,8 @@ version 2 mission "carry" state="ready" { goal "Keep claimed work across a revision." - step "work" { assigned-to "agent/worker"; goal "Do the claimed work." } - step "other" { assigned-to "agent/worker"; goal "Use the first goal." } + step "work" { assigned-to "agent/node.worker"; goal "Do the claimed work." } + step "other" { assigned-to "agent/node.worker"; goal "Use the first goal." } } "#; diff --git a/crates/fractal-store/src/store/work_list.rs b/crates/fractal-store/src/store/work_list.rs index 191c074d724..e9cf0a7333b 100644 --- a/crates/fractal-store/src/store/work_list.rs +++ b/crates/fractal-store/src/store/work_list.rs @@ -248,10 +248,10 @@ pub fn viewer_seat(viewer: &str) -> String { /// Whether `viewer` sees `view` in its own work list, as the direct read's actor filter does. fn visible_to(view: &StepRunView, viewer: &str) -> bool { !view.agentless - && (view.assigned_to.as_deref() == Some(viewer) - || view.claimant.as_deref() == Some(viewer) + && (super::work_actor_matches(view.assigned_to.as_deref(), viewer) + || super::work_actor_matches(view.claimant.as_deref(), viewer) || (matches!(view.status.as_str(), "ready" | "blocked") - && view.available_to.iter().any(|seat| seat == viewer))) + && view.available_to.iter().any(|seat| super::work_actor_matches(Some(seat), viewer)))) } /// How the work list ranks a step's state: ready work first, ended work last. @@ -357,10 +357,12 @@ impl WorkRows { } /// The rows `actor` sees, as indexes into `order`, in the order the direct read gives it: - /// its ready work by its seat queue at this publication's own cut. Built on the actor's - /// first read of this publication; says whether this read built it. + /// its ready work by its seat queue at this publication's own cut. Legacy slash-qualified + /// agent IDs share the canonical actor's memo. Says whether this read built it. pub fn actor_rows(&self, actor: &str) -> (Arc>, bool) { - let viewer = smallclaims::store::normalize_actor(actor, "agent"); + let actor = smallclaims::store::normalize_actor(actor, "agent"); + let viewer = fractal_schema::canonical_agent_actor(&actor) + .unwrap_or(std::borrow::Cow::Borrowed(&actor)); self.actors.get_or_build(&viewer, || self.actor_order(&viewer)) } @@ -374,8 +376,7 @@ impl WorkRows { .map(|(index, step)| (index as u32, &self.rows[step].view)) .filter(|(_, view)| visible_to(view, viewer)) .collect::>(); - // The direct read hands `select` its own seat string (the actor if it names a kind, else - // `agent/ACTOR`, which is `viewer`) and looks the queue up by the normalized seat. + // Queue lookup and the shared selector use the same canonical seat identity. let queue = self.seat_orders.get(&viewer_seat(viewer)).map_or(&[][..], |order| order.as_slice()); let steps = shown .iter() @@ -703,8 +704,13 @@ mod tests { .unwrap() .collect::>>() .unwrap(); + // json_each walks only the bound actor spelling array (at most canonical + legacy), + // not a stored table. Keep rejecting scans of the mission and step projections. for line in &plan { - if line.starts_with("SCAN ") && !line.starts_with("SCAN CONSTANT") { + if line.starts_with("SCAN ") + && !line.starts_with("SCAN CONSTANT") + && !line.starts_with("SCAN json_each VIRTUAL TABLE INDEX ") + { assert!(line.contains("USING "), "a bare table scan:\n{}", plan.join("\n")); } } diff --git a/crates/fractal-telemetry/Cargo.toml b/crates/fractal-telemetry/Cargo.toml index 5ff2eff06ac..b8ca4a498aa 100644 --- a/crates/fractal-telemetry/Cargo.toml +++ b/crates/fractal-telemetry/Cargo.toml @@ -30,7 +30,6 @@ fractal-kernel = { path = "../fractal-kernel" } fractal-runtime = { path = "../fractal-runtime" } tempfile = "3" tokio = { version = "1", features = ["full"] } -toml = "0.9" tracing = "0.1" tracing-opentelemetry = "0.31" tracing-subscriber = { version = "0.3", default-features = false, features = ["ansi", "env-filter", "fmt", "registry", "std"] } diff --git a/crates/fractal-telemetry/src/otlp/slo.rs b/crates/fractal-telemetry/src/otlp/slo.rs index 2cdf09ff9cf..87a312e942d 100644 --- a/crates/fractal-telemetry/src/otlp/slo.rs +++ b/crates/fractal-telemetry/src/otlp/slo.rs @@ -5,7 +5,7 @@ //! collector holds the history. Nothing here runs on a request path; once a minute a task reads //! the same report as `GET /v1/client/request-latency` and sends it to `[observations.otlp]`. //! -//! Every point carries bounded labels, all from `slo/targets.toml` or fixed words: `target`, the +//! Every point carries bounded labels, all from `slo/objectives.kdl` or fixed words: `target`, the //! `series` inside it (`owner` or `remote` for a read target, `reported` for a latency a paired //! phone reported, `statement`, `read`, `write` or `writer_hold` for the store), the `window` //! (`1m`, `5m`, `1h`) and, for the latency gauge, the `stat` (`p50`, `p99`, `max`). The target @@ -154,7 +154,7 @@ pub fn metrics(node: &str, report: &Value, now_unix_ms: u64) -> Option { gauge( "st_slo_target_ms", "ms", - "The target in slo/targets.toml for a series: its 99th percentile, or its maximum", + "The target in slo/objectives.kdl for a series: its 99th percentile, or its maximum", targets, ), gauge( diff --git a/crates/fractal-telemetry/src/slo.rs b/crates/fractal-telemetry/src/slo.rs index c11ed2e4894..40d6f9887f8 100644 --- a/crates/fractal-telemetry/src/slo.rs +++ b/crates/fractal-telemetry/src/slo.rs @@ -1,24 +1,27 @@ -//! The daemon's targets, from `slo/targets.toml`, which is built in. The same file is what -//! `st doctor` and CI's daemon_load read. A target describes what we aim for; it gates nothing. +//! The daemon's targets, from the objectives it ships in `slo/objectives.kdl`, which is built in +//! and read by the same reader as a declared objective (`fractal_kernel::objective`). The same +//! file is what `st doctor` and CI's daemon_load read. A target describes what we aim for; it +//! gates nothing. -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; use std::time::Duration; -use anyhow::{Context, Result, ensure}; -use serde::{Deserialize, Serialize}; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use fractal_kernel::objective::{self, Bound, Indicator, Objective}; +use serde::Serialize; use serde_json::{Value, json}; use smallclaims::windows::StoreWork; -/// The targets file this daemon was built with. -pub const SOURCE: &str = include_str!("../../../slo/targets.toml"); +/// The objectives file this daemon was built with. +pub const SOURCE: &str = include_str!("../../../slo/objectives.kdl"); -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Targets { pub latency: Vec, pub share: Vec, pub statement: Statement, pub transaction: Transaction, + pub writer_hold: WriterHold, pub cpu: Cpu, pub divergence: Divergence, pub database: Database, @@ -26,8 +29,7 @@ pub struct Targets { pub count: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Latency { pub name: String, pub about: String, @@ -37,8 +39,7 @@ pub struct Latency { pub paths: Vec, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Share { pub name: String, pub about: String, @@ -46,25 +47,30 @@ pub struct Share { pub path: String, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Statement { pub about: String, pub p99_ms: u64, pub max_ms: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Transaction { pub about: String, pub max_ms: u64, } +/// Each hold of the store's single writer, and each read transaction past the same bound, by who +/// held it. +#[derive(Debug)] +pub struct WriterHold { + pub about: String, + pub max_ms: u64, +} + /// The member's CPU: its daemon process and every replication worker process beside it, over /// closed UTC minutes. See `smallclaims::windows::cpu_minutes` for what makes a minute idle. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Cpu { pub about: String, /// The most cores the member may use over the hour. @@ -79,15 +85,13 @@ pub struct Cpu { /// Projection divergences, counted fleet-wide from their open faults. A projection is a pure /// function of the claims, so each one is a bug. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Divergence { pub about: String, pub max_open: u64, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct Database { pub about: String, /// The pages in use, not counting free pages SQLite reuses before the file grows. @@ -98,8 +102,7 @@ pub struct Database { /// One percentile of a duration observed outside the daemon, such as a CI run: the objective /// `NAME/pPERCENTILE`. A name may declare several percentiles, each its own objective. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct DurationTarget { pub name: String, pub about: String, @@ -119,8 +122,7 @@ impl DurationTarget { /// Events observed outside the daemon, such as flaky CI results, counted per hour: the /// objective `NAME`. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug)] pub struct CountTarget { pub name: String, pub about: String, @@ -150,10 +152,142 @@ impl Latency { } } -/// Read and check a targets file. A missing, unknown or nonsensical field is an error, never a -/// default. +/// Read and check an objectives file of the objectives Fractal ships. A missing, unknown or +/// nonsensical child is an error, never a default. pub fn parse(text: &str) -> Result { - let targets: Targets = toml::from_str(text).context("the targets file is not valid")?; + read(text).map(|(_, targets)| targets) +} + +fn read(text: &str) -> Result<(Vec, Targets)> { + let objectives = + objective::parse_document(text).map_err(|error| anyhow!("the objectives file is not valid: {error}"))?; + let targets = build(&objectives)?; + check(&targets)?; + Ok((objectives, targets)) +} + +/// Put `value` in the one slot its indicator has, named `expected`: `fractal doctor` and the OTLP +/// export read these by name. +fn one(slot: &mut Option, indicator: &str, expected: &str, name: &str, value: T) -> Result<()> { + ensure!(name == expected, "the {indicator} objective is named {name}, not {expected}"); + ensure!(slot.is_none(), "there are two {indicator} objectives"); + *slot = Some(value); + Ok(()) +} + +/// The targets the shipped objectives say. Every one is a single segment; an interval is only +/// ever declared. +fn build(objectives: &[Objective]) -> Result { + let mut latency = Vec::new(); + let mut share = Vec::new(); + let mut statement = None; + let mut transaction = None; + let mut writer_hold = None; + let mut cpu = None; + let mut divergence = None; + let mut database = None; + let mut duration = Vec::new(); + let mut count = Vec::new(); + for objective in objectives { + let name = objective.name.as_str(); + ensure!(!name.contains('/'), "objective {name} is not one segment: only a declared objective has a namespace"); + let about = objective.about.clone(); + let within = |stat: &str, remote: bool| objective.target(stat, remote).and_then(objective::Target::within_ms); + let required = |stat: &str| within(stat, false).with_context(|| format!("objective {name} has no {stat} duration")); + let max = |stat: &str| { + objective.target(stat, false).and_then(objective::Target::max).with_context(|| format!("objective {name} has no {stat} maximum")) + }; + let whole = |value: f64, what: &str| -> Result { + ensure!(value >= 0.0 && value.fract() == 0.0, "objective {name}'s {what} is not a whole number"); + Ok(value as u64) + }; + match &objective.indicator { + Indicator::Latency { paths } => latency.push(Latency { + name: name.to_owned(), + about, + p99_ms: required("p99")?, + remote_p99_ms: within("p99", true), + paths: paths.clone(), + }), + Indicator::Share { path } => { + let min_percent = match objective.target("live-share", false).map(|target| target.bound) { + Some(Bound::MinPercent(min)) => whole(min, "minimum percent")?, + _ => bail!("objective {name} has no live-share minimum"), + }; + share.push(Share { name: name.to_owned(), about, min_percent, path: path.clone() }); + } + Indicator::Statement => { + let value = Statement { about, p99_ms: required("p99")?, max_ms: required("max")? }; + one(&mut statement, "statement", "sql-statement", name, value)?; + } + Indicator::Transaction => { + let value = Transaction { about, max_ms: required("max")? }; + one(&mut transaction, "transaction", "transaction", name, value)?; + } + Indicator::WriterHold => { + let value = WriterHold { about, max_ms: required("max")? }; + one(&mut writer_hold, "writer-hold", "writer-hold", name, value)?; + } + Indicator::Cpu { idle_min_minutes, load_max_cores } => { + let value = Cpu { + about, + max_cores: max("cores")?, + idle_max_cores: max("idle-cores")?, + idle_min_minutes: *idle_min_minutes, + load_max_cores: *load_max_cores, + }; + one(&mut cpu, "cpu", "cpu", name, value)?; + } + Indicator::Database => { + let value = Database { + about, + max_live_gb: max("live-gb")?, + max_growth_mb_per_day: max("growth-mb-per-day")?, + }; + one(&mut database, "database", "database", name, value)?; + } + Indicator::Divergence => { + let value = Divergence { about, max_open: whole(max("open")?, "open maximum")? }; + one(&mut divergence, "divergence", "projection-divergence", name, value)?; + } + Indicator::CiDuration { series } => { + for target in &objective.targets { + duration.push(DurationTarget { + name: name.to_owned(), + about: about.clone(), + percentile: target.percentile().with_context(|| format!("objective {name}'s target {} is no percentile", target.stat))?, + target_ms: target.within_ms().with_context(|| format!("objective {name}'s target {} has no duration", target.stat))?, + path: series.clone(), + }); + } + } + Indicator::CiCount { series } => count.push(CountTarget { + name: name.to_owned(), + about, + max_per_hour: whole(max("per-hour")?, "per-hour maximum")?, + path: series.clone(), + }), + Indicator::Interval(_) => { + bail!("objective {name} is an interval: Fractal ships none; a fleet declares one with `fractal apply`") + } + } + } + let missing = |indicator: &str| format!("there is no {indicator} objective"); + Ok(Targets { + latency, + share, + statement: statement.with_context(|| missing("statement"))?, + transaction: transaction.with_context(|| missing("transaction"))?, + writer_hold: writer_hold.with_context(|| missing("writer-hold"))?, + cpu: cpu.with_context(|| missing("cpu"))?, + divergence: divergence.with_context(|| missing("divergence"))?, + database: database.with_context(|| missing("database"))?, + duration, + count, + }) +} + +fn check(targets: &Targets) -> Result<()> { let mut names = std::collections::BTreeSet::new(); let mut paths = std::collections::BTreeSet::new(); for latency in &targets.latency { @@ -201,6 +335,7 @@ pub fn parse(text: &str) -> Result { "the statement targets are inconsistent" ); ensure!(targets.transaction.max_ms > 0, "the transaction target is zero"); + ensure!(targets.writer_hold.max_ms > 0, "the writer-hold target is zero"); let cpu = &targets.cpu; ensure!( [cpu.max_cores, cpu.idle_max_cores, cpu.load_max_cores] @@ -245,13 +380,22 @@ pub fn parse(text: &str) -> Result { count.path ); } - Ok(targets) + Ok(()) +} + +/// The shipped objectives and the targets they say. The file is checked by a test, so this cannot +/// fail in a release. +static BUILT_IN: LazyLock<(Vec, Targets)> = + LazyLock::new(|| read(SOURCE).expect("the built-in objectives file is valid")); + +/// The objectives Fractal ships, as the objective reader reads them. +pub fn shipped() -> &'static [Objective] { + &BUILT_IN.0 } -/// The built-in targets. The file is checked by a test, so this cannot fail in a release. +/// The built-in targets. pub fn targets() -> &'static Targets { - static TARGETS: OnceLock = OnceLock::new(); - TARGETS.get_or_init(|| parse(SOURCE).expect("the built-in targets file is valid")) + &BUILT_IN.1 } impl Targets { @@ -274,6 +418,10 @@ impl Targets { pub fn transaction_target(&self) -> Duration { Duration::from_millis(self.transaction.max_ms) } + + pub fn writer_hold_target(&self) -> Duration { + Duration::from_millis(self.writer_hold.max_ms) + } } /// Give the store's windows their targets, once per process. @@ -285,13 +433,10 @@ pub fn install() { StoreWork::Statement, Some(targets.statement_target()), ); - for work in [ - StoreWork::ReadTransaction, - StoreWork::WriteTransaction, - StoreWork::WriterHold, - ] { + for work in [StoreWork::ReadTransaction, StoreWork::WriteTransaction] { smallclaims::windows::set_store_target(work, Some(targets.transaction_target())); } + smallclaims::windows::set_store_target(StoreWork::WriterHold, Some(targets.writer_hold_target())); }); } @@ -486,18 +631,18 @@ const OPEN_READS: usize = 8; /// Routes whose read loans are reported beside the writer's holds; the rest are summed. const LOAN_ROUTES: usize = 8; -/// The writer-hold objective: each hold of the store's single writer against the transaction -/// target, and who held it; each read transaction past that target, and who kept it open. +/// The writer-hold objective: each hold of the store's single writer against its target, and who +/// held it; each read transaction past that target, and who kept it open. fn writer_hold_row() -> Value { let targets = targets(); let series = |work| smallclaims::windows::store_series(work).snapshot(std::time::Instant::now()); let mut row = json!({ "name": "writer-hold", - "about": "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked", - "max_ms": targets.transaction.max_ms, + "about": targets.writer_hold.about, + "max_ms": targets.writer_hold.max_ms, "windows": series(StoreWork::WriterHold), "read_windows": series(StoreWork::ReadTransaction), - "open_reads": smallclaims::sqlite::open_snapshots_over(targets.transaction_target(), OPEN_READS), + "open_reads": smallclaims::sqlite::open_snapshots_over(targets.writer_hold_target(), OPEN_READS), // Every read loan by the route it works for, checked out or with a confirmed snapshot: // a loan held past its budget keeps the WAL from recycling even before it reads. "read_loans": smallclaims::sqlite::loans_by_route(LOAN_ROUTES), @@ -777,8 +922,10 @@ mod tests { use super::*; #[test] - fn the_built_in_targets_file_is_valid_and_names_the_mission_targets() { + fn the_built_in_objectives_file_is_valid_and_names_the_mission_targets() { let targets = parse(SOURCE).unwrap(); + assert_eq!(shipped().len(), 21); + assert!(shipped().iter().all(|objective| !objective.declarable())); let read = targets.for_path("GET /v1/client/agents").unwrap(); assert_eq!((read.p99_ms, read.remote_p99_ms), (300, Some(300))); assert_eq!(read.target(true), Duration::from_millis(300)); @@ -819,7 +966,7 @@ mod tests { let attach = targets.for_path("stream terminal").unwrap(); assert_eq!(attach.p99_ms, 200); assert_eq!((targets.statement.p99_ms, targets.statement.max_ms), (10, 100)); - assert_eq!(targets.transaction.max_ms, 300); + assert_eq!((targets.transaction.max_ms, targets.writer_hold.max_ms), (300, 300)); assert_eq!( (targets.cpu.max_cores, targets.cpu.idle_max_cores, targets.cpu.idle_min_minutes, targets.cpu.load_max_cores), (1.0, 0.5, 10, 2.0) @@ -846,7 +993,7 @@ mod tests { } #[test] - fn every_route_path_in_the_targets_file_is_a_route_the_daemon_serves() { + fn every_route_path_in_the_objectives_file_is_a_route_the_daemon_serves() { // api.rs's route table and the route families the daemon adds to it (fractal-daemon's `api::router`). let source = [ include_str!("../../fractal-api-core/src/api.rs"), @@ -929,7 +1076,7 @@ mod tests { install(); let rows = store_report(json!({})); let row = rows.iter().find(|row| row["name"] == "writer-hold").unwrap(); - assert_eq!(row["max_ms"], targets().transaction.max_ms); + assert_eq!(row["max_ms"], targets().writer_hold.max_ms); for field in ["windows", "read_windows", "open_reads", "holders", "long_holds", "long_reads", "in_progress", "waiting"] { assert!(row.get(field).is_some(), "{field} missing from {row}"); } @@ -942,61 +1089,82 @@ mod tests { assert!(row["windows"]["1h"].is_object()); } + /// The shipped file without objective `name`. + fn without(name: &str) -> String { + let start = SOURCE.find(&format!("\nobjective \"{name}\" {{")).unwrap() + 1; + let end = start + SOURCE[start..].find("\n}\n").unwrap() + 3; + format!("{}{}", &SOURCE[..start], &SOURCE[end..]) + } + + fn error(text: &str) -> String { + parse(text).unwrap_err().to_string() + } + #[test] - fn a_broken_targets_file_is_refused_not_defaulted() { - let without_cpu = SOURCE.split("[cpu]").next().unwrap(); - assert!(parse(without_cpu).is_err()); + fn a_broken_objectives_file_is_refused_not_defaulted() { + for name in ["sql-statement", "transaction", "writer-hold", "cpu", "database", "projection-divergence"] { + assert!(error(&without(name)).contains("there is no"), "{name}"); + } for (from, to) in [ - ("\nmax_cores = 1.0", "\nmax_cores = -1.0"), - ("\nmax_cores = 1.0", "\nmax_cores = 0.4"), - ("idle_max_cores = 0.5", "idle_max_cores = 0.0"), - ("idle_max_cores = 0.5", "idle_max_cores = nan"), - ("load_max_cores = 2.0", "load_max_cores = inf"), - ("idle_min_minutes = 10", "idle_min_minutes = 0"), - ("idle_min_minutes = 10", "idle_min_minutes = 61"), - ("idle_min_minutes = 10", "idle_min_minutes = 10.5"), - ("load_max_cores = 2.0\n", ""), - ("idle_max_cores = 0.5\n", ""), + ("target \"cores\" max=1", "target \"cores\" max=-1.0"), + ("target \"cores\" max=1", "target \"cores\" max=0.4"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=0.0"), + ("target \"idle-cores\" max=0.5", "target \"idle-cores\" max=#nan"), + ("load-max-cores 2", "load-max-cores #inf"), + ("idle-min-minutes 10", "idle-min-minutes 0"), + ("idle-min-minutes 10", "idle-min-minutes 61"), + ("idle-min-minutes 10", "idle-min-minutes 10.5"), + (" load-max-cores 2\n", ""), + (" target \"idle-cores\" max=0.5\n", ""), + ("target \"live-gb\" max=8", "target \"live-gb\" max=0.0"), + (" indicator \"statement\"\n", " indicator \"statement\"\n typo 1\n"), + ("target \"live-share\" min-percent=99", "target \"live-share\" min-percent=99.5"), + ("target \"open\" max=0", "target \"open\" max=0.5"), ] { assert!(SOURCE.contains(from), "{from}"); assert!(parse(&SOURCE.replace(from, to)).is_err(), "{to}"); } - assert!(parse(SOURCE.split("[database]").next().unwrap()).is_err()); - assert!(parse(&SOURCE.replace("max_live_gb = 8.0", "max_live_gb = 0.0")).is_err()); - assert!(parse(&SOURCE.replace("p99_ms = 10\n", "p99_ms = 10\ntypo = 1\n")).is_err()); let twice = format!( - "{SOURCE}\n[[latency]]\nname = \"again\"\nabout = \"\"\np99_ms = 1\npaths = [\"GET /v1/client/now\"]\n" + "{SOURCE}\nobjective \"again\" {{\n about \"x\"\n indicator \"latency\" {{\n path \"GET /v1/client/now\"\n }}\n target \"p99\" within=\"1ms\"\n}}\n" ); - assert!(parse(&twice).unwrap_err().to_string().contains("two targets")); + assert!(error(&twice).contains("two targets")); + // Each statement, transaction, writer-hold, CPU, database and divergence objective is + // the one of its name; the shipped ones are one segment, and none is an interval. + assert!(error(&SOURCE.replace("objective \"database\"", "objective \"store-size\"")).contains("named store-size, not database")); + assert!(error(&SOURCE.replace("objective \"flakes\"", "objective \"ci/flakes\"")).contains("not one segment")); + let interval = format!( + "{SOURCE}\nobjective \"change-rollout\" {{\n about \"x\"\n indicator \"interval\" {{\n subject-prefix \"custom/acme/change/\"\n start \"custom.acme.change-ready\"\n stop \"custom.acme.change-applied\"\n }}\n window \"7d\"\n target \"p95\" within=\"5m\"\n}}\n" + ); + assert!(error(&interval).contains("is an interval"), "{}", error(&interval)); } #[test] fn ci_durations_and_counts_are_checked() { - let error = |text: &str| parse(text).unwrap_err().to_string(); - let p95 = "percentile = 95\ntarget_ms = 600000\npath = \"ci/merge-queue\""; - assert!(parse(SOURCE.split("[[count]]").next().unwrap()).is_err(), "the count kind is required"); - for percentile in ["0", "100", "250"] { - let text = SOURCE.replacen("percentile = 95", &format!("percentile = {percentile}"), 1); - assert!(error(&text).contains("between 1 and 99"), "{percentile}"); + for percentile in ["p0", "p100", "p250", "p-5"] { + let text = SOURCE.replacen("target \"p95\" within=\"13m\"", &format!("target \"{percentile}\" within=\"13m\""), 1); + assert!(error(&text).contains("no target"), "{percentile}"); + } + assert!(parse(&SOURCE.replacen("target \"p95\" within=\"13m\"", "target \"p95\" within=\"0ms\"", 1)).is_err()); + for to in ["max=-1", "max=0.5", "max=0 min-per-hour=0"] { + assert!(parse(&SOURCE.replace("target \"per-hour\" max=0", &format!("target \"per-hour\" {to}"))).is_err(), "{to}"); + } + for series in ["GET /v1/flakes", "ci/Flakes", "ci/"] { + assert!(error(&SOURCE.replace("\"ci/flakes\"", &format!("\"{series}\""))).contains("is not `ci/`"), "{series}"); } - assert!(parse(&SOURCE.replacen("percentile = 95", "percentile = -5", 1)).is_err()); - assert!(error(&SOURCE.replacen("target_ms = 780000", "target_ms = 0", 1)).contains("no duration")); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = -1")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0.5")).is_err()); - assert!(parse(&SOURCE.replace("max_per_hour = 0", "max_per_hour = 0\nmin_per_hour = 0")).is_err()); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"GET /v1/flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/Flakes\"")).contains("not a CI series")); - assert!(error(&SOURCE.replace("\"ci/flakes\"", "\"ci/\"")).contains("not a CI series")); - // The same percentile twice is one objective named twice. - let again = format!("{SOURCE}\n[[duration]]\nname = \"merge-latency\"\nabout = \"\"\n{p95}\n"); - assert!(error(&again).contains("named twice")); + // The same percentile twice is one target repeated. + let again = SOURCE.replace(" target \"p95\" within=\"10m\"\n", " target \"p95\" within=\"10m\"\n target \"p95\" within=\"9m\"\n"); + assert!(error(&again).contains("repeats target")); // Another name may not take a series that feeds one already. - let other = format!("{SOURCE}\n[[duration]]\nname = \"other\"\nabout = \"\"\n{p95}\n"); + let other = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-duration\" {{\n series \"ci/merge-queue\"\n }}\n target \"p95\" within=\"10m\"\n}}\n" + ); assert!(error(&other).contains("two targets")); - let count = format!("{SOURCE}\n[[count]]\nname = \"other\"\nabout = \"\"\nmax_per_hour = 1\npath = \"ci/merge-queue\"\n"); + let count = format!( + "{SOURCE}\nobjective \"other\" {{\n about \"x\"\n indicator \"ci-count\" {{\n series \"ci/merge-queue\"\n }}\n target \"per-hour\" max=1\n}}\n" + ); assert!(error(&count).contains("two targets")); - let named = SOURCE.replace("name = \"flakes\"", "name = \"write-ack\""); - assert!(error(&named).contains("named twice")); + let named = SOURCE.replace("objective \"flakes\"", "objective \"write-ack\""); + assert!(error(&named).contains("declared twice")); } #[test] diff --git a/crates/fractal-ui-model/src/missions/presentation.rs b/crates/fractal-ui-model/src/missions/presentation.rs index 94a9813bca1..65741b6bbb0 100644 --- a/crates/fractal-ui-model/src/missions/presentation.rs +++ b/crates/fractal-ui-model/src/missions/presentation.rs @@ -190,25 +190,42 @@ fn agent(seat: &Seat) -> Actor { } } +fn canonical_seat(mut seat: Seat) -> Option { + let id = fractal_schema::canonical_agent_actor(&seat.id)?; + if !id.starts_with("agent/") { + return None; + } + if let std::borrow::Cow::Owned(id) = id { + seat.id = id; + } + Some(seat) +} + /// The step's seat: its claimant, else its assignee; for a ready unclaimed step, the agent /// queuing it. fn seat_of(step: &MissionStep, fleet: &dyn Fleet, held: Option<&str>) -> Option { if step.agentless { return None; } - let seat = held.filter(|id| id.starts_with("agent/")).map(|id| { - fleet.seat(id).unwrap_or_else(|| Seat { - id: id.to_owned(), - label: id.trim_start_matches("agent/").to_owned(), - state: "unknown".into(), - fault: false, - busy_with: None, - }) + let held = held + .and_then(fractal_schema::canonical_agent_actor) + .filter(|id| id.starts_with("agent/")); + let seat = held.map(|id| { + fleet + .seat(&id) + .and_then(canonical_seat) + .unwrap_or_else(|| Seat { + label: id.trim_start_matches("agent/").to_owned(), + id: id.into_owned(), + state: "unknown".into(), + fault: false, + busy_with: None, + }) }); match seat { Some(seat) if step.claimant.is_some() || step.state != "ready" => Some(seat), // A ready step waits in whichever queue holds it, whoever it was assigned to. - held => fleet.queued_for(&step.id).or(held), + held => fleet.queued_for(&step.id).and_then(canonical_seat).or(held), } } @@ -227,6 +244,7 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl let word = word(mission, &work, !gates.is_empty(), |step| { fleet .queued_for(step) + .and_then(canonical_seat) .map(|seat| QueueOwner { down: seat.down() }) }); let mut steps = work @@ -238,7 +256,8 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl let seat = if person.is_some() { None } else { seat_of(step, fleet, held) }; let queued = (person.is_none() && step.state == "ready" && step.claimant.is_none()) .then(|| fleet.queued_for(&step.id)) - .flatten(); + .flatten() + .and_then(canonical_seat); let note = queued .map(|seat| match &seat.busy_with { Some((mission, path)) if !seat.down() => format!( @@ -319,6 +338,7 @@ pub fn present(mission: &fractal_client::Mission, gates: &[Gate], fleet: &dyn Fl work.iter() .find(|step| step.state == "ready" && step.claimant.is_none()) .and_then(|step| fleet.queued_for(&step.id)) + .and_then(canonical_seat) }) .flatten() .map(|seat| QueuedBehind { diff --git a/crates/fractal-ui-model/src/missions/presentation/tests.rs b/crates/fractal-ui-model/src/missions/presentation/tests.rs index 38c780d9f95..0862ee816cd 100644 --- a/crates/fractal-ui-model/src/missions/presentation/tests.rs +++ b/crates/fractal-ui-model/src/missions/presentation/tests.rs @@ -209,6 +209,119 @@ fn a_failed_step_offers_a_retry_and_names_its_seat() { assert_eq!(shown.progress, Progress { done: 1, total: 2 }); } +#[test] +fn legacy_assignees_and_claimants_resolve_the_canonical_roster_seat() { + let mut agents = Agents::default().with("agent/example/keeper", "stopped", None, &[]); + agents.seats.get_mut("agent/example/keeper").unwrap().label = "Roster keeper".into(); + for holder in [ + json!({"assignee": "example/keeper"}), + json!({"claimant": "example/keeper", "assignee": "agent/example/other"}), + ] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let shown = present(&mission, &[], &agents); + let owner = Actor::Agent { + agent: "agent/example/keeper".into(), + label: "Roster keeper".into(), + }; + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!( + shown.stuck.unwrap().agent.unwrap(), + SeatRef { + id: "agent/example/keeper".into(), + label: "Roster keeper".into(), + state: "stopped".into(), + } + ); + for action in shown + .actions + .iter() + .filter(|action| matches!(action.id, Offer::RestartAgent | Offer::Chat)) + { + assert_eq!(action.target, "agent/example/keeper"); + } + } +} + +#[test] +fn legacy_assignees_and_claimants_have_canonical_fallbacks() { + for holder in [ + json!({"assignee": "example/keeper"}), + json!({"claimant": "example/keeper"}), + ] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let shown = present(&mission, &[], &Agents::default()); + let owner = Actor::Agent { + agent: "agent/example/keeper".into(), + label: "example/keeper".into(), + }; + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!( + shown.stuck.unwrap().agent.unwrap().id, + "agent/example/keeper" + ); + } +} + +#[test] +fn legacy_queued_seats_emit_canonical_agent_ids() { + let mission = mission("running", vec![step("build", "ready", json!({}))]); + let agents = Agents::default().with( + "example/keeper", + "running", + Some(("mission/fleet/example/ship", "deploy")), + &["step-run/audit/build"], + ); + let shown = present(&mission, &[], &agents); + let owner = Actor::Agent { + agent: "agent/example/keeper".into(), + label: "example/keeper".into(), + }; + assert_eq!(shown.word, Word::Queued); + assert_eq!(shown.who, owner); + assert_eq!(shown.steps[0].owner, owner); + assert_eq!(shown.stuck.unwrap().agent.unwrap().id, "agent/example/keeper"); + assert_eq!(shown.queued_behind.unwrap().agent, "agent/example/keeper"); +} + +#[test] +fn invalid_or_non_agent_holders_never_emit_agent_actors() { + for id in ["", "keeper", "example/keep er", "agent/", "person/avery", "daemon/st"] { + for holder in [json!({"assignee": id}), json!({"claimant": id})] { + let mission = mission("running", vec![step("fix", "failed", holder)]); + let agents = Agents::default().with(id, "stopped", None, &[]); + let shown = present(&mission, &[], &agents); + assert_eq!(shown.who, Actor::Nobody, "{id}"); + let owner = if id == "person/avery" { + Actor::Person { + person: id.into(), + label: "avery".into(), + } + } else { + Actor::Nobody + }; + assert_eq!(shown.steps[0].owner, owner, "{id}"); + assert!(!matches!(shown.who, Actor::Agent { .. }), "{id}"); + assert!(!matches!(shown.steps[0].owner, Actor::Agent { .. }), "{id}"); + assert_eq!(shown.stuck.unwrap().agent, None, "{id}"); + } + let mission = mission("running", vec![step("build", "ready", json!({}))]); + let agents = Agents::default().with( + id, + "running", + Some(("mission/fleet/example/ship", "deploy")), + &["step-run/audit/build"], + ); + let shown = present(&mission, &[], &agents); + assert_eq!(shown.who, Actor::Nobody, "{id}"); + assert_eq!(shown.steps[0].owner, Actor::Nobody, "{id}"); + assert!(!matches!(shown.who, Actor::Agent { .. }), "{id}"); + assert!(!matches!(shown.steps[0].owner, Actor::Agent { .. }), "{id}"); + assert_eq!(shown.queued_behind, None, "{id}"); + } +} + #[test] fn working_names_who_holds_it_and_watching_is_st() { let working = mission( diff --git a/crates/fractal/src/driver_cli.rs b/crates/fractal/src/driver_cli.rs index 9e597510d78..87b0b45098a 100644 --- a/crates/fractal/src/driver_cli.rs +++ b/crates/fractal/src/driver_cli.rs @@ -1,5 +1,97 @@ use super::*; +fn managed_declaration_result( + result: std::result::Result, +) -> Result { + match result { + Ok(tree) => Ok(tree), + Err(GeneratedClientError::Api(ClientErrorCode::NotFound, _, _)) => Ok(Value::Null), + Err(error) => Err(error.into()), + } +} + +pub(super) async fn prepare_managed_provider( + client: &Client, + subject: &str, + harness: &str, + argv: &mut [String], +) -> Result<()> { + let actor = client.person.as_deref().unwrap_or(subject); + let declaration = retry_while_daemon_unreachable(subject, || async { + let gateway = generated_client(&client.endpoint, Some(actor))?; + managed_declaration_result( + gateway.agent_declaration_get(subject, None, false).await.map(|reply| reply.value.tree), + ) + }).await?; + fractal_drivers::managed_launch::resolve(harness, argv, &declaration, subject) +} + +#[cfg(test)] +mod managed_declaration_tests { + use super::*; + + fn api_error(code: ClientErrorCode) -> GeneratedClientError { + let envelope = fractal_client::ErrorEnvelope { + api_version: "st3.client.v0".into(), + error_version: "st3.client.error.v0".into(), + request_id: "test".into(), + code: code.clone(), + message: "declaration read failed".into(), + retryable: false, + retry_after_ms: None, + details: BTreeMap::new(), + }; + GeneratedClientError::Api(code, envelope.message.clone(), Box::new(envelope)) + } + + #[test] + fn confirmed_not_found_allows_persona_fallback() { + assert_eq!(managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(), Value::Null); + let spec = json!({"children": [{"name": "name", "arguments": ["Managed seat"]}]}); + assert_eq!(managed_declaration_result(Ok(spec.clone())).unwrap(), spec); + } + + #[test] + fn declaration_failures_do_not_become_missing_specs() { + for code in [ClientErrorCode::Forbidden, ClientErrorCode::Internal] { + assert!(managed_declaration_result(Err(api_error(code))).is_err()); + } + assert!(managed_declaration_result(Err(GeneratedClientError::Transport("unavailable".into()))).is_err()); + assert!(managed_declaration_result(Err(GeneratedClientError::Protocol("invalid JSON".into()))).is_err()); + } + + #[cfg(unix)] + #[test] + fn legacy_resume_and_flagless_agent_calls_select_the_managed_wrapper() { + use std::os::unix::fs::PermissionsExt as _; + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("managed/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let environment = BTreeMap::from([ + ("AGENT_RUNTIME_PROFILE".into(), profile.display().to_string()), + ("PATH".into(), "/native/bin".into()), + ]); + for program in ["codex", "/native/bin/codex"] { + // Legacy re-exec preserves these flagless arguments from the previous image. + let Command::Driver(mut args) = Cli::try_parse_from([ + "fractal", "driver", "codex", "--subject", "agent/example", "--", program, + ]).unwrap().command else { panic!("driver expected") }; + let declaration = managed_declaration_result(Err(api_error(ClientErrorCode::NotFound))).unwrap(); + fractal_drivers::managed_launch::resolve_with_environment( + &args.driver, &mut args.argv, &declaration, &environment, &|_| Ok(()), + ).unwrap(); + assert_eq!(Path::new(&args.argv[0]), wrapper); + } + } +} + /// `st driver-hook NAME [ARGS]`: answer one harness hook with the payload on stdin. pub(super) fn run_driver_hook() -> ExitCode { fractal_client::set_client_name(format!( @@ -150,10 +242,21 @@ pub(super) async fn run_driver(client: &Client, args: DriverArgs, catalog: Optio if let Some(drivers) = std::env::var_os("ST3_DRIVER_STATE_DIR") { fractal_drivers::run::use_harness_state_root(PathBuf::from(drivers).join("sessions")); } - if let Some(state) = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV) { + let resume = fractal_drivers::reexec::resume_path(fractal_drivers::reexec::DRIVER_RESUME_ENV); + // Fresh drivers keep the existing runtime wait across daemon restarts. Adopting + // drivers cannot wait on a new incarnation, but their declaration read retries outages. + let incarnation = if resume.is_none() { + Some(wait_for_agent_incarnation(client, subject).await?) + } else { + None + }; + if matches!(args.driver.as_str(), "omp" | "claude" | "codex") { + prepare_managed_provider(client, subject, &args.driver, &mut argv).await?; + } + if let Some(state) = resume { return resume_native_driver(client, subject, &args.driver, argv, &state).await; } - let incarnation = wait_for_agent_incarnation(client, subject).await?; + let incarnation = incarnation.context("fresh driver has no runtime incarnation")?; let paths = NativePaths::prepare(subject, &args.driver)?; let sessions = paths.session_dir.join("provider-sessions"); if let Some(session) = fractal_daemon::native_seed::first_launch( diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index b518badc45b..ff8ac95f0c7 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7542,7 +7542,7 @@ async fn run_up(args: UpArgs) -> Result<()> { fields: BTreeMap::from([ ( "features".into(), - serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1, + serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1,"mission_authority":1, "harness_access_auto": u8::from(config.active_access_mode() == fractal_drivers::access::AccessMode::Auto), "messages_stream": u8::from(messages_shard.is_ok()), // Counts its stream mail in its idle, unread, held and usage indexes. It @@ -7628,7 +7628,7 @@ async fn run_up(args: UpArgs) -> Result<()> { configured_peers: config.peers.iter().map(|peer| peer.name.clone()).collect(), client_relay: fractal_daemon::peer::ClientRelay::from_config(&config)? .map(|relay| relay.with_links(store.clone())), - native_session_home: std::env::var_os("HOME").map(PathBuf::from), + native_session_home: fractal_daemon::instance::native_session_home()?, planner_default: config.planner.clone(), harness_question_sink: None, assistant_receipts: Default::default(), @@ -7707,6 +7707,7 @@ async fn run_up(args: UpArgs) -> Result<()> { move || { let mut rows = fractal_daemon::api::slo_rows(&store)?; fractal_daemon::objective_faults::check_and_report(&store, &rows); + fractal_daemon::declared_objectives::check_and_report(&store, &rows); rows.extend(fractal_daemon::readiness_slo::sample(&store)); Ok(rows) }, @@ -10964,7 +10965,7 @@ async fn run_slo(client: &Client, args: SloArgs, json_output: bool) -> Result<() let history: Value = client .get(&format!( "/v1/slo/history?id={}&since_ms={since}&until_ms={until}&steps={columns}", - row["id"].as_str().unwrap_or_default().replace('/', "%2F"), + urlencoding::encode(row["id"].as_str().unwrap_or_default()), )) .await?; if json_output { @@ -18740,7 +18741,7 @@ fn tolerate_driver_api_outage( last_warning: &mut Option, ) -> Result<()> { let outage = fractal_daemon::client::daemon_unreachable(&error).map(|outage| outage.summary()); - let transient = outage.is_some() + let transient = daemon_is_unreachable(&error) || error.chain().any(|cause| { let message = cause.to_string(); message.contains("incomplete HTTP response") diff --git a/crates/fractal/src/native_seed_driver_tests.rs b/crates/fractal/src/native_seed_driver_tests.rs index 96424162a74..b906118e336 100644 --- a/crates/fractal/src/native_seed_driver_tests.rs +++ b/crates/fractal/src/native_seed_driver_tests.rs @@ -71,3 +71,127 @@ fn rejects_effective_resume_environment_before_receipt() { } } } + +#[cfg(unix)] +#[test] +fn managed_startup_reads_its_declaration_over_authenticated_unix() { + use std::os::unix::fs::PermissionsExt as _; + + const CHILD: &str = "ST3_TEST_MANAGED_UNIX_ROOT"; + let Some(root) = std::env::var_os(CHILD) else { + // Both the profile environment and the resolver's process-wide provider PATH belong + // to one driver. Isolate them from other tests, just like a real driver process. + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("steward/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join("codex"); + std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let profile = root.path().join("profile.json"); + // Deliberately omit generalist: the environment must select the steward wrapper. + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"steward": {"harnessBin": bin}}}, + })).unwrap()).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "native_seed_driver_tests::managed_startup_reads_its_declaration_over_authenticated_unix", + "--nocapture", + ]) + .env(CHILD, root.path()) + .env("AGENT_RUNTIME_PROFILE", &profile) + .env("AGENT_PERSONA", "steward") + .env("HOME", root.path()) + .output().unwrap(); + assert!(output.status.success(), "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr)); + return; + }; + let root = PathBuf::from(root); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all().build().unwrap(); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(10), async { + const NODE: &str = "managed-startup"; + const FLEET: &str = "3c9a1f2e-8b7d-4e6c-a5f4-1d2e3c4b5a69"; + const SUBJECT: &str = "agent/example/managed"; + let store = Arc::new(Store::open_memory(NODE).unwrap()); + let key = Arc::new(fractal_daemon::fleet::MemberKey::generate().unwrap().0); + store.bind_fleet(FLEET).unwrap(); + store.pin_fleet_anchor(key.public()).unwrap(); + store.set_member_key(Some(key.clone())).unwrap(); + store.admit_fleet_anchor(FLEET, key.public(), "listening").unwrap(); + let source = format!( + "version 2\nagent \"example/managed\" {{ accountable \"person/operator\"; workspace {:?}; harness \"codex\" {{}}; }}", + root, + ); + let intent = fractal_daemon::graph::parse_intent(&source, NODE).unwrap(); + store.apply_as( + &intent, + &BTreeMap::from([(SUBJECT.into(), Vec::new())]), + "managed-startup-declaration", + Some("person/operator"), + ).unwrap(); + let state = AppState { + browser_pairing_login: None, + seat_admission: Default::default(), + owning_person: Some("person/operator".into()), + store, + notify: Arc::new(Notify::new()), + event_notify: watch::channel(0_u64).0, + node: NODE.into(), + state_dir: root.clone(), + pty_root: root.join("pty"), + pty_binary: PathBuf::from("pty"), + fleet_id: Some(FLEET.into()), + configured_peers: vec![], + client_relay: None, + native_session_home: None, + planner_default: PlannerSpec::default(), + harness_question_sink: None, + assistant_receipts: Default::default(), + private_notes: Default::default(), + }; + let socket = root.join("managed.sock"); + #[cfg(target_os = "linux")] + let _startup = { + let startup = fractal_daemon::startup::Startup::begin(&socket).unwrap(); + startup.phase("full-replay/base-claims"); + let Err(error) = generated_client(&Endpoint::Unix(socket.clone()), Some(SUBJECT)) else { + panic!("full-replay startup must refuse client construction before listening"); + }; + assert!(daemon_is_unreachable(&error)); + startup + }; + let server_socket = socket.clone(); + let server = tokio::spawn(async move { + // Managed adoption may arrive before the restarted daemon accepts connections. + tokio::time::sleep(Duration::from_millis(50)).await; + serve_unix(&server_socket, router(state)).await.unwrap(); + }); + let mut client = Client::new(Endpoint::Unix(socket)); + let mut argv = vec!["/raw/bin/codex".into()]; + driver_cli::prepare_managed_provider(&client, SUBJECT, "codex", &mut argv) + .await.expect("managed startup must retry client construction during full replay"); + assert_eq!(Path::new(&argv[0]), root.join("steward/bin/codex")); + let anonymous = generated_client(&client.endpoint, None).unwrap() + .agent_declaration_get(SUBJECT, None, false).await.unwrap_err(); + assert!(matches!(anonymous, + GeneratedClientError::Api(ClientErrorCode::Forbidden, _, _) + ), "anonymous declaration read must be forbidden: {anonymous}"); + + // Declaration GET requires read.declarations, not a live runtime incarnation: + // it is needed before startup can launch or bind the provider. + for person in [Some("person/operator"), None] { + client.person = person.map(str::to_owned); + let mut argv = vec!["/raw/bin/codex".into(), "--model".into(), "example".into()]; + driver_cli::prepare_managed_provider(&client, SUBJECT, "codex", &mut argv) + .await.unwrap(); + assert_eq!(Path::new(&argv[0]), root.join("steward/bin/codex")); + assert_eq!(&argv[1..], ["--model", "example"]); + } + server.abort(); + }).await.expect("managed Unix startup must finish without launching a provider"); + }); +} diff --git a/crates/fractal/tests/authored_resume.rs b/crates/fractal/tests/authored_resume.rs index 335372a5d88..e7320de9d10 100644 --- a/crates/fractal/tests/authored_resume.rs +++ b/crates/fractal/tests/authored_resume.rs @@ -34,7 +34,13 @@ async fn authored_omp_resume_publishes_binding_from_an_empty_managed_directory() .join(&hex::encode(Sha256::digest(subject.as_bytes()))[..24]) .join("sessions/omp/provider-sessions"); std::fs::create_dir_all(&managed).unwrap(); - let provider = root.path().join("omp"); + let managed_bin = root.path().join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.path().join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&serde_json::json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("omp"); let fixture_node = std::env::split_paths(&std::ffi::OsString::from(env!("ST3_FIXTURE_PATH"))) .map(|directory| directory.join("node")) .find(|candidate| candidate.is_file()) @@ -153,6 +159,9 @@ agent "garden/worker" {{ .env("RESUME_TEST_STOP", &stop) .env("ST3_DRIVER_STATE_DIR", &drivers) .env("ST_AGENT", subject) + .env("FRACTAL_AGENT", subject) + .env("AGENT_PERSONA", "generalist") + .env("AGENT_RUNTIME_PROFILE", &profile) .env("ST3_ENDPOINT", &socket) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST_HOOKS", hooks) diff --git a/crates/fractal/tests/client_v0_cli.rs b/crates/fractal/tests/client_v0_cli.rs index 4219dc09d97..56f48aa671b 100644 --- a/crates/fractal/tests/client_v0_cli.rs +++ b/crates/fractal/tests/client_v0_cli.rs @@ -2877,7 +2877,7 @@ agent "queue-chief" { mission "queued-work" state="ready" { concurrent-runs goal "Give the durable seat one step in each run." - step "work" { assigned-to "agent/queue-seat" } + step "work" { assigned-to "agent/client-v0-cli.queue-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-cli").unwrap(); @@ -3167,10 +3167,10 @@ async fn agents_and_missions_show_print_what_the_worker_last_reported() { agent "reporter" { workspace "/tmp"; command "true" } mission "reported-work" state="ready" { goal "Show what the worker said." - step "docs" { assigned-to "agent/reporter" } + step "docs" { assigned-to "agent/client-v0-cli.reporter" } step "build" { title "Build the parser" - assigned-to "agent/reporter" + assigned-to "agent/client-v0-cli.reporter" } } "#; @@ -3359,7 +3359,7 @@ agent "queued-alias-seat" { workspace "/tmp"; command "true" } mission "queued-alias-work" state="ready" { concurrent-runs goal "Give the durable seat one step." - step "work" { assigned-to "agent/queued-alias-seat" } + step "work" { assigned-to "agent/client-v0-cli.queued-alias-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-cli").unwrap(); diff --git a/crates/fractal/tests/client_v0_contract.rs b/crates/fractal/tests/client_v0_contract.rs index 2466dc215a0..8dbcec30cd8 100644 --- a/crates/fractal/tests/client_v0_contract.rs +++ b/crates/fractal/tests/client_v0_contract.rs @@ -3646,7 +3646,7 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { ]; let steps = states .iter() - .map(|(internal, _)| format!("step {internal:?} {{ assigned-to \"agent/builder\" }}")) + .map(|(internal, _)| format!("step {internal:?} {{ assigned-to \"agent/client-v0-baseline.builder\" }}")) .collect::>() .join("\n"); let source = format!( @@ -3738,6 +3738,18 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { .unwrap(); } } + // Older stores can retain slash-qualified assignees without the actor prefix. + let legacy_step = run.steps.iter().find(|step| step.step == "ready").unwrap(); + let connection = rusqlite::Connection::open(root.path().join("claims.sqlite3")).unwrap(); + smallclaims::store::projection_digest::register(&connection).unwrap(); + assert_eq!( + connection.execute( + "UPDATE step_runs SET assignee = ?1 WHERE subject = ?2", + rusqlite::params!["legacy/builder", legacy_step.subject], + ).unwrap(), + 1 + ); + drop(connection); let app = fractal_daemon::api::router(state); for path in ["/v1/client/missions", "/v1/client/missions/state-contract"] { let (status, response) = client_json(app.clone(), path).await; @@ -3764,6 +3776,30 @@ async fn step_states_in_every_client_projection_belong_to_the_contract() { .iter() .any(|step| step["state"] == "claimed") ); + // Use the same presentation emitter as the published mission cards, not a hand-built + // presentation fixture, and validate the enclosing resource including /presentation. + let mut card = mission.clone(); + fractal_daemon::api::published_lists::present(&mut card, &[], Some(&Default::default())); + assert!(card["presentation"].is_object(), "{card}"); + assert_conforms(&contract_validator("Mission"), path, &card); + let presented_steps = card["presentation"]["steps"].as_array().unwrap(); + assert_eq!(presented_steps.len(), states.len()); + for step in presented_steps { + assert_eq!(step["owner"]["kind"], "agent", "{step}"); + assert_conforms( + &contract_validator("AgentId"), + "/presentation/steps/*/owner/agent", + &step["owner"]["agent"], + ); + } + let legacy = presented_steps.iter().find(|step| step["path"] == "ready").unwrap(); + assert_eq!(legacy["owner"]["agent"], "agent/legacy/builder"); + let mut invalid = card.clone(); + invalid["presentation"]["steps"][0]["owner"]["agent"] = serde_json::json!("legacy/builder"); + assert!( + !contract_validator("Mission").is_valid(&invalid), + "strict /presentation AgentId contract must reject an unprefixed actor" + ); } let (status, response) = client_json(app.clone(), "/v1/client/work?history=true").await; assert_eq!(status, StatusCode::OK, "{response}"); @@ -3805,7 +3841,7 @@ version 2 agent "ios-owner" { workspace "/tmp"; command "true" } mission "ios-proof" state="ready" { goal "Run the physical simulator proof." - step "automated-proof" { assigned-to "agent/ios-owner" } + step "automated-proof" { assigned-to "agent/client-v0-baseline.ios-owner" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); @@ -3919,7 +3955,7 @@ version 2 agent "release-owner" { workspace "/tmp"; command "true" } mission "release-proof" state="ready" { goal "Publish the release." - step "tag-proof" { assigned-to "agent/release-owner"; goal "Prove the published tag builds." } + step "tag-proof" { assigned-to "agent/client-v0-baseline.release-owner"; goal "Prove the published tag builds." } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); @@ -4086,7 +4122,7 @@ agent "queue-seat" { workspace "/tmp"; command "true" } mission "queued-work" state="ready" { concurrent-runs goal "Give the durable seat one step in each run." - step "work" { assigned-to "agent/queue-seat" } + step "work" { assigned-to "agent/client-v0-baseline.queue-seat" } } "#; let intent = fractal_daemon::graph::parse_intent(source, "client-v0-baseline").unwrap(); diff --git a/crates/fractal/tests/codex_bootstrap.rs b/crates/fractal/tests/codex_bootstrap.rs index 486f721bd6c..b0ad6b3393b 100644 --- a/crates/fractal/tests/codex_bootstrap.rs +++ b/crates/fractal/tests/codex_bootstrap.rs @@ -341,7 +341,13 @@ async fn delivery_recovery_control( .unwrap(); }); until(|| socket.exists(), "the isolated API did not start").await; - let provider = root.join("provider"); + let managed_bin = root.join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("codex"); let stub = PathBuf::from(test_env!("CARGO_MANIFEST_DIR")) .join("../../scripts/fractal-boot-canaries/stub-codex.py"); std::fs::write( @@ -371,6 +377,9 @@ async fn delivery_recovery_control( ("HOME", root.to_string_lossy().into_owned()), ("PATH", path.to_string_lossy().into_owned()), ("ST_AGENT", SUBJECT.to_owned()), + ("FRACTAL_AGENT", SUBJECT.to_owned()), + ("AGENT_PERSONA", "generalist".to_owned()), + ("AGENT_RUNTIME_PROFILE", profile.to_string_lossy().into_owned()), ( "ST3_SUBJECT", if bound_shell { TERMINAL } else { SUBJECT }.to_owned(), @@ -409,7 +418,7 @@ async fn delivery_recovery_control( } if bound_shell { command.args(["--", bash.to_str().unwrap(), "--noprofile", "--norc", "-c"]) - .arg(format!("while [ ! -e \"$HOME/launch-bound-codex\" ]; do sleep 0.02; done; \"$ST3_BIN\" driver codex --subject \"$ST_AGENT\" -- \"$HOME/provider\"; {BOUND_EXIT_PUBLICATION}; while :; do sleep 1; done")); + .arg(format!("while [ ! -e \"$HOME/launch-bound-codex\" ]; do sleep 0.02; done; \"$ST3_BIN\" driver codex --subject \"$ST_AGENT\" -- \"$HOME/managed-bin/codex\"; {BOUND_EXIT_PUBLICATION}; while :; do sleep 1; done")); } else { command.arg("--").arg(binary).args(["driver", "codex", "--subject", SUBJECT, "--"]).arg(provider); } @@ -1031,7 +1040,13 @@ async fn bootstrap_waits_for_reconciliation(status: &str, previous: Option<&str> .unwrap(); }); until(|| socket.exists(), "the isolated API did not start").await; - let provider = root.path().join("provider"); + let managed_bin = root.path().join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let profile = root.path().join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); + let provider = managed_bin.join("codex"); let marker = root.path().join("provider-invoked"); std::fs::write( &provider, @@ -1050,6 +1065,8 @@ async fn bootstrap_waits_for_reconciliation(status: &str, previous: Option<&str> ("HOME", root.path().to_string_lossy().into_owned()), ("PATH", path.to_string_lossy().into_owned()), ("ST_AGENT", SUBJECT.to_owned()), + ("FRACTAL_AGENT", SUBJECT.to_owned()), + ("AGENT_RUNTIME_PROFILE", profile.to_string_lossy().into_owned()), ("ST3_SUBJECT", SUBJECT.to_owned()), ("ST3_BIN", binary_path.clone()), ("ST3_ENDPOINT", socket.to_string_lossy().into_owned()), diff --git a/crates/fractal/tests/convergence.rs b/crates/fractal/tests/convergence.rs index 1e5411dc27a..17c3c87b821 100644 --- a/crates/fractal/tests/convergence.rs +++ b/crates/fractal/tests/convergence.rs @@ -79,7 +79,7 @@ const SEEDS: &[u64] = &[ fn corpus() -> &'static BTreeMap { static CORPUS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - serde_json::from_str(include_str!("../../../fixtures/convergence/claims-v17.json")) + serde_json::from_str(include_str!("../../../fixtures/convergence/claims-v18.json")) .expect("versioned convergence input schema") }); &CORPUS @@ -1020,7 +1020,24 @@ impl World { }); } let family = self.rng.pick(&spec.subjects).clone(); - let subject = self.subject_of(&family, index)?; + let subject = if spec.kind == "message.expired" { + // Expiry requires existing sent mail. A fresh subject would make every random + // expiry fail admission even with the runtime actor; native admission still + // decides whether delivery or closure has already made this mail ineligible. + let mut subjects = claims(&self.nodes[index].store) + .into_values() + .filter(|claim| claim.kind == "message.sent") + .map(|claim| claim.subject) + .collect::>(); + subjects.sort(); + subjects.dedup(); + if subjects.is_empty() { + return None; + } + self.rng.pick(&subjects).clone() + } else { + self.subject_of(&family, index)? + }; let deployment = deployment_fields(&spec.kind); let (fields, actor) = if let Some(mut fields) = deployment { if spec.kind == "deploy.go" && self.rng.chance(40) { @@ -1035,11 +1052,15 @@ impl World { fields.insert(name.clone(), self.value_of(field, index)); } } - let actor = match self.rng.below(4) { - 0 => None, - 1 => Some(PERSON.to_owned()), - 2 if subject.starts_with("agent/") => Some(subject.clone()), - _ => Some("agent/run-0/worker-0".to_owned()), + let actor = if spec.kind == "message.expired" { + Some("daemon/runtime".to_owned()) + } else { + match self.rng.below(4) { + 0 => None, + 1 => Some(PERSON.to_owned()), + 2 if subject.starts_with("agent/") => Some(subject.clone()), + _ => Some("agent/run-0/worker-0".to_owned()), + } }; (fields, actor) }; @@ -1692,6 +1713,33 @@ fn append_generated_claim( .expect("publication API wrote the generated mission")) } +#[test] +fn generated_message_expiry_is_admitted_and_replicates() { + let mut world = World::new(1, Sabotage::None); + let mut sent = world.claim_of(&corpus()["message.sent"], 0).unwrap(); + sent.actor = Some(PERSON.into()); + sent.fields = BTreeMap::from([ + ("status".into(), json!("sent")), + ("from".into(), json!(PERSON)), + ("to".into(), json!("agent/run-0/worker-0")), + ("content".into(), json!("Convergence expiry fixture")), + ]); + append_generated_claim(&world.nodes[0].store, &sent).unwrap(); + + // Use the random schedule's exact generation and admission paths, not a + // hand-authored expiry that only proves corpus membership. + let input = world.claim_of(&corpus()["message.expired"], 0).unwrap(); + assert_eq!(input.subject, sent.subject); + assert_eq!(input.actor.as_deref(), Some("daemon/runtime")); + assert_eq!(input.fields["status"], json!("expired")); + let claim = append_generated_claim(&world.nodes[0].store, &input).unwrap(); + let expiry = world.nodes[0].store.latest_claim(&input.subject, Some("message.expired")).unwrap().unwrap(); + assert_eq!(expiry.id, claim.id); + world.tap(0); + assert!(claims(&world.oracle).contains_key(&claim.id)); + assert!(world.failures.is_empty(), "{}", world.report()); +} + #[test] fn generated_mission_publications_replicate_as_canonical_missions() { let mut world = World::new(1, Sabotage::None); @@ -1807,14 +1855,13 @@ fn run_seeds(seeds: impl IntoIterator) { assert!(trimmed * 2 >= runs, "only {trimmed} of {runs} runs trimmed"); } -/// Corpus v17 adds operation receipts to kind selection, shifting the shared RNG schedule. -/// Hosted run 42507264532 observed seed 2 converge without trimming; the other fixed seeds trim. -const NEVER_TRIMS: &[u64] = &[2]; +/// Corpus v18 adds durable expiry to kind selection, shifting the shared RNG schedule. +/// Hosted run 42793993503 observed seeds 2 and 34 converge without trimming; the other fixed seeds trim. +const NEVER_TRIMS: &[u64] = &[2, 34]; /// One fixed seed: it converges, and its checkpoints trim unless it is a seed that never does. fn seed_converges(seed: u64) { let outcome = run(seed, Sabotage::None); - eprintln!("convergence corpus v17 seed {seed}: trimmed={}", outcome.trimmed); if let Some(failure) = &outcome.failure { panic!("seed {seed} failed\n{failure}"); } diff --git a/crates/fractal/tests/daemon_cost.rs b/crates/fractal/tests/daemon_cost.rs index ada0ef424db..632ff79d52b 100644 --- a/crates/fractal/tests/daemon_cost.rs +++ b/crates/fractal/tests/daemon_cost.rs @@ -629,6 +629,9 @@ const fn direct(route: &'static str, call: Direct) -> Probe { } const PROBES: &[Probe] = &[ + get("GET /v1/client/claims", "/v1/client/claims?limit=100"), + get("GET /v1/client/claims/by-id/{id}", "/v1/client/claims/by-id/{service_claim}"), + get("GET /v1/client/work-items/{*subject}", "/v1/client/work-items/{step}"), post("POST /v1/client/push-registration", "/v1/client/push-registration", |_, _| push_body()), Probe { route: "DELETE /v1/client/push-registration", @@ -2918,6 +2921,74 @@ fn seed_git_change_reads(store: &Store, scale: f64) { } } +#[cfg(test)] +const SERVICE_PROBE_TOKEN: &str = "invented-cost-service-reader"; + +// This integration-test target alone constructs the disabled Service profile. +#[cfg(test)] +fn seed_service_probe(store: &Store) -> String { + use fractal_daemon::api::client_v0::{credential_digest, external_grants}; + const ID: &str = "787878787878787878787878"; + store.append_claim(&ClaimInput { + subject:external_grants::subject(ID).unwrap(),kind:external_grants::GRANTED.into(), + actor:Some("person/bench-operator".into()), + fields:serde_json::from_value(json!({ + "profile":"service","principal":"plugin/cost/reader","grant_id":ID, + "session_actor":format!("plugin/cost/reader/session/{ID}"), + "issuer":"person/bench-operator","expires_at_unix_ms":u64::MAX, + "credential_hash":credential_digest(SERVICE_PROBE_TOKEN), + "scopes":["read.claims","read.work"], + })).unwrap(),evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap(); + store.append_claim(&ClaimInput { + subject:"gate-operation/cost/service-read".into(),kind:"gate.result".into(),actor:None, + fields:serde_json::from_value(json!({"verdict":"pass"})).unwrap(), + evidence:vec![],expected_subject:None,idempotency_key:None, + }).unwrap().id +} + +#[cfg(test)] +async fn service_probe(app: &Router, path: &str) -> Result { + use axum::{body::Body,http::Request}; + use tower::ServiceExt as _; + let response = app.clone().oneshot(Request::builder().uri(path) + .header("authorization",format!("Bearer {SERVICE_PROBE_TOKEN}")) + .body(Body::empty()).map_err(|error| error.to_string())?) + .await.map_err(|error| error.to_string())?; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(),usize::MAX).await.map_err(|error| error.to_string())?; + if !status.is_success() { + return Err(format!("service read {path} failed: {status}")); + } + let envelope: Value = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let answer = envelope["value"].clone(); + if path.starts_with("/v1/client/claims/by-id/") { + if answer["body"]["fields"]["verdict"] != "pass" { + return Err("service receipt probe must read its populated receipt".into()); + } + } else if path.starts_with("/v1/client/claims?") { + if !answer["claims"].as_array().is_some_and(|claims| !claims.is_empty()) { + return Err("service page probe must read populated claims".into()); + } + } else if !answer["subject"].as_str().is_some_and(|subject| subject.starts_with("step-run/")) { + return Err("service work probe must read a populated work item".into()); + } + Ok(answer) +} + +async fn required_service_probe(app: &Router, path: &str) -> Value { + service_probe(app,path).await.unwrap_or_else(|error| { + panic!("seeded service probe must succeed before cost aggregation: {error}") + }) +} + +#[tokio::test] +#[should_panic(expected = "seeded service probe must succeed before cost aggregation")] +async fn missing_service_probe_is_fatal_before_cost_aggregation() { + let app = Router::new().fallback(|| async { axum::http::StatusCode::NOT_FOUND }); + required_service_probe(&app,"/v1/client/claims/by-id/unseeded").await; +} + fn push_body() -> Value { json!({ "token":"0123456789abcdef0123456789abcdef", @@ -3098,6 +3169,10 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u let mut state = state; state.harness_question_sink = Some(harness_sink.clone()); let paired_router = fractal_daemon::api::fabric_router(state.clone()); + let service_claim = seed_service_probe(&store); + let mut service_state = state.clone(); + service_state.owning_person = Some("person/bench-operator".into()); + let service_router = fractal_daemon::api::fabric_router(service_state); let seeded_cut = store.index().unwrap(); fractal_daemon::api::start_published_lists(&state); tokio::time::timeout(Duration::from_secs(120), @@ -3165,6 +3240,7 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u store.append_claim(&running).unwrap(); } let mut fixture = fixture(&person, &client, subjects).await; + fixture.items.insert("service_claim",service_claim); fixture.items.insert( "harness_question", urlencoding::encode(&harness_question.id).into_owned(), @@ -3434,6 +3510,9 @@ async fn measure(scale: f64, source: &Path, peer_source: &Path, usage_window: (u let method = probe.route.split_once(' ').unwrap().0; let body = probe.body.map(|body| body(&fixture, attempt)); counted(|| paired_push(&paired_router, push_credential, method, body.as_ref())).await + } else if probe.path.starts_with("/v1/client/claims") || probe.path.starts_with("/v1/client/work-items/") { + let path = fixture.fill(probe.path,attempt); + counted(|| async { Ok(required_service_probe(&service_router,&path).await) }).await } else if let Some(call) = probe.direct { let (store, fixture) = (store.clone(), &fixture); counted(|| async move { diff --git a/crates/fractal/tests/daemon_load.rs b/crates/fractal/tests/daemon_load.rs index 97cba643bf7..1af65c48215 100644 --- a/crates/fractal/tests/daemon_load.rs +++ b/crates/fractal/tests/daemon_load.rs @@ -78,8 +78,8 @@ const BASELINE_RUNS: usize = 5; /// The daemon's CPU, in cores, may differ from the baseline by this much whatever the ratio. const CPU_SLACK: f64 = 0.05; -/// The daemon's average CPU over the run may not pass this many cores: `load_max_cores` in -/// `slo/targets.toml`, built into st3, the bound under this synthetic load rather than the +/// The daemon's average CPU over the run may not pass this many cores: the `cpu` objective's +/// `load-max-cores` in `slo/objectives.kdl`, built into st3, the bound under this synthetic load rather than the /// member's production target. A missing file fails the build and an invalid one fails the run; /// there is no default. fn cpu_budget() -> f64 { @@ -348,7 +348,7 @@ struct PathReport { p99_ms: f64, max_ms: f64, budget_ms: f64, - /// The path's p99 target in `slo/targets.toml`, when it has one. It describes; the run's + /// The path's p99 target in `slo/objectives.kdl`, when it has one. It describes; the run's /// gates are `budget_ms` and main's baseline, not this. #[serde(default, skip_serializing_if = "Option::is_none")] descriptive_target_ms: Option, @@ -439,7 +439,7 @@ const HOG_MEASURES: &[&str] = &["person-read", "person-read-fresh", "write-ack", /// Runs the daemon twice on the same generated store, once alone and once beside a CPU and disk /// hog at the priority seats get by default, with the load average held above the core count. /// It reports person-read, person-read-fresh, write-ack and message send against the targets in -/// `slo/targets.toml` (what `GET /v1/client/request-latency` reports) for each run. +/// `slo/objectives.kdl` (what `GET /v1/client/request-latency` reports) for each run. /// /// ```sh /// ST_LOAD_GATE=1 ST_LOAD_HOG=1 ST_LOAD_HOG_REPORT=out TMPDIR=/var/tmp cargo test --release \ @@ -1273,7 +1273,7 @@ fn targets_come_from_the_targets_file_and_describe_without_gating() { ] { let target = targets .for_path(path) - .unwrap_or_else(|| panic!("{path} has no target in slo/targets.toml")); + .unwrap_or_else(|| panic!("{path} has no target in slo/objectives.kdl")); assert_eq!(descriptive_target(name), Some(target.target(false)), "{name}"); } for name in ["claim", "replication exchange", LONG_POLL] { @@ -1479,7 +1479,7 @@ fn print(report: &Report) { name, path.count, path.p50_ms, path.p99_ms, path.max_ms, path.budget_ms, target ); } - println!("target: slo/targets.toml's p99, described and never gated"); + println!("target: slo/objectives.kdl's p99, described and never gated"); if !report.targets.is_empty() { println!("{:<20} {:>7} {:>8} {:>8} {:>8} {:>8}", "target", "n", "p50 ms", "p99 ms", "max ms", "target"); for (name, target) in &report.targets { @@ -2948,7 +2948,7 @@ fn runtime_of(seat: &str) -> String { format!("load-{}-runtime", seat.rsplit('/').next().unwrap_or(seat)) } -/// The path in `slo/targets.toml` a request stands for, when it stands for one. Lease renewal +/// The path in `slo/objectives.kdl` a request stands for, when it stands for one. Lease renewal /// calls the store directly; it is timed as the work action it is. fn target_path(name: &str) -> Option { match name { @@ -3003,7 +3003,7 @@ fn target_rows(timings: &BTreeMap>) -> BTreeMap Option { let path = target_path(name)?; Some(fractal_daemon::slo::targets().for_path(&path)?.target(false)) diff --git a/crates/fractal/tests/daemon_restart.rs b/crates/fractal/tests/daemon_restart.rs index 2c97ba30ca9..9a3d14bcb70 100644 --- a/crates/fractal/tests/daemon_restart.rs +++ b/crates/fractal/tests/daemon_restart.rs @@ -289,6 +289,47 @@ fn seat_command(root: &Path, socket: &Path) -> Command { command } +/// Keep provider supervision real while selecting the fake provider through the managed +/// runtime contract, rather than relying on the raw executable after `--`. +fn managed_seat_command( + root: &Path, + socket: &Path, + subject: &str, + harness: &str, + provider: &str, +) -> Command { + use std::os::unix::fs::PermissionsExt as _; + + let bin = root.join("managed-runtime/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let wrapper = bin.join(harness); + std::fs::write( + &wrapper, + format!( + "#!{}\nexec \"$FRACTAL_TEST_PROVIDER\" \"$@\"\n", + env!("ST3_FIXTURE_BASH"), + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o700)).unwrap(); + let profile = root.join("managed-runtime/profile.json"); + std::fs::write( + &profile, + serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": bin}}}, + })) + .unwrap(), + ) + .unwrap(); + let mut command = seat_command(root, socket); + command + .env("AGENT_RUNTIME_PROFILE", profile) + .env("ST_AGENT", subject) + .env("FRACTAL_AGENT", subject) + .env("FRACTAL_TEST_PROVIDER", provider); + command +} + fn driver_log(root: &Path) -> String { std::fs::read_to_string(root.join("state/st3/driver-api-warnings.log")).unwrap_or_default() } @@ -394,7 +435,7 @@ impl Drop for TestSeat { /// test's process group and temporary home; TestSeat also cleans up on a failed assertion. async fn native_exit_driver(root: &Path, daemon: &mut Daemon) -> TestSeat { let seat = "agent/grove/exit-orchid"; - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "python3"); declare_claude(daemon, seat); daemon.observe_running(seat, "exit-orchid:one"); daemon.start_admitted_protocol().await; @@ -461,7 +502,7 @@ async fn claude_channel_policy_banner_keeps_driver_alive_and_channel_unattached( daemon.start_admitted_protocol().await; let provider = "printf '%s\\n' '--dangerously-load-development-channels blocked by org policy (plugin:st-channel@st)' 'Inbound messages will be silently dropped' 'Have an administrator set channelsEnabled: true in managed settings to enable'; exec sleep 300"; let mut driver = TestSeat(Some( - seat_command(root, &daemon.socket) + managed_seat_command(root, &daemon.socket, seat, "claude", "sh") .env("ST_AGENT", seat) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST3_CLAUDE_ATTACH_WINDOW_MS", "50") @@ -710,7 +751,7 @@ async fn a_quiet_idle_seat_ages_stale_and_recovers_new_evidence_after_daemon_res let seat = "agent/grove/quiet-cedar"; let incarnation = "quiet-cedar:one"; let mut daemon = Daemon::new(root); - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "python3"); declare_claude(&daemon, seat); daemon.observe_running(seat, incarnation); daemon.start_admitted_protocol().await; @@ -886,7 +927,7 @@ async fn title_failure(pty_session: Option<&str>, expected_session: &str) { let root = root.path(); let seat = "agent/grove/title-cedar"; let mut daemon = Daemon::new(root); - let mut command = seat_command(root, &daemon.socket); + let mut command = managed_seat_command(root, &daemon.socket, seat, "claude", "sleep"); declare_claude(&daemon, seat); daemon.observe_running(seat, "title-cedar:one"); daemon.start_admitted_protocol().await; @@ -1035,7 +1076,7 @@ async fn a_claude_seat_starts_through_a_daemon_restart_and_then_keeps_its_mail() daemon.start().await; daemon.stop().await; - let mut driver = seat_command(root, &daemon.socket) + let mut driver = managed_seat_command(root, &daemon.socket, seat, "claude", "sleep") .args(["driver", "claude", "--subject", seat, "--", "sleep", "300"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -2579,7 +2620,7 @@ channel.stdin.flush() time.sleep(300) "#; let mut driver = TestSeat(Some( - seat_command(root, &daemon.socket) + managed_seat_command(root, &daemon.socket, seat, "claude", "python3") .env("ST_AGENT", seat) .env("ST3_MAILBOX_TRANSPORT", "push") .env("ST3_CLAUDE_READINESS_GRACE_MS", HOOKLESS_GRACE_MS.to_string()) diff --git a/crates/fractal/tests/daemon_restart/stop_escalation.rs b/crates/fractal/tests/daemon_restart/stop_escalation.rs index 3300ea85d75..bb14e083e30 100644 --- a/crates/fractal/tests/daemon_restart/stop_escalation.rs +++ b/crates/fractal/tests/daemon_restart/stop_escalation.rs @@ -6,7 +6,7 @@ //! Linux uses pidfds and macOS uses kqueue NOTE_EXIT; both watch exact owned processes. #![cfg(any(target_os = "linux", target_os = "macos"))] -use super::{Daemon, TestSeat, seat_command}; +use super::{Daemon, TestSeat, managed_seat_command, seat_command}; use sha2::{Digest as _, Sha256}; use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; use std::os::unix::process::ExitStatusExt as _; @@ -167,7 +167,11 @@ async fn spawn_driver( ) -> (TestSeat, OwnedFd, OwnedFd, PathBuf) { let ready_path = root.join("ready.sock"); let ready = tokio::net::UnixDatagram::bind(&ready_path).unwrap(); - let mut command = seat_command(root, &daemon.socket); + let mut command = if driver == "claude" { + managed_seat_command(root, &daemon.socket, SUBJECT, driver, "python3") + } else { + seat_command(root, &daemon.socket) + }; declare(daemon, driver); daemon.observe_running(SUBJECT, "stop-orchid:one"); daemon.start_admitted_protocol().await; diff --git a/crates/fractal/tests/delivery_probe.rs b/crates/fractal/tests/delivery_probe.rs index f1c006fdc33..57d6decfd21 100644 --- a/crates/fractal/tests/delivery_probe.rs +++ b/crates/fractal/tests/delivery_probe.rs @@ -27,7 +27,7 @@ fn probe_with_temp(selectors: &[&str], temp: Option<&std::path::Path>) { command.env("TMPDIR", temp); } let result = command - .args(["-f", "env", "-u", "ST_AGENT", "python3"]) + .args(["-f", "env", "-u", "ST_AGENT", "-u", "FRACTAL_AGENT", "-u", "ST3_SUBJECT", "python3"]) .arg(repo.join("scripts/fractal-delivery-probe-test")) .arg("--binary") .arg(test_env!("CARGO_BIN_EXE_fractal-fixture")) diff --git a/crates/fractal/tests/idle_budget.rs b/crates/fractal/tests/idle_budget.rs index 609052f4467..e20a1e91268 100644 --- a/crates/fractal/tests/idle_budget.rs +++ b/crates/fractal/tests/idle_budget.rs @@ -207,13 +207,25 @@ mission "idle/wait-{index}" state="ready" {{ } } -/// A seat driver with the environment the reconciler gives it, its directories in `root`. +/// A seat driver with a private managed profile and idle provider, its directories in `root`. fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { let root = root.join(format!("seat-{index}")); for directory in ["workspace", "home", "state", "config", "runtime", "drivers"] { std::fs::create_dir_all(root.join(directory)).unwrap(); } let (runtime_id, _) = seat(index); + let managed_bin = root.join("managed-bin"); + std::fs::create_dir(&managed_bin).unwrap(); + let provider = managed_bin.join("claude"); + std::fs::write(&provider, format!("#!{}\nexec sleep 600\n", env!("ST3_FIXTURE_BASH"))).unwrap(); + std::fs::set_permissions(&provider, { + use std::os::unix::fs::PermissionsExt as _; + std::fs::Permissions::from_mode(0o700) + }).unwrap(); + let profile = root.join("runtime-profile.json"); + std::fs::write(&profile, serde_json::to_vec(&json!({ + "personas": {"launch": {"generalist": {"harnessBin": managed_bin}}}, + })).unwrap()).unwrap(); fractal_daemon::test_support::command(test_bin!("fractal-fixture")) // Its own process group, so stopping the seat also stops the stand-in provider. .process_group(0) @@ -221,6 +233,10 @@ fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { .env_clear() .env("PATH", std::env::var_os("PATH").unwrap_or_default()) .env("HOME", root.join("home")) + .env("AGENT_RUNTIME_PROFILE", &profile) + .env("AGENT_PERSONA", "generalist") + .env("ST_AGENT", format!("agent/{runtime_id}")) + .env("FRACTAL_AGENT", format!("agent/{runtime_id}")) .env("XDG_STATE_HOME", root.join("state")) .env("XDG_CONFIG_HOME", root.join("config")) .env("XDG_RUNTIME_DIR", root.join("runtime")) @@ -230,7 +246,8 @@ fn seat_driver(root: &Path, socket: &Path, index: usize) -> Child { .arg(socket) .args(["driver", "claude", "--subject"]) .arg(format!("agent/{runtime_id}")) - .args(["--", "sleep", "600"]) + .arg("--") + .arg(provider) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/crates/fractal/tests/no_st2_seat.rs b/crates/fractal/tests/no_st2_seat.rs index 262c4c76a3d..8bfeb1254e4 100644 --- a/crates/fractal/tests/no_st2_seat.rs +++ b/crates/fractal/tests/no_st2_seat.rs @@ -34,6 +34,8 @@ fn a_claude_seat_runs_with_no_st2_on_the_machine() { "-u", "ST_AGENT", "-u", + "FRACTAL_AGENT", + "-u", "ST3_SUBJECT", "python3", ]) diff --git a/crates/fractal/tests/subagents_seat.rs b/crates/fractal/tests/subagents_seat.rs index 7bc3c6b2b21..85afc7977e7 100644 --- a/crates/fractal/tests/subagents_seat.rs +++ b/crates/fractal/tests/subagents_seat.rs @@ -33,6 +33,8 @@ fn a_seats_subagents_appear_renew_and_end() { "-u", "ST_AGENT", "-u", + "FRACTAL_AGENT", + "-u", "ST3_SUBJECT", "python3", ]) diff --git a/crates/smallclaims/src/store.rs b/crates/smallclaims/src/store.rs index f2829fa1561..9254fb815df 100644 --- a/crates/smallclaims/src/store.rs +++ b/crates/smallclaims/src/store.rs @@ -2131,10 +2131,11 @@ pub enum ClaimsSubject { } /// A page of claims after store index `?1` and before `?2`, at most `?4` rows, optionally of kind -/// `?5`. Each filter reads the index that keeps the page in store-index order where it can: a kind +/// `?5` and restricted to allowed kinds in the JSON array `?7`. Both kind restrictions intersect. +/// Each filter reads the index that keeps the page in store-index order where it can: a kind /// walks `claims_kind_index` (with a prefix checked on each row), a subject `claims_subject_index` /// or `claims_subject_kind_index`, and a prefix alone the `claims_subject_index` range. -pub fn claims_page_query(subject: ClaimsSubject, kind: bool, descending: bool) -> String { +pub fn claims_page_query(subject: ClaimsSubject, kind: bool, descending: bool, allowed_kinds: bool) -> String { let (index, filter) = match (subject, kind) { (ClaimsSubject::Any, false) => ("", ""), (ClaimsSubject::Exact, false) => ("", "subject=?3 AND "), @@ -2143,10 +2144,11 @@ pub fn claims_page_query(subject: ClaimsSubject, kind: bool, descending: bool) - (ClaimsSubject::Range, false) => (" INDEXED BY claims_subject_index", "subject>=?3 AND subject (" INDEXED BY claims_kind_index", "kind=?5 AND subject>=?3 AND subject?1 AND (?2 IS NULL OR store_index?1 AND (?2 IS NULL OR store_index.harnessBin>/` +directly. `AGENT_PERSONA`, when set, selects the persona; otherwise `generalist` +is the internal lookup default. Fractal does not export that default as +`AGENT_PERSONA`. The selected `harnessBin` is prepended to the +provider child's PATH, so provider subprocesses also resolve managed wrappers; +the driver's own PATH is unchanged. Before spawning a managed provider child, +any present `ST_AGENT` or `FRACTAL_AGENT` must equal the fenced driver subject; +empty or mismatched values refuse launch. Both child aliases are then set to +that subject. The profile publisher selects the +directory; Fractal does not duplicate persona-to-path rules. A missing persona entry, +`harnessBin`, or executable wrapper refuses launch, naming the missing key/path. +There is no fallback to a raw provider or to a different persona. +Only a confirmed missing Agent Spec permits treating the declaration as absent. +Authorization, malformed-response, and server errors refuse managed launch. +During a classified daemon outage, startup waits without spawning a provider +and retries the authenticated read; it never falls back to native execution. +Prelaunch contract capture measures the selected wrapper, not a +raw executable from login PATH. Driver re-exec uses the managed wrapper for helper +commands while adopting, without restarting the running provider. + +Supported agent-seat driver invocations use this contract, including flagless +legacy driver re-exec. There is no caller-supplied opt-in flag. Declaration reads +use the driver's authenticated client context, not an anonymous projection client. + +Person-owned `bind-terminal` sessions retain their native harness and environment. +The authenticated declaration read determines this exemption; agent-owned +bindings and declarations without a binding use managed launch. Read failures +refuse rather than silently selecting native launch. Prelaunch contract capture +uses the same binding rule. + +Before upgrading a host to this launch contract, publish and activate a runtime +profile containing `personas.launch..harnessBin` for its managed seats. +Existing provider processes are not changed by the upgrade. + To change lookup for one agent, set its `PATH` (the literal `${PATH}` expands to the captured shell PATH): @@ -150,22 +188,23 @@ the captured shell PATH): env { PATH "/opt/codex/bin:${PATH}"; } ``` -To bypass PATH lookup and version-manager shims entirely for the native program, -set `executable` inside the harness: +For `pi` and `opencode`, bypass PATH lookup and version-manager shims by setting +`executable` inside the harness. For example: ```kdl -harness "codex" { - executable "/opt/codex/bin/codex" +harness "pi" { + executable "/opt/pi/bin/pi" effort "high" } ``` `executable` is one executable name or path, not a shell command. Spaces in a -path are preserved; put arguments in `args`, not in this string. An absolute -path is recommended for a pinned installation. A plain name uses the seat's -PATH. Fractal does not interpret `CODEX_BIN` or `CLAUDE_BIN` as executable -overrides, and there is no separate per-harness executable setting in local -daemon configuration. A native harness may still read its own configuration +path are preserved; put arguments in `args`, not in this string. For managed +`omp`, `claude`, and `codex`, Fractal replaces even an explicit raw executable +with the profile's managed wrapper, preserving the remaining provider arguments. +For other harnesses, an absolute path selects a pinned installation and a plain +name uses the seat's PATH. Fractal does not interpret `CODEX_BIN` or `CLAUDE_BIN` +as executable overrides. A native harness may still read its own configuration and environment variables after it starts. ## Preview and gate checks diff --git a/docs/st3/README.md b/docs/st3/README.md index 8977f8e2c4f..3201ceb28fd 100644 --- a/docs/st3/README.md +++ b/docs/st3/README.md @@ -10,6 +10,8 @@ Use these documents for implementation details: - [Architecture](design.md) defines the stable system shape. - [Native runtime invariants](invariants.md) names the current delivery, harness and lifecycle proof obligations. - [Mission graph runtime](mission-graph-runtime.md) defines the KDL language and execution model. +- [Program engine](program-engine.md) is the accepted design for missions whose body is a program: where + it runs, its journal, versioning by bundle hash, authority, child runs and visibility. - [Configuration and KDL](../site/reference/configuration.md) defines day-to-day publication and revision workflows. - [Native driver contracts](native-driver-contracts.md) retains launch, scope, helper, harness-state and context obligations after catalog-runner retirement. - [Owned sets](owned-sets.md) defines complete membership publication, source ordering and retirement. @@ -95,6 +97,8 @@ Use these documents for implementation details: - [Gate evidence](gate-evidence.md) explains signed test and review records, local checks and CI verification. - [Profiling the daemon](profiling.md) explains how the daemon accounts for its own time: waits for the writer and read connections, SQLite statements, CPU, and callers. +- [Objectives](objectives.md) explains the `objective` kind: the objectives Fractal ships in + `slo/objectives.kdl`, the ones a fleet declares, their indicators, targets and breach response. - [What a run waits on](waiting-on.md) explains `fractal missions waiting-on RUN` and `fractal missions blocked`: each open step's wait, a run's stalled verdict, and the fleet's blocked runs by cause, read in milliseconds from published state. diff --git a/docs/st3/client-v0/README.md b/docs/st3/client-v0/README.md index 6ba8363af67..a1ccfcd05ef 100644 --- a/docs/st3/client-v0/README.md +++ b/docs/st3/client-v0/README.md @@ -3610,3 +3610,113 @@ null means unknown, not zero. Null reset means unknown. Freshness expires after the positive horizon, both for snapshot generation and each account observation; consumers must label expired readings stale. Unsupported versions are unavailable. + +## Fabric cold-bootstrap pairing client contract + +The isolated pairing receiver uses the canonical `PairingQrPayload`, +`PairingExchangeRequest`, `PairingExchangeReply`, `PairingResultRequest` and +`PairingAdmissionRecord` schema definitions. Production receivers are unchanged. +Readers must support this contract before an issuer enables QR version 2. +Version 1 retains its existing six fields and behavior. + +QR version 2 adds exactly `fabric: {node_id, attempt_id, secret}`. The NodeID is +64 lowercase hexadecimal characters and pins the gateway. Dial `fabric/pair/1` +on that NodeID; ignore `gateway` on this cold route. The secret exists only in +memory, never logs or persistent storage, and is cleared when the attempt ends. +The permit grants exactly the isolated client service, inbound-only, and expires +at the earlier of the challenge expiry and ten minutes after issue. + +Each exchange message is one UTF-8 JSON envelope: +`{v:1, request_id, op, body}`. A request ID is single-use per attempt and per op. +An identical body digest gets the retained reply or rejoins the in-flight wait; +the same ID with a different digest returns `pairing-request-conflict`. +A new ID for an already successful complete returns `pairing-claim-invalid`, +without a second grant. +Allowed operations are `capabilities`, `claim`, `approval-wait`, `complete`, and `ack`. +Other operations return `pairing-route-not-allowed`. Complete before a successful +claim returns `pairing-claim-invalid`. Claim and complete retain their signed HTTP +request bodies. Success is `{v:1, request_id, ok:true, body}`; refusal is +`{v:1, request_id, ok:false, refusal:{code,message}}`. The completion body is +`{state:"admission-pending",pairing_id}`: it contains no credential or grant. +After queuing a refusal, the server waits for `ack` with body +`{request_id: REFUSED_REQUEST_ID}` or the permit deadline before rejecting. +Ack has no authority effect. + +`approval-wait` uses body `{pairing_id,key_fingerprint}` in that envelope. +The fingerprint must equal the claimed key. The op carries no authority: +only the initiating issuer's local-person approval can approve. Subscribe to +Fractal changes before reading durable approval and expiry state. If already +approved, reply immediately; otherwise hold until approval, refusal or the absolute +permit deadline (at most ten minutes). There is no continuation token. +Return exactly one reply, whose success body is +`{status:"approved",key_fingerprint,approved_at}` (RFC3339 timestamp), or whose +typed refusal has code `pairing-rejected`, `pairing-expired`, +`pairing-key-mismatch`, `pairing-wait-busy`, or `pairing-request-conflict`. +Every refusal uses the single `PairingExchangeRefusal` type: both code and +message are required. `PairingExchangeReply` is the only reply envelope type. +Only one approval wait may be outstanding per attempt. A concurrent wait with a +new request ID returns `pairing-wait-busy`. An identical request rejoins the wait +or replays the reply; after restart, derive its answer from durable approval state. +Never push an unsolicited approved reply on the claim's request ID. + +The client sequence is: +1. Send `claim`, then use Fabric's bounded held receive for `awaiting-approval` + (or already `approved`). +2. Send `approval-wait`, then use held receive. Re-arm receive only after the + previous hold ends. No immediate receive loops or status polling are allowed. + Fabric registers before checking the queue, holds at most the earlier of + 30 seconds and permit deadline, and permits one held receive per attempt; + ordinary receive is unchanged. +3. On approved, send complete once and receive admission-pending, then send + application ack BEFORE waiting for Fabric confirm, which erases the queues. + After confirmation, follow the S1–S5 result flow below. +4. On refusal, send application ack; the owner rejects only after ack or deadline. + + +The server stores the exact confirm request in S1 before calling Fabric confirm. +The advertised completion NodeID must equal Fabric's authenticated bound NodeID. +After confirm the server persists the retained receipt as S2, then atomically +finalizes exactly one grant and stores the completion body as S3. Fabric confirm +and the grant are separate durable transactions; S1 and S2 never return credentials. +The client reads restricted Fabric status after disconnect or completion, clears +the secret on a terminal state, and on confirmed dials the isolated client service. +It verifies the remote NodeID against the QR pin before sending any request. + +POST `/v1/client/pairings/{id}/result` takes +`{api_version,attempt_id,device_public_key,proof}`. Sign exactly four UTF-8 lines: +`pairing.result.v1`, full pairing ID, attempt ID, bound NodeID. Separate lines +with one LF byte and omit a trailing LF. Key and signature encodings are the +same as claim. The key must be the approved key. Transport NodeID comes only +from the trusted Fabric preface on the dedicated isolated Unix receiver, +never a body, query or HTTP header. The ordinary receiver refuses this route. +Wrong key or preface NodeID returns `pairing-key-mismatch`; missing preface is +refused. This receiver also serves credential-authenticated client routes, using +the same trusted preface identity rather than a caller-supplied header. + +Result returns the identical stored completion body in S3 without creating a +grant. In S1 or S2 it waits reactively at most 30 seconds, then returns +`pairing-result-pending`, `retryable:true`, and RFC3339 `deadline`. +The deadline is the permit deadline in S1, or retained Fabric `committed_at` +plus 600 seconds after confirm. Only one hold is open per pairing: a new hold +replaces the previous hold, which returns pending. The client immediately +reissues pending requests and stops at the deadline. A transition wakes a hold, +including reconciliation after restart. S4 and S5 return terminal +`pairing-result-expired`. A usable session requires result fetch followed by one +credential-authenticated client request. + +Reconcile at startup and before every transition using current Fabric owner +access provenance, never a historical confirmed receipt alone. Retry S1 pending +with the identical persisted confirm request; S1 confirmed moves to S2 first. +Expired, rejected or exchange-lost S1 attempts close refused with no grant. +S2 with current owner access and before the retained deadline finalizes once; +missing access in S2 or S3 enters S4 and never re-adds the peer. If no successful +result fetch has occurred by the retained deadline, enter S4. Revoke uses the +same path. Persist S4 before idempotent peer removal, then revoke any grant, +then persist S5. S4 and S5 cannot resurrect access. + +Sweep orphan peers only after reconciling S1. A confirmed S1 attempt first moves +to S2 and must not be swept. Unknown pairing IDs and peers whose pairing record +is S4, S5 or refused enter cleanup. A finalized grant with no owner record also +enters cleanup. No static add-peer, HTTP, pre-grant, or saved-gateway fallback +is permitted. + diff --git a/docs/st3/client-v0/fixtures/pairing-admission-states.json b/docs/st3/client-v0/fixtures/pairing-admission-states.json new file mode 100644 index 00000000000..64ce80c8d20 --- /dev/null +++ b/docs/st3/client-v0/fixtures/pairing-admission-states.json @@ -0,0 +1,35 @@ +{ + "base": {"state":"S1","pairing_id":"pairing/example","device_public_key":"example-key","bound_node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","creation_id":"pairing/example","attempt_id":"attempt-example","permit_deadline":"2026-01-01T00:10:00Z","confirm_request":{"operation":"confirm","owner":"fractal/pairing/example","attempt_id":"attempt-example","expected_node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expected_attempt_revision":1,"expected_policy_revision":1,"request_id":"confirm-example"}}, + "confirmed": {"confirm_receipt":{"committed_at":"2026-01-01T00:01:00Z","policy_revision":2,"node_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","future_field":"retained"},"committed_at":"2026-01-01T00:01:00Z","policy_revision":2}, + "finalized": {"grant_id":"grant/example","completion":{"kind":"paired-session","device_id":"device/example","person_id":"person/example","session_actor":"session/example","credential":"01234567890123456789012345678901","scopes":[],"expires_at":"2026-01-02T00:00:00Z"}}, + "cleanup": {"cleanup_reason":"expired","cleanup_started_at":"2026-01-01T00:11:00Z"}, + "cases": [ + {"name":"S1 pending valid","state":"S1","groups":[],"valid":true}, + {"name":"S1 missing exact confirm request","state":"S1","groups":[],"remove":["confirm_request"],"valid":false}, + {"name":"S2 confirmed valid including unknown receipt field","state":"S2","groups":["confirmed"],"valid":true}, + {"name":"S2 missing receipt","state":"S2","groups":["confirmed"],"remove":["confirm_receipt"],"valid":false}, + {"name":"S2 missing committed time","state":"S2","groups":["confirmed"],"remove":["committed_at"],"valid":false}, + {"name":"S2 missing policy revision","state":"S2","groups":["confirmed"],"remove":["policy_revision"],"valid":false}, + {"name":"receipt missing committed_at","state":"S2","groups":["confirmed"],"remove_receipt":["committed_at"],"valid":false}, + {"name":"receipt missing policy_revision","state":"S2","groups":["confirmed"],"remove_receipt":["policy_revision"],"valid":false}, + {"name":"receipt missing node_id","state":"S2","groups":["confirmed"],"remove_receipt":["node_id"],"valid":false}, + {"name":"S3 finalized valid","state":"S3","groups":["confirmed","finalized"],"valid":true}, + {"name":"S3 missing completion","state":"S3","groups":["confirmed","finalized"],"remove":["completion"],"valid":false}, + {"name":"S3 missing grant","state":"S3","groups":["confirmed","finalized"],"remove":["grant_id"],"valid":false}, + {"name":"S4 unconfirmed cleanup valid","state":"S4","groups":["cleanup"],"valid":true}, + {"name":"S4 confirmed cleanup valid","state":"S4","groups":["confirmed","cleanup"],"valid":true}, + {"name":"S4 missing reason","state":"S4","groups":["cleanup"],"remove":["cleanup_reason"],"valid":false}, + {"name":"S4 missing tombstone timestamp","state":"S4","groups":["cleanup"],"remove":["cleanup_started_at"],"valid":false}, + {"name":"S4 grant requires receipt","state":"S4","groups":["finalized","cleanup"],"valid":false}, + {"name":"S5 refused without confirmation valid","state":"S5","groups":["cleanup"],"set":{"refused":true},"valid":true}, + {"name":"S5 refused with confirmed fields invalid","state":"S5","groups":["confirmed","cleanup"],"set":{"refused":true,"remove_peer_receipt":{}},"valid":false}, + {"name":"S5 refused with grant invalid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"refused":true,"remove_peer_receipt":{},"revoke_receipt":{}},"valid":false}, + {"name":"S1 cannot be refused terminal","state":"S1","groups":[],"set":{"refused":true},"valid":false}, + {"name":"S4 cannot be refused terminal","state":"S4","groups":["cleanup"],"set":{"refused":true},"valid":false}, + {"name":"S5 confirmed removal valid","state":"S5","groups":["confirmed","cleanup"],"set":{"remove_peer_receipt":{}},"valid":true}, + {"name":"S5 confirmed missing removal invalid","state":"S5","groups":["confirmed","cleanup"],"valid":false}, + {"name":"S5 granted revoke valid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"remove_peer_receipt":{},"revoke_receipt":{}},"valid":true}, + {"name":"S5 granted missing revoke invalid","state":"S5","groups":["confirmed","finalized","cleanup"],"set":{"remove_peer_receipt":{}},"valid":false}, + {"name":"S5 missing cleanup invalid","state":"S5","groups":["confirmed"],"set":{"remove_peer_receipt":{}},"valid":false} + ] +} diff --git a/docs/st3/client-v0/fixtures/pairing-approval-wait.json b/docs/st3/client-v0/fixtures/pairing-approval-wait.json new file mode 100644 index 00000000000..dc997836fba --- /dev/null +++ b/docs/st3/client-v0/fixtures/pairing-approval-wait.json @@ -0,0 +1,29 @@ +{ + "description": "Schema fixtures plus normative replay scenarios. Request ID is single-use per attempt and op: identical body digest replays retained reply or rejoins in-flight wait; changed digest refuses pairing-request-conflict. A new complete ID after successful completion refuses pairing-claim-invalid without another grant.", + "cases": [ + {"name":"valid wait request","schema":"PairingExchangeRequest","valid":true,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"missing fingerprint","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example"}}}, + {"name":"missing pairing ID","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"malformed fingerprint","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:short"}}}, + {"name":"authority field not permitted","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"request_id":"wait-1","op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approve":true}}}, + {"name":"missing request ID","schema":"PairingExchangeRequest","valid":false,"value":{"v":1,"op":"approval-wait","body":{"pairing_id":"pairing/example","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"valid durable approved reply","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z"}}}, + {"name":"approved missing timestamp","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}}, + {"name":"approved must not contain credential","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"body":{"status":"approved","key_fingerprint":"sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","approved_at":"2026-01-01T00:01:00Z","credential":"never-before-S3"}}}, + {"name":"rejected refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected","message":"Pairing rejected"}}}, + {"name":"expired refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-expired","message":"Permit deadline reached"}}}, + {"name":"mismatched key refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-key-mismatch","message":"Claimed key does not match"}}}, + {"name":"concurrent wait refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-2","ok":false,"refusal":{"code":"pairing-wait-busy","message":"Approval wait already outstanding"}}}, + {"name":"refusal missing required message","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-rejected"}}}, + {"name":"contradictory approved refusal","schema":"PairingExchangeReply","valid":false,"value":{"v":1,"request_id":"wait-1","ok":true,"refusal":{"code":"pairing-rejected","message":"Pairing rejected"}}}, + {"name":"changed digest conflict is valid approval-wait refusal","schema":"PairingExchangeReply","valid":true,"value":{"v":1,"request_id":"wait-1","ok":false,"refusal":{"code":"pairing-request-conflict","message":"Request ID reused with a different body digest"}}} + ], + "replay_scenarios": [ + {"name":"identical wait while held","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"rejoin-in-flight-wait","new_reply_count":1}, + {"name":"identical wait after reply","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"retained-reply","new_authority":false}, + {"name":"identical wait after daemon restart","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":true,"expected":"reply-from-durable-approval-state","in_memory_wait_authoritative":false}, + {"name":"same request ID different digest","same_attempt":true,"same_op":true,"same_request_id":true,"same_body_digest":false,"expected_code":"pairing-request-conflict"}, + {"name":"new concurrent approval wait","same_attempt":true,"same_op":true,"same_request_id":false,"wait_in_flight":true,"expected_code":"pairing-wait-busy"}, + {"name":"new complete after successful complete","op":"complete","same_request_id":false,"previous_complete_succeeded":true,"expected_code":"pairing-claim-invalid","extra_grants":0} + ] +} diff --git a/docs/st3/client-v0/observations.md b/docs/st3/client-v0/observations.md index 8a6719c15e9..f5d3936523b 100644 --- a/docs/st3/client-v0/observations.md +++ b/docs/st3/client-v0/observations.md @@ -62,10 +62,10 @@ Live share has no histogram, max or latency count. Foreground is 1–60,000 ms a (`foreground_ms - live_ms`) forms over-target. It aims for at least 99% live foreground time. Neither background nor unreported/disconnected phone time is inferred. -The six latency targets in `slo/targets.toml` are `ios-open-to-live` (3000 ms), +The six latency objectives in `slo/objectives.kdl` are `ios-open-to-live` (3000 ms), `ios-connect` (1500), `ios-message-ack` (500), `ios-conversation-open` (1000), `ios-terminal-open` (1500) and `ios-recover` (10000). `ios-live-share` is a separate share -entry at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported +objective at 99%. Client paths are `client/ios/NAME`, with carrier/path populations reported separately from daemon response envelopes. The existing request-latency reader and `slo/NAME` doctor lines show them; no new reader is required. diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index b723adeaf1f..3cafca04064 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -495,9 +495,13 @@ "required": ["api_version", "capabilities"], "properties": { "api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, - "capabilities": {"type": "array", "minItems": 1, "maxItems": 2, "items": { + "capabilities": {"type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["id", "version", "state"], - "properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant"]}, "version": {"const": 1}, "state": {"const": "granted"}} + "properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant", "pairing-key-binding"]}, "version": {"enum": [1, 2]}, "state": {"const": "granted"}}, + "oneOf": [ + {"properties": {"id": {"enum": ["device-key-proofs", "fabric-gateway-grant"]}, "version": {"const": 1}}}, + {"properties": {"id": {"const": "pairing-key-binding"}, "version": {"const": 2}}} + ] }} } }, @@ -637,6 +641,13 @@ "pairing-key-binding-required", "pairing-already-claimed", "pairing-claim-invalid", + "pairing-route-not-allowed", + "pairing-result-pending", + "pairing-result-expired", + "pairing-request-conflict", + "pairing-wait-busy", + "pairing-rejected", + "pairing-expired", "suspend-authored-session-selection", "stale-fence", "cursor-gap", @@ -4353,6 +4364,115 @@ "expires_at": { "$ref": "#/$defs/Timestamp" } } }, + "PairingFabricPermit": { + "description": "Cold-bootstrap permit. Secret stays in memory only, is never logged or persisted, and is cleared when the attempt ends. Node ID pins the gateway dial target.", + "type": "object", "additionalProperties": false, "required": ["node_id", "attempt_id", "secret"], + "properties": { + "node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "attempt_id": { "type": "string", "minLength": 1 }, + "secret": { "type": "string", "minLength": 1, "x-st-codec": "redacted" } + } + }, + "PairingQrPayload": { + "description": "Version 1 retains its six fields. Version 2 adds exactly fabric. A cold client ignores gateway and dials the pinned fabric.node_id; there is no HTTP fallback.", + "type": "object", "additionalProperties": false, + "required": ["_tag", "version", "gateway", "id", "code", "expires_at_unix_ms"], + "properties": { + "_tag": { "const": "fractal.pairing" }, "version": { "enum": [1, 2] }, + "gateway": { "type": "string", "format": "uri-reference", "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:" }, "id": { "$ref": "#/$defs/Id" }, + "code": { "type": "string", "pattern": "^[A-Z2-9]{8}$" }, + "expires_at_unix_ms": { "type": "integer" }, "fabric": { "$ref": "#/$defs/PairingFabricPermit" } + }, + "oneOf": [ + { "properties": { "version": { "const": 1 }, "fabric": false } }, + { "required": ["fabric"], "properties": { "version": { "const": 2 } } } + ] + }, + "PairingAdmissionPending": { + "description": "Exchange completion acknowledges durable S1 only, never a credential or a grant.", + "type": "object", "additionalProperties": false, "required": ["state", "pairing_id"], + "properties": { "state": { "const": "admission-pending" }, "pairing_id": { "$ref": "#/$defs/Id" } } + }, + "PairingExchangeRequest": { + "description": "One UTF-8 JSON envelope per Fabric exchange message. A request_id is single-use per attempt and op: identical body digest replays the retained reply or rejoins the in-flight wait; different digest refuses pairing-request-conflict. A new request_id for an already successful complete refuses pairing-claim-invalid without another grant. Only capabilities, claim, approval-wait, complete and ack are allowed; complete requires a successful claim.", + "type": "object", "additionalProperties": false, "required": ["v", "request_id", "op", "body"], + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "op": { "enum": ["capabilities", "claim", "approval-wait", "complete", "ack"] }, "body": {} }, + "oneOf": [ + { "properties": { "op": { "const": "capabilities" }, "body": { "type": "object", "additionalProperties": false } } }, + { "properties": { "op": { "const": "claim" }, "body": { "$ref": "#/$defs/PairingKeyClaim" } } }, + { "properties": { "op": { "const": "approval-wait" }, "body": { "$ref": "#/$defs/PairingApprovalWaitRequest" } } }, + { "properties": { "op": { "const": "complete" }, "body": { "$ref": "#/$defs/PairingComplete" } } }, + { "properties": { "op": { "const": "ack" }, "body": { "type": "object", "additionalProperties": false, "required": ["request_id"], "properties": { "request_id": { "type": "string", "minLength": 1 } } } } } + ] + }, + "PairingApprovalWaitRequest": { + "description": "Envelope body for approval-wait. Fingerprint must equal the claimed key. No authority is conveyed. Subscribe before reading durable approval/expiry. Exactly one reply derived from durable local-person approval, never an unsolicited push on the claim request_id. Reply at approval, refusal, or absolute permit deadline; no continuation token. Allowed refusal codes: pairing-rejected, pairing-expired, pairing-key-mismatch, pairing-wait-busy, pairing-request-conflict. Every refusal uses PairingExchangeRefusal with code and message required. At most one outstanding wait per attempt; a new concurrent request ID refuses pairing-wait-busy. After restart an identical request replays from durable state.", + "type": "object", "additionalProperties": false, "required": ["pairing_id", "key_fingerprint"], + "properties": { "pairing_id": { "$ref": "#/$defs/Id" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" } } + }, + "PairingApprovalWaitApproved": { + "type": "object", "additionalProperties": false, "required": ["status", "key_fingerprint", "approved_at"], + "properties": { "status": { "const": "approved" }, "key_fingerprint": { "type": "string", "pattern": "^sha256:[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$" }, "approved_at": { "$ref": "#/$defs/Timestamp" } } + }, + "PairingExchangeRefusal": { + "type": "object", "additionalProperties": false, "required": ["code", "message"], + "properties": { "code": { "$ref": "#/$defs/ErrorCode" }, "message": { "type": "string" } } + }, + "PairingExchangeReply": { + "description": "Completion returns admission-pending, never a usable completion body. A typed refusal is queued until ack or the permit deadline, then the attempt is rejected. Ack has no authority effect.", + "type": "object", "additionalProperties": false, "required": ["v", "request_id", "ok"], + "properties": { "v": { "const": 1 }, "request_id": { "type": "string", "minLength": 1 }, "ok": { "type": "boolean" }, "body": { "oneOf": [{ "$ref": "#/$defs/PairingAdvertisement" }, { "$ref": "#/$defs/PairingKeyStatus" }, { "$ref": "#/$defs/PairingApprovalWaitApproved" }, { "$ref": "#/$defs/PairingAdmissionPending" }, { "type": "object", "additionalProperties": false }] }, "refusal": { "$ref": "#/$defs/PairingExchangeRefusal" } }, + "oneOf": [ + { "required": ["body"], "properties": { "ok": { "const": true }, "refusal": false } }, + { "required": ["refusal"], "properties": { "ok": { "const": false }, "body": false } } + ] + }, + "PairingResultRequest": { + "description": "POST /v1/client/pairings/{id}/result, only on the isolated Fabric-preface receiver. Sign four UTF-8 lines: pairing.result.v1, full pairing ID, attempt_id, bound NodeID; single LF separators, no trailing LF. Same key/signature encoding as claim. Transport NodeID comes only from the trusted Fabric preface, never this body, a query or an HTTP header.", + "type": "object", "additionalProperties": false, "required": ["api_version", "attempt_id", "device_public_key", "proof"], + "properties": { + "api_version": { "enum": ["st3.client.v0", "fractal.client.v0"] }, "attempt_id": { "type": "string", "minLength": 1 }, + "device_public_key": { "type": "string", "minLength": 32 }, + "proof": { "type": "string", "pattern": "^[A-Za-z0-9_-]{85}[AQgw]$" } + } + }, + "PairingResultPending": { + "description": "Reactive hold lasts at most 30 seconds. A second hold replaces the first, returning pending to the first. Reissue immediately, stop at deadline. Deadline is retained committed_at + 600 seconds in S2/S3 or the permit deadline in S1.", + "type": "object", "additionalProperties": false, "required": ["code", "retryable", "deadline"], + "properties": { "code": { "const": "pairing-result-pending" }, "retryable": { "const": true }, "deadline": { "$ref": "#/$defs/Timestamp" } } + }, + "PairingFabricConfirmRequest": { + "description": "Exact request persisted before calling Fabric confirm; retries use the identical body.", + "type": "object", "additionalProperties": false, + "required": ["operation", "owner", "attempt_id", "expected_node_id", "expected_attempt_revision", "expected_policy_revision", "request_id"], + "properties": { "operation": { "const": "confirm" }, "owner": { "type": "string" }, "attempt_id": { "type": "string" }, "expected_node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "expected_attempt_revision": { "type": "integer", "minimum": 0 }, "expected_policy_revision": { "type": "integer", "minimum": 0 }, "request_id": { "type": "string" } } + }, + "PairingAdmissionRecord": { + "description": "Durable state only moves forward S1 admission-pending, S2 confirmed-unfinalized, S3 finalized, S4 cleanup tombstone, S5 closed. S4 is persisted before remove_peer, then grant revoke, then S5. Reconcile current owner access at startup and before transitions; absent access never resurrects. Sweep orphans only after S1 reconciliation.", + "type": "object", "additionalProperties": false, + "required": ["state", "pairing_id", "device_public_key", "bound_node_id", "creation_id", "attempt_id", "permit_deadline", "confirm_request"], + "properties": { + "state": { "enum": ["S1", "S2", "S3", "S4", "S5"] }, "pairing_id": { "$ref": "#/$defs/Id" }, + "device_public_key": { "type": "string" }, "bound_node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "creation_id": { "type": "string" }, "attempt_id": { "type": "string" }, "permit_deadline": { "$ref": "#/$defs/Timestamp" }, + "confirm_request": { "$ref": "#/$defs/PairingFabricConfirmRequest" }, + "confirm_receipt": { "type": "object", "additionalProperties": true, "required": ["committed_at", "policy_revision", "node_id"], "properties": { "committed_at": { "$ref": "#/$defs/Timestamp" }, "policy_revision": { "type": "integer", "minimum": 0 }, "node_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, "description": "Full retained Fabric confirm receipt. Unknown fields are retained, while the reconciliation inputs remain typed." }, + "committed_at": { "$ref": "#/$defs/Timestamp" }, "policy_revision": { "type": "integer", "minimum": 0 }, + "completion": { "$ref": "#/$defs/PairedSession" }, "grant_id": { "$ref": "#/$defs/Id" }, + "result_fetched_at": { "$ref": "#/$defs/Timestamp" }, "remove_peer_receipt": { "type": "object" }, "revoke_receipt": { "type": "object" }, + "cleanup_reason": { "type": "string", "minLength": 1 }, "cleanup_started_at": { "$ref": "#/$defs/Timestamp" }, + "refused": { "const": true, "description": "Present only for terminal refused S5 closures, which have no confirmation or grant." } + }, + "allOf": [ + { "if": { "properties": { "state": { "enum": ["S2", "S3"] } } }, "then": { "required": ["confirm_receipt", "committed_at", "policy_revision"] } }, + { "if": { "properties": { "state": { "const": "S3" } } }, "then": { "required": ["completion", "grant_id"] } }, + { "if": { "properties": { "state": { "enum": ["S4", "S5"] } } }, "then": { "required": ["cleanup_reason", "cleanup_started_at"] } }, + { "if": { "properties": { "state": { "enum": ["S4", "S5"] } }, "anyOf": [{ "required": ["confirm_receipt"] }, { "required": ["grant_id"] }] }, "then": { "required": ["confirm_receipt", "committed_at", "policy_revision"] } }, + { "if": { "properties": { "state": { "const": "S5" } }, "required": ["confirm_receipt"] }, "then": { "required": ["remove_peer_receipt"] } }, + { "if": { "properties": { "state": { "const": "S5" } }, "required": ["grant_id"] }, "then": { "required": ["revoke_receipt"] } }, + { "if": { "required": ["refused"], "properties": { "refused": { "const": true } } }, "then": { "properties": { "state": { "const": "S5" }, "confirm_receipt": false, "committed_at": false, "policy_revision": false, "grant_id": false, "completion": false } } } + ] + }, "PairingChallenge": { "oneOf": [ { "properties": { "grant_kind": { "const": "person" }, "principal": false, "grant_id": false, "grant_expires_at": false, "policy_revision": false, "minimal_read_mode": false } }, diff --git a/docs/st3/gate-evidence.md b/docs/st3/gate-evidence.md index 914e6356b39..0c905dc8313 100644 --- a/docs/st3/gate-evidence.md +++ b/docs/st3/gate-evidence.md @@ -1254,7 +1254,7 @@ done, the pull requests still refused on the old key can be lifted in either of members of the aggregate read: the whole of `.github` (workflows, the clippy and fleet-compat baselines) and `scripts` (the stage scripts and their tests), `fixtures` and `templates`. A pull request that changes none of those paths (documentation outside `docs/st3` and the retained `docs/vrs` migration reference, `apps`, `packages`, `release-notes`, `AGENTS.md`, `README*`) has the same key as a tree -on which the whole gate passed. `slo` (`include_str!` of `slo/targets.toml` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), +on which the whole gate passed. `slo` (`include_str!` of `slo/objectives.kdl` into the Fractal crate), `evals` (the example and graph tests read `evals/fractal`), `claude-channel` and `plugins` (the plugin files `include_bytes!`d into fractal-drivers) and `docs/st3` (`crates/fractal/tests/invariants.rs` includes `docs/st3/invariants.md`) are declared because Rust sources open them; a committed-policy test fails when any literal `include_str!`, `include_bytes!` or `include!` path in the Rust sources resolves outside the declared inputs. `release-notes` is deliberately not declared: the Rust sources only name `release-notes` as an diff --git a/docs/st3/instances.md b/docs/st3/instances.md index ac7a4fa281a..d49f62e000b 100644 --- a/docs/st3/instances.md +++ b/docs/st3/instances.md @@ -65,7 +65,8 @@ an existing migrated root, except that `work/` stays at its legacy location): | `bin/` | the `st3`, `st` and `pty` that the installer puts there | | | `claude/` | the instance's Claude account, plugins and `st` skill | `CLAUDE_CONFIG_DIR` | | `codex/` | the instance's Codex account and `st` skill | `CODEX_HOME` | -| `home/` | the skill copy for pi, omp and opencode (`home/.agents/skills`) | | +| `pi/` | the instance's shared pi/omp profile, login and native sessions | `PI_CODING_AGENT_DIR` | +| `home/` | shared skills (`home/.agents/skills`) | | | `work/` | the instance's work root: default workspaces for new agents in `work/agents/NAME`, including the Fractal Assistant | | | `app/` | the macOS app bundle | | @@ -74,6 +75,20 @@ and everything it starts (seats, harnesses, hooks) inherit it, so code that read itself lands in the instance without knowing about instances. Seats of an instance sign in to Claude and Codex separately from your own; that is what makes the first run clean. +Native-session inventory, search, session lists and conversation timelines use a discovery-only +view at `state/native-sessions/`, never the login user's HOME. Checked `.claude`, `.codex` and +`.omp/agent` aliases point to this instance's `claude/`, `codex/` and `pi/` directories. No other +login-home native stores are scanned. The shared pi/omp profile is exposed once as omp; +distinct saved-session labels for pi and omp within a named instance are not supported. +`PI_CODING_AGENT_DIR` selects that same `pi/` profile for launched agents. Managed sessions with +explicit driver-owned transcript paths retain those paths. + +HOME is unchanged for the daemon, drivers, hooks and their children: git, ssh, gh and shell rc +files retain the login environment. The default instance keeps its existing discovery behavior. +Before upgrading a named instance that uses omp, sign in to the instance's `pi/` profile or +explicitly migrate its existing pi/omp credentials and configuration there while agents are +stopped. Login-home profiles and sessions are not copied or deleted automatically. + Beyond directories, an instance has its own names: - **Machine name:** the host's name plus the instance name (`laptop-trial`), so a fleet sees two diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 1f14b74d731..bc20aad4aba 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1178,6 +1178,7 @@ {"id": "lv-h1650bf6a58bc", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "713a1c59ffff86cb801fa5892aec98a04d6796cb005c7ef94339e94c3da332f6", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 117, "excerpt": "& other , | _ | ( )"}, {"id": "lv-h16586c1e56da", "path": "crates/smallclaims/src/store/principals.rs", "kind": "sql-template", "fingerprint": "bf5873267aa948343437eaa2ca0053f5e123c1b8d089c7c933a34e432baa6d90", "owner": "smallclaims::store::principals", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 284, "excerpt": "INSERT OR IGNORE INTO claim_signature_candidates(claim_id,signature,key,nonce) VALUES (?1,?2,?3,?4)"}, {"id": "lv-h166835b7b8cb", "path": "crates/smallclaims/src/store/saved_inventory.rs", "kind": "unresolved-sql-sink", "fingerprint": "fca5d96f6c68c4cc7e0a68c2245ed850d4a3f62fc48485354ad7706c45b31ac4", "owner": "smallclaims::store::saved_inventory", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 73, "excerpt": "[ rowid ] , | row | { Ok ( ReplicaEnvelopeId { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , hash : row . get ( 2 ) ? , } ) }"}, + {"id": "lv-h16928b5eb896", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "c3a0532ab17a84e1ad0287749b26ea41efe293bb61b1c62a42a4093e94414017", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 320, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3 WHERE subject=?1"}, {"id": "lv-h1693db03cced", "path": "crates/smallclaims/src/store/prepared_envelope.rs", "kind": "sql-template", "fingerprint": "7512424f1b97db6e6318c1bce828a00664863a9c0fca3740e0629c5e37936fe6", "owner": "smallclaims::store::prepared_envelope", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 455, "excerpt": "INSERT INTO replica_records( record_ref, writer, sequence, envelope_hash, position, raw, state, claim_id, subject_hint, kind_hint, error_code, error_message, updated_at_unix_ms ) VALUES (?1, ?2, ?3, ?"}, {"id": "lv-h16a68f6ab5e8", "path": "crates/fractal-kernel/src/conversation_search.rs", "kind": "unresolved-sql-sink", "fingerprint": "b9543199298420fe9e37bb76665ae9eb0e08741f55cce132d2c0d5adc31f65eb", "owner": "fractal_kernel::conversation_search", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 45, "excerpt": "\"CREATE VIRTUAL TABLE entries USING fts5(source UNINDEXED, conversation_id UNINDEXED, entry_id UNINDEXED, agent_id UNINDEXED, timestamp UNINDEXED, entry_type UNINDEXED, text, tokenize='unicode61');\" ,"}, {"id": "lv-h16ae820a3029", "path": "crates/fractal-drivers/src/harness_admission/omp.rs", "kind": "included-source", "fingerprint": "8f84f980ca707f77841efd4f297097ee44736098a6abd7e1786847d15e61b38b", "owner": "fractal_drivers::harness_admission::omp", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 47, "excerpt": "include_str ! ( \"omp-probe.ts\" )"}, @@ -1186,6 +1187,7 @@ {"id": "lv-h16cdaf7096de", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "d7a3a4ce31d8071c51534e3e3c490db76d3ec5f75591f2fadb8b15831bd9cebf", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 13051, "excerpt": "params ! [ subject , at_index , incarnation_id ] , | row | { Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , String > ( 1 ) ? , row . get : : < _ , String > ( 2 ) ? , row . get : : <"}, {"id": "lv-h16daeb7e7a0e", "path": "crates/fractal-evidence/src/store/evidence.rs", "kind": "unresolved-sql-sink", "fingerprint": "859af7c67269885a5dc98951007e55b4e941c8e7bc612fa15872caa7e5749bcc", "owner": "fractal_evidence::store::evidence", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 435, "excerpt": "& format ! ( \"SELECT DISTINCT subject, origin, owner FROM {source}\" )"}, {"id": "lv-h16de0529ee16", "path": "crates/fractal-store/src/store/message_thread_legacy_rank_dependency_tests.rs", "kind": "sql-template", "fingerprint": "66570f0959f106ba6df5f56c455ff29ebe80a9a7d012c1d1d906c47e9099e719", "owner": "fractal_store::store::message_thread_legacy_rank_dependency_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 86, "excerpt": "DELETE FROM claims WHERE id='distinct-earlier'"}, + {"id": "lv-h16df3901f435", "path": "crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs", "kind": "sql-template", "fingerprint": "b296d61cf0e851433215fceb31a07fe318f71043a61dbd3a4fe076940dfaffa3", "owner": "fractal_api_core::api::client_v0::external_grants_service_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 380, "excerpt": "INSERT INTO desired(subject,kind,revision,claim_id,body,owner_run) VALUES (?1,'gate-operation',?2,?2,'{}',?3)"}, {"id": "lv-h16e758f1cacf", "path": "crates/fractal-store/src/store/attention_budget/tests.rs", "kind": "sql-template", "fingerprint": "88ff59fecfac14e7f28c58d21e866f2624aa8bea0d8077316c36847879b8a8f7", "owner": "fractal_store::store::attention_budget::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 163, "excerpt": "UPDATE mission_runs SET parent_step_run='step-run/second/work' WHERE id='delegate'"}, {"id": "lv-h170c03d58b90", "path": "crates/fractal-store/src/store/mailbox_fence_tests.rs", "kind": "sql-template", "fingerprint": "d232319d46067666ae089bc710be406c8acb4e82f4f6e50089f55401f6e0651f", "owner": "fractal_store::store::mailbox_fence_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 116, "excerpt": "INSERT INTO batches VALUES (?1,?2,0,NULL,?1,?3)"}, {"id": "lv-h172c21e2590a", "path": "crates/smallclaims/src/store/prepared_seal_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "smallclaims::store::prepared_seal_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 512, "excerpt": "DELETE FROM claims WHERE id=?1"}, @@ -1269,6 +1271,7 @@ {"id": "lv-h1c1e8afac510", "path": "crates/fractal-api-client-v0/src/api/client_v0/runtime_start_diagnostics/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "4ec7c53222c8a758c722e2111541035ce700d5ae7bd0898c5f1b1a743e6450fd", "owner": "fractal_api_client_v0::api::client_v0::runtime_start_diagnostics::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 97, "excerpt": "sql"}, {"id": "lv-h1c315e6d5b5b", "path": "crates/fractal-store/src/store/shared.rs", "kind": "sql-template", "fingerprint": "fcb74cd0f861438e431998d8d16c6c6d5ad3c3cc2a58f09d3403116cc7df6065", "owner": "fractal_store::store::shared", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 244, "excerpt": "INSERT OR IGNORE INTO claims(id, batch_id, subject, kind, origin, actor, body, predecessors, accepted_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"}, {"id": "lv-h1c33889b46f2", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "6e791f3e9ac7e947bd20c73bc6bf2fa922ffbde010045c414512363268d394be", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6357, "excerpt": "INSERT INTO local_work_lease_renewals (subject, attempt, lease_owner, lease_incarnation, lease_expires_at_unix_ms, updated_at_unix_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(subject) DO UPDATE SE"}, + {"id": "lv-h1c3e44d7086a", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "51cc0656fb3569edd52d628ebfaf71a0d8cc9b159da6fb3069aaaf55b3cdc1a5", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6492, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms=?3, lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h1c43f8efcade", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "529ba97714e0bbd2b2f54353606bb7ee6c45f0ab9b67021c1df54d3249100e7f", "owner": "fractal_store::store::resources_preparation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 643, "excerpt": "UPDATE operations SET state='active',canonical_claim_id=?1 WHERE id='operation/example-preparation'"}, {"id": "lv-h1c4dc4dcb731", "path": "crates/fractal-store/src/store/resource_references.rs", "kind": "sql-template", "fingerprint": "baf25f332fef9bac6c8bd426255a4bd93809dcadaea875d9cc73602621f424db", "owner": "fractal_store::store::resource_references", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 225, "excerpt": "UPDATE meta SET value='missions:' WHERE key='{CURSOR_KEY}'"}, {"id": "lv-h1c54bb0d403b", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "unresolved-sql-sink", "fingerprint": "a11ff17b351a8a31b9d385dd17f6f0c5b983c8147434a126bef9fd827da833f9", "owner": "fractal_store::store::glass_heads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 221, "excerpt": "& canonical_sql ( \"SELECT id FROM claims WHERE subject=?1 AND kind='glass.upserted' ORDER BY CANONICAL_ASC(claims) LIMIT 1\" , ) , [ subject ] , | row | row . get ( 0 ) ,"}, @@ -1339,9 +1342,9 @@ {"id": "lv-h20b4ce3791b8", "path": "crates/fractal-store/src/store/published_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "00e16953f852b16396fd7199a772beaa8544e3ecb26b3e18f6e8461c55971338", "owner": "fractal_store::store::published_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 476, "excerpt": "CHILD_RUNS"}, {"id": "lv-h20b935346aaf", "path": "crates/fractal-store/src/store/checkpoint_tests.rs", "kind": "sql-template", "fingerprint": "a466c1bb6842b49c1d34a00a24fdcb27190d6711850fee20c555c3c6c8bfc672", "owner": "fractal_store::store::checkpoint_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 493, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?1 WHERE id=?2"}, {"id": "lv-h20bffcbd0763", "path": "crates/smallclaims/src/store/checkpoint_staging.rs", "kind": "sql-template", "fingerprint": "f742addfcfcc6af8ad5172ff290f31172a85780c82d523ed4e99b3b71e76f2e2", "owner": "smallclaims::store::checkpoint_staging", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 510, "excerpt": "INSERT INTO meta(id, identity, phase, cursor) VALUES (1, ?1, ?2, 0)"}, - {"id": "lv-h20cb3d2e4d9e", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "e318d0369b4e7bf334d1427a20489fefe9c9d84d83b7c4eb72f6a2ca2749c235", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.starte"}, {"id": "lv-h20ede67dacaa", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d5b306c813337790dcc59d3ea0244058138d994c293b1d1b62b4d21f61d94ca", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1816, "excerpt": "params ! [ subject , store_index ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-h20ff92cef130", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1352, "excerpt": "DELETE FROM claims WHERE id=?1"}, + {"id": "lv-h2102123ed04c", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 818, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h21121b909ab8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2beeceba08cc58c8d4101eebee3d7bdc5ff8260a291b89568ff5ea3680d6d8c7", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6708, "excerpt": "& format ! ( \"SELECT json_extract(claims.body, '$.fields.state'), claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='harness.obs"}, {"id": "lv-h212e51b40c29", "path": "crates/smallclaims/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "edfd88af61f0d89329bd18c65812dcd3cbfa77084f879f2aba400ba1e8046e83", "owner": "smallclaims::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 10559, "excerpt": "CHECKPOINT_OPERATION_KEYED_QUERY"}, {"id": "lv-h213133341fb4", "path": "crates/smallclaims/src/store/principals.rs", "kind": "unresolved-sql-sink", "fingerprint": "adf3de0c2d3da5d7033336aaa81da7847ca9767ac4bc8794fb5b9517cb6252bf", "owner": "smallclaims::store::principals", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 408, "excerpt": "params ! [ subject , kind , actor ]"}, @@ -1470,6 +1473,7 @@ {"id": "lv-h28691152dda6", "path": "crates/fractal-store/src/store/resources_preparation.rs", "kind": "unresolved-sql-sink", "fingerprint": "b7eafcbb01a79e6feb02839905b41bafc8a5be2f355d21ab6b4b392a34092eac", "owner": "fractal_store::store::resources_preparation", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 406, "excerpt": "& QUERY"}, {"id": "lv-h287bdf68b661", "path": "crates/fractal-store/src/store/run_eta_tests.rs", "kind": "sql-template", "fingerprint": "52f120d54eceb3dd75ce5a7e2c8b2459f7e6a63b55e20299a2f265be6d7b1749", "owner": "fractal_store::store::run_eta_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 161, "excerpt": "UPDATE mission_runs SET status='failed' WHERE id='r-69'"}, {"id": "lv-h287e13415dd4", "path": "crates/fractal-store/src/store/message_sent.rs", "kind": "unresolved-sql-sink", "fingerprint": "4d4947cbb244446f69537a5aaf2a9086598317c4a68325c448415c3c7663f606", "owner": "fractal_store::store::message_sent", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 49, "excerpt": "SENT_IN_GRAPH_FILE"}, + {"id": "lv-h28928374ca14", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "1d84c831a4aa0ece376bf0e40064fb0bd36cd58e7f04211c859df103c1e40e8c", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 823, "excerpt": "UPDATE step_runs SET status=?2 WHERE subject=?1"}, {"id": "lv-h28991f3f5745", "path": "crates/fractal-store/src/store/schedule_active_run_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "90dbe74bec90013705bd92e02619372a20cb7733a5375be33830ea72c895a370", "owner": "fractal_store::store::schedule_active_run_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 89, "excerpt": "[ subject ] , | row | row . get ( 0 )"}, {"id": "lv-h28ac822f10fc", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "unresolved-sql-sink", "fingerprint": "ed5f7c6ec77211c2891b82298edc2d1b76ae76f8ed8e33a3298e288b1bd8d322", "owner": "fractal_store::store::client_messages", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 374, "excerpt": "[ & work . subject ] , | row | Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) )"}, {"id": "lv-h28bd1a5d15fb", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "d8c07a9a3bd4948049486819192015c314d0a617ea538ab66f285ebd7b75a291", "owner": "smallclaims::store::saved_inventory_tests", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 678, "excerpt": "CREATE TEMP TABLE held AS SELECT {COLUMNS} FROM replica_envelopes ORDER BY rowid; DELETE FROM replica_envelopes; INSERT INTO replica_envelopes({COLUMNS}) SELECT {COLUMNS} FROM held;"}, @@ -1687,6 +1691,7 @@ {"id": "lv-h3476265a54c6", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d10ea77208b3c0ccc9a279b3c26b63991cdc16af0515e542f369a78bc153a89", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 17025, "excerpt": "& canonical_sql ( \"SELECT id FROM claims INDEXED BY claims_accepted_order_index WHERE store_index<=?1 ORDER BY CANONICAL_DESC(claims) LIMIT 1\" ) , [ frontier ] , | row | row . get : : < _ , String > ("}, {"id": "lv-h3477b3e003c0", "path": "crates/fractal-store/src/store/client_messages_version_benchmark.rs", "kind": "sql-template", "fingerprint": "2015f8bd109ba33bc2640da808dcd77e98dad5b5fccf496dc7e00b938bd7e970", "owner": "fractal_store::store::client_messages_version_benchmark", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 114, "excerpt": "INSERT INTO local_client_message_versions VALUES(?1,?2,NULL,?3,?4,?5,?6,?7,?8,?9,?10)"}, {"id": "lv-h3486ce730024", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "4f361d8da67df90d12543d3633d31ea0c1ac3fc12de1279ece08cebb3dd66278", "owner": "smallclaims::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6627, "excerpt": "INSERT OR IGNORE INTO blobs(hash, bytes, size) VALUES (?1, ?2, ?3)"}, + {"id": "lv-h34894d4454a8", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 994, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-h34b0045efaf9", "path": "crates/smallclaims/src/ivm/claim_source.rs", "kind": "sql-template", "fingerprint": "ff61a09f4bd5378817cf14f23f195214d95d0266c752d73daa349502c77a5ff5", "owner": "smallclaims::ivm::claim_source", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 147, "excerpt": "DROP TRIGGER IF EXISTS ivm_install_claim_record_insert"}, {"id": "lv-h34b493bc8e0a", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "sql-template", "fingerprint": "96443f5f09414404e98d6b22a06bf09fd5109892d090c48f6951cea264dcca69", "owner": "fractal_store::store::client_messages", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1222, "excerpt": "DELETE FROM local_client_message_selectors_v1 WHERE subject=?1 AND born_index=-1"}, {"id": "lv-h34c24f65b594", "path": "crates/fractal-store/src/store/message_marks.rs", "kind": "unresolved-sql-sink", "fingerprint": "60da735f8761d8ca4e4890163291cd1f5b396e1492d6103570abb3fa8d4659c4", "owner": "fractal_store::store::message_marks", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 221, "excerpt": "[ message ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , i64 > ( 1 ) ? . max ( 0 ) as u128 ) )"}, @@ -2007,6 +2012,7 @@ {"id": "lv-h48ab0ffcf698", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "c1aacb3fede25950692e1bd5ff8a4d94e7103d64fd39930e45ce02f5a4a51fa5", "owner": "smallclaims::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 567, "excerpt": " CREATE INDEX IF NOT EXISTS claims_batch_index ON claims(batch_id, store_index); -- Heals once compared claim identities through this index; nothing reads it any more. DROP INDEX IF EXISTS claims_batc"}, {"id": "lv-h48b3ca7ad3bd", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "bab8b3f1fb0dc4e7285da9d46afc90898d07b56062965bda597b6326c851788e", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 838, "excerpt": "INSERT INTO ivm_contributions VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(view,key,register,claim_id) DO UPDATE SET value=excluded.value,rank=excluded.rank WHERE value<>excluded.value OR rank<>excluded.rank"}, {"id": "lv-h48c2f0251057", "path": "crates/fractal-reconcile/src/reconcile/tests/start_preemption.rs", "kind": "sql-template", "fingerprint": "e1da33fa7f7a8b72d496ce9b7fd025bfd68585109e931326bd697e3350c7354e", "owner": "fractal_reconcile::reconcile::tests::start_preemption", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 351, "excerpt": "CREATE TEMP TABLE aba_a AS SELECT * FROM desired WHERE subject=?1"}, + {"id": "lv-h48c2f2d4b497", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 930, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h48c487bfe664", "path": "crates/fractal-store/src/store/wake_lookup.rs", "kind": "sql-template", "fingerprint": "cf108edbd6b5605053d2bd547857f497ca909049ab233f1b865ba602f73fdf85", "owner": "fractal_store::store::wake_lookup", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 152, "excerpt": "UPDATE local_work_wake_backfill SET cursor=?1,complete=?2 WHERE singleton=1"}, {"id": "lv-h48c97b07300c", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "c657f8e081eea0e5dab78e468952abad110b99276c6078eaf1bab639d117932b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 511, "excerpt": "INSERT INTO mission_runs SELECT 'page-other',mission_id,initial_revision,current_generation_id, root_revision,'another-root',parent_step_run,workspace,requester,inputs, mode,status,phase,created_at_un"}, {"id": "lv-h48de1ba2ea36", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "a00ace2e63a8007b9c110b64ae019551cb4ffe51e5c1df2c038b31b4bf92b9ac", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 767, "excerpt": "DELETE FROM ivm_claim_views WHERE view=?1 AND input_claim_id=?2"}, @@ -2560,6 +2566,7 @@ {"id": "lv-h6803953fef0f", "path": "crates/fractal/src/main_tests.rs", "kind": "included-source", "fingerprint": "fbdebe81ebfc871537364e636df656215f4bba08d75bce3d5adf5cc7db8a2bdb", "owner": "fractal::main_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 3385, "excerpt": "include_str ! ( \"change_cli.rs\" )"}, {"id": "lv-h680af4f29f70", "path": "crates/fractal-store/src/store/schedule_active_run_tests.rs", "kind": "sql-template", "fingerprint": "202d7532807900d75ae7aa1826a6a2494bfe24fef3ebdd379d309b92ba358408", "owner": "fractal_store::store::schedule_active_run_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 134, "excerpt": "INSERT INTO mission_runs VALUES(?1,'running')"}, {"id": "lv-h680fbaf20f4d", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "281bdafe089183bc7b6b3e803a828b2448a92edf398401b4bc9ca3471ad0d03b", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 642, "excerpt": "params ! [ owner , incarnation , started ] , | row | row . get ( 0 )"}, + {"id": "lv-h6825b80ba287", "path": "crates/fractal-api-core/src/api/client_v0/external_grants_service_tests.rs", "kind": "sql-template", "fingerprint": "ab01873ecaaa220d63b37aec6d7213c13548a25d0bc552b5189ae49cbe7580b6", "owner": "fractal_api_core::api::client_v0::external_grants_service_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 467, "excerpt": "INSERT INTO replica_records(record_ref,writer,sequence,envelope_hash,position,raw,state,subject_hint,kind_hint,updated_at_unix_ms) VALUES(?1,'test-peer',1,?1,0,'{}',?2,?3,?4,'1')"}, {"id": "lv-h682b64691597", "path": "crates/smallclaims/src/store/principals.rs", "kind": "sql-template", "fingerprint": "431a20a22ee8a0624ebf8b79b6734d2e676818196679a825dfb6e14db7d32f95", "owner": "smallclaims::store::principals", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 965, "excerpt": "DELETE FROM {table} WHERE claim_id=?1"}, {"id": "lv-h68322eee5951", "path": "crates/fractal-store/src/store/fleet_admission_tests.rs", "kind": "sql-template", "fingerprint": "1b4ce6d041de99f67aae562e51e55d2d2bcfcb6ac462a0dc6e9d521c8e0fcfc5", "owner": "fractal_store::store::fleet_admission_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 556, "excerpt": "UPDATE replica_records SET state='invalid',error_code='internal', error_message='UNIQUE constraint failed: local_publication_pending.claim_id' WHERE claim_id=?1"}, {"id": "lv-h683d859bacef", "path": "crates/smallclaims/src/store/principals.rs", "kind": "unresolved-sql-sink", "fingerprint": "77939ed876496cca8eeca1601008faa5d0c7ae1619ca25394af99d720d92116f", "owner": "smallclaims::store::principals", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 447, "excerpt": "[ claim_id ] , | row | { Ok ( ClaimRow { id : row . get ( 0 ) ? , subject : row . get ( 1 ) ? , kind : row . get ( 2 ) ? , actor : row . get ( 3 ) ? , body : serde_json : : from_str ( & row . get : : "}, @@ -2782,6 +2789,7 @@ {"id": "lv-h75174e01c7b9", "path": "crates/fractal-store/src/store/glasses.rs", "kind": "unresolved-sql-sink", "fingerprint": "3af344ff1fd37a66087ce591d8e63060f8be28942ca3145226c9eb6b7579d662", "owner": "fractal_store::store::glasses", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6, "excerpt": "& canonical_sql ( \"WITH ranked AS ( SELECT claims.id, ROW_NUMBER() OVER (PARTITION BY claims.subject, claims.kind ORDER BY CANONICAL_ASC(claims)) AS first, ROW_NUMBER() OVER (PARTITION BY claims.subje"}, {"id": "lv-h75356a2b8bf0", "path": "crates/smallclaims/src/store/retired_facts.rs", "kind": "unresolved-sql-sink", "fingerprint": "b7eafcbb01a79e6feb02839905b41bafc8a5be2f355d21ab6b4b392a34092eac", "owner": "smallclaims::store::retired_facts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 93, "excerpt": "& QUERY"}, {"id": "lv-h7554008ab79c", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "692b891eaf4f608d7adcbc24e4f06d36a27ed830efcde23ff111aa095833860d", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 12143, "excerpt": "& format ! ( \"SELECT claims.store_index FROM claims INDEXED BY claims_subject_kind_index JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND {} AND claims.store_index<=?2 ORDER BY {"}, + {"id": "lv-h755979e290e9", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 523, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-h75681bdb4431", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "f11929340270a16d3eaab3c7795e06e30950976c771ce9a1cac08e0a5ec0964e", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 18983, "excerpt": "INSERT INTO mission_definitions(mission_id, revision, state, claim_id) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(mission_id) DO UPDATE SET revision=excluded.revision, state=excluded.state, claim_id=excluded"}, {"id": "lv-h756acba9d94c", "path": "crates/fractal-store/src/store/backup.rs", "kind": "unresolved-sql-sink", "fingerprint": "c03ab0bb4218ae1137c8325b80f94fdd00947c146885ba2f75e191cb999e38b8", "owner": "fractal_store::store::backup", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 626, "excerpt": "\"WITH live AS (SELECT writer,sequence,envelope_hash FROM replica_envelopes WHERE (writer,sequence,envelope_hash)>(?1,?2,?3) AND (batch_id IS NOT NULL OR ?5) ORDER BY writer,sequence,envelope_hash LIMI"}, {"id": "lv-h759f01e6116b", "path": "crates/smallclaims/src/store/schema_install_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "3897109847c3a19ec97cde30e4aea640d36ccbd989e9312169cf6f2ceed3f66d", "owner": "smallclaims::store::schema_install_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 141, "excerpt": "\"BEGIN IMMEDIATE;\""}, @@ -2791,6 +2799,7 @@ {"id": "lv-h75e67f417bdf", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "unresolved-sql-sink", "fingerprint": "f87e92b08082c1a5768f5a1d274ebbbb9d0fa9428c4d495b2f8c280477f139b4", "owner": "smallclaims::store::checkpoint_trim", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1579, "excerpt": "params ! [ cursor . envelope . 0 , cursor . envelope . 1 , cursor . envelope . 2 ] , | row | { Ok ( EnvelopeTombstone { writer : row . get ( 0 ) ? , sequence : row . get ( 1 ) ? , envelope_hash : row "}, {"id": "lv-h75fd665f1236", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "0880674f57b838172ac1420fc767020d5e8391b6a6290c9461350fa9b9a69304", "owner": "smallclaims::store::repair_operation_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "INSERT INTO checkpoint_claims(id,writer,sequence,envelope_hash,subject,kind, actor,predecessors,operation_id,request_digest,accepted_at_unix_ms,checkpoint) VALUES (?1,?2,1,'fixture-envelope',?3,?4,NUL"}, {"id": "lv-h76009380070e", "path": "crates/fractal-store/src/store/backup.rs", "kind": "sql-template", "fingerprint": "715b7832ed9f5968b1975060bb0aba4a0e1595690c6c1c05600a9ff424756c2c", "owner": "fractal_store::store::backup", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 365, "excerpt": "INSERT INTO replica_envelopes(writer,sequence,envelope_hash,previous_hash,accepted_at_unix_ms, payload,relay,receipt_state,received_at_unix_ms) VALUES(?1,?2,?3,?4,?5,?6,'backup','pending',?7)"}, + {"id": "lv-h76038fa53ef8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "e21446038f569a6eec8b9b9a7e8201f454cd571dd47d6c8c72dedd6f6cbfbb2b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 9598, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker', lease_owner=?2 WHERE subject=?1"}, {"id": "lv-h760886f5273f", "path": "crates/fractal-reconcile/src/reconcile/tests/incremental_deadlines.rs", "kind": "sql-template", "fingerprint": "0f9531316b08f058e2c0d39a03024e5449ca43438fdf44adc34e053c26465dbe", "owner": "fractal_reconcile::reconcile::tests::incremental_deadlines", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 1213, "excerpt": "DELETE FROM replica_records WHERE claim_id IN (?1,?2)"}, {"id": "lv-h7609ad2c9eca", "path": "crates/smallclaims/src/store/principals.rs", "kind": "unresolved-sql-sink", "fingerprint": "fd4c6f3ec130c52a2b235ee2129f972a35eaa20929b85326b0c3506b781d7683", "owner": "smallclaims::store::principals", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "params ! [ claim_id , text , signature . key , signature . nonce ]"}, {"id": "lv-h7609b11cc853", "path": "crates/smallclaims/src/ivm.rs", "kind": "sql-template", "fingerprint": "df21b7d33237210ade2853def81712bdfc2fea8c94e2d32ab47f1f689b50b4eb", "owner": "smallclaims::ivm", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 622, "excerpt": "INSERT INTO ivm_views(name,fingerprint,epoch,ready,generation) VALUES(?1,?2,?3,?4,0)"}, @@ -2849,6 +2858,7 @@ {"id": "lv-h78cc0dbbbfdf", "path": "crates/fractal-seats/src/store/runtime_starts.rs", "kind": "unresolved-sql-sink", "fingerprint": "670d6c12ea983a626949e5782c1d54db6dccc8f2ea68d30d296fe871626aadaf", "owner": "fractal_seats::store::runtime_starts", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 507, "excerpt": "& format ! ( \"{} LIMIT 1\" , newest_claims_of_kind_query ( \"claims.id\" , \"runtime.observed\" ) ) , params ! [ input . subject , i64 : : MAX ] , | row | row . get ( 0 ) ,"}, {"id": "lv-h78d8ef99ef2b", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "e03ff7f0a71930d5c5d6083910068bfe154ec51f6bc01e133c429c2cfc91cdd5", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 229, "excerpt": "params ! [ later , \"mission-run/x\" , \"harness.timeline\" ]"}, {"id": "lv-h78de6ffed031", "path": "crates/fractal-seats/src/store/step_labels/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "89772bebf3f0240f364fdf2d898527feb3650484c515dd76efcabfe0ef49b52d", "owner": "fractal_seats::store::step_labels::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 47, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {QUERY}\" )"}, + {"id": "lv-h78e96a5b9d99", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 966, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-h78f223f76c65", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "11962b6d1a71b779748eeb516ee88832780f3b025fe316441be02891568d703f", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7225, "excerpt": "params ! [ subject , i64 : : MAX , incarnation ] , | row | row . get ( 1 )"}, {"id": "lv-h78f4c9ab8fb0", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "990cbb165a23077232f26d13723754e956c38eced662ebabe920afb0d68c90fd", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 20691, "excerpt": "& canonical_sql ( \"SELECT body FROM claims WHERE subject=?1 AND kind='harness.observed' AND accepted_at_unix_ms<=?2 ORDER BY CANONICAL_DESC(claims)\" , )"}, {"id": "lv-h78f68e820943", "path": "crates/fractal-store/src/store/agent_view.rs", "kind": "sql-template", "fingerprint": "077c9f331431c6934709d8a9503267b47b98c79ec7d0c3d87c455ee1369fbfd0", "owner": "fractal_store::store::agent_view", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 508, "excerpt": "INSERT INTO local_agent_harness_rows VALUES(?1,?2) ON CONFLICT(subject) DO UPDATE SET observed=excluded.observed"}, @@ -2864,7 +2874,6 @@ {"id": "lv-h79930c0706a2", "path": "crates/fractal-github/src/changes/index.rs", "kind": "sql-template", "fingerprint": "aa9fdcbf154751b8d296629ca17413b965f7033945f383a83071b42321de981a", "owner": "fractal_github::changes::index", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "INSERT INTO local_git_change_latest(repository,change_id,object_id,generation) VALUES(?1,?2,?3,?4) ON CONFLICT(repository,change_id) DO UPDATE SET object_id=excluded.object_id,generation=excluded.gene"}, {"id": "lv-h79b182e88689", "path": "crates/smallclaims/src/ivm/install.rs", "kind": "unresolved-sql-sink", "fingerprint": "75c20a7ce537b74064d78cc189ef9f1510a06f5507c19c111d6bf0a55bbb4800", "owner": "smallclaims::ivm::install", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 468, "excerpt": "\"RELEASE ivm_install_live\""}, {"id": "lv-h79b67f329d5d", "path": "crates/smallclaims/src/store/sealed_view_cache_tests/index_cost_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "smallclaims::store::sealed_view_cache_tests::index_cost_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 27, "excerpt": "SCHEMA"}, - {"id": "lv-h79c5fdb57d22", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "1802408fd849dd080ad146563b6a720fb7cf929641750256f35812292a594306", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19289, "excerpt": "params ! [ seat , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-h79caef580176", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "sql-template", "fingerprint": "5eaf8e11ccb4375c6b7de77bb4a475d55fe21bfe1c4a79168846090488d5a5d9", "owner": "fractal_seats::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1870, "excerpt": "INSERT INTO desired(subject, kind, revision, claim_id, body, owner_run, owner_generation, owner_step) VALUES (?1, 'agent', 'revision', ?2, '{}', 'mission-run/example', 'run-generation/example', 'step-"}, {"id": "lv-h79ddcf608807", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "78cc83225b2f0636de9f21b4111cea98c10e3dd7f72e1246ca8c08943c4cc95e", "owner": "smallclaims::store::repair_operation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 520, "excerpt": "DELETE FROM operations WHERE id='op/missing'; UPDATE operations SET request_digest=x'80' WHERE id='op/digest'; UPDATE operations SET state='conflict' WHERE id='op/state'; UPDATE operations SET id=NULL"}, {"id": "lv-h79e3a98c6d5e", "path": "crates/fractal-store/src/store/local_trim_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "289d119346b7dcd4b3694a72f93eaab144184687770aad714bcea6c219a990ac", "owner": "fractal_store::store::local_trim_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 935, "excerpt": "[ id ] , | row | row . get : : < _ , bool > ( 0 )"}, @@ -2928,6 +2937,7 @@ {"id": "lv-h7d2ddb9152a1", "path": "crates/fractal-store/src/store/checkpoint_capture_epoch_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "7f278b69cec646287b3aade63b955b9f70fb9971553ab08b7d204585f1d0ef51", "owner": "fractal_store::store::checkpoint_capture_epoch_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 184, "excerpt": "sql , [ ]"}, {"id": "lv-h7d4a5fcca7d0", "path": "crates/fractal-store/src/store/messages_shard/stream_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "cc1ca77503be6a8012163857a8fe815d4b4949bb48e38eb1e8376865dfeb1ba9", "owner": "fractal_store::store::messages_shard::stream_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 84, "excerpt": "rusqlite : : params ! [ at as i64 ] , | row | row . get : : < _ , Option < i64 > > ( 0 )"}, {"id": "lv-h7d50ca37c5a8", "path": "crates/smallclaims/src/store/repair_operation_tests.rs", "kind": "sql-template", "fingerprint": "400236f13df7316e04adb677dc16166ab3b1b2701277721ec220cc75aec69529", "owner": "smallclaims::store::repair_operation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 566, "excerpt": "INSERT INTO projection_digest_repaired_claims(id) VALUES(?1)"}, + {"id": "lv-h7d7fcc2328ee", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d35271b78634a314d43a3538f1037107387d271a4fa449cfd3f69afad27e28ac", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 937, "excerpt": "UPDATE step_runs SET lease_owner=?2, status=?3 WHERE subject=?1"}, {"id": "lv-h7d81d766ec47", "path": "crates/fractal-store/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "670380e6c1568143746eefd5717d30685a73d61436c5663c11ede8fb9d9aec6f", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7441, "excerpt": "& format ! ( \"EXPLAIN QUERY PLAN {query}\" )"}, {"id": "lv-h7d84468a5931", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "dd2b5f9f07e0760d8d29d36a0234e46940679161a2bccaeec3851ef4253997eb", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 7235, "excerpt": "UPDATE idempotency SET expires_at_unix_ms=0"}, {"id": "lv-h7d8afd8a5d72", "path": "crates/fractal-store/src/store/message_marks.rs", "kind": "unresolved-sql-sink", "fingerprint": "60da735f8761d8ca4e4890163291cd1f5b396e1492d6103570abb3fa8d4659c4", "owner": "fractal_store::store::message_marks", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 270, "excerpt": "[ message ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , i64 > ( 1 ) ? . max ( 0 ) as u128 ) )"}, @@ -3134,6 +3144,7 @@ {"id": "lv-h88d82d562560", "path": "crates/fractal-missions/src/store/eta_tests.rs", "kind": "sql-template", "fingerprint": "7648f29922e944fcaaf997a490ebfc96315a26ef7da5badc340a90e8a2ecba8b", "owner": "fractal_missions::store::eta_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 158, "excerpt": "INSERT INTO local_run_eta_samples SELECT 'extra',run_id,definition_hash,step_path,2,duration FROM local_run_eta_samples WHERE subject='sample-0'"}, {"id": "lv-h88ea458f2e12", "path": "crates/fractal-attention/src/store/attention_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "a42ff4c289a7e734672cb0b0042e0fbce20df6f7f3fdbdb1fae520e39ff319ee", "owner": "fractal_attention::store::attention_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 425, "excerpt": "params ! [ after , through , i64 : : try_from ( now ) . unwrap_or ( i64 : : MAX ) ] , | row | row . get ( 0 )"}, {"id": "lv-h88ebca0a4792", "path": "crates/fractal-seats/src/store/usage_period.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c2ed65f6add7630a3428cd5a407227fa4efd13a56a9d5bfcc0e68db8bb931da", "owner": "fractal_seats::store::usage_period", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 22, "excerpt": "& format ! ( \"SELECT id,subject,store_index,body FROM claims INDEXED BY {INDEX} WHERE {PREDICATE} AND {series}=?1 AND {observed}=?2 LIMIT 2\" )"}, + {"id": "lv-h88ff6fefeb9f", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "54829f8306fffea3dfe5d84e805a161bde817b5ae41888c4f253d3879ed5b995", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origi"}, {"id": "lv-h8911c1538a9c", "path": "crates/fractal-store/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 447, "excerpt": "& query"}, {"id": "lv-h89145e643a90", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6972dcbe1981439c50004941304392991f4b835e7c273105cb3287dede71d3a4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 471, "excerpt": "UPDATE checkpoint_envelopes SET envelope_hash=?1 WHERE sequence>=2000000"}, {"id": "lv-h8928d49246cd", "path": "crates/fractal-store/src/store/backup_ledgers.rs", "kind": "sql-template", "fingerprint": "0783930aa84e56d8b6563d8387b70ad63b424bdc19a08715e60f9f7ebe73fdb5", "owner": "fractal_store::store::backup_ledgers", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 145, "excerpt": "INSERT OR IGNORE INTO local_message_marks(message, kind, consumer, recorded_at_unix_ms, receipt) \\ VALUES ({0}, {1}, {2}, {3}, {4})"}, @@ -3278,6 +3289,7 @@ {"id": "lv-h932a52c91553", "path": "crates/fractal-store/src/store/work_summaries_ivm.rs", "kind": "sql-template", "fingerprint": "9a0e6b5fe06e7302fe6dc0b8279892925a5786122c036d53bf30b5c5a7c8bb9a", "owner": "fractal_store::store::work_summaries_ivm", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 15, "excerpt": " CREATE TABLE IF NOT EXISTS local_work_summary_clock ( singleton INTEGER PRIMARY KEY CHECK(singleton=1), through_ms BLOB NOT NULL CHECK(length(through_ms)=16) ); INSERT OR IGNORE INTO local_work_summa"}, {"id": "lv-h934301fb4a5c", "path": "crates/fractal-api-core/src/api/storage_contention_response_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "b2cbbe76784ce7e1e5c37479f1d38c960c8035dd74974ae04e0aad4649d0dc87", "owner": "fractal_api_core::api::storage_contention_response_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 40, "excerpt": "\"BEGIN IMMEDIATE\""}, {"id": "lv-h9344d032159a", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "c82173d3f027536ecae5ea31b9f95e9718406c75c8aec8f449e035e78fdc763b", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 7224, "excerpt": "& claude_attachment_query ( )"}, + {"id": "lv-h934585192b73", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "386e7e1231a36489f2cceb29bad211b82fb2995a96133e6a5663bffbd895e0aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 991, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms='1' WHERE subject=?1"}, {"id": "lv-h936ac0b978c0", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "b2bb09729dbe5040bcad714e4c6a5da5662a151d932f87c414eb10c750176e1e", "owner": "fractal_store::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 150, "excerpt": "UPDATE local_runtime_starts SET {column}=?1 WHERE id='legacy-op'"}, {"id": "lv-h936ef40a4b1a", "path": "crates/fractal/src/main_tests.rs", "kind": "included-source", "fingerprint": "926eaf7cac906fb8fdc1408d4f645bee662c4ad8c26d308562817e08dd64451b", "owner": "fractal::main_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1090, "excerpt": "include_str ! ( \"../tests/fixtures/missions-tree.txt\" )"}, {"id": "lv-h9376410940d5", "path": "crates/fractal-store/src/store/agent_review_transaction.rs", "kind": "unresolved-sql-sink", "fingerprint": "1d4ca002c5d503278cf41edec5422032443ab930c1d86455003c3872aa530f76", "owner": "fractal_store::store::agent_review_transaction", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 80, "excerpt": "\"SAVEPOINT agent_review_answer\""}, @@ -3334,7 +3346,6 @@ {"id": "lv-h96bc76b659d9", "path": "crates/fractal-store/src/store/limits.rs", "kind": "sql-template", "fingerprint": "ed4f67a1120c72d8fb5049d029c8fddf7897fce54e35134f1b91f18d6b85df4c", "owner": "fractal_store::store::limits", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 457, "excerpt": "DELETE FROM account_limit_seats"}, {"id": "lv-h96c572b6f271", "path": "crates/smallclaims/src/store/prepared_seal_tests.rs", "kind": "sql-template", "fingerprint": "918168c7dd7ac179d9703d5da99940c296bf7afc822a0ae3a40f556ef3e80871", "owner": "smallclaims::store::prepared_seal_tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 1067, "excerpt": "INSERT OR IGNORE INTO replica_envelope_signatures( writer, sequence, envelope_hash, member_key, signature, stored_at_unix_ms ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"}, {"id": "lv-h96cc0d92ac25", "path": "crates/fractal-seats/src/store/canonical_audit.rs", "kind": "sql-template", "fingerprint": "d4f694e63f6fd1436ec169fa7d0fee6c7968b09ae7684da28abd0252de5cf807", "owner": "fractal_seats::store::canonical_audit", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1329, "excerpt": "INSERT INTO slo_episode_events(episode,event,member,fleet,objective,ordinal,claim_id,body,conflict,dispatched_at_ms,recorded_at_ms) VALUES('episode-a','pending','alder','fleet-a','objective-a',1,'clai"}, - {"id": "lv-h96d7cb75d46b", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "sql-template", "fingerprint": "93efaf2218bc4b565e8983f0bf5d5283aab054949fc8a85b2c8b6f8bd476244b", "owner": "fractal_missions::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6492, "excerpt": "UPDATE step_runs SET lease_expires_at_unix_ms='0' WHERE subject=?1"}, {"id": "lv-h96d94e7ed715", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "eaf11578bb3d857c5b138045c12b6db76dd2f4d7f0b44739736288265ac0b65c", "owner": "fractal_store::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 6799, "excerpt": "INSERT INTO batches VALUES ('b','writer',1,NULL,'b','1')"}, {"id": "lv-h9702b2285d87", "path": "crates/fractal-store/src/store/gate_runners_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::gate_runners_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 79, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-h97043c8dcb30", "path": "crates/fractal-secrets/src/sekrets/store.rs", "kind": "sql-template", "fingerprint": "1b4c1809d8f3afba5c2566f48e02fe589d6c69d6291c337995ab07771d94bf8d", "owner": "fractal_secrets::sekrets::store", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 12, "excerpt": " CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, owner TEXT NOT NULL, description TEXT, policy TEXT NOT NULL, is_default INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at "}, @@ -3501,6 +3512,7 @@ {"id": "lv-h9fe75ab6969f", "path": "crates/smallclaims/src/store/receive_writer_tests.rs", "kind": "sql-template", "fingerprint": "5650a756ab06b458f8cec93155e560e26236256f1a42304a160aaee0e9f35d99", "owner": "smallclaims::store::receive_writer_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 438, "excerpt": "INSERT INTO checkpoint_envelopes VALUES(?1,?2,?3,?4,'checkpoint/test')"}, {"id": "lv-h9fe9d50b5675", "path": "crates/smallclaims/src/store.rs", "kind": "sql-template", "fingerprint": "c2b3267891be1c5288ab67ef77b10dea76d97ea534255953cccd90889ec18aa8", "owner": "smallclaims::store", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 4501, "excerpt": "INSERT INTO replica_records( record_ref, writer, sequence, envelope_hash, position, raw, state, error_code, error_message, updated_at_unix_ms ) VALUES (?1, ?2, ?3, ?4, 0, ?5, 'invalid', ?6, ?7, ?8) ON"}, {"id": "lv-ha00b1a8a0c42", "path": "crates/fractal-store/src/store/retirements.rs", "kind": "unresolved-sql-sink", "fingerprint": "77abb748296f8d6588bb377e43060276c4b87198064f076635a1ebdf01ae0d6a", "owner": "fractal_store::store::retirements", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 153, "excerpt": "params ! [ recipient , claim . id ] , | row | row . get ( 0 )"}, + {"id": "lv-ha01213f3c4b5", "path": "crates/fractal-kernel/src/objective_tests.rs", "kind": "included-source", "fingerprint": "ffb22acfbbe448358e6dc69c839f176fdf8f9f010e44552da1930ecef54322c4", "owner": "fractal_kernel::objective_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 3, "excerpt": "include_str ! ( \"../../../slo/objectives.kdl\" )"}, {"id": "lv-ha013271629ac", "path": "crates/fractal-github/src/changes/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "bb3beb90db6d03b3fab4ef5029cccc3de43c7be1d9cde0dbf6e2e2ec075bb0af", "owner": "fractal_github::changes::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 288, "excerpt": "fixture . record . clone ( )"}, {"id": "lv-ha017db4fd2e3", "path": "crates/smallclaims/src/store/inventory_generation.rs", "kind": "sql-template", "fingerprint": "2ef160e13f1bd806c0e0cad2a0756638563f1a687c249c8b9725c9c753fa2e4c", "owner": "smallclaims::store::inventory_generation", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "INSERT INTO meta(key,value) VALUES(?1,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"}, {"id": "lv-ha019ff5d6de8", "path": "crates/smallclaims/src/ivm/install.rs", "kind": "sql-template", "fingerprint": "89c588db4d094aa63643b605232e1c67071c36b68dc2df42d02c962c1bbb387a", "owner": "smallclaims::ivm::install", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 322, "excerpt": "UPDATE ivm_install_jobs SET phase='stopped',error=?2 WHERE source=?1 AND phase IN ('scan','catchup')"}, @@ -3513,6 +3525,7 @@ {"id": "lv-ha09adbfb1aac", "path": "crates/smallclaims/src/store/projection_busy_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "b2cbbe76784ce7e1e5c37479f1d38c960c8035dd74974ae04e0aad4649d0dc87", "owner": "smallclaims::store::projection_busy_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 577, "excerpt": "\"BEGIN IMMEDIATE\""}, {"id": "lv-ha09c14267009", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "00269fc304355e61cbacee4f31dde25e977e7fe8afebb9fe1e2a7156d309ddda", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 21592, "excerpt": "params ! [ run_id , reason , updated_at_unix_ms . to_string ( ) , \"the root mission run is terminal\" ] ,"}, {"id": "lv-ha0a315cfb9e7", "path": "crates/fractal-delivery/src/store/message_send_tests.rs", "kind": "sql-template", "fingerprint": "9cbbdf7b8760c993debd0ea2ff4f8e00bba73c72c64d0f231fd7243c9c00c453", "owner": "fractal_delivery::store::message_send_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 121, "excerpt": "DROP TRIGGER reject_fixture_send"}, + {"id": "lv-ha0cd354483f3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "d388a6fd1d0276096452f9c77d126ef8afbe0de0b89086f5df09f5c4c8b8bb5a", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 868, "excerpt": "UPDATE step_runs SET lease_owner=?2 WHERE subject=?1"}, {"id": "lv-ha0cfaa1ec5cc", "path": "crates/smallclaims/src/store/principals.rs", "kind": "sql-template", "fingerprint": "b00efd86cc227528fbfa988943c50c7bc8f88c73ef6908072ddf7ed7cac1bc74", "owner": "smallclaims::store::principals", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1017, "excerpt": "DELETE FROM meta WHERE key=?1"}, {"id": "lv-ha0d48a2a03f2", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1287, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-ha0dce1e45fb7", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 182, "excerpt": "& store , | _ | ( )"}, @@ -3700,6 +3713,7 @@ {"id": "lv-habfb2b0c05da", "path": "crates/fractal-drivers/src/harness_events.rs", "kind": "sql-template", "fingerprint": "a4de6218a5e6b8ef0952c248d783d3f7c23b967bc4ed55fa29cd97be9b913513", "owner": "fractal_drivers::harness_events", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 323, "excerpt": "INSERT INTO events(runtime_incarnation,queued_at_ms,kind,body) VALUES (?1,?2,?3,?4)"}, {"id": "lv-hac01b510f6aa", "path": "crates/fractal-store/src/store/terminal_gate_evidence.rs", "kind": "sql-template", "fingerprint": "4ab028661942ae374c9aa98859505ae497d849f2c3f8da017a0619bf468909a1", "owner": "fractal_store::store::terminal_gate_evidence", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 349, "excerpt": "DELETE FROM test_terminal_meta WHERE namespace=?1"}, {"id": "lv-hac292d5d4ef7", "path": "crates/fractal-seats/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "3edada985fa7f0ed998965d239aab79abb45b29ef59bbe850c29f4bb5ebee977", "owner": "fractal_seats::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 722, "excerpt": "& format ! ( \"SELECT COUNT(*) FROM {table}\" ) , [ ] , | row | row . get : : < _ , u64 > ( 0 )"}, + {"id": "lv-hac330e1ef0a3", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "23d384584d619edc1b8135734fe4536e80b585db990943fa67e8dc9b10b08fa9", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 666, "excerpt": "UPDATE step_runs SET assignee='agent/node/other', available_to='[]', status='ready', lease_owner=NULL, lease_incarnation=NULL, lease_expires_at_unix_ms=NULL WHERE subject=?1"}, {"id": "lv-hac42e9bbbb24", "path": "crates/fractal-store/src/store/message_canonical_sort_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2ffd897d72bda85ddc6e414124c591f9804a4c42e2ad7a174c09ec7618be40e5", "owner": "fractal_store::store::message_canonical_sort_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 116, "excerpt": "& query , [ & message . subject ] , | row | row . get ( 0 )"}, {"id": "lv-hac6412f7b9a9", "path": "crates/fractal-store/src/store/run_eta_tests.rs", "kind": "sql-template", "fingerprint": "b3f2615139e7e6c555490b22cd2f59d6ed21382583e42d59f05b430c103dddc3", "owner": "fractal_store::store::run_eta_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 167, "excerpt": "UPDATE claims SET accepted_at_unix_ms='100' WHERE subject IN('s-0','mission-run/r-0') AND kind IN('step-run.state','mission-run.state')"}, {"id": "lv-hac726e7443c2", "path": "crates/fractal-store/src/store/attention_ivm.rs", "kind": "sql-template", "fingerprint": "107c51545db6e1c417e98c05ec8e5449ea093a898afdccbf59123b972a3eae13", "owner": "fractal_store::store::attention_ivm", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 14, "excerpt": " CREATE INDEX IF NOT EXISTS attention_open_person_sources_v3 ON step_runs(subject) WHERE assignee>='person/' AND assignee<'person0'; CREATE TABLE IF NOT EXISTS local_attention_open ( family TEXT NOT N"}, @@ -3891,6 +3905,7 @@ {"id": "lv-hb7b64cc10c51", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7a41ee06927a48e907a8b303d67368f1f76113a73b7efcaf20c658e641deaac8", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 2069, "excerpt": "params ! [ subject , at_index . unwrap_or ( i64 : : MAX as u64 ) ] , | row | row . get : : < _ , String > ( 0 ) ,"}, {"id": "lv-hb7bee44be324", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "af5beb3369f127015aa1f89c22e5b4abfb98318601a8b0482ff3fbced8deafe2", "owner": "fractal_store::store::tests", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "line_hint": 6902, "excerpt": "INSERT OR IGNORE INTO batches(id,origin,replica_sequence,hash,accepted_at_unix_ms) VALUES(?1,?2,?3,?1,?4)"}, {"id": "lv-hb7c02123e5e8", "path": "crates/fractal-store/src/store/delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "de52185ad8dcad7dc3fa1d30bf1dc897e611878e1021c43109bbe8f41484c4d4", "owner": "fractal_store::store::delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 105, "excerpt": "[ & message . subject ] , | row | row . get ( 0 )"}, + {"id": "lv-hb7cf1dc01667", "path": "crates/fractal-api-core/src/api/published_lists_tests.rs", "kind": "sql-template", "fingerprint": "39c0ce46943adc6d3fa133a261008f77c00d77aa927d346dd8295ee44b6ef104", "owner": "fractal_api_core::api::published_lists_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "UPDATE step_runs SET assignee=?2, created_at_unix_ms=?3 WHERE subject=?1"}, {"id": "lv-hb7ed2976f44f", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "d1f5ab3e6b8522867ac512eae576dbb7ef1e4800b4dbf53a0ec146f1b4054166", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 18506, "excerpt": "params ! [ claim . subject , to ] ,"}, {"id": "lv-hb7f8923255f2", "path": "crates/smallclaims/src/sqlite/read_lifetime/tests.rs", "kind": "sql-template", "fingerprint": "69029be48c65949b99471da0adf147accfc0c78d768e7131ce9386c27e664aff", "owner": "smallclaims::sqlite::read_lifetime::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 81, "excerpt": "UPDATE payload SET value=zeroblob(8192)"}, {"id": "lv-hb806bfc38d25", "path": "crates/smallclaims/src/store/transport_observation_index.rs", "kind": "sql-template", "fingerprint": "0eb11da108f89988de47a90451cafe08bc166a8ab47c59147718245906dfdb64", "owner": "smallclaims::store::transport_observation_index", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 26, "excerpt": " CREATE TABLE IF NOT EXISTS transport_observation_index ( claim_id TEXT PRIMARY KEY, subject TEXT NOT NULL, origin TEXT NOT NULL, accepted_at_unix_ms TEXT NOT NULL ); -- This CREATE INDEX only ever se"}, @@ -3956,10 +3971,12 @@ {"id": "lv-hbaf11ae2e68b", "path": "crates/fractal-missions/src/store/missions.rs", "kind": "sql-template", "fingerprint": "5d3baa4cb1a6ebdc4527226258f80f2f07dfd1bd8aa4ff43204d68c70a101ee7", "owner": "fractal_missions::store::missions", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 4048, "excerpt": "INSERT INTO idempotency(operation_id, response) VALUES (?1, ?2)"}, {"id": "lv-hbaf37261e5a0", "path": "crates/smallclaims/src/store/sealed_view_cache_tests/mutation_tests.rs", "kind": "sql-template", "fingerprint": "1db7f40539728faad5dd9e8915b9946096ae8fbfc4e69aca3d89e5c92efc0e9e", "owner": "smallclaims::store::sealed_view_cache_tests::mutation_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 99, "excerpt": "UPDATE batches SET id='temporary-batch' WHERE id=?1"}, {"id": "lv-hbb02f708c2c5", "path": "crates/fractal-delivery/src/store/mailbox_lease.rs", "kind": "unresolved-sql-sink", "fingerprint": "e8e0bb8a64c8c7cc2ff1889442a2e07a1d22347b0860b43855afef790e981dba", "owner": "fractal_delivery::store::mailbox_lease", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 240, "excerpt": "params ! [ fence . subject , episode ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? ) ) }"}, + {"id": "lv-hbb373c1f9535", "path": "crates/fractal-store/src/store/declared_objectives.rs", "kind": "unresolved-sql-sink", "fingerprint": "4aa7af888f0eb091e0fd5cc90601a0b7752fdd23d3b1a05045615f7bb1fc305e", "owner": "fractal_store::store::declared_objectives", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 66, "excerpt": "[ agent ] , | row | row . get : : < _ , Option < String > > ( 0 )"}, {"id": "lv-hbb4d7ef8a2a5", "path": "crates/fractal-store/src/store/messages_shard.rs", "kind": "unresolved-sql-sink", "fingerprint": "8ffaf16307b0b778471946170f039ab55a6c4a8e018436c074afbec06e2ebf6b", "owner": "fractal_store::store::messages_shard", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 248, "excerpt": "& format ! ( \"SELECT item.subject, item.store_index FROM stream_messages AS item WHERE {} ORDER BY item.store_index LIMIT :limit\" , filter . sql ( ) )"}, {"id": "lv-hbb840ed5c2a8", "path": "crates/smallclaims/src/store/checkpoint_staging/scoped.rs", "kind": "unresolved-sql-sink", "fingerprint": "c32b884bb11aa9d33ad9fa5b6cbd7b18be6b5eec40e5397aa0319a5e4069ab0d", "owner": "smallclaims::store::checkpoint_staging::scoped", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 134, "excerpt": "& sql"}, {"id": "lv-hbb8f1fc66365", "path": "crates/fractal-delivery/src/store/delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "05d1fca38c802b1f8682dc8dd0b15fed8fbff745fec02413bdf5de12122b884e", "owner": "fractal_delivery::store::delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 436, "excerpt": "& canonical_sql ( \"WITH candidates AS ( SELECT id FROM claims INDEXED BY claims_message_to_index WHERE kind='message.sent' AND json_extract(body,'$.fields.to')=?1 AND store_index<=?2 UNION SELECT id F"}, {"id": "lv-hbbb29f6a8f86", "path": "crates/fractal-store/src/store/resources.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_store::store::resources", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 77, "excerpt": "SCHEMA"}, + {"id": "lv-hbbb772f82848", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "f5a08f92b6484e142bfc0fac291d42996b27093a57f64739f414596a86da5406", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 690, "excerpt": "UPDATE step_runs SET assignee=NULL, available_to=?2 WHERE subject=?1"}, {"id": "lv-hbbba4668bfe8", "path": "crates/fractal-store/src/store/coordination.rs", "kind": "sql-template", "fingerprint": "121efaa5ed56abda9af318740eb032c5ca65527348d2dc8d2f6c815b24b479f9", "owner": "fractal_store::store::coordination", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "line_hint": 51, "excerpt": "CREATE INDEX IF NOT EXISTS coordination_sends_time ON coordination_sends(sent_ms,agent_to_agent,silent,to_person); CREATE INDEX IF NOT EXISTS coordination_sends_held ON coordination_sends(held,sent_ms"}, {"id": "lv-hbbc4fec93bc3", "path": "crates/fractal-seats/src/store/canonical_audit.rs", "kind": "sql-template", "fingerprint": "0ffd6f6cf65077624de24ce755a746fb82c2b9795a1ab03ab03a0d385e43add2", "owner": "fractal_seats::store::canonical_audit", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 689, "excerpt": "INSERT INTO temp.write_clock(offset_ms, at_ms) VALUES (0, ?1)"}, {"id": "lv-hbbd28d35da4a", "path": "crates/fractal-store/src/store/person_work.rs", "kind": "sql-template", "fingerprint": "28cc48d36dcfe25db7c1a426f57a2aa683b9d6bd3b4ac416cefa767f774c98ed", "owner": "fractal_store::store::person_work", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 968, "excerpt": "UPDATE mission_runs SET status=?2,phase='terminal',updated_at_unix_ms=?3 WHERE id=?1"}, @@ -4375,6 +4392,7 @@ {"id": "lv-hd355c60374f5", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "a2f391be823a4eafe796bd93333cd89211b452282a5154b2f8c6470e936601e3", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 363, "excerpt": "INSERT INTO step_runs(subject,run_id,generation_id,step_path,definition_hash, status,attempt,goals,created_at_unix_ms,updated_at_unix_ms) VALUES('step-run/'||?1,?1,?1,'build','hash','working',1,'[]','"}, {"id": "lv-hd36f92918948", "path": "crates/fractal-store/src/store/seats.rs", "kind": "unresolved-sql-sink", "fingerprint": "63d233d1b84eedf8c57c7d6d6054137f1456a76f414ca73fbb3fe3e662259782", "owner": "fractal_store::store::seats", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 219, "excerpt": "& canonical_sql ( \"SELECT id FROM claims INDEXED BY claims_harness_auth_diagnostic_index WHERE subject=?1 AND kind='harness.diagnostic' AND json_extract(body, '$.fields.incarnation_id')=?2 AND json_ex"}, {"id": "lv-hd38e7477ff17", "path": "crates/fractal-store/src/store/glass_heads.rs", "kind": "sql-template", "fingerprint": "2bab0f99fb8330ed98b3fb0b31a378077390bea2f9888bc170b4271886f67d69", "owner": "fractal_store::store::glass_heads", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 253, "excerpt": "INSERT INTO glass_heads(subject,person,created_key,head_key,claim_id,deleted) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(subject) DO UPDATE SET person=excluded.person,created_key=excluded.created_key,head_"}, + {"id": "lv-hd38e77dd6432", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "b1e759a7ebb5f7e3e03740e267ed59c93c71a7a6a044bf4224a57aa77dba92c2", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 811, "excerpt": "UPDATE step_runs SET assignee='agent/node/worker' WHERE subject=?1"}, {"id": "lv-hd38e7cfc3d89", "path": "crates/fractal-store/src/store/limits_purity_tests.rs", "kind": "sql-template", "fingerprint": "f8c4c93563c0c8238178ec4a9d4d1bd0f7326a6fd3c15cf50bcd871385ed6f44", "owner": "fractal_store::store::limits_purity_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 171, "excerpt": "INSERT INTO account_limit_seats (subject, driver, account, account_ref, accepted_at_unix_ms, store_index) VALUES (?1,'claude','claude/one','',1,1) ON CONFLICT(subject) DO UPDATE SET driver=excluded.dr"}, {"id": "lv-hd3906354fc71", "path": "crates/fractal-store/src/store/notice_authority_tests.rs", "kind": "sql-template", "fingerprint": "e994daae338c29fa43d6e8fbac011091d151437326784277140db7af7ffbeb5b", "owner": "fractal_store::store::notice_authority_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 388, "excerpt": "UPDATE run_generations SET id='unrelated-next' WHERE id='unrelated'"}, {"id": "lv-hd395e63608d9", "path": "crates/smallclaims/src/store/checkpoint_staging.rs", "kind": "unresolved-sql-sink", "fingerprint": "bc824287c54f7dcf052acfea5e28d5d9080619e3bebb48ded44da68e332ced27", "owner": "smallclaims::store::checkpoint_staging", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 706, "excerpt": "params ! [ row . 0 , row . 1 , row . 2 , row . 3 , row . 4 , row . 5 , row . 6 , row . 7 , row . 8 , row . 9 , row . 1 1 , row . 1 0 = = \"valid\" ]"}, @@ -4421,6 +4439,7 @@ {"id": "lv-hd5f90ba66291", "path": "crates/fractal-store/src/store/custom.rs", "kind": "unresolved-sql-sink", "fingerprint": "45de5b742be9d0e5cd0ba59d294584a610a7f05e563491431ae653f5ffca4e86", "owner": "fractal_store::store::custom", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 120, "excerpt": "& canonical_sql ( \"SELECT claims.id,claims.store_index,claims.batch_id,claims.subject,claims.kind,claims.origin,claims.actor,claims.body,claims.predecessors,claims.accepted_at_unix_ms FROM claims JOIN"}, {"id": "lv-hd5fb23096b84", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "sql-template", "fingerprint": "93a6ae20b41f3b50adacb2d9a47be1570b76c7dc285491a696c3395ce1db9f43", "owner": "fractal_store::store::client_messages", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 922, "excerpt": "INSERT INTO local_client_message_selectors_v1(subject,born_index,created_index,dirty_flags) VALUES(?1,-1,1,2) ON CONFLICT(subject,born_index) DO UPDATE SET dirty_flags=dirty_flags|2,created_index=crea"}, {"id": "lv-hd60ba3ca89ff", "path": "crates/smallclaims/src/store/inventory_generation.rs", "kind": "unresolved-sql-sink", "fingerprint": "5a8493ce00029e2325ab8e1257235dcaac72db782142700f36a73961b0cc40b5", "owner": "smallclaims::store::inventory_generation", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 108, "excerpt": "[ KEY ] , | row | row . get ( 0 )"}, + {"id": "lv-hd60f12cbb0cd", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "2c428368447719337e62bf6baa77578a1f95a5cda24f8fab6b5471192613c58e", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 761, "excerpt": "UPDATE step_runs SET assignee=?2, available_to=?3, lease_owner=?4, status=?5, lease_incarnation=?6, lease_expires_at_unix_ms=?7 WHERE subject=?1"}, {"id": "lv-hd60f51383124", "path": "crates/fractal-attention/src/store/alert_action_controls.rs", "kind": "sql-template", "fingerprint": "f7eee9a8920111e3326b09d284281d362715dc916c127be5d139b2a4d2e65433", "owner": "fractal_attention::store::alert_action_controls", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 98, "excerpt": "UPDATE claims SET body=?1 WHERE id=?2"}, {"id": "lv-hd61a62736b0a", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "7253cb476f96e83b5411c1487a9be9635820964f70c525ef5f88d7c57da5709c", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 13009, "excerpt": "params ! [ subject , at_index , incarnation_id ] , | row | Ok ( ( row . get : : < _ , String > ( 0 ) ? , row . get : : < _ , String > ( 1 ) ? , row . get : : < _ , String > ( 2 ) ? ) )"}, {"id": "lv-hd6399516a97a", "path": "crates/fractal-store/src/store/run_eta.rs", "kind": "unresolved-sql-sink", "fingerprint": "6e4db109e4ae560950a7301b0a4e357f7b7663c394ef6af46e901160eee56dcc", "owner": "fractal_store::store::run_eta", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 162, "excerpt": "& format ! ( \"SELECT kind,body,claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE subject=?1 AND (json_extract(body,'$.fields.attempt') IS NULL OR json_extract(bod"}, @@ -4436,6 +4455,7 @@ {"id": "lv-hd6d5825156e4", "path": "crates/fractal-secrets/src/sekrets/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "fractal_secrets::sekrets::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 180, "excerpt": "SCHEMA"}, {"id": "lv-hd6e104a05fb6", "path": "crates/smallclaims/src/store/document_index.rs", "kind": "unresolved-sql-sink", "fingerprint": "5e9e57b81ceeffbc1630a50f92ef3b700c2b62b3e263bff52cc527c81d2d484b", "owner": "smallclaims::store::document_index", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 55, "excerpt": "params ! [ name , hash , id , key ]"}, {"id": "lv-hd6e8a46ee987", "path": "crates/smallclaims/src/store/checkpoint_trim.rs", "kind": "sql-template", "fingerprint": "39d0916851f71003495abee2210e1ced45da0388cf5184925ffa91d54322c837", "owner": "smallclaims::store::checkpoint_trim", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 1548, "excerpt": "INSERT OR IGNORE INTO meta(key, value) VALUES (?1, (SELECT COALESCE(MAX(rowid), 0) FROM checkpoint_envelopes) || ' ' || (SELECT COALESCE(MAX(rowid), 0) FROM checkpoint_claims))"}, + {"id": "lv-hd700e0550a21", "path": "crates/fractal-missions/src/store/work_renew_tests.rs", "kind": "sql-template", "fingerprint": "65bb03b8ef588bf97692e80e9dbcbf98da45a8a464c18ee0f3b8771cf135a3aa", "owner": "fractal_missions::store::work_renew_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 861, "excerpt": "UPDATE step_runs SET assignee=?2 WHERE subject=?1"}, {"id": "lv-hd7107683547f", "path": "crates/fractal-delivery/src/store/conversation_reads.rs", "kind": "unresolved-sql-sink", "fingerprint": "43356247c35c7c91f2201887e1d25b61cae1f422a2c020edb6d7cd298b51383a", "owner": "fractal_delivery::store::conversation_reads", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 506, "excerpt": "TIMELINE_OWNER_IDS"}, {"id": "lv-hd71123037179", "path": "crates/fractal-delivery/src/store/actual_purity_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_delivery::store::actual_purity_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 133, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-hd729ec9593f5", "path": "crates/fractal-store/src/store/tests.rs", "kind": "included-source", "fingerprint": "6a05cbb4717caa2a60a7abeba00f522c7d6804fbd77fa845016f39ac3ba984e9", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 287, "excerpt": "include ! ( \"subject_reference_readmission_tests.rs\" )"}, @@ -4855,6 +4875,7 @@ {"id": "lv-hf0005c722374", "path": "crates/fractal-store/src/store/slo_breaches.rs", "kind": "sql-template", "fingerprint": "72c42082bb7327a69ecfb944a9b3da418cedbbb9ba6e56444020bf2d9764e900", "owner": "fractal_store::store::slo_breaches", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 160, "excerpt": "INSERT INTO slo_episode_heads(fleet,member,objective,episode,ordinal,conflict) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(fleet,member,objective) DO UPDATE SET episode=excluded.episode,ordinal=excluded.ord"}, {"id": "lv-hf003c2793536", "path": "crates/smallclaims/src/store/projection_digest.rs", "kind": "unresolved-sql-sink", "fingerprint": "fec187433c4f392fddb9c0d1400a095648f252e10b441a3cf1058bac6f7d78d9", "owner": "smallclaims::store::projection_digest", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 135, "excerpt": "SCHEMA"}, {"id": "lv-hf00f330bcb9a", "path": "crates/fractal-store/src/store/checkpoint_capture_epoch_tests.rs", "kind": "sql-template", "fingerprint": "be4832edabef59c7b5168b7abb00f547104e1c3264e49ceaf20a249ffc1d9784", "owner": "fractal_store::store::checkpoint_capture_epoch_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 46, "excerpt": "INSERT INTO mission_revisions(mission_id,revision,state,body,claim_id,created_index) SELECT 'copy',revision,state,body,claim_id,created_index FROM mission_revisions WHERE mission_id='capture'"}, + {"id": "lv-hf01b6d5cecf5", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "95923a5d49ab1c3c1f6b3254e6cf3ccafb8ca33833d9566fa5e90626612d746e", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 19298, "excerpt": "params ! [ spellings , run_id ] , | row | { Ok ( ( row . get : : < _ , Option < i64 > > ( 0 ) ? , row . get : : < _ , Option < String > > ( 1 ) ? , ) ) }"}, {"id": "lv-hf01d490c1378", "path": "crates/fractal-store/src/store.rs", "kind": "sql-template", "fingerprint": "1d725f730d91a1aabeefc8917346db4353d66cc952e2df205cebd12b6f4d9d39", "owner": "fractal_store::store", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 18649, "excerpt": "UPDATE revision_proposals SET status=?2, successor_generation_id=?3, updated_at_unix_ms=?4 WHERE id=?1"}, {"id": "lv-hf02160169cc7", "path": "crates/fractal-missions/src/store/resource_preparation/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_missions::store::resource_preparation::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 96, "excerpt": "& store , | _ | ( )"}, {"id": "lv-hf0338a02af4f", "path": "crates/fractal-store/src/store/client_messages.rs", "kind": "unresolved-sql-sink", "fingerprint": "b53ffc8bab004056486a57354fa6c8d825fe6a3bc6a38e259c2d388b34d610fe", "owner": "fractal_store::store::client_messages", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1266, "excerpt": "& RETIRED , params ! [ expired , PRUNE_ROWS ]"}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index a060aa19457..51552e251c3 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -194,6 +194,7 @@ mission "MISSION_ID" assigned-to "agent/${ST_MISSION_RUN}/owner" // Or repeat available-to. A mission cannot declare agentless. capacity cores=2 memory="4G" // the lane of each agent here that declares none + authority { start "factory/publish"; write "resource/factory/*" } // top-level only completion { when "all-steps-exhausted" } // Or: completion { depends-on { step "publish" completed } } @@ -235,6 +236,21 @@ An exact idempotent retry returns its existing run before the capacity check. A The reviewer defaults to the mission run requester. `revision-cutover` is `restart-active` by default or `when-idle` when declared. The value in the current generation controls how its successor starts; a candidate cannot select its own cutover. +`authority` is optional and belongs on a top-level mission only. It says what the mission's runs +may do as the actor `mission-run/RUN` when a program step acts as the run +([program engine §3](program-engine.md#3-declaration-and-authority)): one `VERB "PATTERN"` rule per +line, in the shape of an agent's `*-authority` blocks. `publish`, `start`, `revise` and `cancel` take +a mission ID without `mission/`; `write` takes a subject, or a registered family alone, before +`/*`. A pattern is exact or ends in `/*`, which covers everything under it. Parsing refuses an empty +block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop +mission. Rules are stored in canonical order, so reordering them is not a revision; any other change +is. A mission without the block grants its runs nothing. The block is stored with the revision; +the daemon does not yet bind a run actor or enforce it. An older member would quarantine a +revision that declares it (it hashes the mission without the field), so publication holds such a +revision until every member advertises `mission_authority=1` on its `daemon.started` +(`mission-authority-unavailable`, also an `apply --dry-run` blocker), as for `capacity` +([seat lanes](seat-lanes.md)). + A mission can contain direct declarations. Direct agents in the mission can revise the complete mission. A mission can contain zero steps. Missions default to `completion { when "all-steps-exhausted" }`, @@ -1291,8 +1307,12 @@ mission's policy; schedules retain their timers, catch-up and no-overlap behavio does not change schedule admission or schedule faults. `stalled-after` remains a positive duration even when stall reports are disabled. -A stall is measured from the run's last sign of life: its last state change, any step's last -change or progress record, and the end of any step's lease. Activity in child runs linked from its steps, +A stall is measured from the run's last sign of life: its last state change and any step's last +change or progress record. A step that a worker holds counts from the start of its current +execution interval (its claim, or the end of its last wait), its last progress record, and a +declared `work wait` until that wait ends. Lease renewals are not a sign of life: a live seat that +holds a step and records no progress is reported like any other silence, so a worker on a long +step records progress with `fractal work progress`. Activity in child runs linked from its steps, including loop rounds and nested work, counts too. A run in cleanup, and a run that still waits on `after`, cannot stall. A stalled run is not failed and can go on; if it makes progress and then goes quiet again, that is a new stall and a new message. The daemon keeps each run's stall limit as a diff --git a/docs/st3/objectives.md b/docs/st3/objectives.md new file mode 100644 index 00000000000..7ee8e642644 --- /dev/null +++ b/docs/st3/objectives.md @@ -0,0 +1,175 @@ +# Objectives + +An objective is a target for something Fractal measures, with an error budget and an optional +response when it is missed. There is one concept and one KDL kind, `objective`. Fractal ships its +own objectives (request latency, the store, CPU, CI) and a fleet declares more with `fractal apply`, +like agents, missions and accounts. Both are read the same way: `fractal slo ls`, `fractal slo +explain ID` and `fractal slo chart ID`. + +## The `objective` kind + +```kdl +version 2 + +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" set="hosts" at="merged_at" + stop "custom.acme.change-applied" member="host" at="applied_at" + cancel "custom.acme.change-abandoned" + label "class" + exclude "exclusion" "deferred-launch" "deferred-runtime" "deferred-workspace" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" owner="agent/ops/release-manager" backup="agent/ops/observability" condition="change-rollout-slo-missed" +} +``` + +- The name is the objective's id and its subject is `objective/NAME`. It is lowercase words joined + by `-`, in segments joined by `/`. A declared objective is named `NAMESPACE/NAME` or deeper, like + an agent; the objectives Fractal ships are one segment each, so the two never collide. +- `about` says what is measured, in one sentence. +- `indicator` says how a sample is measured. Each kind is below. +- `target STAT BOUND` bounds one statistic. `STAT` is what the indicator reports (`p50`, `p99`, + `max`, `cores`, ...) and the bound is `within=DURATION`, `max=NUMBER` or `min-percent=NUMBER`. + Each indicator names its rows (table below). A percentile over claims or CI is the row + `NAME/pNN` (`ops/change-rollout/p95`), one per target. +- `window` is the span the budget is measured over, and `min-samples` the samples it needs before it + is met or missed; with fewer it is `no-data`. Only an indicator over claims takes a window. + Native telemetry keeps its own windows, below. +- `on-breach` is what happens when a row is missed. It is optional; without it a miss is shown and + nothing is delivered. + +A missing, unknown, repeated or nonsensical child is refused, never defaulted. An objective is a root +declaration; it cannot sit inside a mission. + +## Indicators + +### Native telemetry + +Fractal measures these itself, in memory, on every member. They are the objectives Fractal ships, in +[`slo/objectives.kdl`](../../slo/objectives.kdl), which is generated from +[`slo/objectives.kdl.genie.ts`](../../slo/objectives.kdl.genie.ts). A fleet cannot declare them: +each path, store series and CPU reading counts toward exactly one objective, and that is the one +the daemon was built with. + +| Indicator | Measures | Targets | Rows | Windows | +|---|---|---|---|---| +| `latency` | Each `path`: a daemon route (`GET /v1/client/agents`), a stream's first snapshot (`stream agents`), a refresher's publication (`publish missions`) or a paired phone's report (`client/ios/connect`) | `p99 within=`; again with `scope="remote"` for the same reads served from another member | `NAME`, `NAME/remote` | 1m, 5m, 1h; budget over 1h | +| `share` | A phone's live foreground time over its foreground time, at `path` | `live-share min-percent=` | `NAME` | 1m, 5m, 1h | +| `statement` | One SQL statement | `p99 within=`, `max within=` | `NAME` (the p99) | 1m, 5m, 1h | +| `transaction` | Read and write transactions and writer holds | `max within=` | `NAME/read`, `NAME/write`, `NAME/writer_hold` | 1m, 5m, 1h | +| `writer-hold` | Each writer hold and long read, by holder | `max within=` | `NAME` | 1m, 5m, 1h | +| `cpu` | The daemon and its replication workers over closed UTC minutes; children `idle-min-minutes` and `load-max-cores` | `cores max=`, `idle-cores max=` | `NAME`, `NAME/idle` | the hour | +| `database` | The store's pages in use and their daily growth | `live-gb max=`, `growth-mb-per-day max=` | `NAME` | the newest hourly measurement | +| `divergence` | Open projection divergence faults | `open max=` | `NAME`, once a divergence check has run | now | +| `ci-duration` | A CI `series` (`ci/merge-queue`) read by the repository observer | `pNN within=` | `NAME/pNN` | 1d | +| `ci-count` | Events in a CI `series` | `per-hour max=` | `NAME` | 1h | + +The statement, transaction, writer-hold, CPU, database and divergence objectives are one each, +named as the table's rows say (`sql-statement`, `transaction`, `writer-hold`, `cpu`, `database`, +`projection-divergence`): `fractal doctor` and the OTLP export read them by these names. + +These keep the meanings they had as built-in targets: a p99 is missed in a window when more than +1% of its samples are over, a max when any one is, and CI rows are no-data on a member that reads no +Workspace CI runs. + +### Interval over claims + +`interval` measures work that starts and stops in different processes and on different members, +from claims those processes write. Each subject under `subject-prefix` is one sample. + +| Child | Meaning | +|---|---| +| `subject-prefix "P"` | The sample subjects: every subject that starts with `P`. Custom claims (`custom/NS/...`) need no registration and replicate to every member. | +| `start KIND at=FIELD` | The sample starts at the earliest such claim. | +| `expect KIND set=FIELD` | The members a sample must stop on, a list of strings in the newest such claim (by its time, then its claim id, so every member picks the same one). An empty list stops the sample at the expect claim's time. Without `expect`, one stop claim ends the sample. | +| `stop KIND member=FIELD` | One member has stopped: the earliest such claim per value of `FIELD`. The sample stops when every expected member has, at the latest of their times. | +| `cancel KIND` | An open sample with such a claim is cancelled. A sample that stopped before the cancel was accepted keeps its value. | +| `pause KIND resume=KIND` | The clock stops from each pause to the next resume, and a sample still paused is not aging. Pauses and resumes are timed when they were accepted. | +| `label "FIELD"` | Also one row per value of `FIELD` on the start or expect claim: `ops/change-rollout/p95{class=routine}`. A sample without the field is only in the unlabelled row. `label "member"` splits by member, each sample's value being that member's stop: the expected members, or without `expect` the members that stopped. | +| `exclude "FIELD" "A" "B"` | A sample whose start or expect claim has one of these values is excluded. | + +`at=FIELD` reads a time from the claim's fields: an RFC 3339 string or Unix milliseconds, either +also as a JSON-encoded string. Without `at`, the time is when the claim's origin accepted it, which +every member sees the same: a replicated claim keeps its origin's acceptance time. A list field may +also be a JSON-encoded string. A claim whose time, list or member cannot be read is counted as +unreadable and changes no sample. + +A sample is **closed** when it stopped, **open** while it has started and not stopped or been +cancelled, **cancelled**, or **excluded**. A closed sample belongs to the window by its stop time, a +cancelled one by its cancel and an excluded one by its newest claim; an open sample is always +current. A subject with claims but no start yet is counted apart and in no class. + +**Open samples are censored, not dropped.** For each target, an open sample older than the target's +`within` enters that target's percentile at its current age: it is certain to be over, so a stuck +change spends budget instead of hiding. A younger open sample is left out of the percentile, since +it may still finish within. The row counts every class, so nothing is silently dropped: + +``` +ops/change-rollout/p95 missed p95 ≤ 5 min p95 41 min over 23 samples (1 open over) 7d 0% +``` + +The value is the nearest-rank percentile. The budget is the share over `within` against the share +the percentile allows: p95 allows 5 in 100. With fewer than `min-samples` in the percentile the row +is `no-data` and spends no budget; with none it reads `no samples`. + +Each evaluation reads only the claims newer than the last it read, keeping each objective's samples +in memory; a sample closed, cancelled or excluded before the window is dropped from memory. + +### Ratio over Prometheus metrics + +There is none: Fractal does not read Prometheus. It exports its native readings as OTLP gauges +(`st_slo_*`), and Grafana computes ratios from them and from other exporters. An objective whose good +and total events exist only as Prometheus series stays in Grafana until its events reach Fractal as +claims or native telemetry. + +## Breach response + +```kdl +on-breach "fault" owner="agent/NS/NAME" backup="agent/NS/OTHER" condition="NAME-slo-missed" +``` + +A missed row opens one `operational.failure` on `daemon/MEMBER` addressed to `owner`, with the +`condition`. It stays one episode until the row is met again, when `operational.recovered` ends it. +A row with no data neither opens nor ends one. The member that delivers it is the one the owner +agent is declared on: every member evaluates the objective from the same replicated claims, and one +delivers. An owner that is not a live declaration gets nothing, and the explanation says so. + +`backup` is held: it is recorded and shown, and never becomes the fault's reviewer. It differs from +the owner. + +Objectives Fractal ships respond as they always have: a missed CI row is a fault for the fleet fault +agent. + +## Publishing, revisions and replication + +An objective is published with `fractal apply`, like an account. It is stored as the subject's +`intent.desired` claim, and its revision is the hash of the canonical declaration, which `fractal +apply --dry-run` prints as the old and new revision. The claim replicates to every member, so every +member evaluates the same declaration. A revision restarts the objective's evaluation from the claims; +nothing about past samples is stored apart from them. Retiring the subject removes the objective and +its rows. + +A daemon that does not know the `objective` family refuses the replicated claim, so publish an +objective only after every member runs a build that knows it. + +## Reading objectives + +`fractal slo ls` lists every row, shipped and declared alike, from `GET /v1/slo`. `fractal slo explain +ID` adds, for a declared objective, its subject and revision, the sample counts by class, each open +sample over target with its age, and its response. `fractal slo chart ID` draws the budget left a +minute at a time from the local seven-day history, which records declared rows like any other. +A declaration this build cannot read, as one replicated from a newer build, is the one row `NAME`, +`no-data`, whose explanation names its revision and why. + +## Listing claims by kind or subject prefix + +`GET /v1/claims` filters by `kind`, by `subject_prefix` or by an exact `subject`, and pages by store +index. `fractal claims ls --kind KIND --subject-prefix PREFIX` prints them. Both read through the +claims' kind and subject indexes. The interval indicator reads its claims the same way, keeping each +objective's samples in memory and reading only claims newer than the last it saw. diff --git a/docs/st3/principals-and-grants.md b/docs/st3/principals-and-grants.md index 32930ad08b8..56acd079599 100644 --- a/docs/st3/principals-and-grants.md +++ b/docs/st3/principals-and-grants.md @@ -119,6 +119,69 @@ and absent-principal legacy ChatGPT grants retain their existing meaning. Claims persist credential/code digests, never bearer tokens or pairing codes; grant projections expose neither credential material nor its digests. +Service read admission uses a closed GET/scope registry on both the credential +gateway and bearer-authenticated native router. `read.claims` admits +`/v1/client/claims` and `/v1/client/claims/by-id/{id}` with the native readers' +1–500 bounds and numeric `after`/`before` cursors. `read.work` admits exact +`/v1/client/work-items/{subject}` reads without acknowledging wakes. Service work +responses are a separate typed DTO: subject, run/generation/step identifiers, +attempt, status, claimant, claim/submission incarnations, claim expiry, readiness +epoch, worker-reported flag, creation/update timestamps and typed execution clock +timestamps/counters only. Goals, constraints, titles, person answers, summaries, +blocked reasons and other native free-text subviews are excluded on both aliases. +`read.documents` remains a closed scope and its routes remain registered, but its +document namespace allowlist is empty: every service list/content/history request +is refused until a consumer namespace is declared. Native aliases use the same +service decision; the gateway continues to refuse raw routes. + +Service claim bodies are deny-by-default, not field-name redaction. Only +`gate.result` and `work.claimed`, `work.renewed`, `work.progress`, +`work.submitted`, `work.failed`, `work.released`, `work.extended` disclose +schema-listed scalar receipt/state fields. Gate bodies expose `verdict`, +`operation`, `request`, `baseline`, `token_usage`, `stage`; the literal gate name +is excluded. Work bodies +expose `attempt`, `status`, `worker_reported`, `claimant`, `claim_incarnation`, +`claim_expires_at_unix_ms`, `readiness_epoch`, `extend_ms`, `clock`, +`wait_until_unix_ms`, `submitted_incarnation`, with the receipt/state claim's actor. +These support independent gate receipt and work-state validation without arbitrary +payload, summary, reason or handoff objects. +Every other kind returns only `store_index` and the fixed `withheld: true` +marker in pages, with no subject, id, actor, origin, kind, timestamp or body digest. +This includes declarations, grants and pending device/assistant pairings: even +the deterministic pairing subject can be a verifier and is not disclosed. +Nonallowlisted by-id requests return the same not-found response as nonexistent +ids. Service by-id admission first reads only the kind through the id index; +hidden and absent rows return before loading or parsing a body, avoiding a +body-size/JSON-parse existence oracle. Withheld rows retain page positions without gaps. +Service claim selectors (`subject`, `subject_prefix`, `kind` and `owner_run`, the +complete selector set) never match withheld rows: filtered store queries intersect +every selector with the disclosure kind allowlist in SQL before paging. Filtered pages contain only allowlisted claims, +never withheld markers or indexes; empty filtered pages retain the ordinary empty +page/cursor shape. Unfiltered paging still includes withheld positions. +Service `subject` selectors match literal canonical subjects only; services must +supply the stored canonical subject, not an alias. No identity-reference or +lifecycle resolution runs for service selectors. `owner_run` remains a literal +ownership comparison. Non-service reference resolution is unchanged. +The new client claim/work aliases admit only services; device, assistant and +browser clients gain no new routes. Existing native routes retain non-service +semantics. No declaration, assistant, action, upgrade, HEAD or unknown route is admitted. + +The daemon writes only `custom.client.service.v1.read-window` as +`daemon/{origin}`, at most once per grant per 60-second wall-clock window. +Admission-refused requests write no audit claim. Service identity is never an acting party, +and browser ingress refuses a browser cookie or launch token combined with +Authorization. Browser admission requires a single credential source. Service-grant fixtures +are inside `#[cfg(test)]` modules; production +grant append still rejects service issuance. This is not an OS sandbox: +same-UID callers dropping the credential on an unrestricted socket remain +outside credential enforcement (R18). + +Production issuance remains disabled pending owner CLI/rotation, admission +atomicity with concurrent-admission proof, bounded/indexed profile admission, +fleet-wide mixed-profile exclusion, mixed-version/downgrade safety, and auth-path +p95 under concurrent completions. No claim is made that M1 lifecycle or the +real merge consumer acceptance is complete. + New assistant BEGUN claims bind their keyed `code_hash` to the stored full `assistant-grant/` grant ID, with `verifier: "hmac-sha256-v1"` and `verifier_key_id` identifying the member-local secret. The secret is excluded @@ -132,8 +195,8 @@ fallback. See [assistant verifier recovery](external-assistants.md#pairing-verif | --- | --- | --- | --- | --- | | R27 | A host user service outside a harness authenticates as a stable non-person subject provisioned by a person or a node, not as its invoking agent. | M1 | NOT STARTED: #5019/#3268; plugin/assistant primitives do not prove this consumer contract. | Restart unattended service and invoke from two agents; every read retains the same service subject. | | R28 | Its credential file rotates and revokes without changing the stable subject; retired/revoked credentials cease to authenticate. | M1 | NOT STARTED: #5019; general device pairing is insufficient. | Rotate, use old/new credentials, revoke, retry after daemon restart; audit subject continuity. | -| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | NOT STARTED: #5019; client-v0/scoped raw route acceptance UNKNOWN. | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | -| R30 | Every write is refused under the service credential, including free mode, raw routes, client actions, claims, documents and runtime/admin operations. | M1 | NOT STARTED: #5019; unauthenticated local reads do not prove denial. | Enumerate mutation routes/aliases; negative-write suite asserts structured refusal and zero store/runtime effects. | +| R29 | Scoped native routes allow claims by id and paged claims, work-item reads and document reads at admission, queue proof and final merge. | M1 | PARTIAL: closed receipt/state claim bodies and exact work reads implemented; document reads: allowlist empty until a consumer namespace is declared. Issuance remains disabled and real merge-consumer acceptance is UNKNOWN (#5019). | Real merge consumer reads all four families, traverses pages without gaps and validates exact receipts at all three stages. | +| R30 | Every write is refused under the service credential, including free mode, raw routes, client actions, claims, documents and runtime/admin operations. | M1 | PARTIAL: central GET/scope admission and both-router refusal fixtures implemented; production lifecycle acceptance remains UNKNOWN (#5019). | Enumerate mutation routes/aliases; negative-write suite asserts structured refusal and zero store/runtime effects. | ## Shared box and messaging diff --git a/docs/st3/priority.md b/docs/st3/priority.md index bc9a9e2cb8e..3f1bbb4a93c 100644 --- a/docs/st3/priority.md +++ b/docs/st3/priority.md @@ -96,6 +96,7 @@ Declared priority accepts the complete set `background < normal`; declared lane **Budget window.** A mission's `token-budget` is the total cost-weighted tokens for one mission run, including all attempts and retries; retrying does not start a new window. `cpu-seconds` is accumulated CPU seconds over that same mission-run window. +See the [CPU reservation ledger contract](#cpu-reservation-ledger-contract) for the intended accounting. A step-run window is a future extension, not an accepted declaration location. Cost-weighted tokens are `sum(tokens * (class price / base input price))` across @@ -232,6 +233,108 @@ claim kinds are not scanned. A valid ancestry continuation beyond 64 vertices al refuses the whole report, even if the mission was already found; missing provenance remains distinct and may be reported as unattributed. +### CPU reservation ledger (contract) + +Status: contract only, data only. No code implements it yet. Owner of the semantics: +st-budgets. Implementer: the gate-slot retirement program (job reserve/settle hooks +and the cgroup v2 `cpu.stat` collector), after its durable-job work. Decision: Johannes, +`step-run/ask-139e87e814cd8ef4cc9b64262722413d/ask` (R5), with inclusive charging from +`step-run/ask-ba7954f15024216f4209fa4f421b3f2a/ask` (R2). + +**Purpose.** A mission's `cpu-seconds` becomes a total cap on the CPU seconds that its +jobs use in one mission run. One authoritative ledger per mission run records +reservations and settlements. + +**Order of delivery.** + +1. Report: a per-run CPU report (settled, reserved, unknown) ships and is observed on real runs. +2. Admission: then the ledger can refuse to start a job whose reservation does not fit. + +The first stage, reporting, changes no behavior. Turning admission on for real missions +needs Johannes's explicit approval, because budget declarations are data only until then. + +**Unit and measurement.** + +- One CPU second is 10^6 `usage_usec` from cgroup v2 `cpu.stat` of the job attempt's own scope. +- The collector reads the scope at start and at end, and records the delta, the physical + host and the observation timestamps. `usage_usec` includes descendant cgroups. +- A transient scope's cgroup disappears at exit. The collector reads it before removal, + or uses the unit's `CPUUsageNSec` with `CPUAccounting=yes`. +- A job attempt without its own cgroup v2 scope has unknown usage. This includes macOS + hosts, remote builders and a lost host. Unknown is never zero. + +**Ledger records** (append-only, one ledger per mission run, fields named for the contract): + +- `reservation`: ledger run, job id, attempt, host, `reserved_cpu_seconds`, the list of + charged mission runs, and the time. +- `settlement`: the reservation id, then `measured_cpu_seconds` or `unknown` with a reason, + coverage, and the time. + +**Reservation size.** + +- The reservation is the job attempt's provable CPU upper bound, in CPU seconds: the + wall-clock deadline × min(the `cpu.max` rate of the attempt's scope and of every ancestor + cgroup, the CPU count of the scope's `cpuset.cpus.effective`). +- The cgroup hierarchy enforces that minimum. The implementer records which cgroup + supplied the bound. +- A job attempt without a deadline, or on a host without cgroup v2 (macOS, remote + builders), has no provable bound. It can run in report mode only; in admission mode + it is refused for a capped mission. +- The bound is conservative: an uncapped attempt reserves all of its effective CPUs for + its whole deadline, so a capped mission can wait for a long time in admission mode. + Settlement corrects the charge when the attempt ends. A per-job `cpu.max` or a shorter + deadline gives a tighter bound. + +**Reserve before each job attempt.** + +- Before the attempt starts, the ledger adds one reservation for that attempt. +- The ledger charges the reservation to every mission run that declares `cpu-seconds` + on the job's ancestry: the job's own run and every declaring ancestor (R2). A job is + counted at most once in each ledger. +- In admission mode, the attempt starts only if, for every charged run: + settled + open reservations + this reservation ≤ that run's `cpu-seconds`. Otherwise: + - If the attempt could fit after open reservations settle, it waits in its queue. + It is not killed and not failed. + - If the attempt's reservation alone exceeds a charged run's `cpu-seconds` minus that + run's settled usage, the attempt can never fit. The ledger refuses it with the typed + reason `budget-cannot-fit`, which names the run and the numbers, and the report shows it. + +**Settle after each job attempt.** + +- When the attempt ends, the settlement replaces the reservation with the measured value. +- If the measurement is unknown, the reservation stays charged in full (R5). +- If the measured value exceeds the reservation, the measured value is charged, and the + report marks a bound violation: the bound was wrong. + +**Retries and delegation.** + +- Each retry attempt is reserved and settled separately in the same mission-run window + (the #6092 budget window); a retry does not start a new window. +- A delegated child run charges its own ledger and every declaring ancestor's ledger. + +**Limits pin and scope.** The limit comes from each run's initial revision. Only runs +that start after the feature activates are charged (new runs only). + +**Running work.** No missing, late or unknown usage stops or kills running work. Only the +admission of new attempts is affected. + +**Authority and failure.** + +- Each mission run has one authoritative ledger writer: the replica that admits the run's jobs. +- If the ledger is unreachable in admission mode, new attempts of a capped mission wait, + and running attempts continue. +- A replica never admits against a copy that it cannot prove current. + +**Report fields** (per mission run): + +- `cpu_seconds` declared, or tagged `undeclared`; +- `charged_settled`; +- `charged_reserved_open`; +- `charged_unknown_retained`; +- `inclusive_measured` (all usage in the subtree, separate from charged); +- `unknown_attempts`; +- `refused_cannot_fit` (attempts refused with `budget-cannot-fit`). + ## Linux On Linux with a systemd user manager, Fractal starts each terminal in a transient scope that it records diff --git a/docs/st3/profiling.md b/docs/st3/profiling.md index 2a47b88b483..1fc93e82fcd 100644 --- a/docs/st3/profiling.md +++ b/docs/st3/profiling.md @@ -152,7 +152,7 @@ move observers after acknowledgements. ## Targets and live windows -[`slo/targets.toml`](../../slo/targets.toml) holds the daemon's targets: reads a person waits on, +[`slo/objectives.kdl`](../../slo/objectives.kdl) holds the objectives Fractal ships ([objectives](objectives.md)): reads a person waits on, writes and acks, terminal attach to first screen, one SQL statement, any transaction or hold of the writer, daemon CPU, this member's database size, and CI's objectives. Every daemon builds the file in. CI's daemon_load reads the same file. A target describes what we aim for and gates nothing by itself. @@ -306,7 +306,7 @@ holds the history. Each point carries `target`, `series` (`owner` or `remote` fo the store), `window` (`1m`, `5m`, `1h`) and, on latency, `stat` (`p50`, `p99`, `max`), under the resource's `host.name`. The gauges are `st_slo_latency_ms`, `st_slo_over_target_ratio`, `st_slo_samples`, `st_slo_target_ms` (the -figure from `slo/targets.toml`, so one query sets a host's number against its target), +figure from `slo/objectives.kdl`, so one query sets a host's number against its target), `st_slo_cpu_cores` (the member's cores by `window`, sent only once known) and `st_slo_cpu_max_cores`. A window with no samples sends its count only. A phone's live share is a minimum over foreground milliseconds, so it is not in those: it goes out as `st_slo_live_share`, `st_slo_foreground_ms` and `st_slo_min_share_percent`. The task reads only diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md new file mode 100644 index 00000000000..1a1a7856ebf --- /dev/null +++ b/docs/st3/program-engine.md @@ -0,0 +1,440 @@ +# Program engine + +Status: accepted design (owner, 2026-11-10). The decisions below are taken; the [build +plan](#build-plan) says which milestone ships each part. Behavior this document describes is +released only as far as the milestones that shipped it. + +This document specifies how the daemon runs `Fractal.mission(intent, program)`: a mission whose +body is code that branches on step results. It builds on the [mission graph +runtime](mission-graph-runtime.md) (runs, generations, steps, revisions) and the [schema +registry](schema.md). The preview API it executes is in `docs/site/concepts/steps.md` and +`docs/site/guides/missions-in-practice.md` (Effect variant). + +## Why + +A static mission graph cannot branch on a step's value, pass values between steps, or know which +step failed when it compensates. Factory's landing mission needs all three: it routes a change by +its classes, reviews the merged commit, and reverts exactly what a refused publication brought in. +As data it took five per-class missions, a publish mission per main head, a subscription intake +and run-data side channels. As a program it is one 80-line body. + +Two earlier ways to run it were measured on scratch instances: + +| Run model | Routine change live (median) | Cost | +|---|---|---| +| Data DSL, native graph | 11.4 s | ~3.3 s reconciler hop per step handoff, 1.6–9.6 s subscription hop to publish | +| Per-run engine agent (program in a run-scoped seat) | 6.9 s | ~4.2 s agent launch and first claim per run; the seat needs the union of every step's authority | +| **Daemon program engine (this design, spike)** | **4.0 s** | handoff 0.01–0.02 s; start → first step one reconciler pass | + +## Decisions + +Taken by the owner on 2026-11-10: + +| # | Decision | Section | +|---|---|---| +| D1 | Program JavaScript runs in one daemon-supervised Bun host per member; the engine (dispatch, journal, replay, identity, outcome, supervision) is in the daemon. | §1, §2 | +| D2 | Steps act as `mission-run/RUN` with the authority the revision declares, intersected with the starter's authority. | §3 | +| D3 | A program revision is its bundle's sha256. | §4 | +| D4 | The journal is the registered `program.step` claim on the program's step run. | §5 | +| D5 | A serialized section is a child run of a mission with `concurrent-runs max=1`, not a native lane. | §7 | +| D6 | The data DSL compiles into programs: one execution model. | §9 | +| D7 | The program API is the `Fractal` namespace of the `fractal` package, built on Effect's `effect/workflow`: `Fractal.step` is an `Activity`, `Fractal.ask` a `DurableDeferred`, the daemon is the `WorkflowEngine` and its journal is native claims. Mission inputs and outputs are Effect Schemas. | §11 | + +## Requirements + +### Durable + +- **R01 Journal before continuing:** a program continues past a step only after the step's result + is durable in the graph. +- **R02 Resume:** a program interrupted by a daemon restart, a host crash or a member restart + continues from its journal without repeating a journaled step. +- **R03 Fenced effects:** a step key has at most one journaled result per attempt. A second, + different result for the same key and attempt is refused. + +### Versioned + +- **R04 Pinned code:** a run executes exactly the bytes its mission revision pins. +- **R05 Old runs finish on old code:** publishing new program code never changes a running + generation; a run moves to new code only by a new generation. +- **R06 No divergent replay:** replay never feeds a journal to different code. + +### Accountable + +- **R07 Steps act as the run:** a program's effects act as the mission run under authority the + mission revision declares, not as an engine agent and not with the union of all steps' authority. + +### Composable + +- **R08 Linked children:** a child run a program starts is linked to the program's step; + cancelling the parent cancels the child, and a failed child is a typed failure in the parent. +- **R09 Serialized sections:** a program can run a section that at most one run executes at a time. + +### Observable + +- **R10 Inner steps are visible:** the run view, `missions show` and `missions waiting-on` show + each inner step's status, timing and failure. +- **R11 Stalls are detected:** a program step that makes no journal progress, or whose host is + gone, raises the same run fault as other stalled work. + +### One model + +- **R12 One execution model:** a mission authored in the data DSL runs on the same engine as a + program; the native step graph stays the substrate for worker claims, gates and products. + +### Fast + +- **R13 Handoff:** the median handoff between two inner steps is under 50 ms on an idle member. +- **R14 First step:** the first inner step starts within one reconciler pass of the run start. + +## Acceptable tradeoffs + +- **T01 At-least-once effects:** an effect interrupted between running and journaling runs again. + Every step effect must be idempotent (merge by `Factory-Change`, revert by `Factory-Revert-Of`). +- **T02 Shared crash domain per member:** one host process runs every program on a member, each + attempt in its own worker; a host process crash interrupts all of them, and each resumes from its + journal. +- **T03 Fresh start on a new generation:** a revised run restarts its program from the top under + the new code; only native steps carry forward. + +## Spec + +### 1. Where program bodies execute + +``` +fractal daemon (Rust) program host (Bun, one per member) +┌──────────────────────────────────────┐ stdin ┌──────────────────────────────────┐ +│ reconciler: program step ready → │──────▶ │ loads bundle@sha256 (once) │ +│ ProgramEngine.dispatch(step, journal)│ │ runs body; Fractal.step → effect │ +│ journal writes (program.step claims) │◀────── │ journal entry → waits for ack │ +│ step outcome (completed/failed) │ stdout │ effects: process groups it owns │ +│ supervision: restart host, resume │ └──────────────────────────────────┘ +└──────────────────────────────────────┘ +``` + +The engine (dispatch, journal, replay, identity, outcome, supervision) lives in the daemon. Only +evaluation of the program's JavaScript is delegated, to one daemon-supervised host process per +member. Options considered: + +| | Embedded JS runtime in the daemon (deno_core/V8 or rquickjs) | **Supervised host per member (chosen)** | Per-run engine agent (prototype) | +|---|---|---|---| +| Start of a run | none | none (host stays warm) | 2.2–3.6 s launch + ~2 s first claim | +| Handoff between steps | in-process | one pipe round trip, 0.01–0.02 s | 0.02–0.05 s, CLI calls | +| A program bug (loop, OOM, crash) | stalls or kills the daemon and every seat | a crash kills the host and the daemon restarts it, programs resume; a busy loop is confined to its program's worker, which the host terminates after the liveness deadline (DQ8); the daemon's writes to the host are queued, so it never blocks | kills one run's agent | +| Effect 4 + Node/Bun APIs the step libraries use | V8 needs Node shims; QuickJS lacks them | native Bun | native Bun | +| Daemon build and binary | +V8 (tens of MB, longer builds) | +1 embedded 5 KB script | none | +| Authority | daemon-held | daemon-held; host has no credential | agent identity = union of steps | +| Process count | 0 | 1 per member | 1 per run | + +The host contract is a narrow protocol (§2), so a later embedded runtime can replace the host +without changing a bundle. + +### 2. Host protocol + +One JSON object per line. The pipe is the host's only write path: a host orphaned by a dead daemon +can run an effect to its end but can never journal it. + +| Direction | Message | Fields | Meaning | +|---|---|---|---| +| daemon → host | `start` | `step`, `attempt`, `module`, `sha256`, `payload`, `env`, `cwd`, `journal[]` | start or resume one program step | +| host → daemon | `journal` | `step`, `attempt`, `seq`, `key`, `status` (`started`/`completed`/`failed`), `value`/`error`, `ms` | an inner step began or ended | +| daemon → host | `ack` | `step`, `attempt`, `seq` | the completed/failed entry is durable (R01) | +| daemon → host | `nack` | `step`, `attempt`, `seq`, `error` | the entry could not be written; the program fails with a defect | +| daemon → host | `cancel` | `step`, `attempt`, `reason` | the step ended or moved to another attempt; stop the program and its effects | +| host → daemon | `run` | `step`, `attempt`, `seq`, `key`, `mission`, `input` | start or adopt the linked child run for `key` (§6) | +| host → daemon | `ask` | `step`, `attempt`, `seq`, `key`, `for`, `request`, `deadline`, `default` | open or adopt the person ask for `key` (§6) | +| daemon → host | `resolved` | `step`, `attempt`, `seq`, `key`, `exit` | the child run or ask for `key` ended; its result is journaled | +| host → daemon | `done` | `step`, `attempt`, `exit` (`{_tag: Success, value}` or `{_tag: Failure, error}`) | the program ended | +| host → daemon | `unresponsive` | `step`, `attempt`, `ms` | the program's worker missed the liveness deadline and was terminated (DQ8) | +| daemon → host / host → daemon | `ping` / `pong` | `seq` | host liveness (DQ8) | + +Both sides drop a message for another attempt than the one running, and the daemon drops messages +from a host generation it no longer runs. A host that exits takes exactly its own programs with it; +the next host resumes each from its journal. + +A bundle is one self-contained ES module whose default export carries the entry +`Symbol.for('fractal.program.v1')`. The program API (`Fractal.mission/step/command`) and Effect +are inside the bundle, so the host is version-free and the bundle hash pins the runtime library +too. The host verifies the hash before loading and runs each program attempt in its own worker. It +starts each step effect in its own process group and kills those groups on `cancel` and when its +stdin closes; groups that outlive a crashed host or daemon are ended by the next daemon (DQ2). + +### 3. Declaration and authority + +```kdl +mission "factory/land-program" state="ready" timeout="2h" { + concurrent-runs max=8 + input "change" kind="text" + goal "Land one change on main and prove it live, or leave main as it was." + authority { publish "scratch/*"; write "resource/factory/*" } + step "program" timeout="1h" { + program "/abs/bundles/land-aff9555cbaf1.mjs" sha256="aff9555c…48f09" + } +} +``` + +`program` makes the step agentless and gives the engine sole ownership of its outcome: the step +may not declare an assignee, gates, products, nested or used missions, or `after-run`, and it may not +be a `finally` step: cancellation cleanup ends final agentless work that has no runtime process, so +a program compensates inside its body. + +Steps act as the run (R07). The engine starts every effect with a run credential scoped to +`(run, step run, attempt)`; the daemon binds a request carrying it to the actor +`mission-run/RUN`. That actor holds what the revision's `authority` block grants, intersected with +the authority of whoever started the run, and nothing else. The daemon writes journal claims as +that actor. Changing `authority` is a mission revision, so factory classifies it as an +authority-class change. + +#### Run authority + +**Grammar.** A top-level mission may declare one `authority` block in the shape of the agent +`*-authority` blocks, one `VERB "PATTERN"` rule per line. `publish`, `start`, `revise` and `cancel` +take mission IDs exactly as `mission-authority` does (no `mission/` prefix); `write` takes a +subject, or a registered family alone, before `/*`. A pattern is exact or a terminal `/*` +namespace covering everything under it. Parsing refuses the block in a nested or loop body, a +repeated block or rule, an empty block, an unknown verb and any other `*`. There is no `"none"` +form: a mission without the block grants its runs nothing. The block is a field of the mission +spec, kept in canonical order and absent when undeclared, so existing revisions hash unchanged and +changing it is a mission revision. Publication holds a revision that declares it until every +member advertises `mission_authority=1`, as for `capacity`. + +**Starter.** A run's starter is the actor of its `mission-run.created`: its requester, or, for a +child a program starts (§6), the parent run, `mission-run/PARENT`. The requester stays the human. +The starter's authority is evaluated when the run writes, not copied at start, so a starter that +loses authority takes it from its running runs. + +**Effective authority** of `mission-run/RUN` is the `authority` of its current generation's +revision intersected with its starter's. A run starter contributes its own effective authority, +recursively, so a child never exceeds its parent. A person, agent or daemon starter contributes all +authority in free mode, and the rules that restrict it (`rule.set` in enforce mode) decide each of +the run's writes as well. With exact-or-namespace patterns the intersection is exact: for each +pair with one verb, the narrower pattern if one covers the other, nothing otherwise. + +**Credential.** For each start or resume of an attempt the engine mints a store capability of kind +`program-run` whose subject is the step run and whose incarnation is the attempt (the secret is +hashed at rest and never journaled), and passes it as `ST_RUN_CREDENTIAL` in the `start` +message's `env`. The host spreads the run env last, so an effect cannot replace it. + +**Binding.** The ancestry walk reads `ST_RUN_CREDENTIAL` from each ancestor's exec-time environment +beside `ST_AGENT`, nearest ancestor first; one environment carrying both binds neither. The daemon +binds a request to `mission-run/RUN` only while the credential's attempt is its step run's current, +live attempt, so the credential dies with its attempt and needs no revocation; one that no longer +binds leaves the caller unverifiable. A run-bound request follows the bound-agent rules: no internal +routes, no person headers, and `actor`/`requester` must be the run. The CLI defaults its actor to +the run when the credential is present. The same-UID boundary is cooperative, as for agents. + +**Journal.** The engine writes `program.step` claims as `mission-run/RUN`; a run may always journal +on its own step runs, whatever it declares. + +**Enforcement** is one decision at the store's write admission, beside the rules gate, for every +claim whose actor is a run. The claim names the grant it needs: `mission.published` on `mission/ID` +needs `publish ID`; a run's creation and its initial generation need `start ID`; a later generation +needs `revise ID`; any other claim needs `write SUBJECT`. Setting another run's outcome takes a +person or agent actor, so a run cannot cancel one. The daemon's `[programs] authority` says what +a write outside the authority does: `enforce` refuses it with `run-authority-denied`; `audit` +lets it through and records `rule.audited` on `rule/run-authority`, so a new program can discover +the grants it needs. + +### 4. Versioning + +A program's revision is its bundle hash: `sha256` is part of the step definition, so it enters the +step's `definition_hash` and the mission revision. A run generation binds one revision, hence one +bundle. + +1. `fractal apply` of a mission with a `program` reads the declared path, checks its sha256 and + stores the bytes as a blob keyed by that hash. The step definition names the hash in a + `bundle_hash` field, so the graph replicates the blob with the mission declaration, the way it + replicates every blob a claim's `bundle_hash` names ([attachments](attachments.md#what-travels-and-what-does-not)). + The member that runs the step reads the blob, never the author's path, which stays as provenance. +2. Every dispatch and every resume reads the blob of the revision's hash, whatever the source path + holds now (R05). +3. A declaration whose source no longer matches its sha256 is refused at `apply`; a step whose blob + is missing fails with `program bundle … is not in this member's store` (R06). +4. Revising a running run creates a new generation with a new step run, hence an empty journal: the + program restarts under the new code (T03). There is no Temporal-style patching. + +### 5. Journal + +Each inner step writes registered `program.step` claims on the program's step run subject +(`step-run/GENERATION/PATH`), system-only, append, durable: + +| Field | Type | Meaning | +|---|---|---| +| `key` | string | step identity in this execution: `id`, then `id#2`, `id#3`… for repeats | +| `status` | `started` / `completed` / `failed` | `started` is for visibility only and never replayed | +| `attempt` | integer | the program step's attempt that wrote it | +| `program` | string | the bundle sha256 that wrote it | +| `value` / `error` | any | the encoded success value, or the typed error with `_tag` and fields | +| `ms` | integer | effect duration | + +Idempotency key: `program:STEP:ATTEMPT:KEY:result` for both `completed` and `failed`, so a key has +one result per attempt whatever its status, and a double execution that produces a different +result is refused by the store (R03); the daemon then sends `nack` and the program fails instead of +continuing past a result that is not durable. `started` entries are not fenced. + +Replay reads the subject's claims in pages of 500 inside the daemon (no CLI trace limit) and returns +the `completed` and `failed` entries of the current attempt. A resumed attempt continues where it +stopped. A retried attempt starts from the top, because an earlier attempt's compensation (a revert) +may have undone what its completed steps did. A replayed failure comes back with its `_tag` and +fields, so `catchTag` matches as it did live. + +Step identity in loops: the n-th occurrence of an id in one execution has key `id#n`. That is +deterministic only for a deterministic body, so two concurrent steps with the same id are refused +(a defect naming the id). `Effect.retry` of a step re-runs it as `id#2`, `id#3`, … + +Determinism contract for code between steps: no clock, randomness or I/O outside a step. The program +API offers journaled `Fractal.now` and `Fractal.random` (DQ5). Typed failures are journaled; defects +fail the step (a bug), interrupts (host or daemon gone) are never journaled and resume. + +Long-lived loops do not grow one journal forever: a recurring program is a schedule of finite runs, +and an iterative program that needs more than a few thousand steps runs rounds as child runs (§6). + +### 6. Linked child runs and worker turns + +`Fractal.run(mission, inputs)` asks the daemon to create a child run with +`create_child_mission_run`, the path `uses-mission` already takes: `parent_step_run` is the program +step, the idempotency key is `program:STEP:ATTEMPT:KEY`, so a resumed attempt adopts its child +instead of starting another, while a retried attempt starts a new one (§5). The child is natively +linked: cancelling the parent run cancels the child through `cancel_descendant_mission_runs_tx` +(R08); a capacity-full child start waits (mission graph runtime §Run ownership). The engine learns +the child's terminal state from its own store transition, not by polling, and journals it under the +child step's key. + +A worker turn (`Fractal.step(...).pipe(Effect.provide(Fractal.Worker.layer(agent)))`) is a child run +of a one-step mission whose step is assigned to that agent, with the step's `goal` and `success` +schema; the result is the child's declared product. A run-scoped reviewer +(`agent/${ST_MISSION_RUN}/reviewer`, one-shot) is declared by that child mission, so independence +holds as in LandingMission. + +Cancellation is eager: when the reconciler terminates a program step (run cancelled, timed out), it +tells the engine, which sends `cancel` to the host; the host interrupts the program and kills its +effects' process groups. + +`Fractal.ask` is a native person ask on the program step (`work ask --step` semantics, deadlines and +defaults), resolved through the same journal key. + +### 7. Serialized sections + +Two concurrent landings must not publish one host at once (R09). + +| | Native lane around publish | **Child run of `factory/publish`, `concurrent-runs max=1`** | +|---|---|---| +| Native semantics | a lane needs an owning run that works its front entry | a capacity-full child start waits; already native | +| Visibility | lane entries | a run per publication, with its own journal | +| Cancel | leave the lane | cascades from the landing run | +| Coalescing | the owner can skip superseded heads | a queued publish for an older head finds the host at or past it and is a no-op | +| Cost | lane bookkeeping (0.03 s) plus an owner | child run start (no agent: one reconciler pass) | + +Choice: a child run. It needs no standing owner and reuses §6. + +### 8. Visibility and stall detection + +- `missions show` and the run view project `program.step` claims under the program step: each key + with its latest status, duration and failure, and child runs linked by key. +- `missions waiting-on` names the innermost open key: a running effect, a child run or an ask. +- Stalls: a program step whose journal shows no new entry within its `stalled-after` raises the + run fault with scope `program/stalled`, naming the open key. A dead host that the engine cannot + restart raises `program/host` on every program step it held. + +### 9. One execution model + +The data DSL lowers into a program instead of a step graph. A data-DSL mission becomes a generated +program body whose bundle the publisher pins; the KDL a person reads stays a projection. + +| Data DSL construct | Lowers to | +|---|---| +| `depends-on { step "x" completed }` | `yield*` order | +| sibling steps without mutual dependencies | `Effect.all([...], { concurrency: 'unbounded' })` | +| agentless `exec` step + `exit_code is 0` gate | `Fractal.step({ id }, Fractal.command(argv))` failing on a non-zero exit | +| `assigned-to` worker step, products | worker turn (§6); products are its success schema | +| field / exists / exec gate | a step that reads through the host contract and fails typed | +| human gate | `Fractal.ask` | +| `retry { attempts n }` | `Effect.retry(Schedule.recurs(n - 1))` | +| `finally { revert }` | `Effect.onError` / `catchTag` with the failed key in scope | +| `uses-mission` | `Fractal.run` (§6) | +| run-data side channels | step values | + +Native graph execution remains for published KDL missions until each is republished from the DSL. +The `Fractal.steps.*` catalogue (classify, check, merge, publish, verify, revert, review) is one set +of program steps that both authoring forms call. + +### 10. Priority + +The daemon starts the host directly, so the host and its effects run at the daemon's live-path +priority, never under macOS `taskpolicy -b`. A step that is background work says so on its +command (`Fractal.command(argv, { priority: 'background' })`); this is the program form of +`ST_SEAT_PRIORITY`. + +### 11. Program API + +The author-facing API is the `Fractal` namespace of the `fractal` package (D7), on `effect/workflow`: + +| `Fractal` | `effect/workflow` | Engine | +|---|---|---| +| `Fractal.mission(intent, program)` | `Workflow.make` with `intent.input` as payload and `intent.output` as success schema | one `program` step; the bundle's entry runs the workflow body for the step run's current attempt | +| `Fractal.step({ id, success, error }, effect)` | `Activity.make` named by the step's key (`id`, `id#2`, …) | a `program.step` entry under that key, replayed by the key | +| `Fractal.ask({ id, for, request, deadline, default })` | `DurableDeferred.make` named by the ask's key | a native person ask on the program step; the answer is journaled under the key | +| `Fractal.run(mission, input)` | a child workflow execution | a linked child run (§6), its terminal result journaled under the key | +| — | `WorkflowEngine` | the host's engine: `activityExecute` returns a journaled result or runs the activity and records it through the host protocol; `deferredResult` reads the journal | + +Inputs and outputs are Effect Schemas. `intent.input` is a Struct: each field renders as a native +mission `input`, `missions start` checks the inputs against it, and the program receives the decoded +value. The success value is encoded with `intent.output` and journaled under the reserved key +`$result`; a typed failure is encoded with its `_tag` and fields and fails the step with it. + +A program waiting on an ask or a child run stays loaded in the host. A replaced host replays the +program to the same wait. + +## Design questions + +- **DQ1 Bundle distribution:** decided: replicated blobs stored at `apply` (§4). +- **DQ2 Effect processes across a daemon crash:** decided: each effect's process group ends with its + leader (the host kills the group when the leader exits). The host records each group in the + engine's state directory with the boot id and the leader's start time, and removes the record when + it kills the group. Before it starts a host, a daemon kills each recorded group whose leader still + has the recorded start time, or whose leader is gone while the group lives on, and drops records + from an earlier boot without a kill, so a reused pid is never signalled. Proved by a kill test that + asserts no survivor. +- **DQ3 Host isolation:** decided: one host process per member, one worker per program attempt, so + one program's busy loop cannot starve the others (DQ8). +- **DQ4 Large values:** step values above the claim field limit go to documents and the journal keeps + the reference. +- **DQ5 Deterministic helpers:** which non-step sources (clock, randomness, environment) the API + journals, and whether replay checks the body's step sequence against the journal and refuses a + mismatch. +- **DQ6 Execution clock:** whether time a program waits on a child run or an ask stops the step's + timeout clock, as `work wait` does for seats. +- **DQ7 Native graph retirement:** whether native step-graph execution is retired once every + data-DSL mission lowers to a program. +- **DQ8 Program liveness:** decided: the host pings each program's worker; a worker that does not + answer within the deadline is terminated and its step fails with the `program/host` fault naming + the deadline, so a deterministic busy loop fails once instead of looping across restarts. The + engine pings the host the same way and replaces a host that does not answer; its programs resume. + +## Build plan + +Each milestone ships as reviewed and merged pull requests, and routine-landing latency is measured +on a scratch instance at each. + +| Milestone | Ships | +|---|---| +| M1 Engine | `program` steps and the `program.step` journal (§5); the supervised host with a worker per program, replay, eager cancellation, recorded effect process groups (DQ2) and liveness pings (DQ8); the step definition names its bundle by `bundle_hash` and publishing the declaration stores the blob, so neither the field nor definition hashes change in later milestones (§4); linked child runs with cancellation cascade and serialized sections (§6, §7); the `effect/workflow` binding (§11); mission-run authority (§3); inner steps in the run view and `waiting-on`, and the `program/stalled` and `program/host` faults (§8). | +| M2 Publication | `fractal apply` carries a program mission's bundle to the daemon it publishes on and validates it (§4); the `fractal` package's loader renders `Fractal.mission(intent, program)` to a bundle and its KDL declaration. Factory's landing mission becomes the first production program. | +| M3 One model | The data DSL compiles into programs (§9), with byte and behaviour parity for existing data-DSL missions; published KDL missions keep running on the native graph until they are republished. | + +## Spike results (scratch, macOS, load 80–280) + +LandingProgram's routine-change program, unchanged apart from its runtime import, on instance `dpe` +with a local bare repository. Times are seconds after `fractal missions start` returned. + +| Run | First step started | Live (publish done) | Run terminal | +|---|---|---|---| +| 9 routine runs | median 0.70 (0.34–2.05) | **median 3.97 (3.41–5.57)** | median 4.78 (4.39–6.17) | +| admission refused → revert | 0.09 | refused 2.94; **main reverted 3.45** | failed 4.12 (`Reverted`) | +| daemon SIGKILL after merge journaled | — | 6.09 | completed 8.43; one merge commit | +| daemon SIGKILL during check | — | 5.48 | completed 6.51 | +| daemon SIGKILL during publish | — | 6.34 | completed 7.63 | +| run cancelled during check | — | — | cancelled; merge never ran; main unchanged | +| revision pins a different hash | — | — | failed at once with the hash mismatch | + +Inner step handoff: 0.01–0.02 s. The remaining time is the step effects themselves (render, git, +`fractal apply`) and one reconciler pass before the first step and after the last. diff --git a/docs/st3/replication.md b/docs/st3/replication.md index 4fc44c95694..6f06ddb8c3f 100644 --- a/docs/st3/replication.md +++ b/docs/st3/replication.md @@ -1296,7 +1296,7 @@ episode stays open; that projection opens its own episode. The same pair and pro diverging again reopens the same episode. Comparisons are held in memory, so after a restart an open fault stays open until the next comparison that agrees. -The `projection-divergence` objective in `slo/targets.toml` counts open divergence faults +The `projection-divergence` objective in `slo/objectives.kdl` counts open divergence faults fleet-wide, with a target of none. A daemon that runs the check keeps the count its last check read, so `fractal doctor` (as `slo/projection-divergence`), `fractal slo` and the SLO history read no claims for it; a daemon that never checks shows neither the line nor the row. A miss is `info`, like any diff --git a/docs/st3/schema.md b/docs/st3/schema.md index fcbc51e8263..c75297b8798 100644 --- a/docs/st3/schema.md +++ b/docs/st3/schema.md @@ -36,6 +36,7 @@ Storage digest: `c98d0a28f88e6d5e8cf58f93fee817e690662de7a334089feeb8be0e6cdb2d5 | `message` | `message/ID` | yes | A Fractal message. | | `mission` | `mission/ID` | no | An immutable mission revision lineage. | | `mission-run` | `mission-run/ID` | no | A mission execution. | +| `objective` | `objective/NAME` | no | A declared objective: an interval over claims, its targets, window and breach response. | | `observer` | `observer/RUN/LOCAL_ID` | no | A mission-run resource observer. | | `owned-set` | `owned-set/NAME` | no | A graph-owned set of declarations with source ordering and omission retirement. | | `person` | `person/IDENTITY` | no | A human actor. | @@ -140,7 +141,7 @@ Custom subjects use `custom/NAMESPACE/NAME`. Custom claims use `custom.NAMESPACE | `harness.timeline` | `agent` | `same-subject-actor` | `append` | `local` | `body!:object`, `driver!:string`, `entry_id!:string`, `entry_type!:string`, `final!:boolean`, `incarnation_id!:string`, `observed_at_unix_ms:integer`, `operation!:string`, `revision!:integer`, `role!:string`, `sequence:integer`, `source_id:string` | | | `harness.todo.observed` | `agent` | `same-subject-actor` | `append` | `latest` | `harness!:string`, `incarnation_id!:string`, `observed_at!:string`, `phases!:array`, `session_id!:string`, `source_op!:string`, `totals!:object`, `truncated!:boolean` | | | `harness.usage` | `agent` | `same-subject-actor` | `append` | `latest` | `account:string`, `cache_write_1h_tokens:integer`, `cache_write_tokens:integer`, `cached_tokens:integer`, `compactions:integer`, `context_used_percent:number`, `context_used_tokens:integer`, `context_window_tokens:integer`, `cost:number`, `cost_microusd:integer`, `currency:string`, `driver!:string`, `host:string`, `incarnation_id!:string`, `input_tokens:integer`, `last_compaction_ms:integer`, `last_compaction_trigger:string`, `model:string`, `native_session_id:string`, `observed_at_unix_ms:integer`, `output_tokens:integer`, `owner_run:string`, `owner_step:string`, `pricing:string`, `pricing_provenance:array`, `reported_cost_microusd:integer`, `semantics!:string`, `total_tokens:integer`, `unpriced_tokens:integer` | | -| `intent.desired` | `*` | `authorized-requester` | `state-transition` | `durable` | `accountable:subject-reference(person)`, `desired:object`, `kind:string`, `revision:string` | `account`, `agent`, `doc`, `exec`, `host`, `lane`, `message`, `observer`, `mission`, `mission-run`, `planning-session`, `pty`, `resource`, `schedule`, `step`, `stop`, `subscription` | +| `intent.desired` | `*` | `authorized-requester` | `state-transition` | `durable` | `accountable:subject-reference(person)`, `desired:object`, `kind:string`, `revision:string` | `account`, `agent`, `doc`, `exec`, `host`, `lane`, `message`, `observer`, `objective`, `mission`, `mission-run`, `planning-session`, `pty`, `resource`, `schedule`, `step`, `stop`, `subscription` | | `lane.approved` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `reason:string` | | | `lane.joined` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `reason:string` | | | `lane.left` | `lane` | `authorized-participant` | `append` | `durable` | `entry!:subject-reference`, `outcome!:string`, `reason:string` | | @@ -152,6 +153,7 @@ Custom subjects use `custom/NAMESPACE/NAME`. Custom claims use `custom.NAMESPACE | `member.admission-override` | `host` | `authorized-requester` | `append` | `durable` | `peers!:array`, `reason!:string`, `start!:string` | | | `message.closed` | `message` | `authorized-participant` | `once-per-actor` | `durable` | `acted_for:subject-reference(person)`, `acting_for:subject-reference(agent)`, `delegation:object`, `status!:string` | | | `message.delivered` | `message` | `system-only` | `once-per-actor` | `durable` | `recipient:subject-reference`, `runtime_id:string`, `status!:string`, `transport:string` | `message` | +| `message.expired` | `message` | `system-only` | `once` | `durable` | `reason!:string`, `status!:string` | | | `message.read` | `message` | `authorized-participant` | `once-per-actor` | `durable` | `acting_for:subject-reference(agent)`, `status!:string` | | | `message.sent` | `message` | `ordinary-client` | `once` | `durable` | `assistant_grant_id:string`, `assistant_owner:subject-reference`, `assistant_session_actor:string`, `attachments:array`, `content:string`, `from:subject-reference`, `in_reply_to:subject-reference`, `sender_class:string`, `session_id:string`, `status!:string`, `tags:array`, `title:string`, `to:subject-reference` | `message` | | `message.staged` | `message` | `system-only` | `once-per-actor` | `durable` | `recipient:subject-reference`, `runtime_id:string`, `status!:string`, `transport:string` | `message` | diff --git a/docs/st3/seat-deploys.md b/docs/st3/seat-deploys.md index 439b7af795e..888e187e27f 100644 --- a/docs/st3/seat-deploys.md +++ b/docs/st3/seat-deploys.md @@ -163,6 +163,17 @@ An agent can explicitly inspect that retained mail with `fractal conversations l and read it with `fractal conversations read MESSAGE --as "$ST_AGENT"`. New mail on the live connection continues to arrive normally, with the channel's usual handoff deduplication and receipt handling. +The registry also accepts a durable graph `message.expired` outcome, written only by +`daemon/runtime`, with reason `cutoff-never-offered`, `cutoff-uncertain-attempt`, or +`no-live-recipient`. This reader contract does not enable automatic expiration or change +mailbox recovery. Expiry is not a delivered/read receipt; later actual delivery or read evidence +supersedes the outcome without removing the expiry claim from history. Harness receipts cannot +write expiry, and the messages shard still replicates only `message.sent`. Outcome readers, +projections and sender surfaces must be deployed to every pinned host before any expiry writer +is enabled; older registries retain the new kind as unknown rather than interpreting its outcome. +The writer must also enforce original-accepting-member authority before activation; replicated +expiry admission here checks the daemon actor, not that future writer prerequisite. + Claude distinguishes native queue acceptance (`queue-operation/enqueue`, which records `delivered`) from consumption (a user transcript entry, including `isMeta`, or an explicit `queue-operation/remove` with `reason: absorbed_mid_turn`, which records `read`). Receipt matching diff --git a/docs/st3/terminal-binding.md b/docs/st3/terminal-binding.md index 85ffc08e328..2ac6aa36c72 100644 --- a/docs/st3/terminal-binding.md +++ b/docs/st3/terminal-binding.md @@ -17,6 +17,8 @@ The terminal must already be declared on the same host. Publish with the termina This declaration records a harness configuration but never launches it. The caller runs `fractal driver claude --subject agent/example/terminal-claude -- ` as a child of its shell with its chosen permission settings, driver environment, channel and hook registrations. The launcher owns argument merging and unmanaged fallback. Declaring a binding alone does not arrange those registrations. +Person-owned terminal bindings retain native harness execution; they do not select the managed agent-seat wrapper. The driver reads the declaration through its authenticated client before selecting this path. Agent-owned bindings and agents without a binding use the managed launch contract for OMP, Claude, and Codex. + The driver keeps the terminal's `st3.subject` tag. It reads the binding and matches that terminal's tag and exact PTY incarnation in the local registry. Its agent incarnation appends `:bound:` to the PTY incarnation. Mail and driver exit reports use that invocation fence, so a prior harness in the same shell cannot end the next one. Reconciliation adopts the running PTY without a start. Once the driver records an exit for the current invocation, the seat stays exited across daemon restarts while the terminal's shell remains running. A missing or replacement PTY ends the binding without a launch. Stopping a bound seat detaches its graph runtime; it does not stop the harness process or shell. The person can exit the harness from its terminal. Bound seats cannot declare checkouts, rendering, terminals or exec sidecars, rollout, fresh-context recovery or one-shot retirement and cannot be materialized inside mission runs. diff --git a/fixtures/convergence/README.md b/fixtures/convergence/README.md index 0d35ae1a393..7f2ad6ae876 100644 --- a/fixtures/convergence/README.md +++ b/fixtures/convergence/README.md @@ -235,3 +235,15 @@ The first characterization run failed only the four stale v14 trim expectations. `claims-v16.json` adds `runtime.refresh` and optional receipt `launch_contract` fields; v1–v15 stay frozen. Generate with `cargo run -p fractal-schema --example claims_json -- fixtures/convergence/claims-v16.json`. Shared RNG draws shift: all 32 fixed seeds converge; 30 trim, exactly `[2, 34]` never trim (27 now trims). Refresh claims stay whole. + +`claims-v18.json` captures the complete registry with `message.expired`, the durable, +runtime-authored mail expiry outcome. Versions 1 through 17 stay frozen. Generate it +from the merged expiry registry with +`cargo run -p fractal-schema --example claims_json -- fixtures/convergence/claims-v18.json`; +the example serializes `fractal_schema::registry().claims` with +`serde_json::to_string_pretty`, followed by a newline. Do not hand-edit the snapshot. +The simulation selects v18. Expiry draws select an existing sent message in stable +subject order and use `daemon/runtime`; native admission still rejects mail with a +terminal receipt. The focused expiry regression generates the outcome through the +same path as random schedules and checks native admission and replication to the +oracle, rather than treating corpus membership as write coverage. diff --git a/fixtures/convergence/claims-v18.json b/fixtures/convergence/claims-v18.json new file mode 100644 index 00000000000..da97c02df0d --- /dev/null +++ b/fixtures/convergence/claims-v18.json @@ -0,0 +1,10946 @@ +{ + "agent.account": { + "kind": "agent.account", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "account" + ] + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": "agents", + "wakes_reconciler": false + }, + "agent.helper-launched": { + "kind": "agent.helper-launched", + "subjects": [ + "agent" + ], + "fields": { + "credential_home_label": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "helper_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launcher": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "operation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true, + "retention": "local" + }, + "agent.launcher-bound": { + "kind": "agent.launcher-bound", + "subjects": [ + "agent" + ], + "fields": { + "creation_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launcher": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true, + "retention": "local" + }, + "agent.placement.source-offline": { + "kind": "agent.placement.source-offline", + "subjects": [ + "agent" + ], + "fields": { + "desired_token": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "destination": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sources": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.presence": { + "kind": "agent.presence", + "subjects": [ + "agent" + ], + "fields": { + "presence": { + "value_type": "string", + "required": true, + "values": [ + "available", + "busy", + "dnd", + "offline" + ], + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "values": [ + "reachable", + "unreachable", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.queue.moved": { + "kind": "agent.queue.moved", + "subjects": [ + "agent" + ], + "fields": { + "anchor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "placement": { + "value_type": "string", + "required": true, + "values": [ + "top", + "bottom", + "before", + "after" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.renamed": { + "kind": "agent.renamed", + "subjects": [ + "agent" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "preserved_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "agents", + "wakes_reconciler": true + }, + "agent.retired": { + "kind": "agent.retired", + "subjects": [ + "agent" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "desired_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "retired_by": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person", + "daemon" + ] + }, + "runtime_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "transfer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "agents", + "wakes_reconciler": true + }, + "arrangement.edited": { + "kind": "arrangement.edited", + "subjects": [ + "arrangement" + ], + "fields": { + "action_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "action_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operations": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "version": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "arrangements", + "wakes_reconciler": false + }, + "attention.requested": { + "kind": "attention.requested", + "subjects": [ + "attention" + ], + "fields": { + "closed_by": { + "value_type": "string", + "required": false, + "values": [ + "person", + "st" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "step": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "step_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "until": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once", + "projection": "attention", + "wakes_reconciler": false + }, + "attention.resolved": { + "kind": "attention.resolved", + "subjects": [ + "attention" + ], + "fields": { + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "resolved", + "dismissed", + "withdrawn" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once", + "projection": "attention", + "wakes_reconciler": false + }, + "checkpoint.excused": { + "kind": "checkpoint.excused", + "subjects": [ + "checkpoint-excusal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "writer": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "checkpoint.sealed": { + "kind": "checkpoint.sealed", + "subjects": [ + "checkpoint" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "checkpoint_protocol": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "cut_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "participants": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "rules_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_count": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "checkpoint.verified": { + "kind": "checkpoint.verified", + "subjects": [ + "checkpoint" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "checkpoint_protocol": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "cut_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "drop_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "dropped_claims": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "dropped_envelopes": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "graph_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "participants": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reader_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "retained_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "rules_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sealed_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "checkpoints", + "wakes_reconciler": false + }, + "client.action-result": { + "kind": "client.action-result", + "subjects": [ + "custom" + ], + "fields": { + "authority_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "retention": "local" + }, + "client.hang": { + "kind": "client.hang", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.js-error": { + "kind": "client.js-error", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.launch": { + "kind": "client.launch", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.native-crash": { + "kind": "client.native-crash", + "subjects": [ + "client" + ], + "fields": { + "app_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "capture_source": { + "value_type": "string", + "required": true, + "values": [ + "js", + "native-marker", + "metrickit" + ], + "immutable": false, + "reference": false + }, + "captured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "event_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "launch_id_basis": { + "value_type": "string", + "required": true, + "values": [ + "process", + "metric-interval" + ], + "immutable": false, + "reference": false + }, + "native_build": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurred_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence_time_basis": { + "value_type": "string", + "required": true, + "values": [ + "exact", + "metric-interval-end" + ], + "immutable": false, + "reference": false + }, + "os_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "paired_device": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "pairing_grant": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "payload": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "person_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "platform": { + "value_type": "string", + "required": true, + "values": [ + "ios" + ], + "immutable": false, + "reference": false + }, + "runtime_version": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "info", + "warning", + "error", + "fatal" + ], + "immutable": false, + "reference": false + }, + "update_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "client.operation-authorized": { + "kind": "client.operation-authorized", + "subjects": [ + "custom" + ], + "fields": { + "completion_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "record_hash": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "record_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true + }, + "custom.client.action-result": { + "kind": "custom.client.action-result", + "subjects": [ + "custom" + ], + "fields": { + "authority_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request_digest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true + }, + "daemon.diagnostic": { + "kind": "daemon.diagnostic", + "subjects": [ + "daemon" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": true, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": true, + "retention": "local" + }, + "daemon.started": { + "kind": "daemon.started", + "subjects": [ + "daemon" + ], + "fields": { + "features": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "schema": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "schema_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "running" + ], + "immutable": false, + "reference": false + }, + "version": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": true, + "source_kdl": [ + "reset" + ] + }, + "delivery.hold": { + "kind": "delivery.hold", + "subjects": [ + "agent" + ], + "fields": { + "held": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "legacy_adoption": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "until_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "state-transition", + "projection": "delivery-holds", + "wakes_reconciler": false + }, + "deploy.compat-signoff": { + "kind": "deploy.compat-signoff", + "subjects": [ + "resource" + ], + "fields": { + "newCommit": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "oldCommit": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "trainId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go": { + "kind": "deploy.go", + "subjects": [ + "resource" + ], + "fields": { + "actor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "combined": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "compatSignoffId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "expiresAt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "failureRestore": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "issuedAt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "new": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "old": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "install", + "rollback" + ], + "immutable": false, + "reference": false + }, + "previewDigest": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "trainId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go-consumed": { + "kind": "deploy.go-consumed", + "subjects": [ + "resource" + ], + "fields": { + "goId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "install", + "rollback" + ], + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.go-revoked": { + "kind": "deploy.go-revoked", + "subjects": [ + "resource" + ], + "fields": { + "goId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.stop": { + "kind": "deploy.stop", + "subjects": [ + "resource" + ], + "fields": { + "hosts": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "liftCondition": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "stopId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "deploy.stop-lifted": { + "kind": "deploy.stop-lifted", + "subjects": [ + "resource" + ], + "fields": { + "releaseGoId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "stopId": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "doc.bound": { + "kind": "doc.bound", + "subjects": [ + "doc" + ], + "fields": { + "executable": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "size": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "documents", + "wakes_reconciler": true, + "source_kdl": [ + "doc" + ] + }, + "eval.verdict": { + "kind": "eval.verdict", + "subjects": [ + "mission-run" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "residue": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "pass", + "fail", + "void" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "evals", + "wakes_reconciler": true + }, + "fault.dismissed": { + "kind": "fault.dismissed", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "episode": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "file.observed": { + "kind": "file.observed", + "subjects": [ + "file" + ], + "fields": { + "blob_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "content": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "content_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "path": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "observed", + "unreadable" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "files", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "fleet.invite-created": { + "kind": "fleet.invite-created", + "subjects": [ + "fleet-invite" + ], + "fields": { + "created_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sponsor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "host" + ] + }, + "transports": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.invite-redeemed": { + "kind": "fleet.invite-redeemed", + "subjects": [ + "fleet-invite" + ], + "fields": { + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "name": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.invite-revoked": { + "kind": "fleet.invite-revoked", + "subjects": [ + "fleet-invite" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "revoked_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-admitted": { + "kind": "fleet.member-admitted", + "subjects": [ + "host" + ], + "fields": { + "admitted_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "fleet_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "invite": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "fleet-invite" + ] + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "listening", + "dial-out" + ], + "immutable": false, + "reference": false + }, + "node_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sponsor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "host" + ] + }, + "via": { + "value_type": "string", + "required": true, + "values": [ + "anchor", + "invite", + "migration" + ], + "immutable": false, + "reference": false + }, + "writer_floor": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-endpoints": { + "kind": "fleet.member-endpoints", + "subjects": [ + "host" + ], + "fields": { + "build": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "endpoints": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "listening", + "dial-out" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-left": { + "kind": "fleet.member-left", + "subjects": [ + "host" + ], + "fields": { + "high_water": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.member-removed": { + "kind": "fleet.member-removed", + "subjects": [ + "host" + ], + "fields": { + "high_water": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "member_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "removed_by": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "fleet.shard-bounds": { + "kind": "fleet.shard-bounds", + "subjects": [ + "host" + ], + "fields": { + "member_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "shard_high_water": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": false + }, + "gate.head-bound": { + "kind": "gate.head-bound", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "author": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "boundary": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "change": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "mission_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "previous_binding": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request_operation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "review_targets": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "scope": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.remediation-attached": { + "kind": "gate.remediation-attached", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt_receipt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "author_attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "author_generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "author_step": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "boundary_attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "finding_refs": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewed_head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.requested": { + "kind": "gate.requested", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "author": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "baseline": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "capability_expires_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "capability_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "change": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "decisions": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "delta_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "head": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "values": [ + "approve", + "feedback" + ], + "immutable": false, + "reference": false + }, + "model": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "question": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "review_targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runner": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "token_budget": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "tools": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.result": { + "kind": "gate.result", + "subjects": [ + "gate-operation" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "baseline": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "field": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "stage": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "token_usage": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "value": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "pass", + "fail", + "error", + "feedback" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.review-verdict": { + "kind": "gate.review-verdict", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "carry_forward_from": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "change": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "finding_refs": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "review_targets": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reviewed_head": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "scope": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "source": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "verdict": { + "value_type": "string", + "required": true, + "values": [ + "approved", + "changes-requested", + "abstained" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.review-withdrawn": { + "kind": "gate.review-withdrawn", + "subjects": [ + "gate-operation" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "authorization_evidence": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "binding_claim": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "gate_subject": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "gate-operation" + ] + }, + "generation": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "run-generation" + ] + }, + "previous_binding": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "verdict": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "withdrawing_actor": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "gate.reviewer-reassigned": { + "kind": "gate.reviewer-reassigned", + "subjects": [ + "gate-operation" + ], + "fields": { + "owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "previous_reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "gates", + "wakes_reconciler": true, + "source_kdl": [ + "gate" + ] + }, + "github.event": { + "kind": "github.event", + "subjects": [ + "github-event" + ], + "fields": { + "at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "detail": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "event": { + "value_type": "string", + "required": true, + "values": [ + "opened", + "closed", + "merged", + "reopened", + "head-changed", + "became-draft", + "became-ready", + "merge-queue-entered", + "merge-queue-left", + "checks-passed", + "checks-failed", + "comment", + "review" + ], + "immutable": false, + "reference": false + }, + "head_sha": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "item": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "resource" + ] + }, + "observer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "observer" + ] + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "resources", + "wakes_reconciler": true + }, + "github.posted": { + "kind": "github.posted", + "subjects": [ + "github-post" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "id": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "item": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": true, + "values": [ + "comment", + "review" + ], + "immutable": false, + "reference": false + }, + "login": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "repository": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "url": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "subscriptions", + "wakes_reconciler": true + }, + "glass.deleted": { + "kind": "glass.deleted", + "subjects": [ + "glass" + ], + "fields": { + "base_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "replaced_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "glasses", + "wakes_reconciler": false + }, + "glass.upserted": { + "kind": "glass.upserted", + "subjects": [ + "glass" + ], + "fields": { + "base_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "body": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "replaced_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "glasses", + "wakes_reconciler": false + }, + "harness-question.observed": { + "kind": "harness-question.observed", + "subjects": [ + "harness-question" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "native_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "questions": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "runtime_incarnation": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "session": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "harness-question.resolved": { + "kind": "harness-question.resolved", + "subjects": [ + "harness-question" + ], + "fields": { + "answers": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "receipt": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "answered", + "answered_elsewhere", + "expired", + "cancelled" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "harness.context-clear.requested": { + "kind": "harness.context-clear.requested", + "subjects": [ + "agent" + ], + "fields": { + "context_epoch": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "harness-control", + "wakes_reconciler": true + }, + "harness.context-clear.result": { + "kind": "harness.context-clear.result", + "subjects": [ + "agent" + ], + "fields": { + "context_epoch": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "values": [ + "confirmed", + "failed", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "harness-control", + "wakes_reconciler": true + }, + "harness.diagnostic": { + "kind": "harness.diagnostic", + "subjects": [ + "agent" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "auth_attention_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "matched_line": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_since_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ownership_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "retry_after_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "retry_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "selector_scope": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "severity": { + "value_type": "string", + "required": false, + "values": [ + "warning", + "error" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "wake_attempts": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.inventory.observed": { + "kind": "harness.inventory.observed", + "subjects": [ + "agent" + ], + "fields": { + "commands": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "dynamic_commands": { + "value_type": "string", + "required": true, + "values": [ + "supported", + "unsupported", + "unavailable" + ], + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "values": [ + "omp" + ], + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "latest" + }, + "harness.limits": { + "kind": "harness.limits", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "account_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "five_hour_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "five_hour_resets_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "measured_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "plan": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "weekly_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "weekly_resets_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.observed": { + "kind": "harness.observed", + "subjects": [ + "agent" + ], + "fields": { + "ask": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "background_jobs": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "blocked_on": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "evidence_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "input_buffer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "observed_since_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ownership_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "provider_auth_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "quiescent": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "starting", + "ready", + "idle", + "working", + "blocked", + "ended", + "indeterminate" + ], + "immutable": false, + "reference": false + }, + "status_transition": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "transition_sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": true, + "retention": "latest" + }, + "harness.session-file": { + "kind": "harness.session-file", + "subjects": [ + "agent" + ], + "fields": { + "account_ref": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "agent": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "discovery_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "modified_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "path": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "source_session": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "values": [ + "active", + "inactive", + "missing", + "unknown" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false + }, + "harness.telemetry": { + "kind": "harness.telemetry", + "subjects": [ + "agent" + ], + "fields": { + "driver": { + "value_type": "string", + "required": true, + "values": [ + "claude" + ], + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "signals": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "unit": { + "value_type": "string", + "required": true, + "values": [ + "hook" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "local" + }, + "harness.timeline": { + "kind": "harness.timeline", + "subjects": [ + "agent" + ], + "fields": { + "body": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entry_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entry_type": { + "value_type": "string", + "required": true, + "values": [ + "message", + "content", + "tool_call", + "tool_result", + "status", + "error", + "usage", + "redaction", + "truncation" + ], + "immutable": false, + "reference": false + }, + "final": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": true, + "values": [ + "append", + "replace", + "finalize" + ], + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "role": { + "value_type": "string", + "required": true, + "values": [ + "system", + "user", + "assistant", + "tool" + ], + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "source_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false, + "retention": "local" + }, + "harness.todo.observed": { + "kind": "harness.todo.observed", + "subjects": [ + "agent" + ], + "fields": { + "harness": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "phases": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "source_op": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "totals": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "truncated": { + "value_type": "boolean", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false, + "retention": "latest" + }, + "harness.usage": { + "kind": "harness.usage", + "subjects": [ + "agent" + ], + "fields": { + "account": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "cache_write_1h_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cache_write_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cached_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "compactions": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "context_used_percent": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "context_used_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "context_window_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cost": { + "value_type": "number", + "required": false, + "immutable": false, + "reference": false + }, + "cost_microusd": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "currency": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "input_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "last_compaction_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "last_compaction_trigger": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "model": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "output_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "owner_run": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_step": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "pricing": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "pricing_provenance": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reported_cost_microusd": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "semantics": { + "value_type": "string", + "required": true, + "values": [ + "context_occupancy", + "session_cumulative", + "response", + "response_rollup" + ], + "immutable": false, + "reference": false + }, + "total_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "unpriced_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "harnesses", + "wakes_reconciler": false, + "retention": "latest" + }, + "intent.desired": { + "kind": "intent.desired", + "subjects": [ + "*" + ], + "fields": { + "accountable": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "desired": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "state-transition", + "projection": "desired", + "wakes_reconciler": true, + "source_kdl": [ + "account", + "agent", + "doc", + "exec", + "host", + "lane", + "message", + "observer", + "mission", + "mission-run", + "planning-session", + "pty", + "resource", + "schedule", + "step", + "stop", + "subscription" + ] + }, + "lane.approved": { + "kind": "lane.approved", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.joined": { + "kind": "lane.joined", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.left": { + "kind": "lane.left", + "subjects": [ + "lane" + ], + "fields": { + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "removed" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.marked": { + "kind": "lane.marked", + "subjects": [ + "lane" + ], + "fields": { + "detail": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "head": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "waiting", + "held", + "ready", + "running" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "lane.moved": { + "kind": "lane.moved", + "subjects": [ + "lane" + ], + "fields": { + "anchor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "entry": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true + }, + "placement": { + "value_type": "string", + "required": true, + "values": [ + "top", + "bottom", + "before", + "after" + ], + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "lanes", + "wakes_reconciler": false + }, + "loop.round-dispatch": { + "kind": "loop.round-dispatch", + "subjects": [ + "loop-run" + ], + "fields": { + "candidate": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "dispatch": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "item_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "rescheduled" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop", + "round" + ] + }, + "loop.round-result": { + "kind": "loop.round-result", + "subjects": [ + "loop-run" + ], + "fields": { + "candidate": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "doc" + ] + }, + "item": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + }, + "metrics": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "failed", + "discarded" + ], + "immutable": false, + "reference": false + }, + "token_usage": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop", + "round" + ] + }, + "loop.state": { + "kind": "loop.state", + "subjects": [ + "loop-run" + ], + "fields": { + "best_metrics": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "best_round": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "doc" + ] + }, + "items": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "round": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "running", + "completed", + "failed", + "exhausted" + ], + "immutable": false, + "reference": false + }, + "winner": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "loops", + "wakes_reconciler": true, + "source_kdl": [ + "loop" + ] + }, + "member.admission-override": { + "kind": "member.admission-override", + "subjects": [ + "host" + ], + "fields": { + "peers": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "start": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "fleet", + "wakes_reconciler": true + }, + "message.closed": { + "kind": "message.closed", + "subjects": [ + "message" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "acting_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "closed" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true + }, + "message.delivered": { + "kind": "message.delivered", + "subjects": [ + "message" + ], + "fields": { + "recipient": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "delivered" + ], + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "message.expired": { + "kind": "message.expired", + "subjects": [ + "message" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "values": [ + "cutoff-never-offered", + "cutoff-uncertain-attempt", + "no-live-recipient" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "expired" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "messages", + "wakes_reconciler": true + }, + "message.read": { + "kind": "message.read", + "subjects": [ + "message" + ], + "fields": { + "acting_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent" + ] + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "read" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true + }, + "message.sent": { + "kind": "message.sent", + "subjects": [ + "message" + ], + "fields": { + "assistant_grant_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "assistant_session_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "attachments": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "content": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "in_reply_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "sender_class": { + "value_type": "string", + "required": false, + "values": [ + "external-assistant" + ], + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "sent" + ], + "immutable": false, + "reference": false + }, + "tags": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "once", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "message.staged": { + "kind": "message.staged", + "subjects": [ + "message" + ], + "fields": { + "recipient": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "staged" + ], + "immutable": false, + "reference": false + }, + "transport": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once-per-actor", + "projection": "messages", + "wakes_reconciler": true, + "source_kdl": [ + "message" + ] + }, + "mission-run.created": { + "kind": "mission-run.created", + "subjects": [ + "mission-run" + ], + "fields": { + "ad_hoc_title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "after": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "current_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "deadline_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "default_selector": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "initial_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "inputs": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_spec": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "parent_step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "report_completed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_stalls": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "root_mission_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "root_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "stalled_after_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "timeout_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "mission-runs", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run" + ] + }, + "mission-run.report-to": { + "kind": "mission-run.report-to", + "subjects": [ + "mission-run" + ], + "fields": { + "report_completed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_stalls": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "report_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "stalled_after_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "mission-runs", + "wakes_reconciler": true + }, + "mission-run.state": { + "kind": "mission-run.state", + "subjects": [ + "mission-run" + ], + "fields": { + "completion": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "finally": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "mission-runs", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "completion", + "finally", + "cancellation" + ] + }, + "mission.produced": { + "kind": "mission.produced", + "subjects": [ + "mission", + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "name": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "step_definition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "capability-holder", + "cardinality": "append", + "projection": "mission-products", + "wakes_reconciler": true, + "source_kdl": [ + "produces" + ] + }, + "mission.provenance": { + "kind": "mission.provenance", + "subjects": [ + "mission" + ], + "fields": { + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "provenance": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "missions", + "wakes_reconciler": true, + "source_kdl": [ + "provenance" + ] + }, + "mission.published": { + "kind": "mission.published", + "subjects": [ + "mission" + ], + "fields": { + "body": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "missions", + "wakes_reconciler": true, + "source_kdl": [ + "mission" + ] + }, + "mission.renamed": { + "kind": "mission.renamed", + "subjects": [ + "mission" + ], + "fields": { + "declaration": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "from": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + }, + "owner_host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "preserved_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "mission" + ] + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "missions", + "wakes_reconciler": true + }, + "observer.observed": { + "kind": "observer.observed", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "changed": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "changed_fields": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "cursor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "locator": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "next_check_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "provider": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resource": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "observer" + ] + }, + "observer.refresh-requested": { + "kind": "observer.refresh-requested", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "refresh" + ] + }, + "observer.state": { + "kind": "observer.state", + "subjects": [ + "observer" + ], + "fields": { + "attempt": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "error_code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "next_check_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "healthy", + "degraded", + "unreachable", + "stopped" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "observers", + "wakes_reconciler": true, + "source_kdl": [ + "observer" + ] + }, + "operational.failure": { + "kind": "operational.failure", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "condition": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reproduction": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "severity": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "targets": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "operational.recovered": { + "kind": "operational.recovered", + "subjects": [ + "agent", + "exec", + "pty", + "observer", + "subscription", + "schedule", + "daemon", + "machine", + "step-run", + "mission-run", + "loop-run", + "resource", + "checkpoint" + ], + "fields": { + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "failure": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "operational", + "wakes_reconciler": true + }, + "ordered-membership.edited": { + "kind": "ordered-membership.edited", + "subjects": [ + "arrangement" + ], + "fields": { + "action_digest": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "action_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operations": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "owner": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "ordered-memberships", + "wakes_reconciler": false + }, + "owned-set.revised": { + "kind": "owned-set.revised", + "subjects": [ + "owned-set" + ], + "fields": { + "body": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "owned-sets", + "wakes_reconciler": true + }, + "person.delegation-set": { + "kind": "person.delegation-set", + "subjects": [ + "person" + ], + "fields": { + "actions": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": null, + "wakes_reconciler": false + }, + "person.directive-note-set": { + "kind": "person.directive-note-set", + "subjects": [ + "person" + ], + "fields": { + "expires_at": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "text": { + "value_type": "string", + "required": true, + "nullable": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "state-transition", + "projection": "directive-notes", + "wakes_reconciler": false + }, + "person.rename.completed": { + "kind": "person.rename.completed", + "subjects": [ + "person" + ], + "fields": { + "new_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "old_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "report": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "planning-session.approved": { + "kind": "planning-session.approved", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "kdl": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "markdown": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.cancelled": { + "kind": "planning-session.cancelled", + "subjects": [ + "planning-session" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "cancellation" + ] + }, + "planning-session.candidate-submitted": { + "kind": "planning-session.candidate-submitted", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "kdl": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "markdown": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.previewed": { + "kind": "planning-session.previewed", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "diff": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "graph": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "store_index": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.question-answered": { + "kind": "planning-session.question-answered", + "subjects": [ + "planning-session" + ], + "fields": { + "decision_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "expected_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "explanation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "response": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.question-requested": { + "kind": "planning-session.question-requested", + "subjects": [ + "planning-session" + ], + "fields": { + "decision_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "decision_type": { + "value_type": "string", + "required": true, + "values": [ + "boolean", + "single-choice", + "multiple-choice", + "rank" + ], + "immutable": false, + "reference": false + }, + "options": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "planner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "question": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true + }, + "planning-session.revision-requested": { + "kind": "planning-session.revision-requested", + "subjects": [ + "planning-session" + ], + "fields": { + "candidate_revision": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "feedback": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "variant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "feedback" + ] + }, + "planning-session.started": { + "kind": "planning-session.started", + "subjects": [ + "planning-session" + ], + "fields": { + "mission": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "planner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "planner_config": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "target_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "target_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "workspace": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "planning", + "wakes_reconciler": true, + "source_kdl": [ + "planning-session" + ] + }, + "principal.key-granted": { + "kind": "principal.key-granted", + "subjects": [ + "person", + "agent" + ], + "fields": { + "issuer": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "issuer_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "label": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "role": { + "value_type": "string", + "required": true, + "values": [ + "root", + "device", + "agent", + "plugin" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "principals", + "wakes_reconciler": false + }, + "principal.key-revoked": { + "kind": "principal.key-revoked", + "subjects": [ + "person", + "agent" + ], + "fields": { + "key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "principals", + "wakes_reconciler": false + }, + "publication.operation": { + "kind": "publication.operation", + "subjects": [ + "*" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "accepted" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "source_kdl": [ + "revision", + "reset", + "cancellation", + "refresh", + "feedback" + ] + }, + "reconcile.fault": { + "kind": "reconcile.fault", + "subjects": [ + "daemon", + "mission-run", + "observer", + "schedule", + "step-run", + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scope": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "faulted", + "recovered" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "daemons", + "wakes_reconciler": false + }, + "record.repaired": { + "kind": "record.repaired", + "subjects": [ + "repair" + ], + "fields": { + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "record": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "replacement": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "ordinary-client", + "cardinality": "once", + "projection": "replication", + "wakes_reconciler": false, + "source_kdl": [ + "repair" + ] + }, + "render.applied": { + "kind": "render.applied", + "subjects": [ + "agent", + "exec", + "pty" + ], + "fields": { + "warnings": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "writes": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": false, + "retention": "local" + }, + "repair.applied": { + "kind": "repair.applied", + "subjects": [ + "repair" + ], + "fields": { + "affected_subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "item_count": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "token": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "repairs", + "wakes_reconciler": true + }, + "resource.observed": { + "kind": "resource.observed", + "subjects": [ + "resource" + ], + "fields": { + "attribution_only": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "kind": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "observed_at": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "any", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": true, + "write_policy": "ordinary-client", + "cardinality": "append", + "projection": "resources", + "wakes_reconciler": true, + "source_kdl": [ + "resource" + ] + }, + "revision-proposal.applied": { + "kind": "revision-proposal.applied", + "subjects": [ + "revision-proposal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.approved": { + "kind": "revision-proposal.approved", + "subjects": [ + "revision-proposal" + ], + "fields": { + "all_approved": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once-per-actor", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.cancelled": { + "kind": "revision-proposal.cancelled", + "subjects": [ + "revision-proposal" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "revision-proposal.created": { + "kind": "revision-proposal.created", + "subjects": [ + "revision-proposal" + ], + "fields": { + "candidate_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "compatible_steps": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "cutover": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "preview_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reviewers": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "once", + "projection": "revision-proposals", + "wakes_reconciler": true + }, + "rule.audited": { + "kind": "rule.audited", + "subjects": [ + "rule" + ], + "fields": { + "action": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "actor": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "rule": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "target": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "rules", + "wakes_reconciler": false + }, + "rule.set": { + "kind": "rule.set", + "subjects": [ + "rule" + ], + "fields": { + "actors": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "description": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "except": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "kinds": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": true, + "values": [ + "off", + "audit", + "enforce" + ], + "immutable": false, + "reference": false + }, + "subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "unless_subjects": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "rules", + "wakes_reconciler": false + }, + "run-generation.created": { + "kind": "run-generation.created", + "subjects": [ + "run-generation" + ], + "fields": { + "compatible_steps": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "predecessor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "revision" + ] + }, + "run-generation.state": { + "kind": "run-generation.state", + "subjects": [ + "run-generation" + ], + "fields": { + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "mission-run", + "step", + "completion", + "finally", + "revision", + "cancellation" + ] + }, + "run-generation.superseded": { + "kind": "run-generation.superseded", + "subjects": [ + "run-generation" + ], + "fields": { + "phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "previous_phase": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "successor": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "run-generations", + "wakes_reconciler": true, + "source_kdl": [ + "revision" + ] + }, + "runtime.action.deadline-reached": { + "kind": "runtime.action.deadline-reached", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.failed": { + "kind": "runtime.action.failed", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.requested": { + "kind": "runtime.action.requested", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "fresh_session": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "ignore_fit": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.action.succeeded": { + "kind": "runtime.action.succeeded", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "action": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "blocking": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "code": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "credential_home": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "harness": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "launch_contract": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "native_session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "operation_status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "rollout": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "rollout_operation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_pid_start_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source_host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtime-actions", + "wakes_reconciler": true, + "source_kdl": [ + "stop", + "gate" + ], + "retention": "system-local" + }, + "runtime.observed": { + "kind": "runtime.observed", + "subjects": [ + "agent", + "exec", + "pty", + "gate-operation" + ], + "fields": { + "adopted": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit_code": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "exit_signal": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "host": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "restarting_from": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "shutdown_timeout_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "terminal": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.readiness-deadline-reached": { + "kind": "runtime.readiness-deadline-reached", + "subjects": [ + "agent" + ], + "fields": { + "deadline_unix_ms": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true, + "retention": "local" + }, + "runtime.reconcile-decision": { + "kind": "runtime.reconcile-decision", + "subjects": [ + "agent", + "exec", + "pty", + "schedule" + ], + "fields": { + "decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "gate": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "input_number": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "owner_run": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reachability": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "restart_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.refresh": { + "kind": "runtime.refresh", + "subjects": [ + "agent" + ], + "fields": { + "episode": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true + }, + "runtime.restart-window-reset": { + "kind": "runtime.restart-window-reset", + "subjects": [ + "agent", + "exec", + "pty" + ], + "fields": { + "desired_token": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "runtimes", + "wakes_reconciler": true, + "source_kdl": [ + "reset" + ] + }, + "schedule.occurrence-cancelled": { + "kind": "schedule.occurrence-cancelled", + "subjects": [ + "schedule" + ], + "fields": { + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.occurrence-reached": { + "kind": "schedule.occurrence-reached", + "subjects": [ + "schedule" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scheduled": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "scheduled_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "standby": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.occurrence-scheduled": { + "kind": "schedule.occurrence-scheduled", + "subjects": [ + "schedule" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "scheduled_at_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "standby": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-failed": { + "kind": "schedule.work-failed", + "subjects": [ + "schedule" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-requested": { + "kind": "schedule.work-requested", + "subjects": [ + "schedule" + ], + "fields": { + "inputs": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "mission_revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "occurrence": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "revision": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "schedule.work-started": { + "kind": "schedule.work-started", + "subjects": [ + "schedule" + ], + "fields": { + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "schedules", + "wakes_reconciler": true, + "source_kdl": [ + "schedule" + ] + }, + "sekret.called": { + "kind": "sekret.called", + "subjects": [ + "sekret" + ], + "fields": { + "argv": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "cwd": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "grant": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "login": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "tty": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.changed": { + "kind": "sekret.changed", + "subjects": [ + "sekret" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "change": { + "value_type": "string", + "required": true, + "values": [ + "profile-created", + "profile-removed", + "policy-set", + "put", + "unset", + "grant-added", + "grant-removed", + "locked", + "unlocked", + "daemon-registered" + ], + "immutable": false, + "reference": false + }, + "detail": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.exited": { + "kind": "sekret.exited", + "subjects": [ + "sekret" + ], + "fields": { + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "call": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "error": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "exit_code": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "signal": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "sekret.refused": { + "kind": "sekret.refused", + "subjects": [ + "sekret" + ], + "fields": { + "argv": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "caller": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "person": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "profile": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "seq": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "sekrets", + "wakes_reconciler": false, + "retention": "local" + }, + "slo.breach-transition": { + "kind": "slo.breach-transition", + "subjects": [ + "resource" + ], + "fields": { + "record": { + "value_type": "object", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "slo-breaches", + "wakes_reconciler": true + }, + "step-run.carried": { + "kind": "step-run.carried", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "definition_hash": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "source": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "source_step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "once", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "step-run.retried": { + "kind": "step-run.retried", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "goals": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "not_before_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "step-run.state": { + "kind": "step-run.state", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "step-runs", + "wakes_reconciler": true, + "source_kdl": [ + "step" + ] + }, + "subagent.appeared": { + "kind": "subagent.appeared", + "subjects": [ + "agent" + ], + "fields": { + "description": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "driver": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "lease_expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "session_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "started_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "step_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "step-run" + ] + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "subagent_type": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subagent.ended": { + "kind": "subagent.ended", + "subjects": [ + "agent" + ], + "fields": { + "cache_write_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "cached_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "duration_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "ended_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "input_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "outcome": { + "value_type": "string", + "required": true, + "values": [ + "completed", + "failed", + "interrupted", + "expired", + "session-ended", + "harness-exited", + "seat-stopped" + ], + "immutable": false, + "reference": false + }, + "output_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "total_tokens": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subagent.renewed": { + "kind": "subagent.renewed", + "subjects": [ + "agent" + ], + "fields": { + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "lease_expires_at_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "subagent_id": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": "subagents", + "wakes_reconciler": false + }, + "subscription.batch-sent": { + "kind": "subscription.batch-sent", + "subjects": [ + "subscription" + ], + "fields": { + "entries": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "message": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "message" + ] + }, + "through": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.batched": { + "kind": "subscription.batched", + "subjects": [ + "subscription" + ], + "fields": { + "delivery_key": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "entries": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-deferred": { + "kind": "subscription.mission-deferred", + "subjects": [ + "subscription" + ], + "fields": { + "not_before_unix_ms": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-failed": { + "kind": "subscription.mission-failed", + "subjects": [ + "subscription" + ], + "fields": { + "code": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-request-cancelled": { + "kind": "subscription.mission-request-cancelled", + "subjects": [ + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-request-released": { + "kind": "subscription.mission-request-released", + "subjects": [ + "subscription" + ], + "fields": { + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-requested": { + "kind": "subscription.mission-requested", + "subjects": [ + "subscription" + ], + "fields": { + "delivery_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "discovery": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "held": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "mission": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission" + ] + }, + "mission_revision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "requester": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "agent", + "person" + ] + }, + "resource": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "resource" + ] + }, + "resource_input": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "text": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "text_input": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.mission-started": { + "kind": "subscription.mission-started", + "subjects": [ + "subscription" + ], + "fields": { + "mission_run": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "mission-run" + ] + }, + "request": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.state": { + "kind": "subscription.state", + "subjects": [ + "subscription" + ], + "fields": { + "fields": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + }, + "observer": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "pending", + "active", + "stopped" + ], + "immutable": false, + "reference": false + }, + "to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "state-transition", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "subscription.watch-ended": { + "kind": "subscription.watch-ended", + "subjects": [ + "subscription" + ], + "fields": { + "message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "message" + ] + }, + "reason": { + "value_type": "string", + "required": true, + "values": [ + "closed", + "merged", + "deadline", + "unwatched", + "seat-ended" + ], + "immutable": false, + "reference": false + }, + "since_unix_ms": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "subscriptions", + "wakes_reconciler": true, + "source_kdl": [ + "subscription" + ] + }, + "terminal.input.requested": { + "kind": "terminal.input.requested", + "subjects": [ + "agent", + "pty" + ], + "fields": { + "byte_count": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "intent": { + "value_type": "string", + "required": false, + "values": [ + "context-compaction" + ], + "immutable": false, + "reference": false + }, + "mode": { + "value_type": "string", + "required": false, + "values": [ + "line", + "raw", + "key", + "text", + "binary" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "sha256": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "terminal-control", + "wakes_reconciler": true + }, + "terminal.input.result": { + "kind": "terminal.input.result", + "subjects": [ + "agent", + "pty" + ], + "fields": { + "incarnation_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "result": { + "value_type": "string", + "required": true, + "values": [ + "written", + "failed" + ], + "immutable": false, + "reference": false + }, + "runtime_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sequence": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "terminal-control", + "wakes_reconciler": true + }, + "terminal.launch-geometry": { + "kind": "terminal.launch-geometry", + "subjects": [ + "person" + ], + "fields": { + "columns": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + }, + "rows": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "transport.observed": { + "kind": "transport.observed", + "subjects": [ + "host" + ], + "fields": { + "last_success_at": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "protocol": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "remote_heads": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": true, + "values": [ + "up", + "down", + "unknown" + ], + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "transport", + "wakes_reconciler": true + }, + "usage.limits.observed": { + "kind": "usage.limits.observed", + "subjects": [ + "agent" + ], + "fields": { + "credit_readings": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + }, + "readings": { + "value_type": "array", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "same-subject-actor", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "vcs.item.state": { + "kind": "vcs.item.state", + "subjects": [ + "resource" + ], + "fields": { + "checks_state": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "draft": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "head_sha": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "item_kind": { + "value_type": "string", + "required": true, + "values": [ + "vcs.pull-request", + "vcs.issue" + ], + "immutable": false, + "reference": false + }, + "merge_queue": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "merged": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + }, + "observer": { + "value_type": "subject-reference", + "required": true, + "immutable": false, + "reference": true, + "reference_families": [ + "observer" + ] + }, + "required_checks": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "review_decision": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "state": { + "value_type": "string", + "required": true, + "values": [ + "open", + "closed" + ], + "immutable": false, + "reference": false + }, + "version": { + "value_type": "integer", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": "resources", + "wakes_reconciler": true + }, + "work.claimed": { + "kind": "work.claimed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "state-transition", + "projection": "work", + "wakes_reconciler": true + }, + "work.extended": { + "kind": "work.extended", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.failed": { + "kind": "work.failed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-attempt", + "projection": "work", + "wakes_reconciler": true + }, + "work.handoff-returned": { + "kind": "work.handoff-returned", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "disposition": { + "value_type": "string", + "required": false, + "values": [ + "withdrawn", + "declined" + ], + "immutable": false, + "reference": false + }, + "handoff_claim": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "return_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.nudged": { + "kind": "work.nudged", + "subjects": [ + "step-run" + ], + "fields": { + "agent": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "idle_since_unix_ms": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "waits": { + "value_type": "array", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": false + }, + "work.person-asked": { + "kind": "work.person-asked", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "deadline_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "default_answer": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "legacy_request": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "mission_spec": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "origin_attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "origin_step": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner_generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "owner_run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "requester_declaration": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "title": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "waiting_since": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-cancelled": { + "kind": "work.person-cancelled", + "subjects": [ + "step-run" + ], + "fields": { + "answer": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resolved_by": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-done": { + "kind": "work.person-done", + "subjects": [ + "step-run" + ], + "fields": { + "acted_for": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "answer": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_grant_id": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "assistant_owner": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true, + "reference_families": [ + "person" + ] + }, + "assistant_session_actor": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "delegation": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "resolved_by": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "sender_class": { + "value_type": "string", + "required": false, + "values": [ + "external-assistant" + ], + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.person-reassigned": { + "kind": "work.person-reassigned", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "episode": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "generation": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "previous_person": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "run": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + } + }, + "additional_fields": false, + "write_policy": "authorized-requester", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.progress": { + "kind": "work.progress", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.released": { + "kind": "work.released", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.renewed": { + "kind": "work.renewed", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "append", + "projection": "work", + "wakes_reconciler": true + }, + "work.submitted": { + "kind": "work.submitted", + "subjects": [ + "step-run" + ], + "fields": { + "attempt": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_expires_at_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "claim_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "claimant": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "clock": { + "value_type": "string", + "required": false, + "values": [ + "paused", + "running" + ], + "immutable": false, + "reference": false + }, + "extend_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_acknowledged": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_key": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_message": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "handoff_request": { + "value_type": "object", + "required": false, + "immutable": false, + "reference": false + }, + "handoff_to": { + "value_type": "subject-reference", + "required": false, + "immutable": false, + "reference": true + }, + "readiness_epoch": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "reason": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "status": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "submitted_incarnation": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "summary": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "wait_until_unix_ms": { + "value_type": "integer", + "required": false, + "immutable": false, + "reference": false + }, + "worker_reported": { + "value_type": "boolean", + "required": false, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "authorized-participant", + "cardinality": "once-per-attempt", + "projection": "work", + "wakes_reconciler": true + }, + "workspace.observed": { + "kind": "workspace.observed", + "subjects": [ + "agent" + ], + "fields": { + "host": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + }, + "repository": { + "value_type": "string", + "required": false, + "immutable": false, + "reference": false + }, + "workspace": { + "value_type": "string", + "required": true, + "immutable": false, + "reference": false + } + }, + "additional_fields": false, + "write_policy": "system-only", + "cardinality": "append", + "projection": null, + "wakes_reconciler": true, + "retention": "latest" + } +} diff --git a/packages/fractal/COVERAGE.md b/packages/fractal/COVERAGE.md index fc8bc68b89f..8c4a318b000 100644 --- a/packages/fractal/COVERAGE.md +++ b/packages/fractal/COVERAGE.md @@ -30,6 +30,7 @@ Authoritative grammar: `compoundingtech/smalltalk`, `crates/st3/src/graph.rs` an | `resource` | Partial | vcs.repository, filesystem.file, vcs.pull-request and vcs.ref. Additional native resource kinds are not modeled. | | `doc`, `observer`, `subscription` | Partial | Immutable document bindings and step pins, named/pinned document gates, github.ref observers selecting head/ancestors, and message subscriptions with field conditions. Mission/step/round declarations preserve native run scoping and alias rewriting. Other observer providers and subscription delivery modes are not modeled. | | mission `lane` | Covered | Unique run-owned names, optional slash-terminated `entries` prefixes and `person/` approvers. Lane blocks precede execution blocks and omit absent children. Root lane declarations are not modeled. | +| `objective` | Covered | `Fractal.objective`, `indicator.*` and `response.fault` check the native reader's rules (`fractal_kernel::objective`) and `objectiveNode` lowers every indicator, target, window and breach response. A subject tree declares interval objectives only; native telemetry objectives are rendered by `slo/objectives.kdl.genie.ts`. | | `account`, `pty`, `host`, `person`, `mission-run`, `planning-session`, `message`, `repair`, `stop` | Not covered | Additional root grammar nodes omitted. | | `version 2` | Covered | All emitted documents start with this directive. | @@ -37,10 +38,12 @@ Schedule work accepts either a pinned `mission@revision` or an unpinned mission ## Subject trees (`fractal/tree`, Node only) -`loadSubjectTree({ root, context })` discovers `agent.ts|kdl`, `mission.ts|kdl` and `schedule.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected. +`loadSubjectTree({ root, context })` discovers `agent.ts|kdl`, `mission.ts|kdl`, `schedule.ts|kdl` and `objective.ts|kdl` declarations recursively. IDs come from directories relative to the root. A seat and mission may share a directory; mission and schedule cannot. Symlink aliases load once under their canonical path, and declarations outside the tree are rejected. TS modules export an intent, a context factory, or `defineAgent|defineMission|defineSchedule({ meta: import.meta, intent })`. Typed agent refs are accepted for `under.target`, required mission `reportTo`, mission/step `assignedTo`, and loop/final workers; they lower to validated `{ id }` agent references, never legacy assignee strings. Typed mission refs lower to unpinned schedule work IDs. Plain imported declarations and attributed people retain the authoring schemas' contracts. +Objective modules export `Fractal.objective(...)`, its plain fields, a context factory, or `defineObjective({ meta: import.meta, intent })`. The subject is `objective/`; a `name` field, if present, must equal the ID. Typed agent refs in `onBreach` `owner`/`backup` lower to `agent/`, then `Fractal.objective` checks the result. Only an interval over claims named `NAMESPACE/NAME` loads, from TS or KDL; the native telemetry indicators are Fractal's own. + Mission modules pass through `missionWire` before strict schema decoding, preserving scoped step handles, input/product identities and text templates until assembly. Reference lowering leaves unchanged authoring objects intact. Extra properties and invalid current declarations remain errors. Named module exports are retained beside the decoded intent. KDL declarations are checked for version, root kind and path-derived ID; native st remains their grammar authority. Native conformance is opt-in: `ST_BIN` selects the production binary for the agent-attributed round-trip; `ST_FIXTURE_BIN` selects upstream's separately compiled, pinned `st3-fixture` test-support binary for synthetic-person gate semantics. Both use isolated private runtime directories. The fixture mode does not prove production person authentication; see [the acceptance boundary](./HUMAN_GATE_ACCEPTANCE.md). diff --git a/packages/fractal/README.md b/packages/fractal/README.md index 3eb70d36e85..0aae80eec57 100644 --- a/packages/fractal/README.md +++ b/packages/fractal/README.md @@ -1,16 +1,17 @@ # fractal -Typed authoring for Fractal declarations: agents, missions and schedules kept as TypeScript modules in a directory tree, validated with Effect schemas and rendered to KDL v2. This is the private workspace package `fractal`; it is not published to npm. +Typed authoring for Fractal declarations: agents, missions, schedules and objectives kept as TypeScript modules in a directory tree, validated with Effect schemas or the native readers' rules and rendered to KDL v2. This is the private workspace package `fractal`; it is not published to npm. | Entrypoint | Runtime | Contents | |---|---|---| -| `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule` nodes, `emit`, `fractalKdl`) as named exports | +| `fractal` | isomorphic | The `Fractal` authoring namespace; declaration schemas and the KDL lowering (`agent`, `mission`, `schedule`, `objectiveNode` nodes, `emit`, `fractalKdl`) as named exports | | `fractal/tree` | Node | `loadSubjectTree` plus the define forms and reference types | | `fractal/testing` | Node | `scratchDaemonLayer`, an isolated daemon for conformance tests | +| `fractal/effect` | Bun program host, isomorphic | The `Fractal` namespace with the program forms of `mission` and `step`, plus `ask`, `run`, `command`, `env`, `cwd` and `Worker`; the host contract (`ProgramEntry`, `HostContextV1`) as named exports | ## Authoring -Each declaration is the default export of `/agent.ts`, `/mission.ts` or `/schedule.ts` below a tree root. `meta: import.meta` ties the declaration to its module; the loader derives the ID from the module's directory, so declarations never repeat it. Imported declarations are typed references. +Each declaration is the default export of `/agent.ts`, `/mission.ts`, `/schedule.ts` or `/objective.ts` below a tree root. `meta: import.meta` ties the declaration to its module; the loader derives the ID from the module's directory, so declarations never repeat it. Imported declarations are typed references. ```ts // garden/worker/agent.ts @@ -39,18 +40,48 @@ export default Fractal.mission({ }) ``` -The namespace also holds `step`, `gate`, `person`, `input`, `product`, `t`, `doc`, `pr`, `childMission`, `runId`, `completed`/`failed`/`terminal`, `Timing`, `Launch`, `Review`, `Fragment`, `Mission`, `Round`, `omp` and `pinMission`. `Fractal.defineAgent`, `Fractal.defineMission` and `Fractal.defineSchedule` are the low-level forms: `{ meta, intent }`, where `intent` may be a function of the loader's `context`. +An objective (`docs/st3/objectives.md`) is a target over something Fractal measures. A fleet declares intervals over claims, named `NAMESPACE/NAME` by their directory; `objective.ts` needs no `meta` because nothing references an objective, and a `name`, if given, must equal the path ID. + +```ts +// ops/change-rollout/objective.ts +import { Fractal } from 'fractal' + +import releaseManager from '../release-manager/agent.ts' + +export default Fractal.objective({ + about: 'A change, from its author marking it ready to every member that serves it', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready', at: 'ready_at' }, + expect: { kind: 'custom.acme.change-merged', set: 'hosts', at: 'merged_at' }, + stop: { kind: 'custom.acme.change-applied', member: 'host', at: 'applied_at' }, + cancel: 'custom.acme.change-abandoned', + labels: ['class'], + }), + window: { span: '7d', minSamples: 20 }, + targets: [ + { stat: 'p50', within: '60s' }, + { stat: 'p95', within: '5m' }, + ], + onBreach: Fractal.response.fault({ owner: releaseManager, condition: 'change-rollout-slo-missed' }), +}) +``` + +`Fractal.objective` checks the rules the native reader enforces and throws on the first broken one. The other indicators (`latency`, `share`, `statement`, `transaction`, `writerHold`, `cpu`, `database`, `divergence`, `ciDuration`, `ciCount`) are native telemetry: Fractal ships them in [`slo/objectives.kdl`](../../slo/objectives.kdl), rendered by [`slo/objectives.kdl.genie.ts`](../../slo/objectives.kdl.genie.ts), and the tree loader refuses them. `src/objective.ts` and `src/kdl.ts` import nothing outside the package, so Genie bundles that file without installing dependencies. + +The namespace also holds `step`, `gate`, `person`, `input`, `product`, `t`, `doc`, `pr`, `childMission`, `runId`, `completed`/`failed`/`terminal`, `Timing`, `Launch`, `Review`, `Fragment`, `Mission`, `Round`, `omp` and `pinMission`. `Fractal.defineAgent`, `Fractal.defineMission`, `Fractal.defineSchedule` and `Fractal.defineObjective` are the low-level forms: `{ meta, intent }`, where `intent` may be a function of the loader's `context`. `step()` and `gate.*()` validate eagerly, so a step handle's `assignedTo` must be an object with an `id`; a path-derived reference works in an inline step object, which the loader lowers. ## Loading and rendering -`loadSubjectTree({ root, context })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, and decodes the result with the strict schemas. Render a loaded subject with the named exports: +`loadSubjectTree({ root, context })` imports every declaration module below `root`, lowers typed references to IDs without copying authored step and gate handles, assembles missions with `missionWire`, decodes the result with the strict schemas, and checks objectives with `Fractal.objective`. Render a loaded subject with the named exports: ```ts -import { agent, emit, mission } from 'fractal' +import { agent, emit, mission, objectiveNode } from 'fractal' const kdl = emit([subject.kind === 'agent' ? agent(intent) : mission(intent)]) +const objectiveKdl = emit([objectiveNode({ ...intent, name: subject.id })]) ``` Validate the output with `fractal validate` before publishing it with `fractal apply`. @@ -59,9 +90,48 @@ Validate the output with `fractal validate` before publishing it with `fractal a [`@fractal/declarations`](../../clients/typescript/fractal-declarations/README.md) is the generated, dependency-free encoder for a subset of the grammar. This package covers more of the grammar (gates, loops, inputs and products, documents, observers, calendar schedules) and path-derived trees. For the shared subset both produce the same declarations; `src/parity.unit.test.ts` and `crates/fractal/tests/validate.rs` check that. [COVERAGE.md](./COVERAGE.md) records grammar coverage and conformance evidence; [docs/requirements.md](./docs/requirements.md) and [docs/spec.md](./docs/spec.md) hold the contract. -## Roadmap +## Programs + +`fractal/effect` holds the program form of a mission (design: [`docs/st3/program-engine.md`](../../docs/st3/program-engine.md)), built on Effect's `effect/workflow`. A program's body branches on step values. Its bundle runs in the daemon's program host, which journals every step and replays the journal after a restart; this package implements the bundle's side of that host contract. + +```ts +import { Effect, Schema } from 'effect' +import { Fractal } from 'fractal/effect' + +import factory from '../agent.ts' + +export default Fractal.mission( + { + meta: import.meta, + id: 'factory/land', + goal: 'Land one change on main.', + reportTo: factory, + input: Schema.Struct({ change: Schema.String }), + output: Schema.Struct({ commit: Schema.String }), + }, + ({ change }) => + Effect.gen(function* () { + const out = yield* Fractal.step( + { id: 'merge', success: Schema.String }, + Effect.map(Fractal.command(['./merge', change]), (result) => result.stdout.trim()), + ) + return { commit: out } + }), +) +``` + +| Call | `effect/workflow` | Journal | +|---|---|---| +| `Fractal.mission(intent, program)` | a `Workflow` with `intent.input` as payload and `intent.output` as success | the success, encoded with `output`, or the typed failure fails the program step | +| `Fractal.step({ id, success?, error? }, effect)` | an `Activity` named by its key: `id`, then `id#2`… for repeats, so `Effect.retry` runs it again under a new key | its encoded value, or its typed failure with `_tag` and fields; defects and interrupts are not journaled | +| `Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))` | a `DurableDeferred` | a worker turn: a linked child run of `mission`; the product `resource//`, decoded with `success`, is the value | +| `Fractal.ask({ id, for, title, request, deadline?, default? })` | a `DurableDeferred` | a native person ask on the program step, answered `AskAnswer` or failed `AskCancelled` | +| `Fractal.run(mission, input, { id? })` | a `DurableDeferred` | a linked child run, `ChildRun` or `ChildFailed` | +| `Fractal.command(argv, { env?, cwd?, priority? })` | — | not journaled: run it inside a step; the host runs it as a process group it ends on cancel | + +Every `input` field must be required and encode to a string, since each one is a native `input "name" kind="text"`; a payload that does not decode refuses the run with `InvalidInput`. Without `error`, a step or program journals any tagged error as `_tag` plus its fields and the step fails with that plain object (typed `Journaled`), live and on replay, so `catchTag` matches; an untagged error is a defect. With `error`, the schema rebuilds the error as its class, which a program's `error` class needs. Only one fiber may use a step id: concurrent branches would take `id#n` keys in completion order, which replay does not reproduce, so a second branch using the id is a defect. A journal entry the daemon refuses ends the attempt with a defect that the body cannot catch. -Program-form missions, `Fractal.mission(intent, program)` with `Fractal.step(...)` effects and `Fractal.ask`, are a design target shown in the documentation's Effect tab. They have no runtime yet: missions are declared as data. +A program mission's default export carries `[ProgramEntry]` (`Symbol.for('fractal.program.v1')`), the entry the program host calls with a `HostContextV1`; `fixtures/programs/factory/land-program` is the factory's landing program, and `src/program-bundle.unit.test.ts` runs its `bun build --target=bun` bundle. Bundling a program and rendering its `program` step declaration are not part of `loadSubjectTree` yet. ## Checks diff --git a/packages/fractal/fixtures/programs/factory/agent.ts b/packages/fractal/fixtures/programs/factory/agent.ts new file mode 100644 index 00000000000..64bab8d4bc8 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/agent.ts @@ -0,0 +1,9 @@ +import { Fractal } from '../../../src/index.ts' + +/** The factory seat landing missions report to. */ +export default Fractal.agent({ + meta: import.meta, + workspace: '/work/factory', + restart: 'always', + harness: { kind: 'claude', model: 'sonnet' }, +}) diff --git a/packages/fractal/fixtures/programs/factory/land-program/mission.ts b/packages/fractal/fixtures/programs/factory/land-program/mission.ts new file mode 100644 index 00000000000..75bd6449ac0 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/land-program/mission.ts @@ -0,0 +1,121 @@ +/** + * The factory's landing mission as a program: route a change by its classes, review what needs + * review, merge, publish and verify, and revert exactly what reached main when a later step fails. + */ +import { Data, Effect, Schedule, Schema } from 'effect' + +import { Fractal } from '../../../../src/effect.ts' +import factory from '../agent.ts' +import * as steps from './steps.ts' + +const owner = Fractal.person('person/owner') +const host = 'local' + +class NoGo extends Data.TaggedError('NoGo')<{ + readonly step: string + readonly findings: readonly string[] +}> {} +class Refused extends Data.TaggedError('Refused')<{ readonly reason: string }> {} +class Reverted extends Data.TaggedError('Reverted')<{ + readonly failed: string + readonly reason: string + readonly commits: readonly string[] +}> {} + +/** The strictest class wins: code > removal > authority > cross-owner > routine. */ +const route = (classes: readonly steps.Class[]): steps.Class => + (['code', 'removal', 'authority', 'cross-owner'] as const).find((c) => classes.includes(c)) ?? + 'routine' + +/** Undo what reached main, then fail the run with the reason. */ +const revertBecause = (failed: string, reason: string) => + Effect.flatMap(steps.revert({ failed, reason }), (r) => + Effect.fail(new Reverted({ failed, reason, commits: r.reverted.map((c) => c.revert_commit) })), + ) + +const requireGo = (verdict: steps.Verdict, step: string) => + verdict.verdict === 'GO' + ? Effect.void + : Effect.fail(new NoGo({ step, findings: verdict.findings })) + +export default Fractal.mission( + { + meta: import.meta, + id: 'factory/land-program', + goal: 'Land one change on main and prove it live, or leave main as it was.', + reportTo: factory, + timeout: '2h', + input: Schema.Struct({ change: Schema.String, reviewer: Schema.Literals(['llm', 'scripted']) }), + output: Schema.Struct({ class: steps.Class, merged: Schema.String, head: Schema.String }), + }, + ({ change, reviewer }) => + Effect.gen(function* () { + const classification = yield* steps.classify({ change }) + const cls = route(classification.classes) + const mode: steps.Mode = cls === 'code' ? 'full' : 'declaration' + + // Pre-merge: check, plus whatever this class needs before main may move. + if (cls === 'authority') { + const [, verdict] = yield* Effect.all( + [ + steps.check({ change, mode }), + steps.review({ + id: 'review-authority', + change, + checklist: 'authority', + mode: 'pre-merge', + reviewer, + }), + ], + { concurrency: 'unbounded' }, + ) + yield* requireGo(verdict, 'review-authority') + } else if (cls === 'cross-owner') { + const [, ack] = yield* Effect.all( + [steps.check({ change, mode }), steps.ownerAck({ owner, change, deadline: '1h' })], + { concurrency: 'unbounded' }, + ) + if (ack === 'Rejected') + return yield* new Refused({ + reason: 'the owner declined (or the deadline default applied)', + }) + } else { + yield* steps.check({ change, mode }) + } + + const merged = yield* steps.merge({ change, mode }) + + // Post-hoc review for code and removals: main already moved, so NO-GO means revert. + if (cls === 'code' || cls === 'removal') { + const checklist = cls === 'code' ? 'publisher-code' : 'removal' + const verdict = yield* steps.review({ + id: `review-${checklist}`, + change, + checklist, + mode: 'post-hoc', + commit: merged.commit, + reviewer, + }) + if (verdict.verdict === 'NO-GO') + return yield* revertBecause(`review-${checklist}`, verdict.findings.join('; ')) + } + + // Publish and verify; an admission refusal or a stale subject reverts the offending commits. + const published = yield* steps + .publish({ host }) + .pipe(Effect.catchTag('StepFailed', (e) => revertBecause(e.step, e.reason))) + yield* steps.verify({ host }).pipe( + Effect.flatMap((v) => + v.stale.length === 0 + ? Effect.void + : Effect.fail( + new steps.StepFailed({ step: 'verify', reason: `stale: ${v.stale.join(', ')}` }), + ), + ), + // The same step id runs again under the keys verify-local#2, verify-local#3. + Effect.retry(Schedule.recurs(2)), + Effect.catchTag('StepFailed', (e) => revertBecause(e.step, e.reason)), + ) + return { class: cls, merged: merged.commit, head: published.head } + }), +) diff --git a/packages/fractal/fixtures/programs/factory/land-program/steps.ts b/packages/fractal/fixtures/programs/factory/land-program/steps.ts new file mode 100644 index 00000000000..bb14f6b5e71 --- /dev/null +++ b/packages/fractal/fixtures/programs/factory/land-program/steps.ts @@ -0,0 +1,183 @@ +/** + * The landing step catalogue as program steps. Agentless steps run `steps/factory.py ` in the + * run's workspace, which writes the step's JSON result to `rundata//.json`. A review + * is a worker turn of the factory's `factory/step/review` mission; the owner's acknowledgement is a + * native ask. + */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { Data, Effect, Schema } from 'effect' + +import { Fractal } from '../../../../src/effect.ts' +import type { PersonReference } from '../../../../src/index.ts' + +export class StepFailed extends Data.TaggedError('StepFailed')<{ + readonly step: string + readonly reason: string +}> {} + +export type Mode = 'declaration' | 'full' +export const Class = Schema.Literals(['routine', 'authority', 'code', 'removal', 'cross-owner']) +export type Class = typeof Class.Type + +/** One `factory.py ` effect; its JSON result file, decoded with `schema`, is the value. */ +const tool = >( + verb: string, + env: Readonly>, + result: string, + schema: S, +) => + Effect.gen(function* () { + const run = (yield* Fractal.env).ST_MISSION_RUN ?? 'adhoc' + const cwd = yield* Fractal.cwd + const out = yield* Fractal.command(['python3', 'steps/factory.py', verb], { + env: { ...env, FACTORY_SCRATCH_ROOT: cwd }, + }) + if (out.code !== 0) + return yield* new StepFailed({ + step: verb, + reason: out.stderr.trim().split('\n').slice(-3).join(' '), + }) + const file = join(cwd, 'rundata', run.replaceAll('/', '_'), `${result}.json`) + const json = yield* Effect.promise(() => readFile(file, 'utf8')) + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(json).pipe( + Effect.mapError((error) => new StepFailed({ step: verb, reason: error.message })), + ) + }) + +export const Classification = Schema.Struct({ + change: Schema.String, + author: Schema.String, + classes: Schema.Array(Class), + reasons: Schema.Array(Schema.String), +}) +/** classify: every class this change belongs to; the program routes, so every class is accepted. */ +export const classify = ({ change }: { readonly change: string }) => + Fractal.step( + { id: 'classify', success: Classification }, + tool('classify', { CHANGE: change, ACCEPT: Class.literals.join(',') }, 'class', Classification), + ) + +export const CheckResult = Schema.Struct({ + mode: Schema.String, + errors: Schema.Array(Schema.String), +}) +/** check: declaration mode (render, offline validate, guard, `Removes:`) or full (plus the suite). */ +export const check = ({ change, mode }: { readonly change: string; readonly mode: Mode }) => + Fractal.step( + { id: 'check', success: CheckResult }, + tool('check', { CHANGE: change, MODE: mode }, 'check', CheckResult), + ) + +export const Merged = Schema.Struct({ + commit: Schema.String, + parent: Schema.String, + attempts: Schema.Number, +}) +/** merge: squash onto main by compare-and-swap; re-checks the exact merge result when main moved. */ +export const merge = ({ change, mode }: { readonly change: string; readonly mode: Mode }) => + Fractal.step( + { id: 'merge', success: Merged }, + tool('merge', { CHANGE: change, MODE: mode }, 'merged', Merged), + ) + +export const Published = Schema.Struct({ + host: Schema.String, + head: Schema.String, + changed: Schema.Array(Schema.String), +}) +/** publish: self-admission on one host (render main, dry-run every changed subject), then apply. */ +export const publish = ({ host }: { readonly host: string }) => + Fractal.step( + { id: `publish-${host}`, success: Published }, + tool('publish', { HOST: host }, 'publish', Published), + ) + +export const Verified = Schema.Struct({ head: Schema.String, stale: Schema.Array(Schema.String) }) +/** verify: every subject this run applied is live at its rendered revision. */ +export const verify = ({ host }: { readonly host: string }) => + Fractal.step( + { id: `verify-${host}`, success: Verified }, + tool('verify', { HOST: host }, 'verify', Verified), + ) + +export const Reverted = Schema.Struct({ + why: Schema.String, + reverted: Schema.Array(Schema.Struct({ reverted: Schema.String, revert_commit: Schema.String })), +}) +/** revert: undo exactly the offending commits on main; the program says which step failed and why. */ +export const revert = ({ failed, reason }: { readonly failed: string; readonly reason: string }) => + Fractal.step( + { id: 'revert', success: Reverted }, + tool('revert', { FAILED: failed, FAILED_REASON: reason }, 'revert', Reverted), + ) + +export type Checklist = 'authority' | 'publisher-code' | 'removal' | 'cross-owner' +export const Verdict = Schema.Struct({ + verdict: Schema.Literals(['GO', 'NO-GO']), + head: Schema.String, + findings: Schema.Array(Schema.String), +}) +export type Verdict = typeof Verdict.Type + +/** + * review: a worker turn by a fresh run-scoped reviewer that `factory/step/review` declares, never + * the author. The checklist picks the prompt; the verdict is the child run's product. + */ +export const review = (options: { + readonly id: string + readonly change: string + readonly checklist: Checklist + readonly mode: 'pre-merge' | 'post-hoc' + readonly commit?: string + readonly reviewer: 'llm' | 'scripted' +}) => + Fractal.step({ + id: options.id, + goal: `Review the exact change against the ${options.checklist} checklist.`, + success: Verdict, + }).pipe( + Effect.provide( + Fractal.Worker.layer({ + mission: 'factory/step/review', + product: 'review/verdict', + input: { + change: options.change, + checklist: options.checklist, + mode: options.mode, + commit: options.commit ?? options.change, + reviewer: options.reviewer, + }, + }), + ), + ) + +/** owner-ack: the owning person accepts a cross-owner write; the deadline default declines. */ +export const ownerAck = (options: { + readonly owner: PersonReference + readonly change: string + readonly deadline: string +}) => + Fractal.ask({ + id: 'owner-ack', + for: options.owner, + title: `Accept change ${options.change.slice(0, 10)} into your subjects`, + request: { + version: 1, + type: 'decision', + question: `Accept the write into your subjects by change ${options.change.slice(0, 10)}?`, + why_person: 'The owner decides writes into their own namespace.', + answers: [ + { id: 'approve', label: 'Accept', consequence: 'The change lands.', outcome: 'accept' }, + { + id: 'reject', + label: 'Decline', + consequence: 'The landing fails as refused.', + outcome: 'decline', + }, + ], + }, + deadline: options.deadline, + default: 'reject', + }).pipe(Effect.map((answer) => (answer.answer?.id === 'approve' ? 'Approved' : 'Rejected'))) diff --git a/packages/fractal/package.json b/packages/fractal/package.json index 72c6da0ce17..a949c352e26 100644 --- a/packages/fractal/package.json +++ b/packages/fractal/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./effect": "./src/effect.ts", "./testing": "./src/testing.ts", "./tree": "./src/tree.ts" }, diff --git a/packages/fractal/package.json.genie.ts b/packages/fractal/package.json.genie.ts index a7d75b52cf2..29eaf67c37f 100644 --- a/packages/fractal/package.json.genie.ts +++ b/packages/fractal/package.json.genie.ts @@ -32,6 +32,7 @@ export default packageJson({ type: 'module', exports: { '.': './src/index.ts', + './effect': './src/effect.ts', './tree': './src/tree.ts', './testing': './src/testing.ts', }, diff --git a/packages/fractal/src/define.ts b/packages/fractal/src/define.ts index 5cb8ba8d1ce..4a3c5d39cf4 100644 --- a/packages/fractal/src/define.ts +++ b/packages/fractal/src/define.ts @@ -1,4 +1,5 @@ import type { AgentSchema, MissionInput, ScheduleSchema, WorkSchema } from './mod.ts' +import type { ObjectiveInput } from './objective.ts' import type { SubjectKind, SubjectDefinition, @@ -108,6 +109,12 @@ export const defineMission: Define<'mission', MissionIntent> = define('mission') /** Defines the scheduled mission declared by a `schedule.ts` module. */ export const defineSchedule: Define<'schedule', ScheduleIntent> = define('schedule') +/** Objective fields for an `objective.ts` module; the name, if given, must be its path ID. */ +export type ObjectiveIntent = ObjectiveInput + +/** Defines the objective declared by an `objective.ts` module; see `Fractal.objective`. */ +export const defineObjective: Define<'objective', ObjectiveIntent> = define('objective') + /** The defining module's `import.meta`; the loader derives the subject ID from the module's path. */ type Meta = { readonly meta: { readonly url: string } } diff --git a/packages/fractal/src/effect.ts b/packages/fractal/src/effect.ts new file mode 100644 index 00000000000..bd1c3ad47f1 --- /dev/null +++ b/packages/fractal/src/effect.ts @@ -0,0 +1,26 @@ +/** + * Program-form missions, imported as `import { Fractal } from 'fractal/effect'`. The named exports + * are the host contract a program bundle implements (docs/st3/program-engine.md §2). + */ +export * as Fractal from './fractal-effect.ts' +export { + AskAnswer, + AskCancelled, + ChildFailed, + ChildRun, + type InputSchema, + type Journaled, + type ProgramIntent, + type ProgramMission, + type StepOptions, +} from './program.ts' +export { + Program, + ProgramEntry, + type CommandOptions, + type CommandResult, + type HostContextV1, + type JournalEntry, + type ProgramExit, + type ProgramServices, +} from './program-engine.ts' diff --git a/packages/fractal/src/fractal-effect.ts b/packages/fractal/src/fractal-effect.ts new file mode 100644 index 00000000000..0ec8b492ab7 --- /dev/null +++ b/packages/fractal/src/fractal-effect.ts @@ -0,0 +1,7 @@ +/** + * The authoring namespace of `fractal/effect`: everything `Fractal` in `fractal` holds, with the + * program forms of `mission` and `step` and the program calls `ask`, `run`, `command`, `env`, `cwd` + * and `Worker`. + */ +export * from './fractal.ts' +export { ask, command, cwd, env, mission, run, step, Worker } from './program.ts' diff --git a/packages/fractal/src/fractal.ts b/packages/fractal/src/fractal.ts index b4079c89425..d72674922c1 100644 --- a/packages/fractal/src/fractal.ts +++ b/packages/fractal/src/fractal.ts @@ -3,8 +3,10 @@ * * `agent`, `mission` and `schedule` declare the subject of an `agent.ts`, `mission.ts` or * `schedule.ts` module from flat fields plus `meta: import.meta`; the tree loader - * (`fractal/tree`) derives each ID from the module's path. `defineAgent`, `defineMission` and - * `defineSchedule` are the low-level forms whose `intent` may also be a context factory. + * (`fractal/tree`) derives each ID from the module's path. `objective` declares the objective of + * an `objective.ts` module, measured by an `indicator` and answered by a `response`. + * `defineAgent`, `defineMission`, `defineSchedule` and `defineObjective` are the low-level forms + * whose `intent` may also be a context factory. */ export { agent, @@ -13,13 +15,30 @@ export { defineAgent, defineMission, defineSchedule, + defineObjective, pinMission, type AgentIntent, type MissionIntent, type ScheduleIntent, + type ObjectiveIntent, type PinnedMissionRef, } from './define.ts' export type { AgentRef, MissionRef, SubjectRef, SubjectDefinition } from './subject-ref.ts' +export { + objective, + indicator, + response, + type AgentTarget, + type ClaimEvent, + type Duration, + type Fault, + type Indicator, + type Interval, + type Objective, + type ObjectiveInput, + type Target, + type Window, +} from './objective.ts' export { step, gate, diff --git a/packages/fractal/src/index.ts b/packages/fractal/src/index.ts index 2310b273382..a3fb70b17eb 100644 --- a/packages/fractal/src/index.ts +++ b/packages/fractal/src/index.ts @@ -4,3 +4,4 @@ */ export * as Fractal from './fractal.ts' export * from './mod.ts' +export { objectiveNode } from './objective.ts' diff --git a/packages/fractal/src/kdl.ts b/packages/fractal/src/kdl.ts new file mode 100644 index 00000000000..8772b0820fa --- /dev/null +++ b/packages/fractal/src/kdl.ts @@ -0,0 +1,119 @@ +/** + * KDL v2 nodes and their serialization. This module imports nothing, so Genie files that render + * declarations (`slo/objectives.kdl.genie.ts`) bundle it without the package's dependencies. + */ + +/** A KDL argument or property value. */ +export type Value = string | number | boolean + +/** A KDL node; `children: undefined` renders without a children block. */ +export type Node = { + readonly name: string + readonly args: readonly Value[] + readonly props: Readonly> + readonly children?: readonly Node[] +} + +const immutableNodes = new WeakSet() + +/** Builds a KDL node; omitted args and props are empty. */ +export const node = ({ + name, + args = [], + props = {}, + children, +}: { + readonly name: string + readonly args?: readonly Value[] + readonly props?: Readonly> + readonly children?: readonly Node[] +}): Node => { + const value = Object.freeze({ + name, + args: Object.freeze([...args]), + props: Object.freeze({ ...props }), + ...(children === undefined + ? {} + : { + children: Object.freeze( + children.map((child) => (immutableNodes.has(child) === true ? child : node(child))), + ), + }), + }) + immutableNodes.add(value) + return value +} + +const renderValue = (value: Value): string => { + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return `#${value}` + if (Number.isFinite(value) === false) throw new RangeError('KDL numbers must be finite') + return String(value) +} + +const identifier = (text: string): string => + /^[A-Za-z_][\w-]*$/u.test(text) === true ? text : JSON.stringify(text) + +const render = ({ + node: current, + depth, +}: { + readonly node: Node + readonly depth: number +}): string => { + const pad = ' '.repeat(depth) + const args = current.args.map((arg) => ` ${renderValue(arg)}`).join('') + const props = Object.entries(current.props) + .toSorted(([a], [b]) => a.localeCompare(b, 'en')) + .map(([key, value]) => ` ${identifier(key)}=${renderValue(value)}`) + .join('') + const header = `${pad}${identifier(current.name)}${args}${props}` + if (current.children === undefined) return `${header}\n` + const body = current.children.map((child) => render({ node: child, depth: depth + 1 })).join('') + return `${header} {\n${body}${pad}}\n` +} + +/** Serializes nodes as a KDL v2 document. */ +export const emit = (nodes: readonly Node[]): string => + `version 2\n${nodes.map((current) => render({ node: current, depth: 0 })).join('')}` + +/** One validation finding, in the shape Genie's `GenieValidationIssue` expects. */ +export interface KdlValidationIssue { + readonly severity: 'error' + readonly packageName: string + readonly dependency: string + readonly rule: string + readonly message: string +} + +/** + * A generated KDL declaration file. The shape is Genie's `GenieOutput` contract, declared + * structurally so a Genie file can return it without this package depending on Genie. + */ +export interface KdlFileOutput { + readonly data: readonly Node[] + readonly stringify: () => string + readonly validate: () => KdlValidationIssue[] +} + +/** Genie output for a Fractal KDL declaration file. */ +export const fractalKdl = (nodes: readonly Node[]): KdlFileOutput => ({ + data: nodes, + stringify: () => emit(nodes), + validate: () => { + try { + emit(nodes) + return [] + } catch (cause) { + return [ + { + severity: 'error', + packageName: 'fractal', + dependency: '', + rule: 'fractal-kdl', + message: cause instanceof Error ? cause.message : String(cause), + }, + ] + } + }, +}) diff --git a/packages/fractal/src/mod.ts b/packages/fractal/src/mod.ts index 25e475a29d1..6e9dd7c0bf7 100644 --- a/packages/fractal/src/mod.ts +++ b/packages/fractal/src/mod.ts @@ -2,6 +2,7 @@ import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from '@noble/hashes/utils.js' import { Schema, SchemaGetter } from 'effect' +import { node, type Node, type Value } from './kdl.ts' import type { ChildMission, DocumentReference, @@ -91,79 +92,8 @@ export type { TextTemplate, } from './run-values.ts' -/** A KDL argument or property value. */ -export type Value = string | number | boolean - -/** A KDL node; `children: undefined` renders without a children block. */ -export type Node = { - readonly name: string - readonly args: readonly Value[] - readonly props: Readonly> - readonly children?: readonly Node[] -} - -const immutableNodes = new WeakSet() - -/** Builds a KDL node; omitted args and props are empty. */ -export const node = ({ - name, - args = [], - props = {}, - children, -}: { - readonly name: string - readonly args?: readonly Value[] - readonly props?: Readonly> - readonly children?: readonly Node[] -}): Node => { - const value = Object.freeze({ - name, - args: Object.freeze([...args]), - props: Object.freeze({ ...props }), - ...(children === undefined - ? {} - : { - children: Object.freeze( - children.map((child) => (immutableNodes.has(child) === true ? child : node(child))), - ), - }), - }) - immutableNodes.add(value) - return value -} - -const renderValue = (value: Value): string => { - if (typeof value === 'string') return JSON.stringify(value) - if (typeof value === 'boolean') return `#${value}` - if (Number.isFinite(value) === false) throw new RangeError('KDL numbers must be finite') - return String(value) -} - -const identifier = (text: string): string => - /^[A-Za-z_][\w-]*$/u.test(text) === true ? text : JSON.stringify(text) - -const render = ({ - node: current, - depth, -}: { - readonly node: Node - readonly depth: number -}): string => { - const pad = ' '.repeat(depth) - const args = current.args.map((arg) => ` ${renderValue(arg)}`).join('') - const props = Object.entries(current.props) - .toSorted(([a], [b]) => a.localeCompare(b, 'en')) - .map(([key, value]) => ` ${identifier(key)}=${renderValue(value)}`) - .join('') - const header = `${pad}${identifier(current.name)}${args}${props}` - if (current.children === undefined) return `${header}\n` - const body = current.children.map((child) => render({ node: child, depth: depth + 1 })).join('') - return `${header} {\n${body}${pad}}\n` -} - -/** Serializes nodes as a KDL v2 document. */ -export const emit = (nodes: readonly Node[]): string => - `version 2\n${nodes.map((current) => render({ node: current, depth: 0 })).join('')}` +export { emit, fractalKdl, node } from './kdl.ts' +export type { KdlFileOutput, KdlValidationIssue, Node, Value } from './kdl.ts' const child = ({ name, value }: { readonly name: string; readonly value: Value }): Node => node({ name, args: [value] }) @@ -2580,44 +2510,3 @@ export const agent = (input: typeof AgentSchema.Encoded): Node => { for (const task of a.exec ?? []) children.push(taskNode({ kind: 'exec', task })) return node({ name: 'agent', args: [a.id], children }) } - -/** One validation finding, in the shape Genie's `GenieValidationIssue` expects. */ -export interface KdlValidationIssue { - readonly severity: 'error' - readonly packageName: string - readonly dependency: string - readonly rule: string - readonly message: string -} - -/** - * A generated KDL declaration file. The shape is Genie's `GenieOutput` contract, declared - * structurally so a Genie file can return it without this package depending on Genie. - */ -export interface KdlFileOutput { - readonly data: readonly Node[] - readonly stringify: () => string - readonly validate: () => KdlValidationIssue[] -} - -/** Genie output for a Fractal KDL declaration file. */ -export const fractalKdl = (nodes: readonly Node[]): KdlFileOutput => ({ - data: nodes, - stringify: () => emit(nodes), - validate: () => { - try { - emit(nodes) - return [] - } catch (cause) { - return [ - { - severity: 'error', - packageName: 'fractal', - dependency: '', - rule: 'fractal-kdl', - message: cause instanceof Error ? cause.message : String(cause), - }, - ] - } - }, -}) diff --git a/packages/fractal/src/objective.ts b/packages/fractal/src/objective.ts new file mode 100644 index 00000000000..dce58f583a3 --- /dev/null +++ b/packages/fractal/src/objective.ts @@ -0,0 +1,651 @@ +/** + * Objectives (docs/st3/objectives.md): an indicator that measures samples, the targets that bound + * them, the window an interval is judged over and who a miss is delivered to. `objective` checks + * the rules the native reader (`fractal_kernel::objective`) enforces, so a declaration fails where + * it is written; `objectiveNode` lowers it to the `objective` KDL node. + * + * This module imports only `./kdl.ts`: `slo/objectives.kdl.genie.ts` renders the objectives + * Fractal ships with it, and Genie bundles it without the package's dependencies. + */ +import { node, type Node, type Value } from './kdl.ts' +import type { AgentRef } from './subject-ref.ts' + +/** A native duration: an integer with `ms`, `s`, `m`, `h` or `d` (`300ms`, `5m`, `7d`). */ +export type Duration = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` + +/** A claim that marks a moment of a sample; `at` names the field holding its time. */ +export interface ClaimEvent { + readonly kind: string + /** A field with an RFC 3339 time or Unix milliseconds; without it, the claim's accepted time. */ + readonly at?: string +} + +/** Work that starts and stops in claims, one sample per subject under `subjectPrefix`. */ +export interface Interval { + readonly subjectPrefix: string + readonly start: ClaimEvent + /** The members a sample must stop on: a list of strings in field `set` of the newest claim. */ + readonly expect?: ClaimEvent & { readonly set: string } + /** One member stopping, named by field `member` (required with `expect`). */ + readonly stop: ClaimEvent & { readonly member?: string } + readonly cancel?: string + readonly pause?: { readonly kind: string; readonly resume: string } + /** Fields of the start or expect claim, or `member`, that split the rows. */ + readonly labels?: readonly string[] + /** Leave out a sample whose start or expect claim has one of `values` in `field`. */ + readonly exclude?: readonly { readonly field: string; readonly values: readonly string[] }[] +} + +/** How a sample is measured; `kind` is the KDL indicator name. */ +export type Indicator = + | { readonly kind: 'latency'; readonly paths: readonly string[] } + | { readonly kind: 'share'; readonly path: string } + | { readonly kind: 'statement' } + | { readonly kind: 'transaction' } + | { readonly kind: 'writer-hold' } + | { readonly kind: 'cpu'; readonly idleMinMinutes: number; readonly loadMaxCores: number } + | { readonly kind: 'database' } + | { readonly kind: 'divergence' } + | { readonly kind: 'ci-duration'; readonly series: string } + | { readonly kind: 'ci-count'; readonly series: string } + | ({ readonly kind: 'interval' } & Interval) + +type IndicatorOf = Extract + +/** One statistic and its bound; `scope: 'remote'` is a latency target for reads served remotely. */ +export type Target = { readonly stat: string; readonly scope?: 'remote' } & ( + | { readonly within: Duration; readonly max?: never; readonly minPercent?: never } + | { readonly max: number; readonly within?: never; readonly minPercent?: never } + | { readonly minPercent: number; readonly within?: never; readonly max?: never } +) + +/** The span an interval's budget is measured over, and the samples it needs to be met or missed. */ +export interface Window { + readonly span: Duration + readonly minSamples?: number +} + +/** An agent: a typed reference to an `agent.ts` module, or its `agent/ID` subject. */ +export type AgentTarget = AgentRef | `agent/${string}` + +/** A miss opens a fault for `owner`; `backup` is held, recorded and never the reviewer. */ +export interface Fault { + readonly kind: 'fault' + readonly owner: AgentTarget + readonly backup?: AgentTarget + readonly condition: string +} + +/** An objective's fields; `name` is absent in a subject tree, which derives it from the path. */ +export interface ObjectiveInput { + readonly name?: string + readonly about: string + readonly indicator: Indicator + readonly window?: Window + readonly targets: readonly Target[] + readonly onBreach?: Fault +} + +/** A checked objective: exactly the fields of its input, frozen. */ +export type Objective = ObjectiveInput + +const freeze = (value: T): T => Object.freeze(value) + +/** The indicators an objective measures with. */ +export const indicator = { + /** Work that starts and stops in claims (the one kind a fleet declares). */ + interval: (input: Interval): IndicatorOf<'interval'> => freeze({ kind: 'interval', ...input }), + /** Daemon routes, stream first snapshots, publications or phone reports, by `METHOD /route`. */ + latency: ({ paths }: { readonly paths: readonly string[] }): IndicatorOf<'latency'> => + freeze({ kind: 'latency', paths }), + /** A phone's live foreground time over its foreground time. */ + share: ({ path }: { readonly path: string }): IndicatorOf<'share'> => freeze({ kind: 'share', path }), + /** One SQL statement. */ + statement: (): IndicatorOf<'statement'> => freeze({ kind: 'statement' }), + /** Read and write transactions and writer holds. */ + transaction: (): IndicatorOf<'transaction'> => freeze({ kind: 'transaction' }), + /** Each writer hold and long read, by holder. */ + writerHold: (): IndicatorOf<'writer-hold'> => freeze({ kind: 'writer-hold' }), + /** The member's daemon and replication workers over closed UTC minutes. */ + cpu: ({ + idleMinMinutes, + loadMaxCores, + }: { + readonly idleMinMinutes: number + readonly loadMaxCores: number + }): IndicatorOf<'cpu'> => freeze({ kind: 'cpu', idleMinMinutes, loadMaxCores }), + /** The store's pages in use and their daily growth. */ + database: (): IndicatorOf<'database'> => freeze({ kind: 'database' }), + /** Open projection divergence faults. */ + divergence: (): IndicatorOf<'divergence'> => freeze({ kind: 'divergence' }), + /** A percentile of a CI series' durations, `ci/NAME`. */ + ciDuration: ({ series }: { readonly series: string }): IndicatorOf<'ci-duration'> => + freeze({ kind: 'ci-duration', series }), + /** Events in a CI series per hour. */ + ciCount: ({ series }: { readonly series: string }): IndicatorOf<'ci-count'> => + freeze({ kind: 'ci-count', series }), +} + +/** What a missed row delivers. */ +export const response = { + /** One `operational.failure` for `owner` per episode, with `condition`. */ + fault: (input: Omit): Fault => freeze({ kind: 'fault', ...input }), +} + +/** Lowercase words joined by `-`, as a name segment and a fault condition are. */ +export const validWord = (word: string): boolean => + /^[a-z][a-z0-9-]*$/u.test(word) && word.endsWith('-') === false && word.includes('--') === false + +/** One or more words joined by `/`: `cpu`, `ops/change-rollout`. */ +export const validName = (name: string): boolean => + name.length <= 128 && name.split('/').every(validWord) + +const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const + +/** A native duration in milliseconds; bare integers are seconds, as the native reader reads them. */ +const durationMs = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): number => { + const match = typeof value === 'string' ? /^([0-9]+)(ms|s|m|h|d)?$/u.exec(value) : null + if (match === null) return fail(`invalid duration \`${String(value)}\``) + const ms = Number(match[1]) * units[(match[2] ?? 's') as keyof typeof units] + if (Number.isSafeInteger(ms) === false) return fail('duration is too large') + if (ms === 0) return fail('a required duration must be positive') + return ms +} + +type Fail = (message: string) => never +type Fields = Readonly> + +const isRecord = (value: unknown): value is Fields => + typeof value === 'object' && value !== null && Array.isArray(value) === false + +const isField = (value: unknown): value is string => + typeof value === 'string' && /^[A-Za-z0-9_-]+$/u.test(value) + +const isAgentRef = (value: unknown): value is AgentRef => + isRecord(value) && value._tag === 'SubjectRef' && value.kind === 'agent' + +const isCiSeries = (series: string): boolean => + /^ci\/[a-z0-9-]+(\/[a-z0-9-]+)*$/u.test(series) + +/** Refuses fields other than `allowed`; an `undefined` field counts as absent. */ +const only = ({ + value, + allowed, + what, + fail, +}: { + readonly value: Fields + readonly allowed: readonly string[] + readonly what: string + readonly fail: Fail +}): void => { + const unknown = Object.keys(value).find( + (key) => value[key] !== undefined && allowed.includes(key) === false, + ) + if (unknown !== undefined) fail(`${what} has an unknown field \`${unknown}\``) +} + +const percentile = (stat: string): number | undefined => + /^p[1-9][0-9]?$/u.test(stat) ? Number(stat.slice(1)) : undefined + +const event = ({ + value, + name, + extra, + fail, +}: { + readonly value: unknown + readonly name: string + readonly extra: readonly string[] + readonly fail: Fail +}): Fields & ClaimEvent => { + if (isRecord(value) === false) return fail(`\`${name}\` needs a claim \`kind\``) + only({ value, allowed: ['kind', 'at', ...extra], what: `\`${name}\``, fail }) + if (typeof value.kind !== 'string' || value.kind === '') fail(`\`${name}\` names no claim kind`) + if (value.at !== undefined && isField(value.at) === false) fail('`at` is not a field name') + return value as Fields & ClaimEvent +} + +const parseInterval = ({ value, fail }: { readonly value: Fields; readonly fail: Fail }): IndicatorOf<'interval'> => { + only({ + value, + allowed: ['kind', 'subjectPrefix', 'start', 'expect', 'stop', 'cancel', 'pause', 'labels', 'exclude'], + what: 'indicator `interval`', + fail, + }) + const prefix = value.subjectPrefix + if (prefix === undefined) fail('an interval needs `subjectPrefix`') + if ( + typeof prefix !== 'string' || + prefix.endsWith('/') === false || + prefix.length < 2 || + prefix.startsWith('/') + ) { + fail(`subjectPrefix ${JSON.stringify(prefix)} must name subjects and end with \`/\``) + } + if (value.start === undefined) fail('an interval needs `start`') + const start = event({ value: value.start, name: 'start', extra: [], fail }) + const expect = + value.expect === undefined + ? undefined + : event({ value: value.expect, name: 'expect', extra: ['set'], fail }) + if (expect !== undefined && isField(expect.set) === false) fail('`expect` needs `set` naming a field') + if (value.stop === undefined) fail('an interval needs `stop`') + const stop = event({ value: value.stop, name: 'stop', extra: ['member'], fail }) + if (stop.member !== undefined && isField(stop.member) === false) fail('`member` is not a field name') + if (value.cancel !== undefined && typeof value.cancel !== 'string') fail('`cancel` needs one string') + const pause = value.pause + if (pause !== undefined) { + if (isRecord(pause) === false || typeof pause.kind !== 'string') return fail('`pause` needs one string') + only({ value: pause, allowed: ['kind', 'resume'], what: '`pause`', fail }) + if (typeof pause.resume !== 'string' || pause.resume === '') fail('`pause` needs `resume`') + } + const labels = value.labels ?? [] + if (Array.isArray(labels) === false) return fail('`labels` is a list of fields') + labels.forEach((label: unknown, index) => { + if (isField(label) === false || labels.indexOf(label) !== index) { + fail(`label ${JSON.stringify(label)} is not a field, or is repeated`) + } + }) + const excludes = value.exclude ?? [] + if (Array.isArray(excludes) === false) return fail('`exclude` is a list') + for (const exclude of excludes as unknown[]) { + if ( + isRecord(exclude) === false || + isField(exclude.field) === false || + Array.isArray(exclude.values) === false || + exclude.values.length === 0 || + exclude.values.some((v: unknown) => typeof v !== 'string' || v === '') + ) { + fail('`exclude` needs a field and at least one value') + } + only({ value: exclude as Fields, allowed: ['field', 'values'], what: '`exclude`', fail }) + } + if (expect !== undefined && stop.member === undefined) fail('with `expect`, `stop` needs `member`') + if (labels.includes('member') && stop.member === undefined) { + fail('`label "member"` needs `stop.member`') + } + // Every field was checked above. + const interval = value as unknown as IndicatorOf<'interval'> + const kinds = [ + interval.start.kind, + ...(interval.expect === undefined ? [] : [interval.expect.kind]), + interval.stop.kind, + ...(interval.cancel === undefined ? [] : [interval.cancel]), + ...(interval.pause === undefined ? [] : [interval.pause.kind, interval.pause.resume]), + ] + const repeated = kinds.find((kind, index) => kinds.indexOf(kind) !== index) + if (repeated !== undefined) fail(`claim kind ${JSON.stringify(repeated)} has two roles`) + const eventOf = (e: ClaimEvent) => ({ kind: e.kind, ...(e.at === undefined ? {} : { at: e.at }) }) + return freeze({ + kind: 'interval', + subjectPrefix: interval.subjectPrefix, + start: freeze(eventOf(interval.start)), + ...(interval.expect === undefined + ? {} + : { expect: freeze({ ...eventOf(interval.expect), set: interval.expect.set }) }), + stop: freeze({ + ...eventOf(interval.stop), + ...(interval.stop.member === undefined ? {} : { member: interval.stop.member }), + }), + ...(interval.cancel === undefined ? {} : { cancel: interval.cancel }), + ...(interval.pause === undefined + ? {} + : { pause: freeze({ kind: interval.pause.kind, resume: interval.pause.resume }) }), + ...(interval.labels === undefined ? {} : { labels: freeze([...interval.labels]) }), + ...(interval.exclude === undefined + ? {} + : { + exclude: freeze( + interval.exclude.map(({ field, values }) => freeze({ field, values: freeze([...values]) })), + ), + }), + }) +} + +const parseIndicator = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): Indicator => { + if (isRecord(value) === false || typeof value.kind !== 'string') return fail('`indicator` needs a `kind`') + const kind = value.kind + const allow = (fields: readonly string[]) => + only({ value, allowed: ['kind', ...fields], what: `indicator \`${kind}\``, fail }) + switch (kind) { + case 'latency': { + allow(['paths']) + const paths = value.paths + if (Array.isArray(paths) === false || paths.length === 0) return fail('a latency indicator needs a `path`') + if (paths.some((path: unknown) => typeof path !== 'string')) return fail('`path` needs one string') + return freeze({ kind, paths: freeze([...(paths as string[])]) }) + } + case 'share': + allow(['path']) + if (typeof value.path !== 'string') return fail('indicator `share` needs one `path`') + return freeze({ kind, path: value.path }) + case 'statement': + case 'transaction': + case 'writer-hold': + case 'database': + case 'divergence': + allow([]) + return freeze({ kind }) + case 'cpu': { + allow(['idleMinMinutes', 'loadMaxCores']) + const { idleMinMinutes, loadMaxCores } = value + if (typeof idleMinMinutes !== 'number' || Number.isSafeInteger(idleMinMinutes) === false || idleMinMinutes <= 0) { + return fail('idleMinMinutes is not a positive integer') + } + if (typeof loadMaxCores !== 'number' || Number.isFinite(loadMaxCores) === false) { + return fail('loadMaxCores is not a number') + } + if (loadMaxCores <= 0) return fail('loadMaxCores is not positive') + return freeze({ kind, idleMinMinutes, loadMaxCores }) + } + case 'ci-duration': + case 'ci-count': { + allow(['series']) + const series = value.series + if (typeof series !== 'string') return fail(`indicator \`${kind}\` needs one \`series\``) + if (isCiSeries(series) === false) return fail(`series ${JSON.stringify(series)} is not \`ci/\` and lowercase words`) + return freeze({ kind, series }) + } + case 'interval': + return parseInterval({ value, fail }) + default: + return fail(`has an unknown indicator \`${kind}\``) + } +} + +/** A target with its bound in milliseconds or as a number. */ +type CheckedTarget = { readonly target: Target; readonly ms?: number; readonly number?: number } + +const parseTarget = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): CheckedTarget => { + if (isRecord(value) === false || typeof value.stat !== 'string') return fail('a target needs a `stat`') + only({ value, allowed: ['stat', 'within', 'max', 'minPercent', 'scope'], what: '`target`', fail }) + const stat = value.stat + const bounds = (['within', 'max', 'minPercent'] as const).filter((name) => value[name] !== undefined) + if (bounds.length !== 1) return fail(`target \`${stat}\` needs one bound: \`within\`, \`max\` or \`minPercent\``) + if (value.scope !== undefined && value.scope !== 'remote') { + return fail(`target \`${stat}\` has an unknown scope ${JSON.stringify(value.scope)}`) + } + const scope = value.scope === undefined ? {} : { scope: 'remote' as const } + const bound = bounds[0]! + const raw = value[bound] + if (bound === 'within') { + if (typeof raw !== 'string') return fail(`target \`${stat}\`'s \`within\` is not a duration`) + const ms = durationMs({ value: raw, fail }) + return { target: freeze({ stat, within: raw as Duration, ...scope }), ms } + } + if (typeof raw !== 'number' || Number.isFinite(raw) === false) { + return fail(`target \`${stat}\`'s \`${bound}\` is not a number`) + } + if (bound === 'minPercent') { + if ((raw > 0 && raw <= 100) === false) return fail(`target \`${stat}\`'s \`minPercent\` is not between 0 and 100`) + return { target: freeze({ stat, minPercent: raw, ...scope }), number: raw } + } + return { target: freeze({ stat, max: raw, ...scope }), number: raw } +} + +const parseAgent = ({ value, field, fail }: { readonly value: unknown; readonly field: string; readonly fail: Fail }): AgentTarget => { + if (isAgentRef(value)) return value + if (typeof value === 'string' && value.startsWith('agent/') && value.length > 'agent/'.length) { + return value as AgentTarget + } + return fail(`\`onBreach\`'s \`${field}\` is not an agent: "agent/..." or an agent.ts reference`) +} + +const sameAgent = (a: AgentTarget, b: AgentTarget): boolean => + typeof a === 'string' || typeof b === 'string' ? a === b : a.url === b.url + +const parseBreach = ({ value, fail }: { readonly value: unknown; readonly fail: Fail }): Fault => { + if (isRecord(value) === false || value.kind !== 'fault') return fail('`onBreach` is `fault`, the only response') + only({ value, allowed: ['kind', 'owner', 'backup', 'condition'], what: '`onBreach`', fail }) + const owner = parseAgent({ value: value.owner, field: 'owner', fail }) + const backup = value.backup === undefined ? undefined : parseAgent({ value: value.backup, field: 'backup', fail }) + if (backup !== undefined && sameAgent(owner, backup)) { + return fail("`onBreach`'s `backup` is an agent other than the owner") + } + if (typeof value.condition !== 'string' || validWord(value.condition) === false) { + return fail('`onBreach` needs `condition`, lowercase words joined by `-`') + } + return freeze({ kind: 'fault', owner, ...(backup === undefined ? {} : { backup }), condition: value.condition }) +} + +/** Each stat an indicator reports, and the bound it takes. */ +const statBound = ({ indicator, checked }: { readonly indicator: Indicator; readonly checked: CheckedTarget }): boolean => { + const { target, ms, number } = checked + const within = ms !== undefined + const max = ({ integer, zero }: { readonly integer: boolean; readonly zero: boolean }) => + 'max' in target && + number !== undefined && + (number > 0 || (zero && number === 0)) && + (integer === false || Number.isInteger(number)) + switch (indicator.kind) { + case 'latency': + return target.stat === 'p99' && within + case 'share': + return target.stat === 'live-share' && 'minPercent' in target + case 'statement': + return (target.stat === 'p99' || target.stat === 'max') && within + case 'transaction': + case 'writer-hold': + return target.stat === 'max' && within + case 'cpu': + return (target.stat === 'cores' || target.stat === 'idle-cores') && max({ integer: false, zero: false }) + case 'database': + return (target.stat === 'live-gb' || target.stat === 'growth-mb-per-day') && max({ integer: false, zero: false }) + case 'divergence': + return target.stat === 'open' && max({ integer: true, zero: true }) + case 'ci-count': + return target.stat === 'per-hour' && max({ integer: true, zero: true }) + case 'ci-duration': + case 'interval': + return percentile(target.stat) !== undefined && within + } +} + +const requiredStats: Readonly> = { + latency: ['p99'], + share: ['live-share'], + statement: ['p99', 'max'], + transaction: ['max'], + 'writer-hold': ['max'], + cpu: ['cores', 'idle-cores'], + database: ['live-gb', 'growth-mb-per-day'], + divergence: ['open'], + 'ci-duration': [], + 'ci-count': ['per-hour'], + interval: [], +} + +const checkTargets = ({ + indicator, + targets, + hasWindow, + hasBreach, + fail, +}: { + readonly indicator: Indicator + readonly targets: readonly CheckedTarget[] + readonly hasWindow: boolean + readonly hasBreach: boolean + readonly fail: Fail +}): void => { + const local = (stat: string) => targets.find(({ target }) => target.stat === stat && target.scope === undefined) + targets.forEach(({ target }, index) => { + if (targets.findIndex((other) => other.target.stat === target.stat && other.target.scope === target.scope) !== index) { + fail(`repeats target \`${target.stat}\``) + } + if (target.scope === 'remote' && indicator.kind !== 'latency') fail('only a latency target has a remote scope') + }) + const wrong = targets.find((checked) => statBound({ indicator, checked }) === false) + if (wrong !== undefined) fail(`a \`${indicator.kind}\` indicator has no target \`${wrong.target.stat}\` with that bound`) + const missing = requiredStats[indicator.kind].find((stat) => local(stat) === undefined) + if (missing !== undefined) fail(`a \`${indicator.kind}\` indicator needs target \`${missing}\``) + if (targets.length === 0) fail('has no `target`') + const remote = targets.find(({ target }) => target.stat === 'p99' && target.scope === 'remote') + if (remote !== undefined && remote.ms! < (local('p99')?.ms ?? 0)) fail('its remote latency is below its own') + if (indicator.kind === 'statement' && local('max')!.ms! < local('p99')!.ms!) fail('its max is below its p99') + if (indicator.kind === 'cpu' && local('idle-cores')!.number! > local('cores')!.number!) { + fail("its idle CPU target is above the member's") + } + if (indicator.kind === 'cpu' && indicator.idleMinMinutes > 60) { + fail('its idle minimum is not between 1 and 60 minutes') + } + if (indicator.kind === 'interval' && hasWindow === false) fail('an interval needs a `window`') + if (indicator.kind !== 'interval' && hasWindow) { + fail('only an interval takes a `window`; native telemetry keeps its own') + } + if (indicator.kind !== 'interval' && hasBreach) { + fail('only an interval takes `onBreach`; native objectives respond as they are built') + } +} + +/** + * Checks an objective by the native reader's rules and returns it frozen. A missing, unknown, + * repeated or nonsensical field throws a `TypeError`, never a default. `name` may be absent only + * where the subject tree derives it from the module's path. + */ +export const objective = (input: I): Objective & Pick => { + if (isRecord(input) === false) throw new TypeError('an objective is an object of fields') + const name: unknown = input.name + if (name !== undefined && (typeof name !== 'string' || validName(name) === false)) { + throw new TypeError('an objective has one name: lowercase words joined by `-`, segments by `/`') + } + const fail: Fail = (message) => { + throw new TypeError(name === undefined ? `objective: ${message}` : `objective \`${name}\`: ${message}`) + } + only({ value: input, allowed: ['name', 'about', 'indicator', 'window', 'targets', 'onBreach'], what: 'an objective', fail }) + if (input.about === undefined) fail('has no `about`') + if (typeof input.about !== 'string') fail('`about` needs one string') + if (input.about.trim() === '') fail('`about` is empty') + if (input.indicator === undefined) fail('has no `indicator`') + const indicator = parseIndicator({ value: input.indicator, fail }) + const window: unknown = input.window + if (window !== undefined) { + if (isRecord(window) === false) fail('`window` needs a `span`') + only({ value: window as Fields, allowed: ['span', 'minSamples'], what: '`window`', fail }) + durationMs({ value: input.window!.span, fail }) + const minSamples: unknown = input.window!.minSamples + if (minSamples !== undefined && (typeof minSamples !== 'number' || Number.isSafeInteger(minSamples) === false || minSamples <= 0)) { + fail('minSamples is not a positive integer') + } + } + if (Array.isArray(input.targets) === false) fail('has no `target`') + const targets = input.targets.map((value) => parseTarget({ value, fail })) + const onBreach = input.onBreach === undefined ? undefined : parseBreach({ value: input.onBreach, fail }) + checkTargets({ indicator, targets, hasWindow: window !== undefined, hasBreach: onBreach !== undefined, fail }) + return freeze({ + ...(name === undefined ? {} : { name: name as string }), + about: input.about, + indicator, + ...(input.window === undefined + ? {} + : { + window: freeze({ + span: input.window.span, + ...(input.window.minSamples === undefined ? {} : { minSamples: input.window.minSamples }), + }), + }), + targets: freeze(targets.map(({ target }) => target)), + ...(onBreach === undefined ? {} : { onBreach }), + }) +} + +const agentSubject = ({ agent, name }: { readonly agent: AgentTarget; readonly name: string }): string => { + if (typeof agent === 'string') return agent + throw new TypeError( + `objective \`${name}\`: an agent reference lowers through loadSubjectTree, which resolves its path`, + ) +} + +const intervalChildren = (interval: Interval): Node[] => { + const at = (e: ClaimEvent): Record => (e.at === undefined ? {} : { at: e.at }) + return [ + node({ name: 'subject-prefix', args: [interval.subjectPrefix] }), + node({ name: 'start', args: [interval.start.kind], props: at(interval.start) }), + ...(interval.expect === undefined + ? [] + : [node({ name: 'expect', args: [interval.expect.kind], props: { ...at(interval.expect), set: interval.expect.set } })]), + node({ + name: 'stop', + args: [interval.stop.kind], + props: { ...at(interval.stop), ...(interval.stop.member === undefined ? {} : { member: interval.stop.member }) }, + }), + ...(interval.cancel === undefined ? [] : [node({ name: 'cancel', args: [interval.cancel] })]), + ...(interval.pause === undefined + ? [] + : [node({ name: 'pause', args: [interval.pause.kind], props: { resume: interval.pause.resume } })]), + ...(interval.labels ?? []).map((label) => node({ name: 'label', args: [label] })), + ...(interval.exclude ?? []).map(({ field, values }) => node({ name: 'exclude', args: [field, ...values] })), + ] +} + +const indicatorChildren = (indicator: Indicator): Node[] => { + switch (indicator.kind) { + case 'latency': + return indicator.paths.map((path) => node({ name: 'path', args: [path] })) + case 'share': + return [node({ name: 'path', args: [indicator.path] })] + case 'cpu': + return [ + node({ name: 'idle-min-minutes', args: [indicator.idleMinMinutes] }), + node({ name: 'load-max-cores', args: [indicator.loadMaxCores] }), + ] + case 'ci-duration': + case 'ci-count': + return [node({ name: 'series', args: [indicator.series] })] + case 'interval': + return intervalChildren(indicator) + case 'statement': + case 'transaction': + case 'writer-hold': + case 'database': + case 'divergence': + return [] + } +} + +const targetProps = (target: Target): Record => ({ + ...(target.within === undefined ? {} : { within: target.within }), + ...(target.max === undefined ? {} : { max: target.max }), + ...(target.minPercent === undefined ? {} : { 'min-percent': target.minPercent }), + ...(target.scope === undefined ? {} : { scope: target.scope }), +}) + +/** Checks a named objective and lowers it to its `objective` KDL node. */ +export const objectiveNode = (input: ObjectiveInput & { readonly name: string }): Node => { + const o = objective(input) + const name = o.name + if (name === undefined) throw new TypeError('objective: lowering needs a `name`') + const kids = indicatorChildren(o.indicator) + return node({ + name: 'objective', + args: [name], + children: [ + node({ name: 'about', args: [o.about] }), + node({ name: 'indicator', args: [o.indicator.kind], ...(kids.length === 0 ? {} : { children: kids }) }), + ...(o.window === undefined + ? [] + : [ + node({ + name: 'window', + args: [o.window.span], + props: o.window.minSamples === undefined ? {} : { 'min-samples': o.window.minSamples }, + }), + ]), + ...o.targets.map((target) => node({ name: 'target', args: [target.stat], props: targetProps(target) })), + ...(o.onBreach === undefined + ? [] + : [ + node({ + name: 'on-breach', + args: ['fault'], + props: { + owner: agentSubject({ agent: o.onBreach.owner, name }), + ...(o.onBreach.backup === undefined ? {} : { backup: agentSubject({ agent: o.onBreach.backup, name }) }), + condition: o.onBreach.condition, + }, + }), + ]), + ], + }) +} diff --git a/packages/fractal/src/objective.unit.test.ts b/packages/fractal/src/objective.unit.test.ts new file mode 100644 index 00000000000..4cc770ef54a --- /dev/null +++ b/packages/fractal/src/objective.unit.test.ts @@ -0,0 +1,416 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { NodeFileSystem } from '@effect/platform-node' +import { describe, expect, it } from '@effect/vitest' +import { Effect } from 'effect' + +import { Fractal } from './index.ts' +import { emit, type KdlFileOutput } from './kdl.ts' +import { objectiveNode, type Objective, type ObjectiveInput } from './objective.ts' +import { loadSubjectTree } from './tree.ts' + +/** + * The Genie source lies outside this package's ESM `tsconfig` scope, so it is imported at runtime; + * its exports are the `fractalKdl` output and the checked objectives it lowers. + */ +const genie = (await import(new URL('../../../slo/objectives.kdl.genie.ts', import.meta.url).href)) as { + readonly default: KdlFileOutput + readonly objectives: readonly (Objective & { readonly name: string })[] +} +const builtins = genie.default +const objectives = genie.objectives + +const render = (input: ObjectiveInput & { readonly name: string }) => emit([objectiveNode(input)]) + +const rollout = { + name: 'ops/change-rollout', + about: 'A change, from its author marking it ready to every member that serves it', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready', at: 'ready_at' }, + expect: { kind: 'custom.acme.change-merged', set: 'hosts', at: 'merged_at' }, + stop: { kind: 'custom.acme.change-applied', member: 'host', at: 'applied_at' }, + cancel: 'custom.acme.change-abandoned', + pause: { kind: 'custom.acme.change-held', resume: 'custom.acme.change-released' }, + labels: ['class'], + exclude: [{ field: 'exclusion', values: ['deferred-launch', 'deferred-runtime'] }], + }), + window: { span: '7d', minSamples: 20 }, + targets: [ + { stat: 'p50', within: '60s' }, + { stat: 'p95', within: '5m' }, + ], + onBreach: Fractal.response.fault({ + owner: 'agent/ops/release-manager', + backup: 'agent/ops/observability', + condition: 'change-rollout-slo-missed', + }), +} satisfies ObjectiveInput + +describe('Fractal.objective lowering', () => { + it('lowers an interval over claims as docs/st3/objectives.md declares it', () => { + expect(render(rollout)).toBe(`version 2 +objective "ops/change-rollout" { + about "A change, from its author marking it ready to every member that serves it" + indicator "interval" { + subject-prefix "custom/acme/change/" + start "custom.acme.change-ready" at="ready_at" + expect "custom.acme.change-merged" at="merged_at" set="hosts" + stop "custom.acme.change-applied" at="applied_at" member="host" + cancel "custom.acme.change-abandoned" + pause "custom.acme.change-held" resume="custom.acme.change-released" + label "class" + exclude "exclusion" "deferred-launch" "deferred-runtime" + } + window "7d" min-samples=20 + target "p50" within="60s" + target "p95" within="5m" + on-breach "fault" backup="agent/ops/observability" condition="change-rollout-slo-missed" owner="agent/ops/release-manager" +} +`) + }) + + it('lowers a minimal interval: no expect, accepted times, no window minimum or response', () => { + expect( + render({ + name: 'ops/build', + about: 'A build', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/build/', + start: { kind: 'custom.acme.build-started' }, + stop: { kind: 'custom.acme.build-finished' }, + }), + window: { span: '1d' }, + targets: [{ stat: 'p99', within: '30m' }], + }), + ).toBe(`version 2 +objective "ops/build" { + about "A build" + indicator "interval" { + subject-prefix "custom/acme/build/" + start "custom.acme.build-started" + stop "custom.acme.build-finished" + } + window "1d" + target "p99" within="30m" +} +`) + }) + + const native: ReadonlyArray<{ + readonly indicator: ObjectiveInput['indicator'] + readonly targets: ObjectiveInput['targets'] + readonly kdl: string + }> = [ + { + indicator: Fractal.indicator.latency({ paths: ['GET /v1/client/agents', 'stream agents'] }), + targets: [ + { stat: 'p99', within: '300ms' }, + { stat: 'p99', within: '500ms', scope: 'remote' }, + ], + kdl: ' indicator "latency" {\n path "GET /v1/client/agents"\n path "stream agents"\n }\n target "p99" within="300ms"\n target "p99" scope="remote" within="500ms"\n', + }, + { + indicator: Fractal.indicator.share({ path: 'client/ios/live-share' }), + targets: [{ stat: 'live-share', minPercent: 99.5 }], + kdl: ' indicator "share" {\n path "client/ios/live-share"\n }\n target "live-share" min-percent=99.5\n', + }, + { + indicator: Fractal.indicator.statement(), + targets: [ + { stat: 'p99', within: '10ms' }, + { stat: 'max', within: '100ms' }, + ], + kdl: ' indicator "statement"\n target "p99" within="10ms"\n target "max" within="100ms"\n', + }, + { + indicator: Fractal.indicator.transaction(), + targets: [{ stat: 'max', within: '300ms' }], + kdl: ' indicator "transaction"\n target "max" within="300ms"\n', + }, + { + indicator: Fractal.indicator.writerHold(), + targets: [{ stat: 'max', within: '300ms' }], + kdl: ' indicator "writer-hold"\n target "max" within="300ms"\n', + }, + { + indicator: Fractal.indicator.cpu({ idleMinMinutes: 10, loadMaxCores: 2.5 }), + targets: [ + { stat: 'cores', max: 1 }, + { stat: 'idle-cores', max: 0.5 }, + ], + kdl: ' indicator "cpu" {\n idle-min-minutes 10\n load-max-cores 2.5\n }\n target "cores" max=1\n target "idle-cores" max=0.5\n', + }, + { + indicator: Fractal.indicator.database(), + targets: [ + { stat: 'live-gb', max: 8 }, + { stat: 'growth-mb-per-day', max: 300 }, + ], + kdl: ' indicator "database"\n target "live-gb" max=8\n target "growth-mb-per-day" max=300\n', + }, + { + indicator: Fractal.indicator.divergence(), + targets: [{ stat: 'open', max: 0 }], + kdl: ' indicator "divergence"\n target "open" max=0\n', + }, + { + indicator: Fractal.indicator.ciDuration({ series: 'ci/merge-queue' }), + targets: [{ stat: 'p50', within: '10m' }], + kdl: ' indicator "ci-duration" {\n series "ci/merge-queue"\n }\n target "p50" within="10m"\n', + }, + { + indicator: Fractal.indicator.ciCount({ series: 'ci/flakes' }), + targets: [{ stat: 'per-hour', max: 0 }], + kdl: ' indicator "ci-count" {\n series "ci/flakes"\n }\n target "per-hour" max=0\n', + }, + ] + it.each(native)('lowers indicator $indicator.kind', ({ indicator, targets, kdl }) => { + expect(render({ name: 'shipped', about: 'Measured', indicator, targets })).toBe( + `version 2\nobjective "shipped" {\n about "Measured"\n${kdl}}\n`, + ) + }) + + it('returns frozen, checked values and keeps an unnamed objective for the subject tree', () => { + const { name: _, ...unnamed } = rollout + const value = Fractal.objective(unnamed) + expect(Object.isFrozen(value)).toBe(true) + expect(value.name).toBeUndefined() + expect(() => objectiveNode(value as ObjectiveInput & { name: string })).toThrow(/needs a `name`/) + }) +}) + +describe('Fractal.objective validation', () => { + const base = rollout as ObjectiveInput + const interval = rollout.indicator + const latency = Fractal.indicator.latency({ paths: ['GET /v1/x'] }) + const cases: ReadonlyArray<{ readonly name: string; readonly input: unknown; readonly message: RegExp }> = [ + { name: 'a malformed name', input: { ...base, name: 'Ops/x' }, message: /lowercase words joined by `-`/ }, + { name: 'a name ending in a hyphen', input: { ...base, name: 'ops/x-' }, message: /lowercase words/ }, + { name: 'an unknown field', input: { ...base, owner: 'x' }, message: /unknown field `owner`/ }, + { name: 'no about', input: { ...base, about: undefined }, message: /has no `about`/ }, + { name: 'an empty about', input: { ...base, about: ' ' }, message: /`about` is empty/ }, + { name: 'an unknown indicator', input: { ...base, indicator: { kind: 'ratio' } }, message: /unknown indicator `ratio`/ }, + { name: 'an empty latency', input: { name: 'x', about: 'a', indicator: { kind: 'latency', paths: [] }, targets: [{ stat: 'p99', within: '1s' }] }, message: /needs a `path`/ }, + { name: 'an extra indicator field', input: { name: 'x', about: 'a', indicator: { kind: 'statement', path: 'p' }, targets: [] }, message: /indicator `statement` has an unknown field `path`/ }, + { name: 'a fractional idle minimum', input: { name: 'cpu', about: 'a', indicator: { kind: 'cpu', idleMinMinutes: 1.5, loadMaxCores: 1 }, targets: [] }, message: /idleMinMinutes is not a positive integer/ }, + { name: 'an idle minimum over an hour', input: { name: 'cpu', about: 'a', indicator: Fractal.indicator.cpu({ idleMinMinutes: 61, loadMaxCores: 1 }), targets: [{ stat: 'cores', max: 1 }, { stat: 'idle-cores', max: 0.5 }] }, message: /idle minimum is not between 1 and 60/ }, + { name: 'idle cores above cores', input: { name: 'cpu', about: 'a', indicator: Fractal.indicator.cpu({ idleMinMinutes: 10, loadMaxCores: 1 }), targets: [{ stat: 'cores', max: 1 }, { stat: 'idle-cores', max: 2 }] }, message: /idle CPU target is above/ }, + { name: 'a non-CI series', input: { name: 'x', about: 'a', indicator: { kind: 'ci-count', series: 'merge-queue' }, targets: [] }, message: /is not `ci\/` and lowercase words/ }, + { name: 'a relative subject prefix', input: { ...base, indicator: { ...interval, subjectPrefix: '/custom/' } }, message: /must name subjects and end with `\/`/ }, + { name: 'a prefix without a slash', input: { ...base, indicator: { ...interval, subjectPrefix: 'custom' } }, message: /must name subjects/ }, + { name: 'no stop', input: { ...base, indicator: { ...interval, stop: undefined } }, message: /an interval needs `stop`/ }, + { name: 'expect without stop member', input: { ...base, indicator: { ...interval, stop: { kind: 'custom.acme.change-applied' } } }, message: /with `expect`, `stop` needs `member`/ }, + { name: 'a label "member" without stop member', input: { ...base, indicator: { ...interval, expect: undefined, stop: { kind: 'k' }, labels: ['member'] } }, message: /`label "member"` needs `stop.member`/ }, + { name: 'a time field that is not a field', input: { ...base, indicator: { ...interval, start: { kind: 'k', at: 'ready at' } } }, message: /`at` is not a field name/ }, + { name: 'a repeated label', input: { ...base, indicator: { ...interval, labels: ['class', 'class'] } }, message: /label "class" is not a field, or is repeated/ }, + { name: 'an exclude without values', input: { ...base, indicator: { ...interval, exclude: [{ field: 'exclusion', values: [] }] } }, message: /needs a field and at least one value/ }, + { name: 'a pause without resume', input: { ...base, indicator: { ...interval, pause: { kind: 'held', resume: '' } } }, message: /`pause` needs `resume`/ }, + { name: 'a claim kind in two roles', input: { ...base, indicator: { ...interval, cancel: 'custom.acme.change-ready' } }, message: /claim kind "custom.acme.change-ready" has two roles/ }, + { name: 'an interval without a window', input: { ...base, window: undefined }, message: /an interval needs a `window`/ }, + { name: 'a window on native telemetry', input: { name: 'x', about: 'a', indicator: latency, window: { span: '1d' }, targets: [{ stat: 'p99', within: '1s' }] }, message: /only an interval takes a `window`/ }, + { name: 'a zero window', input: { ...base, window: { span: '0d' } }, message: /must be positive/ }, + { name: 'a malformed duration', input: { ...base, targets: [{ stat: 'p95', within: '5 minutes' }] }, message: /invalid duration `5 minutes`/ }, + { name: 'a zero minimum sample count', input: { ...base, window: { span: '1d', minSamples: 0 } }, message: /minSamples is not a positive integer/ }, + { name: 'two bounds', input: { ...base, targets: [{ stat: 'p95', within: '5m', max: 1 }] }, message: /needs one bound/ }, + { name: 'no target', input: { ...base, targets: [] }, message: /has no `target`/ }, + { name: 'a repeated target', input: { ...base, targets: [{ stat: 'p95', within: '5m' }, { stat: 'p95', within: '6m' }] }, message: /repeats target `p95`/ }, + { name: 'a non-percentile interval stat', input: { ...base, targets: [{ stat: 'p100', within: '5m' }] }, message: /no target `p100` with that bound/ }, + { name: 'a remote interval target', input: { ...base, targets: [{ stat: 'p95', within: '5m', scope: 'remote' }] }, message: /only a latency target has a remote scope/ }, + { name: 'an unknown scope', input: { ...base, targets: [{ stat: 'p95', within: '5m', scope: 'local' }] }, message: /unknown scope "local"/ }, + { name: 'a missing required stat', input: { name: 'sql', about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }] }, message: /needs target `max`/ }, + { name: 'a statement max below p99', input: { name: 'sql', about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }, { stat: 'max', within: '5ms' }] }, message: /max is below its p99/ }, + { name: 'a remote latency below local', input: { name: 'x', about: 'a', indicator: latency, targets: [{ stat: 'p99', within: '1s' }, { stat: 'p99', within: '500ms', scope: 'remote' }] }, message: /remote latency is below its own/ }, + { name: 'a fractional divergence max', input: { name: 'x', about: 'a', indicator: Fractal.indicator.divergence(), targets: [{ stat: 'open', max: 0.5 }] }, message: /no target `open` with that bound/ }, + { name: 'a min-percent over 100', input: { name: 'x', about: 'a', indicator: Fractal.indicator.share({ path: 'p' }), targets: [{ stat: 'live-share', minPercent: 101 }] }, message: /is not between 0 and 100/ }, + { name: 'a response on native telemetry', input: { name: 'x', about: 'a', indicator: latency, targets: [{ stat: 'p99', within: '1s' }], onBreach: rollout.onBreach }, message: /only an interval takes `onBreach`/ }, + { name: 'an owner that is not an agent', input: { ...base, onBreach: { ...rollout.onBreach, owner: 'person/ada' } }, message: /`owner` is not an agent/ }, + { name: 'a backup equal to the owner', input: { ...base, onBreach: { ...rollout.onBreach, backup: rollout.onBreach.owner } }, message: /`backup` is an agent other than the owner/ }, + { name: 'a malformed condition', input: { ...base, onBreach: { ...rollout.onBreach, condition: 'Missed' } }, message: /needs `condition`/ }, + { name: 'a response other than fault', input: { ...base, onBreach: { ...rollout.onBreach, kind: 'page' } }, message: /`onBreach` is `fault`/ }, + ] + it.each(cases)('refuses $name', ({ input, message }) => { + expect(() => Fractal.objective(input as ObjectiveInput)).toThrow(message) + }) + + it('names the objective in its errors', () => { + expect(() => Fractal.objective({ ...base, targets: [] })).toThrow( + 'objective `ops/change-rollout`: has no `target`', + ) + }) +}) + +describe('the objectives Fractal ships', () => { + it('render slo/objectives.kdl from slo/objectives.kdl.genie.ts', () => { + const committed = readFileSync(new URL('../../../slo/objectives.kdl', import.meta.url), 'utf8') + expect(committed).toBe( + `// Generated file - DO NOT EDIT\n// Source: objectives.kdl.genie.ts\n${builtins.stringify()}`, + ) + expect(builtins.validate()).toEqual([]) + }) + + it('are the 21 native objectives, one segment each, so none is declarable', () => { + expect(objectives.map(({ name }) => name)).toEqual([ + 'person-read', + 'person-read-fresh', + 'projection-fresh', + 'write-ack', + 'terminal-attach', + 'sql-statement', + 'transaction', + 'writer-hold', + 'cpu', + 'ios-open-to-live', + 'ios-connect', + 'ios-message-ack', + 'ios-conversation-open', + 'ios-terminal-open', + 'ios-recover', + 'ios-live-share', + 'projection-divergence', + 'database', + 'merge-latency', + 'ci-critical-path', + 'flakes', + ]) + expect(objectives.some(({ indicator, name }) => indicator.kind === 'interval' || name.includes('/'))).toBe( + false, + ) + }) +}) + +const treeModule = new URL('./tree.ts', import.meta.url).href +const indexModule = new URL('./index.ts', import.meta.url).href + +const fixture = (files: Readonly>): string => { + const root = mkdtempSync(join(tmpdir(), 'fractal-objective-')) + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), text) + } + return root +} + +const load = (root: string) => + loadSubjectTree({ root, context: { prefix: 'custom/acme/change/' } }).pipe(Effect.provide(NodeFileSystem.layer)) + +const declared = `{ + about: 'A change, from ready to applied everywhere', + indicator: Fractal.indicator.interval({ + subjectPrefix: 'custom/acme/change/', + start: { kind: 'custom.acme.change-ready' }, + stop: { kind: 'custom.acme.change-applied' }, + }), + window: { span: '7d' }, + targets: [{ stat: 'p95', within: '5m' }], +}` + +const header = `import { Fractal } from '${indexModule}'\nimport { defineAgent, defineObjective } from '${treeModule}'\n` + +describe('objective subject trees', () => { + it.effect('declare objective/ from objective.ts and objective.kdl', () => + Effect.gen(function* () { + const kdl = render({ ...rollout, name: 'ops/kdl' }) + const root = fixture({ + 'ops/release-manager/agent.ts': `${header}export default defineAgent({ meta: import.meta, intent: { workspace: '/srv/ops' } })`, + 'ops/change-rollout/objective.ts': `${header}import owner from '../release-manager/agent.ts' +export default Fractal.objective({ ...${declared}, onBreach: Fractal.response.fault({ owner, backup: 'agent/ops/observability', condition: 'change-rollout-slo-missed' }) })`, + 'ops/factory/objective.ts': `${header}export default defineObjective({ meta: import.meta, intent: (ctx: { prefix: string }) => ({ ...${declared}, name: 'ops/factory', indicator: Fractal.indicator.interval({ subjectPrefix: ctx.prefix, start: { kind: 'a' }, stop: { kind: 'b' } }) }) })`, + 'ops/kdl/objective.kdl': kdl, + }) + const subjects = (yield* load(root)).filter(({ kind }) => kind === 'objective') + expect(subjects.map(({ id, path, declaration }) => ({ id, path, tag: declaration._tag }))).toEqual([ + { id: 'ops/change-rollout', path: 'ops/change-rollout/objective.ts', tag: 'Module' }, + { id: 'ops/factory', path: 'ops/factory/objective.ts', tag: 'Module' }, + { id: 'ops/kdl', path: 'ops/kdl/objective.kdl', tag: 'Kdl' }, + ]) + const [rolloutSubject, factory, kdlSubject] = subjects + if (rolloutSubject?.kind !== 'objective' || rolloutSubject.declaration._tag !== 'Module') { + return yield* Effect.die('objective.ts did not load') + } + expect(rolloutSubject.declaration.intent.onBreach).toEqual({ + kind: 'fault', + owner: 'agent/ops/release-manager', + backup: 'agent/ops/observability', + condition: 'change-rollout-slo-missed', + }) + expect(emit([objectiveNode({ ...rolloutSubject.declaration.intent, name: rolloutSubject.id })])).toContain( + 'on-breach "fault" backup="agent/ops/observability" condition="change-rollout-slo-missed" owner="agent/ops/release-manager"', + ) + expect(factory?.declaration).toMatchObject({ + _tag: 'Module', + intent: { name: 'ops/factory', indicator: { subjectPrefix: 'custom/acme/change/' } }, + }) + expect(kdlSubject?.declaration).toEqual({ _tag: 'Kdl', text: kdl }) + }), + ) + + const rejections: ReadonlyArray<{ + readonly name: string + readonly files: Readonly> + readonly message: string + }> = [ + { + name: 'a name other than the path', + files: { 'ops/x/objective.ts': `${header}export default Fractal.objective({ ...${declared}, name: 'ops/y' })` }, + message: 'Declared name ops/y must equal the subject directory ops/x', + }, + { + name: 'native telemetry', + files: { + 'ops/x/objective.ts': `${header}export default Fractal.objective({ about: 'a', indicator: Fractal.indicator.statement(), targets: [{ stat: 'p99', within: '10ms' }, { stat: 'max', within: '100ms' }] })`, + }, + message: 'A declared objective is an interval over claims named NAMESPACE/NAME; objective/ops/x is not', + }, + { + name: 'a one-segment name', + files: { 'x/objective.ts': `${header}export default ${declared}` }, + message: 'objective/x is not', + }, + { + name: 'a path that is not an objective name', + files: { 'ops/Upper/objective.ts': `${header}export default ${declared}` }, + message: 'Invalid objective declaration', + }, + { + name: 'an invalid plain intent', + files: { 'ops/x/objective.ts': `${header}export default { ...${declared}, window: undefined }` }, + message: 'Invalid objective declaration', + }, + { + name: 'an owner reference to a mission', + files: { + 'ops/x/objective.ts': `${header}export default Fractal.objective({ ...${declared}, onBreach: { kind: 'fault', owner: { _tag: 'SubjectRef', kind: 'mission', url: new URL('../m/mission.ts', import.meta.url).href }, condition: 'missed' } })`, + }, + message: 'is not an agent', + }, + { + name: 'a KDL objective over native telemetry', + files: { + 'ops/x/objective.kdl': 'version 2\nobjective "ops/x" {\n about "a"\n indicator "transaction"\n target "max" within="1s"\n}\n', + }, + message: 'objective/ops/x is not', + }, + { + name: 'a KDL objective under another name', + files: { 'ops/x/objective.kdl': 'version 2\nobjective "ops/y" {\n}\n' }, + message: 'exactly one `objective "ops/x"` node', + }, + { + name: 'a KDL objective under a path that is not an objective name', + files: { + 'ops/Upper/objective.kdl': 'version 2\nobjective "ops/Upper" {\n indicator "interval"\n}\n', + }, + message: 'objective/ops/Upper is not', + }, + ] + it.effect.each(rejections)('rejects $name', ({ files, message }) => + Effect.gen(function* () { + const error = yield* load(fixture(files)).pipe(Effect.flip) + const cause = error.cause instanceof Error ? ` ${error.cause.message}` : '' + expect(`${error.message}${cause}`).toContain(message) + }), + ) +}) diff --git a/packages/fractal/src/program-bundle.unit.test.ts b/packages/fractal/src/program-bundle.unit.test.ts new file mode 100644 index 00000000000..becfee5df29 --- /dev/null +++ b/packages/fractal/src/program-bundle.unit.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { describe, expect, it } from '@effect/vitest' +import { Effect } from 'effect' + +import { ProgramEntry, type HostContextV1, type ProgramExit } from './program-engine.ts' +import { fakeHost, journalOf } from './program-host.fixture.ts' + +const landing = fileURLToPath( + new URL('../fixtures/programs/factory/land-program/mission.ts', import.meta.url), +) + +/** What `steps/factory.py ` writes for a routine change, by verb: result file and value. */ +const routine: Readonly> = { + classify: ['class', { change: 'abc', author: 'dev', classes: ['routine'], reasons: [] }], + check: ['check', { mode: 'declaration', errors: [] }], + merge: ['merged', { commit: 'c1', parent: 'p0', attempts: 1 }], + publish: ['publish', { host: 'local', head: 'c1', changed: ['mission/factory/x'] }], + verify: ['verify', { head: 'c1', stale: [] }], +} + +describe('a program bundle', () => { + it.effect( + 'built with bun --target=bun runs the landing program against a host', + () => + Effect.gen(function* () { + // Programs are Bun bundles; `nix develop .#web` puts bun on PATH. + const version = spawnSync('bun', ['--version'], { encoding: 'utf8' }) + expect(version.status, 'bun must be on PATH: run the tests in `nix develop .#web`').toBe(0) + const cwd = mkdtempSync(join(tmpdir(), 'fractal-program-')) + const bundle = join(cwd, 'land.mjs') + const build = spawnSync( + 'bun', + ['build', landing, '--target=bun', '--format=esm', `--outfile=${bundle}`], + { encoding: 'utf8' }, + ) + expect(build.status, build.stderr).toBe(0) + const module: { + readonly default: { + readonly [ProgramEntry]: (host: HostContextV1) => Promise + } + } = yield* Effect.promise(() => import(pathToFileURL(bundle).href)) + + const verbs: string[] = [] + const host = fakeHost({ + payload: { change: 'abc', reviewer: 'scripted' }, + cwd, + spawn: async (argv) => { + const verb = argv[2]! + verbs.push(verb) + const [result, value] = routine[verb]! + const dir = join(cwd, 'rundata', 'mission-run_run-1') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, `${result}.json`), JSON.stringify(value)) + return { code: 0, signal: null, stdout: '', stderr: '' } + }, + }) + const exit = { _tag: 'Success', value: { class: 'routine', merged: 'c1', head: 'c1' } } + expect(yield* Effect.promise(() => module.default[ProgramEntry](host.context))).toEqual( + exit, + ) + expect(verbs).toEqual(['classify', 'check', 'merge', 'publish', 'verify']) + expect(host.recorded.map((entry) => entry.key)).toEqual([ + 'classify', + 'check', + 'merge', + 'publish-local', + 'verify-local', + ]) + + const replay = fakeHost({ + payload: { change: 'abc', reviewer: 'scripted' }, + cwd, + journal: journalOf(host.recorded), + spawn: () => Promise.reject(new Error('a replayed step ran its command')), + }) + expect(yield* Effect.promise(() => module.default[ProgramEntry](replay.context))).toEqual( + exit, + ) + }), + 60_000, + ) +}) diff --git a/packages/fractal/src/program-engine.ts b/packages/fractal/src/program-engine.ts new file mode 100644 index 00000000000..6199e2e36ab --- /dev/null +++ b/packages/fractal/src/program-engine.ts @@ -0,0 +1,349 @@ +/** + * The bundle side of the program host contract (docs/st3/program-engine.md §2, §5, §11): the + * `fractal.program.v1` entry, the journal keys and the `WorkflowEngine` that runs one program + * attempt inside the daemon's program host. + * + * The engine replays from the journal the daemon hands over: a journaled step returns its recorded + * value, or fails again with its recorded typed error, without running. A new step runs, and the + * program continues only once the daemon has acknowledged its result. Typed failures are journaled + * with their `_tag` and fields; defects and interrupts are not, so a resumed attempt runs that step + * again. Asks and child runs wait in the host until the daemon resolves them; it journals their + * result under the same key. + */ +import { Cause, Context, Effect, Exit, Option, Schema, SchemaGetter, SchemaIssue } from 'effect' +import { Workflow, WorkflowEngine } from 'effect/workflow' + +/** An outcome as the host protocol carries it: a program's, or one journaled key's. */ +export type ProgramExit = + | { readonly _tag: 'Success'; readonly value: unknown } + | { readonly _tag: 'Failure'; readonly error: unknown } + +/** The key of the entry a bundle's default export carries: version 1 of the host contract. */ +export const ProgramEntry: unique symbol = Symbol.for('fractal.program.v1') + +export interface CommandOptions { + readonly env?: Readonly> + readonly cwd?: string + /** Background work runs at background priority; the default is the daemon's live priority. */ + readonly priority?: 'background' +} + +export interface CommandResult { + readonly code: number + readonly signal: string | null + readonly stdout: string + readonly stderr: string +} + +/** A step's result as the bundle hands it to the daemon to journal. */ +export interface JournalEntry { + readonly key: string + readonly status: 'completed' | 'failed' + readonly value?: unknown + readonly error?: unknown + readonly ms: number +} + +/** What the program host hands a bundle's entry for one attempt of one program step. */ +export interface HostContextV1 { + /** The program's step run subject. */ + readonly step: string + readonly attempt: number + /** The mission run subject. */ + readonly run: string + /** The run's native inputs, by name. */ + readonly payload: Readonly> + /** The run's `ST_*` variables. */ + readonly env: Readonly> + readonly cwd: string + /** This attempt's completed and failed results by key. */ + readonly journal: ReadonlyMap + /** Aborted when the daemon cancels the attempt. */ + readonly signal: AbortSignal + started(key: string): void + /** Resolves once the daemon has made the entry durable; rejects on nack or cancel. */ + record(entry: JournalEntry): Promise + /** + * Starts or adopts the linked child run for `key` (protocol message `run`); the daemon journals + * its end under `key`. + */ + child(request: { + readonly key: string + readonly mission: string + readonly input: Readonly> + }): Promise + /** Opens or adopts the person ask for `key` on the program step; journaled under `key`. */ + ask(request: { + readonly key: string + readonly for: string + readonly title: string + readonly request: unknown + readonly deadline?: string + readonly default?: 'approve' | 'reject' + }): Promise + /** Runs a command as its own process group, which the host ends on cancel. */ + spawn(argv: readonly string[], options?: CommandOptions): Promise +} + +/** One running program attempt: its host, the keys taken so far and its open asks and runs. */ +export class Program extends Context.Service< + Program, + { + readonly host: HostContextV1 + readonly occurrences: Map + /** By id: the fiber that uses it. */ + readonly owners: Map + readonly requests: Map Promise> + } +>()('fractal/Program') {} + +/** What a program body may require: the attempt and the workflow it runs as. */ +export type ProgramServices = + | Program + | WorkflowEngine.WorkflowEngine + | WorkflowEngine.WorkflowInstance + +/** + * Runs `use` under the next key of `id`: `id`, then `id#2`, `id#3`… in one execution. Only one + * fiber may use an id: repeats within a fiber (a loop, `Effect.retry`) take their keys in program + * order, while concurrent branches would take them in completion order, which replay does not + * reproduce, so a second fiber using the id is a defect. + */ +export const keyed = ( + id: string, + use: (key: string, program: Program['Service']) => Effect.Effect, +): Effect.Effect => + Effect.withFiber((fiber) => + Effect.flatMap(Effect.service(Program), (program): Effect.Effect => { + const owner = program.owners.get(id) + if (owner !== undefined && owner !== fiber.id) + return Effect.die( + new Error(`steps in concurrent branches share the id \`${id}\`; give each its own id`), + ) + program.owners.set(id, fiber.id) + const n = (program.occurrences.get(id) ?? 0) + 1 + program.occurrences.set(id, n) + return use(n === 1 ? id : `${id}#${n}`, program) + }), + ) + +/** A typed failure: an error with a string `_tag`. */ +export type Tagged = { readonly _tag: string } + +const isTagged = (u: unknown): u is Tagged => + typeof u === 'object' && u !== null && '_tag' in u && typeof u._tag === 'string' + +/** + * The error codec of steps and programs that declare none: a tagged error is journaled as its + * `_tag` plus its own fields and replays as that plain object, so `catchTag` matches; anything + * else fails to encode, which makes it a defect. + */ +export const TaggedFailure = Schema.Json.pipe( + Schema.decodeTo(Schema.Unknown, { + decode: SchemaGetter.transformEffect((json: Schema.Json) => + isTagged(json) + ? Effect.succeed(json) + : Effect.fail( + new SchemaIssue.InvalidValue({ message: 'a typed failure has a _tag' }, json), + ), + ), + encode: SchemaGetter.transformEffect((error: unknown) => + isTagged(error) + ? Effect.succeed(JSON.parse(JSON.stringify({ ...error, _tag: error._tag })) as Schema.Json) + : Effect.fail( + new SchemaIssue.InvalidValue({ message: 'an untagged error is a defect' }, error), + ), + ), + }), +) + +const toExit = (exit: ProgramExit): Exit.Exit => + exit._tag === 'Success' ? Exit.succeed(exit.value) : Exit.fail(exit.error) + +const notInPrograms = (what: string) => + Effect.die(new Error(`${what} is not available in a Fractal program`)) + +/** The engine of one program attempt; it runs the one workflow registered with it. */ +const hostEngine = ( + program: Program['Service'], + refuse: (key: string, cause: unknown) => void, +): WorkflowEngine.Encoded => { + const { host, requests } = program + /** Waits for the ack; a refused entry ends the attempt and never resumes the step. */ + const durable = (entry: JournalEntry) => + Effect.promise(() => + host.record(entry).catch((cause: unknown) => { + refuse(entry.key, cause) + return new Promise(() => {}) + }), + ) + const workflows = new Map< + string, + ( + payload: object, + executionId: string, + ) => Effect.Effect< + unknown, + unknown, + WorkflowEngine.WorkflowInstance | WorkflowEngine.WorkflowEngine + > + >() + return { + register: (workflow, execute) => Effect.sync(() => void workflows.set(workflow._tag, execute)), + execute: ( + workflow: Workflow.Any, + options: { + readonly executionId: string + readonly payload: object + readonly discard: Discard + }, + ) => { + const execute = workflows.get(workflow._tag) + if (execute === undefined || options.discard) return notInPrograms('Workflow.execute') + return execute(options.payload, options.executionId).pipe( + Workflow.intoResult, + Effect.provideService( + WorkflowEngine.WorkflowInstance, + WorkflowEngine.WorkflowInstance.initial(workflow, options.executionId), + ), + // Asks and child runs wait in the host, so nothing can resume a suspended program. + Effect.flatMap((result) => + result._tag === 'Suspended' + ? Effect.die(new Error('the program suspended on a wait the host cannot resume')) + : Effect.succeed(result), + ), + // The workflow engine service reaches the body through `register`. + ) as Effect.Effect> + }, + poll: () => notInPrograms('Workflow.poll'), + interrupt: () => notInPrograms('Workflow.interrupt'), + interruptUnsafe: () => notInPrograms('Workflow.interrupt'), + resume: () => notInPrograms('Workflow.resume'), + scheduleClock: () => notInPrograms('DurableClock'), + deferredDone: () => notInPrograms('DurableDeferred.done'), + activityExecute: (activity) => + Effect.gen(function* () { + const key = activity.name + const journaled = host.journal.get(key) + if (journaled !== undefined) return new Workflow.Complete({ exit: toExit(journaled) }) + const instance = yield* WorkflowEngine.WorkflowInstance + host.started(key) + const started = Date.now() + const result: Workflow.Result = yield* activity.executeEncoded.pipe( + Workflow.intoResult, + Effect.provideService( + WorkflowEngine.WorkflowInstance, + WorkflowEngine.WorkflowInstance.initial(instance.workflow, instance.executionId), + ), + ) + if (result._tag === 'Suspended') return result + const ms = Date.now() - started + const { exit } = result + if (Exit.isSuccess(exit)) { + yield* durable({ key, status: 'completed', value: exit.value ?? null, ms }) + } else if ( + Cause.hasDies(exit.cause) === false && + Cause.hasInterrupts(exit.cause) === false + ) { + const error = Cause.findErrorOption(exit.cause) + if (Option.isSome(error)) + yield* durable({ key, status: 'failed', error: error.value, ms }) + } + return result + }), + deferredResult: (deferred) => { + const journaled = host.journal.get(deferred.name) + if (journaled !== undefined) return Effect.succeed(Option.some(toExit(journaled))) + const request = requests.get(deferred.name) + if (request === undefined) return Effect.succeed(Option.none()) + return Effect.map(Effect.promise(request), (exit) => Option.some(toExit(exit))) + }, + } +} + +/** A program as its bundle runs it. */ +export interface ProgramDefinition { + readonly id: string + readonly input: Workflow.AnyStructSchema & Schema.Codec>> + readonly output: Schema.Codec + readonly error: Schema.Codec + readonly body: (input: P) => Effect.Effect +} + +const encodeJson = (schema: Schema.Codec, value: T) => + Effect.orDie(Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(value)) + +/** + * The `fractal.program.v1` entry: decodes the run's inputs, runs the body as a workflow whose + * execution is the step run, and returns the success encoded with `output` or the typed failure + * encoded with `error`. Inputs that do not decode refuse the run with `InvalidInput`; a defect or + * a refused journal entry returns `Defect`. After a cancel the host discards the exit. + */ +export const programEntry = + (definition: ProgramDefinition) => + async (host: HostContextV1): Promise => { + // Ends the attempt when the daemon cancels it or refuses a journal entry. Interrupting the root + // fiber, rather than failing the step, keeps the body from continuing past a result that is + // not durable. + const stop = new AbortController() + const cancel = () => stop.abort() + host.signal.addEventListener('abort', cancel, { once: true }) + if (host.signal.aborted) stop.abort() + let refused: string | undefined + const refuse = (key: string, cause: unknown) => { + refused ??= `the daemon refused the journal entry for \`${key}\`: ${cause instanceof Error ? cause.message : String(cause)}` + stop.abort() + } + const program: Program['Service'] = { + host, + occurrences: new Map(), + owners: new Map(), + requests: new Map(), + } + const engine = WorkflowEngine.makeUnsafe(hostEngine(program, refuse)) + const workflow = Workflow.make(definition.id, { + payload: definition.input, + success: definition.output, + error: definition.error, + idempotencyKey: () => host.step, + }) + const attempt = Effect.gen(function* () { + const decoded = yield* Effect.result( + Schema.decodeUnknownEffect(definition.input, { onExcessProperty: 'error' })(host.payload), + ) + if (decoded._tag === 'Failure') + return { + _tag: 'Failure', + error: { _tag: 'InvalidInput', message: decoded.failure.message }, + } satisfies ProgramExit + yield* engine.register(workflow, (input) => definition.body(input)) + const exit = yield* Effect.exit( + engine.execute(workflow, { executionId: host.step, payload: decoded.success }), + ) + if (Exit.isSuccess(exit)) + return { + _tag: 'Success', + value: (yield* encodeJson(definition.output, exit.value)) ?? null, + } satisfies ProgramExit + const error = Cause.findErrorOption(exit.cause) + if (Cause.hasDies(exit.cause) || Cause.hasInterrupts(exit.cause) || Option.isNone(error)) + return yield* Effect.failCause(exit.cause) + return { + _tag: 'Failure', + error: yield* encodeJson(definition.error, error.value), + } satisfies ProgramExit + }).pipe( + Effect.scoped, + Effect.provideService(Program, program), + Effect.provideService(WorkflowEngine.WorkflowEngine, engine), + ) + const exit = await Effect.runPromiseExit(attempt, { signal: stop.signal }).finally(() => + host.signal.removeEventListener('abort', cancel), + ) + const message = Exit.isSuccess(exit) ? '' : Cause.pretty(exit.cause).slice(0, 2000) + if (host.signal.aborted) return { _tag: 'Failure', error: { _tag: 'Interrupted', message } } + if (refused !== undefined) + return { _tag: 'Failure', error: { _tag: 'Defect', message: refused } } + if (Exit.isSuccess(exit)) return exit.value + return { _tag: 'Failure', error: { _tag: 'Defect', message } } + } diff --git a/packages/fractal/src/program-host.fixture.ts b/packages/fractal/src/program-host.fixture.ts new file mode 100644 index 00000000000..7eb05251e81 --- /dev/null +++ b/packages/fractal/src/program-host.fixture.ts @@ -0,0 +1,65 @@ +import { Effect } from 'effect' + +import { ProgramEntry, type HostContextV1, type ProgramExit } from './program-engine.ts' + +export interface Recorded { + readonly key: string + readonly status: 'completed' | 'failed' + readonly value?: unknown + readonly error?: unknown +} + +/** An in-memory host for one attempt; `record` keeps what a daemon would journal. */ +export const fakeHost = ( + options: { + readonly payload?: Readonly> + readonly journal?: Iterable + readonly cwd?: string + readonly child?: HostContextV1['child'] + /** Fails every journal write, as a daemon nack does. */ + readonly nack?: string + readonly ask?: HostContextV1['ask'] + readonly spawn?: HostContextV1['spawn'] + } = {}, +) => { + const recorded: Recorded[] = [] + const started: string[] = [] + const controller = new AbortController() + const context: HostContextV1 = { + step: 'step-run/gen-1/program', + attempt: 1, + run: 'mission-run/run-1', + payload: options.payload ?? { change: 'abc' }, + env: { ST_MISSION_RUN: 'mission-run/run-1' }, + cwd: options.cwd ?? '/work', + journal: new Map(options.journal ?? []), + signal: controller.signal, + started: (key) => void started.push(key), + record: async ({ key, status, value, error }) => { + if (options.nack !== undefined) throw new Error(options.nack) + // The daemon stores JSON: anything else would not replay. + recorded.push(JSON.parse(JSON.stringify({ key, status, value, error }))) + }, + child: options.child ?? (() => Promise.reject(new Error('unexpected child run'))), + ask: options.ask ?? (() => Promise.reject(new Error('unexpected ask'))), + spawn: + options.spawn ?? + (async (argv) => ({ code: 0, signal: null, stdout: argv.join(' '), stderr: '' })), + } + return { context, recorded, started, cancel: () => controller.abort() } +} + +/** The journal a daemon replays from what an attempt recorded. */ +export const journalOf = (recorded: readonly Recorded[]) => + recorded.map((entry): readonly [string, ProgramExit] => [ + entry.key, + entry.status === 'completed' + ? { _tag: 'Success', value: entry.value } + : { _tag: 'Failure', error: entry.error }, + ]) + +/** Runs one attempt of `program` on `host`, as the program host does. */ +export const start = ( + program: { readonly [ProgramEntry]: (host: HostContextV1) => Promise }, + host: { readonly context: HostContextV1 }, +) => Effect.promise(() => program[ProgramEntry](host.context)) diff --git a/packages/fractal/src/program.ts b/packages/fractal/src/program.ts new file mode 100644 index 00000000000..2a1dfcd37fb --- /dev/null +++ b/packages/fractal/src/program.ts @@ -0,0 +1,334 @@ +/** + * The program form of a mission, `Fractal.mission(intent, program)`, and what its body calls: + * `Fractal.step` (an `Activity`), worker turns, `Fractal.ask` and `Fractal.run` (`DurableDeferred`s + * the daemon resolves), `Fractal.command`, `Fractal.env` and `Fractal.cwd`. + * See docs/st3/program-engine.md §11. + */ +import { Context, Effect, Layer, Schedule, Schema, SchemaAST } from 'effect' +import { Activity, DurableDeferred, type Workflow } from 'effect/workflow' + +import { mission as dataMission, type MissionIntent } from './define.ts' +import type { PersonReference } from './mod.ts' +import { + keyed, + Program, + programEntry, + ProgramEntry, + TaggedFailure, + type CommandOptions, + type CommandResult, + type HostContextV1, + type ProgramExit, + type ProgramServices, + type Tagged, +} from './program-engine.ts' +import type { SubjectDefinition } from './subject-ref.ts' + +/** A schema without decoding or encoding services. */ +type Codec = Schema.Codec + +/** A mission input Struct: every field is required and encodes to a string. */ +export type InputSchema = Workflow.AnyStructSchema & + Schema.Codec + +/** A linked child run that completed: its subject and the products its steps declare. */ +export const ChildRun = Schema.Struct({ + run: Schema.String, + status: Schema.Literal('completed'), + products: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}) +export type ChildRun = typeof ChildRun.Type + +/** A linked child run ended without completing. */ +export class ChildFailed extends Schema.TaggedError()('ChildFailed', { + run: Schema.String, + status: Schema.String, + reason: Schema.String, +}) {} + +/** A person's answer to an ask; `resolved_by` is the person or `deadline`. */ +export const AskAnswer = Schema.Struct({ + answer: Schema.optionalKey( + Schema.Struct({ + type: Schema.String, + outcome: Schema.optionalKey(Schema.String), + id: Schema.optionalKey(Schema.String), + label: Schema.optionalKey(Schema.String), + text: Schema.optionalKey(Schema.String), + }), + ), + summary: Schema.optionalKey(Schema.String), + resolved_by: Schema.String, +}) +export type AskAnswer = typeof AskAnswer.Type + +/** The ask was withdrawn while the run went on. */ +export class AskCancelled extends Schema.TaggedError()('AskCancelled', { + ask: Schema.String, +}) {} + +/** The intent of a program mission; `input` fields become its native text inputs. */ +export interface ProgramIntent< + I extends InputSchema, + O extends Codec, + ES extends Codec | undefined, +> { + readonly id: string + readonly goal: string + readonly reportTo: MissionIntent['reportTo'] + readonly timeout?: string + readonly input: I + readonly output?: O + readonly error?: ES +} + +/** A mission whose body is a program; its default export is the bundle's entry. */ +export interface ProgramMission< + I extends InputSchema, + O extends Codec, + E, +> extends SubjectDefinition<'mission', ProgramIntent, never> { + readonly intent: ProgramIntent + readonly program: (input: I['Type']) => Effect.Effect + readonly [ProgramEntry]: (host: HostContextV1) => Promise +} + +type Meta = { readonly meta: { readonly url: string } } + +/** Each input is one native `input "name" kind="text"`, and a run supplies every one. */ +const encodesToString = (ast: SchemaAST.AST): boolean => + SchemaAST.isString(ast) || + SchemaAST.isTemplateLiteral(ast) || + (SchemaAST.isLiteral(ast) && typeof ast.literal === 'string') || + (SchemaAST.isEnum(ast) && ast.enums.every(([, value]) => typeof value === 'string')) || + (SchemaAST.isUnion(ast) && ast.types.every(encodesToString)) + +/** + * Declares a mission. With flat fields it is a data mission (see `Fractal.mission` in `fractal`). + * With `(intent, program)` it is a program: `program` receives the decoded `intent.input`, its + * success is encoded with `intent.output` and its typed failure with `intent.error`, or as `_tag` + * plus fields when the intent declares none. + */ +export function mission( + intent: Meta & MissionIntent, +): SubjectDefinition<'mission', MissionIntent, never> +export function mission< + I extends InputSchema, + O extends Codec = typeof Schema.Void, + E extends Tagged = never, +>( + intent: Meta & ProgramIntent, + program: (input: I['Type']) => Effect.Effect, +): ProgramMission +export function mission< + I extends InputSchema, + ES extends Codec, + O extends Codec = typeof Schema.Void, +>( + intent: Meta & ProgramIntent & { readonly error: ES }, + program: (input: I['Type']) => Effect.Effect, +): ProgramMission +export function mission( + intent: Meta & (MissionIntent | ProgramIntent), + program?: (input: object) => Effect.Effect, +): SubjectDefinition<'mission', unknown, never> { + if (program === undefined) return dataMission(intent as Meta & MissionIntent) + const { meta, ...rest } = intent as Meta & ProgramIntent + for (const [name, field] of Object.entries(rest.input.fields)) + if ( + SchemaAST.isOptional(field.ast) || + encodesToString(SchemaAST.toEncoded(field.ast)) === false + ) + throw new TypeError(`Program input ${name} must be required and encode to a string`) + const definition: ProgramMission = { + _tag: 'SubjectRef', + kind: 'mission', + url: meta.url, + intent: rest, + program, + [ProgramEntry]: programEntry({ + id: rest.id, + input: rest.input, + output: rest.output ?? Schema.Void, + error: rest.error ?? TaggedFailure, + body: program, + }), + } + return definition +} + +/** + * The child mission a worker turn runs, and its product's path below the child run + * (`review/verdict` names `resource/mission-run/CHILD/review/verdict`), whose value is the step's. + */ +export class Worker extends Context.Service< + Worker, + { + readonly mission: string + readonly input?: Readonly> + readonly product?: string + } +>()('fractal/Worker') { + /** Runs the worker turns it is provided to as child runs of `mission`. */ + static readonly layer = (options: Worker['Service']) => Layer.succeed(Worker, options) +} + +/** Waits for the linked child run `key` names; the daemon journals its end under `key`. */ +const child = (id: string, mission: string, input: Readonly>) => + keyed(id, (key, program) => { + program.requests.set(key, () => program.host.child({ key, mission, input })) + return DurableDeferred.await( + DurableDeferred.make(key, { success: ChildRun, error: ChildFailed }), + ).pipe(Effect.ensuring(Effect.sync(() => program.requests.delete(key)))) + }) + +const noInterruptRetry = Schedule.recurs(0) + +export interface StepOptions { + readonly id: string + readonly goal?: string +} + +/** + * The failure a step without `error` delivers, live and on replay: its tag and data fields as a + * plain object, without the class's prototype, `Error` members or methods. + */ +export type Journaled = E extends unknown + ? { + readonly [K in keyof E as K extends keyof Error | symbol + ? never + : E[K] extends (...args: never) => unknown + ? never + : K]: E[K] + } + : never + +/** + * A step of a program, journaled under its key (`id`, then `id#2`… for repeats, so `Effect.retry` + * runs it again under a new key). With an effect it is an `Activity`: replay returns the journaled + * value decoded with `success` (void without one) or fails again with the journaled typed error, + * rebuilt as its class with `error`, or as its plain `Journaled` shape without. Without an effect + * it is a worker turn: a child run of the provided `Worker` mission, whose product decoded with + * `success` is the value. + */ +export function step( + options: StepOptions & { readonly success?: undefined; readonly error?: undefined }, + effect: Effect.Effect, +): Effect.Effect, R | ProgramServices> +export function step( + options: StepOptions & { readonly success: S; readonly error?: undefined }, + effect: Effect.Effect, +): Effect.Effect, R | ProgramServices> +export function step( + options: StepOptions & { readonly success?: S; readonly error: ES }, + effect: Effect.Effect, +): Effect.Effect +export function step( + options: StepOptions & { readonly success?: S }, +): Effect.Effect +export function step( + options: StepOptions & { + readonly success?: Codec | undefined + readonly error?: Codec | undefined + }, + effect?: Effect.Effect, +): Effect.Effect { + const { success } = options + if (effect !== undefined) + return keyed(options.id, (key) => + Activity.make({ + name: key, + success: success ?? Schema.Void, + error: options.error ?? TaggedFailure, + execute: success === undefined ? Effect.asVoid(effect) : effect, + interruptRetryPolicy: noInterruptRetry, + }), + ) + return Effect.flatMap(Effect.service(Worker), (worker) => + Effect.flatMap(child(options.id, worker.mission, worker.input ?? {}), (done) => { + if (success === undefined) return Effect.void + // The daemon keys products by full subject: `resource//`. + const subject = `resource/${done.run}/${worker.product}` + const value = worker.product === undefined ? undefined : done.products?.[subject] + if (value === undefined) + return Effect.die( + new Error(`worker turn ${options.id}: ${done.run} has no product ${subject}`), + ) + return Effect.orDie(Schema.decodeUnknownEffect(success)(value)) + }), + ) +} + +/** + * Asks a person on the program step and waits for the answer, journaled under `id`. An ask that a + * cancelled run withdraws is never answered; one withdrawn while the run goes on fails with + * `AskCancelled`. + */ +export const ask = (options: { + readonly id: string + readonly for: PersonReference + readonly title: string + readonly request: Schema.Json + readonly deadline?: string + readonly default?: 'approve' | 'reject' +}): Effect.Effect => + keyed(options.id, (key, program) => { + const { id: _, for: person, ...request } = options + program.requests.set(key, () => program.host.ask({ ...request, key, for: person.subject })) + return DurableDeferred.await( + DurableDeferred.make(key, { success: AskAnswer, error: AskCancelled }), + ).pipe(Effect.ensuring(Effect.sync(() => program.requests.delete(key)))) + }) + +/** + * Starts a linked child run of `mission` and waits for its end, journaled under `options.id` + * (default: the mission's id). Cancelling the parent run cancels the child; a child that does not + * complete fails with `ChildFailed`. + */ +export function run( + mission: ProgramMission, + input: I['Type'], + options?: { readonly id?: string }, +): Effect.Effect +export function run( + mission: string, + input: Readonly>, + options?: { readonly id?: string }, +): Effect.Effect +export function run( + mission: string | ProgramMission, + input: object, + options?: { readonly id?: string }, +): Effect.Effect { + if (typeof mission === 'string') + return child(options?.id ?? mission, mission, input as Readonly>) + const { id, input: schema } = mission.intent + return Effect.flatMap(Effect.orDie(Schema.encodeUnknownEffect(schema)(input)), (encoded) => + child(options?.id ?? id, id, encoded), + ) +} + +/** + * Runs a command as its own process group, which the host ends when the program is cancelled. + * `priority: 'background'` runs it as background work. Commands are not journaled: run them + * inside a step. + */ +export const command = ( + argv: readonly string[], + options?: CommandOptions, +): Effect.Effect => + Effect.flatMap(Effect.service(Program), (program) => + Effect.promise(() => program.host.spawn(argv, options)), + ) + +/** The run's `ST_*` variables (`ST_MISSION_RUN`, `ST_STEP_RUN`, …). */ +export const env: Effect.Effect>, never, Program> = Effect.map( + Effect.service(Program), + (program) => program.host.env, +) + +/** The run's workspace directory. */ +export const cwd: Effect.Effect = Effect.map( + Effect.service(Program), + (program) => program.host.cwd, +) diff --git a/packages/fractal/src/program.unit.test.ts b/packages/fractal/src/program.unit.test.ts new file mode 100644 index 00000000000..b5f1740a76e --- /dev/null +++ b/packages/fractal/src/program.unit.test.ts @@ -0,0 +1,570 @@ +import { describe, expect, it } from '@effect/vitest' +import { Data, Effect, Schedule, Schema } from 'effect' + +import { + AskCancelled, + ChildFailed, + Fractal, + type ProgramExit, + type ProgramServices, +} from './effect.ts' +import { fakeHost, journalOf, start } from './program-host.fixture.ts' +import type { AgentRef } from './subject-ref.ts' + +class StepFailed extends Data.TaggedError('StepFailed')<{ + readonly step: string + readonly reason: string +}> {} +class Refused extends Schema.TaggedError()('Refused', { reason: Schema.String }) {} + +const reportTo: AgentRef = { + _tag: 'SubjectRef', + kind: 'agent', + url: 'file:///tree/factory/agent.ts', +} +const meta = { url: 'file:///tree/factory/test/mission.ts' } +const Change = Schema.Struct({ change: Schema.String }) + +describe('program replay', () => { + const effects: string[] = [] + const land = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Merge the change.', + reportTo, + input: Change, + output: Schema.Struct({ merged: Schema.String, errors: Schema.Number }), + }, + ({ change }) => + Effect.gen(function* () { + const checked = yield* Fractal.step( + { id: 'check', success: Schema.Struct({ errors: Schema.Array(Schema.String) }) }, + Effect.sync(() => { + effects.push('check') + return { errors: [] } + }), + ) + const merged = yield* Fractal.step( + { id: 'merge', success: Schema.String }, + Effect.sync(() => { + effects.push('merge') + return `merged ${change}` + }), + ) + return { merged, errors: checked.errors.length } + }), + ) + + it.effect('returns journaled values without running their effects again', () => + Effect.gen(function* () { + const first = fakeHost() + expect(yield* start(land, first)).toEqual({ + _tag: 'Success', + value: { merged: 'merged abc', errors: 0 }, + }) + expect(first.recorded).toEqual([ + { key: 'check', status: 'completed', value: { errors: [] } }, + { key: 'merge', status: 'completed', value: 'merged abc' }, + ]) + expect(effects.splice(0)).toEqual(['check', 'merge']) + + const replay = fakeHost({ journal: journalOf(first.recorded) }) + expect(yield* start(land, replay)).toEqual({ + _tag: 'Success', + value: { merged: 'merged abc', errors: 0 }, + }) + expect(effects).toEqual([]) + expect(replay.recorded).toEqual([]) + expect(replay.started).toEqual([]) + + const resume = fakeHost({ journal: journalOf(first.recorded.slice(0, 1)) }) + yield* start(land, resume) + expect(effects.splice(0)).toEqual(['merge']) + expect(resume.started).toEqual(['merge']) + }), + ) + + it.effect('stops with a defect when the daemon refuses a journal write', () => + Effect.gen(function* () { + const exit = yield* start(land, fakeHost({ nack: 'idempotency-conflict' })) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('idempotency-conflict') + // The program does not continue past a result that is not durable. + expect(effects.splice(0)).toEqual(['check']) + }), + ) + + it.effect('ends the attempt on a refused journal write even when the body catches defects', () => + Effect.gen(function* () { + const ran: string[] = [] + const catching = Fractal.mission( + { meta, id: 'factory/test', goal: 'Merge.', reportTo, input: Change }, + () => + Effect.gen(function* () { + yield* Effect.exit( + Fractal.step( + { id: 'check' }, + Effect.sync(() => ran.push('check')), + ), + ) + yield* Fractal.step( + { id: 'merge' }, + Effect.sync(() => ran.push('merge')), + ) + }), + ) + const exit = yield* start(catching, fakeHost({ nack: 'idempotency-conflict' })) + expect(exit).toEqual({ + _tag: 'Failure', + error: { + _tag: 'Defect', + message: 'the daemon refused the journal entry for `check`: idempotency-conflict', + }, + }) + expect(ran).toEqual(['check']) + }), + ) +}) + +describe('typed failures', () => { + const effects: string[] = [] + const publish = Fractal.mission( + { meta, id: 'factory/test', goal: 'Publish.', reportTo, input: Change, output: Schema.String }, + () => + Effect.gen(function* () { + const reverted = yield* Fractal.step( + { id: 'publish' }, + Effect.suspend(() => { + effects.push('publish') + return Effect.fail(new StepFailed({ step: 'publish', reason: 'admission refused' })) + }), + ).pipe(Effect.catchTag('StepFailed', (e) => Effect.succeed(`reverted: ${e.reason}`))) + const refused = yield* Fractal.step( + { id: 'verify', error: Refused }, + Effect.suspend(() => { + effects.push('verify') + return Effect.fail(new Refused({ reason: 'stale' })) + }), + ).pipe( + Effect.catchTag('Refused', (e) => + Effect.succeed(e instanceof Refused ? `rebuilt ${e.reason}` : 'plain'), + ), + ) + return `${reverted}; ${refused}` + }), + ) + + it.effect('are journaled with their tag and fields and match catchTag on replay', () => + Effect.gen(function* () { + const first = fakeHost() + const exit = { _tag: 'Success', value: 'reverted: admission refused; rebuilt stale' } + expect(yield* start(publish, first)).toEqual(exit) + expect(first.recorded).toEqual([ + { + key: 'publish', + status: 'failed', + error: { _tag: 'StepFailed', step: 'publish', reason: 'admission refused' }, + }, + { key: 'verify', status: 'failed', error: { _tag: 'Refused', reason: 'stale' } }, + ]) + expect(effects.splice(0)).toEqual(['publish', 'verify']) + + expect(yield* start(publish, fakeHost({ journal: journalOf(first.recorded) }))).toEqual(exit) + expect(effects).toEqual([]) + }), + ) + + it.effect('fail the program with their tag and fields; defects are not journaled', () => + Effect.gen(function* () { + const failing = Fractal.mission( + { meta, id: 'factory/test', goal: 'Fail.', reportTo, input: Change }, + () => + Fractal.step( + { id: 'check' }, + Effect.fail(new StepFailed({ step: 'check', reason: 'red' })), + ), + ) + expect(yield* start(failing, fakeHost())).toEqual({ + _tag: 'Failure', + error: { _tag: 'StepFailed', step: 'check', reason: 'red' }, + }) + + const buggy = Fractal.mission( + { meta, id: 'factory/test', goal: 'Fail.', reportTo, input: Change }, + () => Fractal.step({ id: 'check' }, Effect.die(new Error('bug'))), + ) + const host = fakeHost() + const exit = yield* start(buggy, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(host.recorded).toEqual([]) + expect(host.started).toEqual(['check']) + }), + ) + + it.effect('fail a program that declares an error class when the step declares it too', () => + Effect.gen(function* () { + const refused = Fractal.mission( + { meta, id: 'factory/test', goal: 'Refuse.', reportTo, input: Change, error: Refused }, + () => + Fractal.step( + { id: 'verify', error: Refused }, + Effect.fail(new Refused({ reason: 'stale' })), + ), + ) + expect(yield* start(refused, fakeHost())).toEqual({ + _tag: 'Failure', + error: { _tag: 'Refused', reason: 'stale' }, + }) + // Without `error` the step fails with the plain journaled shape, which is not the class. + // @ts-expect-error a plain `{ _tag, reason }` is not a `Refused` instance + const plain: Effect.Effect = Fractal.step( + { id: 'verify' }, + Effect.fail(new Refused({ reason: 'stale' })), + ) + expect(plain).toBeDefined() + }), + ) +}) + +describe('step keys', () => { + it.effect('repeat as id#n under Effect.retry and replay in that order', () => + Effect.gen(function* () { + let runs = 0 + const verify = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Verify.', + reportTo, + input: Change, + output: Schema.Number, + }, + () => + Fractal.step( + { id: 'verify', success: Schema.Number }, + Effect.sync(() => ++runs), + ).pipe( + Effect.flatMap((n) => + n < 3 + ? Effect.fail(new StepFailed({ step: 'verify', reason: 'stale' })) + : Effect.succeed(n), + ), + Effect.retry(Schedule.recurs(5)), + ), + ) + const first = fakeHost() + expect(yield* start(verify, first)).toEqual({ _tag: 'Success', value: 3 }) + expect(first.recorded.map((entry) => [entry.key, entry.value])).toEqual([ + ['verify', 1], + ['verify#2', 2], + ['verify#3', 3], + ]) + expect(yield* start(verify, fakeHost({ journal: journalOf(first.recorded) }))).toEqual({ + _tag: 'Success', + value: 3, + }) + expect(runs).toBe(3) + }), + ) + + it.effect('refuse two concurrent steps with one id as a defect', () => + Effect.gen(function* () { + const twice = Fractal.mission( + { meta, id: 'factory/test', goal: 'Check twice.', reportTo, input: Change }, + () => + Effect.asVoid( + Effect.all( + [ + Fractal.step({ id: 'check' }, Effect.never), + Fractal.step({ id: 'check' }, Effect.never), + ], + { concurrency: 'unbounded' }, + ), + ), + ) + const host = fakeHost() + const exit = yield* start(twice, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('steps in concurrent branches share the id `check`') + expect(host.recorded).toEqual([]) + }), + ) + + it.effect('refuse one id in two concurrent branches even when the uses do not overlap', () => + Effect.gen(function* () { + // Live, the fast branch would take `x` and the slow one `x#2`; on replay `a` returns at once + // and the branches would swap keys and values. + const branches = Fractal.mission( + { meta, id: 'factory/test', goal: 'Branch.', reportTo, input: Change }, + () => + Effect.asVoid( + Effect.all( + [ + Fractal.step({ id: 'a' }, Effect.yieldNow).pipe( + Effect.andThen(Fractal.step({ id: 'x' }, Effect.void)), + ), + Fractal.step({ id: 'x' }, Effect.void), + ], + { concurrency: 'unbounded' }, + ), + ), + ) + const exit = yield* start(branches, fakeHost()) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Defect' } }) + expect(JSON.stringify(exit)).toContain('steps in concurrent branches share the id `x`') + }), + ) +}) + +describe('asks, child runs and worker turns', () => { + const Verdict = Schema.Struct({ verdict: Schema.Literals(['GO', 'NO-GO']) }) + const review = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Review.', + reportTo, + input: Change, + output: Schema.Array(Schema.String), + }, + ({ change }) => + Effect.gen(function* () { + const ack = yield* Fractal.ask({ + id: 'owner-ack', + for: Fractal.person('person/owner'), + title: 'Accept the change', + request: { version: 1, type: 'decision' }, + default: 'reject', + }) + const child = yield* Fractal.run('factory/publish', { change }, { id: 'publish' }) + const failed = yield* Fractal.run('factory/publish', { change }, { id: 'publish' }).pipe( + Effect.catchTag('ChildFailed', (e) => + Effect.succeed(e instanceof ChildFailed ? `failed ${e.reason}` : 'plain'), + ), + ) + const verdict = yield* Fractal.step({ id: 'review', success: Verdict }).pipe( + Effect.provide( + Fractal.Worker.layer({ + mission: 'factory/step/review', + input: { change }, + product: 'review/verdict', + }), + ), + ) + return [ + ack.answer?.id ?? 'none', + ack.resolved_by, + child.run, + String(failed), + verdict.verdict, + ] + }), + ) + const output = ['approve', 'person/owner', 'mission-run/child-1', 'failed cancelled', 'GO'] + + it.effect('wait for the daemon, then replay from its journal', () => + Effect.gen(function* () { + const requests: unknown[] = [] + const resolutions: Record = { + 'owner-ack': { + _tag: 'Success', + value: { + answer: { type: 'decision', outcome: 'accept', id: 'approve' }, + resolved_by: 'person/owner', + }, + }, + publish: { _tag: 'Success', value: { run: 'mission-run/child-1', status: 'completed' } }, + 'publish#2': { + _tag: 'Failure', + error: { + _tag: 'ChildFailed', + run: 'mission-run/child-2', + status: 'cancelled', + reason: 'cancelled', + }, + }, + review: { + _tag: 'Success', + value: { + run: 'mission-run/child-3', + status: 'completed', + products: { + 'resource/mission-run/child-3/review/verdict': { verdict: 'GO', head: 'h1' }, + }, + }, + }, + } + const host = fakeHost({ + ask: async (request) => { + requests.push(request) + return resolutions[request.key]! + }, + child: async (request) => { + requests.push(request) + return resolutions[request.key]! + }, + }) + expect(yield* start(review, host)).toEqual({ _tag: 'Success', value: output }) + expect(requests).toEqual([ + { + key: 'owner-ack', + for: 'person/owner', + title: 'Accept the change', + request: { version: 1, type: 'decision' }, + default: 'reject', + }, + { key: 'publish', mission: 'factory/publish', input: { change: 'abc' } }, + { key: 'publish#2', mission: 'factory/publish', input: { change: 'abc' } }, + { key: 'review', mission: 'factory/step/review', input: { change: 'abc' } }, + ]) + expect(host.recorded).toEqual([]) + + const replay = fakeHost({ journal: Object.entries(resolutions) }) + expect(yield* start(review, replay)).toEqual({ _tag: 'Success', value: output }) + }), + ) + + it.effect('fail typed when the daemon withdraws an ask', () => + Effect.gen(function* () { + const asking = Fractal.mission( + { meta, id: 'factory/test', goal: 'Ask.', reportTo, input: Change, output: Schema.String }, + () => + Fractal.ask({ + id: 'ack', + for: Fractal.person('person/owner'), + title: 'Ack', + request: null, + }).pipe( + Effect.as('answered'), + Effect.catchTag('AskCancelled', (e) => + Effect.succeed(e instanceof AskCancelled ? e.ask : ''), + ), + ), + ) + const journal = [ + ['ack', { _tag: 'Failure', error: { _tag: 'AskCancelled', ask: 'step-run/g/ask-1' } }], + ] as const + expect(yield* start(asking, fakeHost({ journal }))).toEqual({ + _tag: 'Success', + value: 'step-run/g/ask-1', + }) + }), + ) +}) + +describe('inputs and outputs', () => { + const counted = Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'Count.', + reportTo, + input: Schema.Struct({ + count: Schema.NumberFromString, + reviewer: Schema.Literals(['llm', 'scripted']), + }), + output: Schema.Struct({ next: Schema.NumberFromString }), + }, + ({ count }) => Effect.succeed({ next: count + 1 }), + ) + + it.effect('decode the payload and encode the success with the output schema', () => + Effect.gen(function* () { + expect( + yield* start(counted, fakeHost({ payload: { count: '41', reviewer: 'llm' } })), + ).toEqual({ + _tag: 'Success', + value: { next: '42' }, + }) + }), + ) + + it.effect('refuse a payload that does not decode', () => + Effect.gen(function* () { + for (const payload of [ + { count: '1', reviewer: 'nobody' }, + { count: '1' }, + { count: '1', reviewer: 'llm', extra: 'x' }, + ]) + expect(yield* start(counted, fakeHost({ payload }))).toMatchObject({ + _tag: 'Failure', + error: { _tag: 'InvalidInput' }, + }) + }), + ) + + it('refuse input fields that are optional or do not encode to a string', () => { + const body = () => Effect.void + expect(() => + Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'g', + reportTo, + input: Schema.Struct({ change: Schema.optionalKey(Schema.String) }), + }, + body, + ), + ).toThrow('Program input change must be required and encode to a string') + expect(() => + Fractal.mission( + { + meta, + id: 'factory/test', + goal: 'g', + reportTo, + // @ts-expect-error a number does not encode to a native text input + input: Schema.Struct({ count: Schema.Number }), + }, + body, + ), + ).toThrow('Program input count must be required and encode to a string') + }) +}) + +describe('cancellation', () => { + it.effect('interrupts the running step through the host signal and journals nothing', () => + Effect.gen(function* () { + let interrupted = false + const host = fakeHost() + // The step's effect is running when the daemon cancels the attempt. + const waiting = Fractal.mission( + { meta, id: 'factory/test', goal: 'Wait.', reportTo, input: Change }, + () => + Fractal.step( + { id: 'merge' }, + Effect.sync(() => host.cancel()).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Effect.sync(() => (interrupted = true))), + ), + ), + ) + const exit = yield* start(waiting, host) + expect(exit).toMatchObject({ _tag: 'Failure', error: { _tag: 'Interrupted' } }) + expect(interrupted).toBe(true) + expect(host.started).toEqual(['merge']) + expect(host.recorded).toEqual([]) + }), + ) +}) + +describe('commands', () => { + it.effect('run through the host and return its result', () => + Effect.gen(function* () { + const echo = Fractal.mission( + { meta, id: 'factory/test', goal: 'Echo.', reportTo, input: Change, output: Schema.String }, + ({ change }) => + Fractal.step( + { id: 'echo', success: Schema.String }, + Effect.map( + Fractal.command(['echo', change], { priority: 'background' }), + (out) => out.stdout, + ), + ), + ) + expect(yield* start(echo, fakeHost())).toEqual({ _tag: 'Success', value: 'echo abc' }) + }), + ) +}) diff --git a/packages/fractal/src/subject-ref.ts b/packages/fractal/src/subject-ref.ts index 833363c141d..6a9bcb74c4b 100644 --- a/packages/fractal/src/subject-ref.ts +++ b/packages/fractal/src/subject-ref.ts @@ -1,5 +1,5 @@ /** The declaration kind; a schedule is a mission carrying native timing. */ -export type SubjectKind = 'agent' | 'mission' | 'schedule' +export type SubjectKind = 'agent' | 'mission' | 'schedule' | 'objective' /** An imported source module identity, resolved against the subject tree at publication. */ export interface SubjectRef { diff --git a/packages/fractal/src/tree.ts b/packages/fractal/src/tree.ts index 840ec041708..ea3f73b871f 100644 --- a/packages/fractal/src/tree.ts +++ b/packages/fractal/src/tree.ts @@ -7,16 +7,19 @@ import { parse } from '@overeng/kdl' import { AgentSchema, MissionSchema, missionWire, type MissionInput, SubjectId } from './mod.ts' import type { PinnedMissionRef } from './define.ts' +import { objective, validName, type Objective, type ObjectiveInput } from './objective.ts' import type { SubjectKind, SubjectRef } from './subject-ref.ts' export { defineAgent, defineMission, defineSchedule, + defineObjective, pinMission, type AgentIntent, type MissionIntent, type PinnedMissionRef, type ScheduleIntent, + type ObjectiveIntent, } from './define.ts' export type { SubjectKind, @@ -55,14 +58,27 @@ export type Subject = readonly path: string readonly declaration: Declaration } + | { + readonly kind: 'objective' + readonly id: SubjectId + readonly path: string + readonly declaration: Declaration + } + +const declarationFile = /^(agent|mission|schedule|objective)\.(ts|kdl)$/u -const declarationFile = /^(agent|mission|schedule)\.(ts|kdl)$/u +/** The subject family of a kind: missions and schedules share `mission/`. */ +const family = (kind: SubjectKind) => (kind === 'schedule' ? 'mission' : kind) -const scheduleMismatch: Record, string> = { +const scheduleMismatch: Record<'mission' | 'schedule', string> = { mission: 'A mission with a schedule must be declared in schedule.ts or schedule.kdl', schedule: 'A schedule file must declare a mission with a schedule', } +/** A fleet declares only interval objectives named `NAMESPACE/NAME`; Fractal ships the rest. */ +const declaredObjective = (id: string) => + `A declared objective is an interval over claims named NAMESPACE/NAME; objective/${id} is not` + const isRef = (value: unknown): value is SubjectRef => typeof value === 'object' && value !== null && '_tag' in value && value._tag === 'SubjectRef' @@ -302,6 +318,7 @@ const loadModule = Effect.fnUntraced(function* ({ try: (): unknown => (typeof source === 'function' ? source(context) : source), catch: (cause) => new SubjectTreeError({ path, message: 'Subject factory failed', cause }), }) + if (kind === 'objective') return yield* loadObjective({ root, path, id, intent, exports }) // Definitions take their ID from the path; an explicit `id` in the intent must still agree. const identified = definition !== undefined && typeof intent === 'object' && intent !== null @@ -354,6 +371,64 @@ const loadModule = Effect.fnUntraced(function* ({ return subject }) +/** + * Checks an objective module's intent: its `name`, if given, is the path ID; `onBreach` agent + * references resolve to `agent/`; and it is an interval over claims named `NAMESPACE/NAME`, + * the only objective a fleet declares (the native telemetry ones ship with Fractal). + */ +const loadObjective = Effect.fnUntraced(function* ({ + root, + path, + id, + intent, + exports, +}: { + readonly root: string + readonly path: string + readonly id: SubjectId + readonly intent: unknown + readonly exports: Readonly> +}) { + if (typeof intent !== 'object' || intent === null) { + return yield* new SubjectTreeError({ path, message: 'An objective module exports an objective' }) + } + if ('name' in intent && intent.name !== undefined && intent.name !== id) { + return yield* new SubjectTreeError({ + path, + message: `Declared name ${String(intent.name)} must equal the subject directory ${id}`, + }) + } + const agent = (value: unknown) => + isRef(value) === true + ? resolveRef({ root, path, ref: value, expected: 'agent' }).pipe(Effect.map((agentId) => `agent/${agentId}`)) + : Effect.succeed(value) + const breach = 'onBreach' in intent ? intent.onBreach : undefined + const onBreach = + typeof breach === 'object' && breach !== null + ? { + ...breach, + ...('owner' in breach ? { owner: yield* agent(breach.owner) } : {}), + ...('backup' in breach && breach.backup !== undefined ? { backup: yield* agent(breach.backup) } : {}), + } + : breach + const named: unknown = { ...intent, ...(onBreach === undefined ? {} : { onBreach }), name: id } + const value = yield* Effect.try({ + // The intent is untrusted module data; `objective` checks every field, unknown ones included. + try: () => objective(named as ObjectiveInput), + catch: (cause) => new SubjectTreeError({ path, message: 'Invalid objective declaration', cause }), + }) + if (value.indicator.kind !== 'interval' || id.includes('/') === false) { + return yield* new SubjectTreeError({ path, message: declaredObjective(id) }) + } + const subject: Subject = { + kind: 'objective', + id, + path, + declaration: { _tag: 'Module', intent: value, exports }, + } + return subject +}) + const loadKdl = Effect.fnUntraced(function* ({ fs, file, @@ -378,7 +453,7 @@ const loadKdl = Effect.fnUntraced(function* ({ try: () => parse(text).nodes, catch: (cause) => new SubjectTreeError({ path, message: 'Invalid KDL declaration', cause }), }) - const name = kind === 'agent' ? 'agent' : 'mission' + const name = family(kind) const [version, declaration] = nodes if ( nodes.length !== 2 || @@ -393,23 +468,32 @@ const loadKdl = Effect.fnUntraced(function* ({ }) } const scheduled = declaration.children?.findNodeByName('schedule') !== undefined - if (kind !== 'agent' && scheduled !== (kind === 'schedule')) { + if ((kind === 'mission' || kind === 'schedule') && scheduled !== (kind === 'schedule')) { return yield* new SubjectTreeError({ path, message: scheduleMismatch[kind] }) } + if ( + kind === 'objective' && + (declaration.children?.findNodeByName('indicator')?.getArgument(0) !== 'interval' || + id.includes('/') === false || + validName(id) === false) + ) { + return yield* new SubjectTreeError({ path, message: declaredObjective(id) }) + } const subject: Subject = { kind, id, path, declaration: { _tag: 'Kdl', text } } return subject }) /** - * Loads `//{agent,mission,schedule}.{ts,kdl}` declarations in path order. + * Loads `//{agent,mission,schedule,objective}.{ts,kdl}` declarations in path order. * * The ID is the POSIX directory path of the file's realpath below `root`'s realpath, so symlinked * aliases load once and links leaving the tree are rejected. Seats use the `agent/` namespace; missions - * and schedules share `mission/`, so one directory declares at most one of them. KDL files must - * hold `version 2` and one node whose kind and ID match the path. A TS module's default export is a - * `define*` definition for its own path and kind, a plain intent, or a factory; factories receive - * `context`. Typed references are lowered, then intents are decoded with `AgentSchema` or - * `MissionSchema`. Subject modules are trusted code and are imported. + * and schedules share `mission/`, so one directory declares at most one of them; objectives use + * `objective/`. KDL files must hold `version 2` and one node whose kind and ID match the path. A + * TS module's default export is a `define*` definition for its own path and kind, a plain intent, or + * a factory; factories receive `context`. Typed references are lowered, then intents are decoded + * with `AgentSchema` or `MissionSchema`, or checked by `Fractal.objective`. A declared objective is an + * interval over claims. Subject modules are trusted code and are imported. */ export const loadSubjectTree = Effect.fn('fractal.loadSubjectTree')(function* ({ root, @@ -452,7 +536,7 @@ export const loadSubjectTree = Effect.fn('fractal.loadSubjectTree')(function* .harnessBin. Person-owned terminal bindings retain native execution; one authenticated declaration read selects this path, while agent-owned or absent bindings remain managed. Prelaunch capture follows the same rule. Select AGENT_PERSONA when set, otherwise generalist as an internal default without exporting AGENT_PERSONA. Only confirmed declaration not-found permits an absent declaration; classified daemon outages wait without spawning, while other read errors refuse. Provider PATH is prefixed. Before spawn, any present ST_AGENT/FRACTAL_AGENT must equal the fenced subject (including rejecting empty values); both child aliases are then set to that subject. Driver environment and person-owned native sessions are unchanged."}, + "downtime": {"status": "none", "detail": "Existing provider processes are unchanged."}, + "manual": {"status": "changed", "detail": "Rollout dependency: do not upgrade a host to a Fractal release containing this change until that host has activated a runtime profile with personas.launch..harnessBin for its managed seats. Read an absolute AGENT_RUNTIME_PROFILE when set, otherwise the stable HOME-relative coding-agent-runtime Nix profile; missing profile, persona key, or wrapper refuses new managed launches."}, + "recovery": {"status": "none", "detail": "Correct the runtime profile or install the named wrapper before retrying a refused launch. Prelaunch subscriptions watch the profile and selected wrapper even when resolution fails, so repairing either input re-triggers reconciliation without falling back to a raw provider."} +} diff --git a/release-notes/ios-test-variant-prep.json b/release-notes/ios-test-variant-prep.json new file mode 100644 index 00000000000..31b38defca9 --- /dev/null +++ b/release-notes/ios-test-variant-prep.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Prepare an isolated iOS test app and independent XCUITest observation target through the existing daily delivery lane", + "replay": {"status": "none", "detail": "No claim replay changes."}, + "schema": {"status": "none", "detail": "No database schema changes."}, + "rules": {"status": "none", "detail": "No checkpoint rule changes."}, + "compatibility": {"status": "none", "detail": "Normal delivery remains the default with its existing identifiers and state path. The opt-in test variant uses a separate app, keychain entitlement, app group and delivery state namespace."}, + "downtime": {"status": "none", "detail": "No normal-app replacement or pairing migration."}, + "manual": {"status": "changed", "detail": "Before the first signed device build of the test variant, Johannes registers and provisions the test app com.compoundingtech.fractal.test and the XCUITest runner com.compoundingtech.fractal.test.xctrunner (app group, keychain group, both profiles). Initial pairing and enrollment need their own consent; this change does neither. The normal app needs no action."}, + "recovery": {"status": "none", "detail": "Normal delivery state is not migrated. Test artifacts and delivery state are scoped under ios-test-variant."} +} diff --git a/release-notes/isolated-fabric-pairing.json b/release-notes/isolated-fabric-pairing.json new file mode 100644 index 00000000000..9b568803b40 --- /dev/null +++ b/release-notes/isolated-fabric-pairing.json @@ -0,0 +1 @@ +{"type":"security","description":"Define isolated Fabric cold-bootstrap pairing with pinned QR permits, signed result recovery, and fail-closed durable admission reconciliation."} diff --git a/release-notes/mail-expiry-contract.json b/release-notes/mail-expiry-contract.json new file mode 100644 index 00000000000..3d07899d0de --- /dev/null +++ b/release-notes/mail-expiry-contract.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Register durable mailbox expiry outcomes and preserve later delivery evidence without enabling automatic expiry", + "replay": {"status": "changed", "detail": "The graph registry admits daemon/runtime message.expired claims with explicit reason codes. Later delivered/read evidence remains admissible and supersedes the expiry outcome while its claim stays in history. No production expiry writer or recovery-policy change is enabled."}, + "schema": {"status": "changed", "detail": "Adds the durable message.expired graph claim kind and a store outcome reader. The messages shard still replicates only message.sent; no local receipt-table format changes."}, + "rules": {"status": "changed", "detail": "message.expired is durable retention history, not a transport receipt or proof of non-consumption. Transactional notification entry points cannot bypass expiry admission; late delivered evidence after read remains admissible."}, + "compatibility": {"status": "changed", "detail": "The registry digest changes. Older registries retain expiry as an unknown kind and cannot interpret the outcome. Deploy all outcome readers, projections and sender surfaces to every pinned host before enabling a writer. Harness receipt authorization is unchanged."}, + "downtime": {"status": "none", "detail": "No data migration or startup rebuild is introduced."}, + "manual": {"status": "none", "detail": "This release enables no expiry writer and requires no operator action."}, + "recovery": {"status": "changed", "detail": "Rollback retains authenticated expiry history as an unknown kind on older readers; it does not authorize replay of an uncertain handoff. Do not enable expiry writers until the full reader rollout is complete."} +} diff --git a/release-notes/mission-agentid-canonical.json b/release-notes/mission-agentid-canonical.json new file mode 100644 index 00000000000..3081040a65b --- /dev/null +++ b/release-notes/mission-agentid-canonical.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Emit canonical agent identities in mission presentation while preserving historical writer spelling", + "replay": {"status": "none", "detail": "Historical rows remain unchanged; presentation canonicalizes legacy slash-qualified agent identities on read while preserving explicit person, daemon, host and plugin principal prefixes. Person assignees remain person actors rather than agent seats. Re-parsing stored declarations preserves historical host-relative qualification and malformed assignee normalization rather than rejecting them."}, + "schema": {"status": "none", "detail": "The strict client AgentId schema and database schema are unchanged."}, + "rules": {"status": "changed", "detail": "Work reads, renewal polling and presentation compare canonical identities. All mutations preserve main's actor normalization and literal matching, including ownership admission, eligibility, capacity, queue admission, carried-claim successor selection, output authority and ancestor lease extension. Claim-admission queues and automatic work-wake selection use literal selectors and literal queue join and move inputs, so an earlier legacy-assigned ready step does not block a canonical seat's claim or reorder its wake; legacy-only assignments do not wake a canonical seat or create its wake deadline. Read and presentation queues still show both spellings. Bare work actors such as node.worker normalize to agent/node.worker before validation and can claim, renew, release and complete literally assigned work. An unexpired legacy-owned lease refuses a differently spelled actor with work-already-claimed when the literal eligibility selector admits that actor, without changing claim state, publishing a mutation or extending the operational lease. Alias-owned carried claims do not redirect a mutation from a stale generation; literal owners still reach their carried successor. Successful child mutations extend only literally owned ancestor leases. Work read filters retain default-agent qualification for bare names, including published lists and explicit ready-work queue ordering. Declaration, run-start and successful work ownership writes retain main's spelling during expansion; local work actions refuse malformed or unprefixed slash-qualified claimants that old validators reject, with a typed error directing callers to the canonical spelling."}, + "compatibility": {"status": "changed", "detail": "Expand phase only: read both actor spellings and preserve the previous writer spelling, with no feature gate or capability advertisement. LIVE-MIGRATION BRIDGE arn:lmig:fractal:2026-11-07-agent-actor-ownership: a later spelling switch and stored-row migration require a durable one-way fleet fence. Once raw legacy-signed ownership facts exist, the historical reader floor must prohibit old readers that cannot admit those facts; a reversible capability check cannot establish that floor."}, + "downtime": {"status": "none", "detail": "No downtime or stored-row migration is required for this read-compatible expansion."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Lease fencing remains unchanged. Signed legacy work ownership can arrive before its target row during cold import; projection checks canonical eligibility and records a defect without taking the lease if the target selector does not match."} +} diff --git a/release-notes/mission-run-authority-block.json b/release-notes/mission-run-authority-block.json new file mode 100644 index 00000000000..91c1df15a69 --- /dev/null +++ b/release-notes/mission-run-authority-block.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "A top-level mission can declare `authority { VERB \"PATTERN\" }`: what its runs may do as `mission-run/RUN` (publish, start, revise and cancel missions by ID, write subjects); it parses, renders and is stored with the revision, and is not enforced yet", + "replay": {"status": "none", "detail": "No claim kind or projection change. `mission.published` bodies of missions that declare the block carry a new optional `authority` array in the mission spec; absent otherwise."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "New declared authority grammar for mission runs. Nothing binds a run actor or enforces it in this change; later changes bind program steps to `mission-run/RUN` and enforce the block intersected with the starter's authority."}, + "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). Each daemon advertises `mission_authority=1` on `daemon.started`; a new revision that declares `authority` is refused with `mission-authority-unavailable` (and shown as an `apply --dry-run` blocker) while any current member has not advertised it, the barrier `capacity` already uses, now one table of mission-field barriers. An older CLI refuses a file that declares `authority` at apply as an unknown mission child."}, + "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Remove the `authority` block and publish again to return to an undeclared revision."} +} diff --git a/release-notes/program-api-effect-workflow.json b/release-notes/program-api-effect-workflow.json new file mode 100644 index 00000000000..73d26e36b13 --- /dev/null +++ b/release-notes/program-api-effect-workflow.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The private `fractal` package gains `fractal/effect`: program-form missions (`Fractal.mission(intent, program)`) whose steps, worker turns, asks and child runs are `effect/workflow` activities and durable deferreds, and whose bundle entry implements the program host contract v1", + "replay": {"status": "none", "detail": "No daemon, claim or projection change. The package implements the bundle side of the program.step journal: it reads the journal the host hands over and writes entries through the host; the daemon side ships separately."}, + "schema": {"status": "none", "detail": "No table, index, migration or claim kind change."}, + "rules": {"status": "none", "detail": "No rule or authority change."}, + "compatibility": {"status": "changed", "detail": "Additive: a new `fractal/effect` package entry point. Existing `fractal`, `fractal/tree` and `fractal/testing` exports are unchanged. The host contract's child-run method is `child(request)`, since the context already has a `run` field naming the mission run."}, + "downtime": {"status": "none", "detail": "No deploy-time effect; the package is private and not published to npm."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Nothing to recover: no state is written outside a program host."} +} diff --git a/release-notes/program-engine-design.json b/release-notes/program-engine-design.json new file mode 100644 index 00000000000..f66f174e8ef --- /dev/null +++ b/release-notes/program-engine-design.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Documentation only: the accepted program engine design (docs/st3/program-engine.md), with its decisions and build plan", + "replay": {"status": "none", "detail": "Documentation only; no claim, projection or replay change."}, + "schema": {"status": "none", "detail": "Documentation only; no table, index or migration change."}, + "rules": {"status": "none", "detail": "Documentation only; no rule or authority change."}, + "compatibility": {"status": "none", "detail": "Documentation only; no client, wire, CLI or behaviour change."}, + "downtime": {"status": "none", "detail": "No restart or migration."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Revert the commit."} +} diff --git a/release-notes/service-read-gate.json b/release-notes/service-read-gate.json new file mode 100644 index 00000000000..9e56b68606b --- /dev/null +++ b/release-notes/service-read-gate.json @@ -0,0 +1 @@ +{"version":1,"summary":"Add closed read-only service admission for receipt/state claims and typed work items; service document namespaces and production issuance remain disabled","replay":{"status":"none","detail":"No migration; grant storage and digest authentication retain their existing meanings."},"schema":{"status":"none","detail":"No fractal-schema or device pairing changes."},"rules":{"status":"changed","detail":"Service bearers on both routers require a registered GET/read-scope pair. Claim bodies use a closed gate-result/work-state field schema excluding literal gate names. All other kinds expose only store_index and a fixed withheld marker in unfiltered pages and return not-found by id. Service selectors match only allowlisted claim kinds, restricted in SQL before paging; filtered pages never contain withheld markers or indexes. Work reads use a separate typed identifier/state/clock DTO excluding person answers, summaries and all other free-text subviews, without acknowledging wakes. Document scope and routes remain registered but every service document read is refused because the namespace allowlist is empty. Unknown routes, upgrades and mutations are refused before dispatch. Daemon-owned read-window audits are bounded per grant."},"compatibility":{"status":"changed","detail":"New client claim/work aliases are service-only; existing non-service native reads retain their semantics. Browser ingress refuses mixed admitted-cookie/Authorization requests instead of discarding the bearer. Assistant scopes and disclosure consent are unchanged. Service issuance remains disabled; owner lifecycle and rollout safety remain prerequisites."},"downtime":{"status":"none","detail":"No downtime is required for this change."},"manual":{"status":"none","detail":"No manual step is required; production service issuance remains disabled."},"recovery":{"status":"none","detail":"No recovery action is needed; this change introduces no migration or persisted-state conversion."}} diff --git a/release-notes/shipped-objectives-kdl.json b/release-notes/shipped-objectives-kdl.json new file mode 100644 index 00000000000..be668617d61 --- /dev/null +++ b/release-notes/shipped-objectives-kdl.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The daemon's built-in targets are the objectives it ships in slo/objectives.kdl, read by the objective reader; slo/targets.toml is gone", + "replay": {"status": "none", "detail": "No replay or rebuild."}, + "schema": {"status": "none", "detail": "No tables, columns, indexes or triggers."}, + "rules": {"status": "none", "detail": "Checkpoint rules and digests are unchanged."}, + "compatibility": {"status": "none", "detail": "Behavior is unchanged: the same 25 rows with the same ids, abouts, targets and windows in st slo, GET /v1/slo, the request-latency read, st doctor and the OTLP gauges. The file is built into each daemon, so no running config changes. writer-hold reads its own objective's 300 ms bound, equal to the transaction bound it used before; st_slo_target_ms's description names slo/objectives.kdl."}, + "downtime": {"status": "none", "detail": "Standard daemon restart only."}, + "manual": {"status": "none", "detail": "No manual step."}, + "recovery": {"status": "none", "detail": "Binary rollback restores the TOML-built targets, which are the same numbers."} +} diff --git a/release-notes/stall-ignores-lease-renewals.json b/release-notes/stall-ignores-lease-renewals.json new file mode 100644 index 00000000000..1402e6b4181 --- /dev/null +++ b/release-notes/stall-ignores-lease-renewals.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Report a stalled run when a worker holds a step and only renews its lease; lease renewals no longer count as a sign of life", + "replay": { "status": "none", "detail": "No replay or projection changes. The stall deadline is recomputed at each run evaluation from existing step timing and progress claims." }, + "schema": { "status": "none", "detail": "No persisted schema, claim vocabulary or migration changes." }, + "rules": { "status": "none", "detail": "Checkpoint and retention rules are unchanged." }, + "compatibility": { "status": "changed", "detail": "Behaviour change for runs that name a reporter (report-to): a step a live seat holds now stalls after stalled-after (30 minutes unless set) without a work progress record, a new claim or resume, or a declared work wait. Before, the minute-by-minute lease renewal postponed the stall for as long as the seat lived. A worker on a long step should record progress with fractal work progress, or the reporter gets one stalled message per silence. Runs without report-to are unaffected. Members on different builds each evaluate only their own runs, so a mixed fleet reports by the build of the member that owns the run. Not tested against an older build." }, + "downtime": { "status": "none", "detail": "Normal binary rollout. The stall check adds one indexed step read per held step, only for runs that name a reporter and only when the run is evaluated." }, + "manual": { "status": "none", "detail": "Nothing to configure. To silence stall reports for a run, use report-stalls=#false or fractal missions report-to RUN --report-stalls false. The rule is in docs/st3/mission-graph-runtime.md under Run reports." }, + "recovery": { "status": "none", "detail": "Ordinary binary rollback restores the previous stall clock. Stalled messages already sent stay in the reporter's mailbox." } +} diff --git a/scripts/ci-perf b/scripts/ci-perf index 83df95431b5..f21be80d856 100755 --- a/scripts/ci-perf +++ b/scripts/ci-perf @@ -56,7 +56,7 @@ summarize_load() { printf '| Request | n | p50 ms | p99 ms | max ms | budget ms | target ms |\n| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n' jq -r '.paths | to_entries[] | "| \(.key) | \(.value.count) | \(.value.p50_ms | floor) | \(.value.p99_ms | floor) | \(.value.max_ms | floor) | \(.value.budget_ms | floor) | \(if .value.descriptive_target_ms == null then "-" else "\(.value.descriptive_target_ms | floor)\(if .value.over_descriptive_target then " over" else "" end)" end) |"' "$report" - printf '\nTarget: the p99 in slo/targets.toml, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' + printf '\nTarget: the p99 in slo/objectives.kdl, described and never gated. Budgets and main'"'"'s baseline are the gates.\n' printf 'Runner reference: %s ms to sort 2^21 values on one core (the same work on every host).\n' \ "$(jq -r '.runner_reference_ms // "-"' "$report")" jq -r 'if (.unavailable // {}) == {} then empty else @@ -169,7 +169,7 @@ PY if [ -s "$RUNNER_TEMP/perf-source-state.json" ]; then export AGENT_SPEC_REVISION="$(git rev-parse HEAD)" fi - # The daemon beside a CPU and disk hog at seat priority; reported against slo/targets.toml, + # The daemon beside a CPU and disk hog at seat priority; reported against slo/objectives.kdl, # never a required check (docs/ci.md). # arn:lmig:smalltalk:2026-10-16-env-fractal-dual-read FRACTAL_LOAD_GATE=1 ST_LOAD_GATE=1 FRACTAL_LOAD_HOG=1 ST_LOAD_HOG=1 FRACTAL_LOAD_HOG_REPORT="$out/hog" ST_LOAD_HOG_REPORT="$out/hog" \ diff --git a/scripts/ci-perf-slot b/scripts/ci-perf-slot index 78d83a290dd..7db3eb6e598 100755 --- a/scripts/ci-perf-slot +++ b/scripts/ci-perf-slot @@ -47,7 +47,7 @@ Measuring a change (docs/ci.md, "Performance gate") Run the load test above on your branch alone: the slot makes sure nothing else of ours is measuring at the same time. Its report is a table of each request's p50/p99/max against its budget, and the daemon's CPU in cores. The budgets that apply are the absolute p99 budget of - every request and the 2-core CPU ceiling (slo/targets.toml); locally there is no baseline, so + every request and the 2-core CPU ceiling (slo/objectives.kdl); locally there is no baseline, so those are the whole gate. CI adds the relative gate, twice the worst of main's last five reports, when you dispatch Performance on your branch. A run that misses a budget fails; do not retry it into passing, find the cause. diff --git a/scripts/fractal-delivery-probe-test b/scripts/fractal-delivery-probe-test index 55bbdf0506c..bcc8bff57b9 100644 --- a/scripts/fractal-delivery-probe-test +++ b/scripts/fractal-delivery-probe-test @@ -996,6 +996,13 @@ class NativeProbeTests(unittest.IsolatedAsyncioTestCase): env["DBUS_SESSION_BUS_ADDRESS"] = os.environ["DBUS_SESSION_BUS_ADDRESS"] # The fault eval's default phase bound for node startup and each CLI call. nodes = [faults.Node(directory, host, BINARY, env, binary_directory, 60) for host in ("amber", "cobalt")] + for node in nodes: + runtime_profile = node.root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(node.root / "bin")}}}, + })) + node.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + node.env["AGENT_PERSONA"] = "generalist" if publication_gap: # Hold the launcher's return after the real PTY is published. The # native channel can already see its local incarnation while the diff --git a/scripts/fractal-no-st2-eval/run b/scripts/fractal-no-st2-eval/run index 774c10a4c3f..c49911cc4ec 100755 --- a/scripts/fractal-no-st2-eval/run +++ b/scripts/fractal-no-st2-eval/run @@ -2,7 +2,8 @@ """A Claude seat on a machine with no st2: no `st2` on PATH and no st2 state directory. One isolated daemon (private HOME, XDG dirs, PATH and sockets) runs one Claude seat whose -`claude` is ./claude, a stand-in that drives the real hook, status-line and channel contracts. +`claude` is ./claude, a stand-in selected through a private managed runtime profile that drives +the real hook, status-line and channel contracts. The eval checks, from outside the seat, that st3 alone: - publishes harness.observed working and idle from the seat's hooks; - binds Claude's native session, so the client API timeline shows the native transcript; @@ -59,6 +60,10 @@ class Node: stub = root / "bin/claude" stub.write_text(f'#!/bin/sh\nexec "{python}" "{root / "bin/claude.py"}" "$@"\n') stub.chmod(0o755) + runtime_profile = root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(root / "bin")}}}, + })) self.path = isolated_path(root / "bin") shell = shutil.which("bash", path=self.path) if not shell: @@ -74,6 +79,8 @@ class Node: "XDG_DATA_HOME": str(root / "home/.local/share"), "XDG_RUNTIME_DIR": str(root / "run"), "ST3_ENDPOINT": str(self.endpoint), "ST3_DAEMON_WAIT": "0"} + self.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + self.env["AGENT_PERSONA"] = "generalist" # The daemon rebuilds PATH from a login shell; pin it to the isolated one. profile = f"export PATH={shlex.quote(self.path)}\n" for name in (".profile", ".bash_profile", ".bashrc", ".zprofile", ".zshenv"): diff --git a/scripts/fractal-subagents-eval/run b/scripts/fractal-subagents-eval/run index e5b2ad6257f..f0d2f779290 100755 --- a/scripts/fractal-subagents-eval/run +++ b/scripts/fractal-subagents-eval/run @@ -2,7 +2,8 @@ """Subagent claims on an isolated daemon, driven through a stand-in Claude with no model. One isolated daemon (private HOME, XDG dirs, PATH and sockets) runs three Claude seats whose -`claude` is ./claude, a stand-in that reports subagents through the real hooks. The eval checks, +`claude` is ./claude, a stand-in selected through a private managed runtime profile that reports +subagents through the real hooks. The eval checks, from outside the seats, that st records one `subagent.appeared` and one `subagent.ended` per subagent on its parent seat, and lease renewals only while one runs: - appear then end, with the subagent's type, one-line description, session and held step, and @@ -82,6 +83,10 @@ class Node: stub = root / "bin/claude" stub.write_text(f'#!/bin/sh\nexec "{python}" "{root / "bin/claude.py"}" "$@"\n') stub.chmod(0o755) + runtime_profile = root / "runtime-profile.json" + runtime_profile.write_text(json.dumps({ + "personas": {"launch": {"generalist": {"harnessBin": str(root / "bin")}}}, + })) self.path = isolated_path(root / "bin") shell = shutil.which("bash", path=self.path) if not shell: @@ -94,6 +99,8 @@ class Node: "XDG_DATA_HOME": str(root / "home/.local/share"), "XDG_RUNTIME_DIR": str(root / "run"), "ST3_ENDPOINT": str(self.endpoint), "ST3_DAEMON_WAIT": "0"} + self.env["AGENT_RUNTIME_PROFILE"] = str(runtime_profile) + self.env["AGENT_PERSONA"] = "generalist" # The daemon rebuilds PATH from a login shell; pin it to the isolated one. profile = f"export PATH={shlex.quote(self.path)}\n" for name in (".profile", ".bash_profile", ".bashrc", ".zprofile", ".zshenv"): diff --git a/slo/objectives.kdl b/slo/objectives.kdl new file mode 100644 index 00000000000..5fd55a4cd13 --- /dev/null +++ b/slo/objectives.kdl @@ -0,0 +1,181 @@ +// Generated file - DO NOT EDIT +// Source: objectives.kdl.genie.ts +version 2 +objective "person-read" { + about "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" + indicator "latency" { + path "GET /v1/client/agents" + path "GET /v1/client/agents/{*id}" + path "stream agents" + path "GET /v1/client/missions" + path "GET /v1/client/missions/{*id}" + path "stream missions" + path "GET /v1/client/sessions/{*id}" + path "stream conversation" + path "GET /v1/client/conversations/{id}/changes" + path "GET /v1/client/work" + path "GET /v1/client/work/{*id}" + path "GET /v1/work" + path "stream work" + path "GET /v1/client/now" + path "GET /v1/client/attention" + path "stream attention" + path "GET /v1/mission-runs/{run}/waiting-on" + path "GET /v1/blocked-runs" + path "GET /v1/client/documents/content" + path "GET /v1/documents" + path "GET /v1/documents/content" + } + target "p99" within="300ms" + target "p99" scope="remote" within="300ms" +} +objective "person-read-fresh" { + about "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" + indicator "latency" { + path "GET /v1/client/agents (fresh)" + } + target "p99" within="300ms" +} +objective "projection-fresh" { + about "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" + indicator "latency" { + path "publish missions" + path "publish agents" + path "publish attention" + } + target "p99" within="1500ms" +} +objective "write-ack" { + about "Writes and acks: send, work actions, harness events" + indicator "latency" { + path "POST /v1/messages" + path "POST /v1/client/actions" + path "POST /v1/work/{action}/{*subject}" + path "POST /v1/work/extend/{*subject}" + path "POST /v1/work/done" + path "POST /v1/work/ask" + path "POST /v1/work/cancel-ask" + path "POST /v1/work/start" + path "POST /v1/work/handoff/{*subject}" + path "POST /v1/work/acknowledge/{*subject}" + path "POST /v1/harness-events" + path "POST /v1/mailbox/receipts" + path "POST /v1/messages/read/{*subject}" + } + target "p99" within="300ms" +} +objective "terminal-attach" { + about "Terminal attach to first screen" + indicator "latency" { + path "GET /v1/client/terminals/{id}/screen" + path "stream terminal" + } + target "p99" within="200ms" +} +objective "sql-statement" { + about "One SQL statement" + indicator "statement" + target "p99" within="10ms" + target "max" within="100ms" +} +objective "transaction" { + about "Any transaction, read or write, and any hold of the store's writer" + indicator "transaction" + target "max" within="300ms" +} +objective "writer-hold" { + about "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked" + indicator "writer-hold" + target "max" within="300ms" +} +objective "cpu" { + about "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" + indicator "cpu" { + idle-min-minutes 10 + load-max-cores 2 + } + target "cores" max=1 + target "idle-cores" max=0.5 +} +objective "ios-open-to-live" { + about "Foreground to first live Home snapshot; reported phone population only" + indicator "latency" { + path "client/ios/open-to-live" + } + target "p99" within="3000ms" +} +objective "ios-connect" { + about "Client connection; reported phone population only" + indicator "latency" { + path "client/ios/connect" + } + target "p99" within="1500ms" +} +objective "ios-message-ack" { + about "Client message acknowledgement; reported phone population only" + indicator "latency" { + path "client/ios/message-ack" + } + target "p99" within="500ms" +} +objective "ios-conversation-open" { + about "Client conversation opening; reported phone population only" + indicator "latency" { + path "client/ios/conversation-open" + } + target "p99" within="1000ms" +} +objective "ios-terminal-open" { + about "Client terminal opening; reported phone population only" + indicator "latency" { + path "client/ios/terminal-open" + } + target "p99" within="1500ms" +} +objective "ios-recover" { + about "Client feed recovery; reported phone population only" + indicator "latency" { + path "client/ios/recover" + } + target "p99" within="10000ms" +} +objective "ios-live-share" { + about "Live foreground time / reported foreground time; disconnected foreground is over-target" + indicator "share" { + path "client/ios/live-share" + } + target "live-share" min-percent=99 +} +objective "projection-divergence" { + about "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" + indicator "divergence" + target "open" max=0 +} +objective "database" { + about "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" + indicator "database" + target "live-gb" max=8 + target "growth-mb-per-day" max=300 +} +objective "merge-latency" { + about "A pull request's time in the merge queue, from entering it to merging" + indicator "ci-duration" { + series "ci/merge-queue" + } + target "p50" within="10m" + target "p95" within="10m" +} +objective "ci-critical-path" { + about "The workspace CI's critical path, from a change to all checks green" + indicator "ci-duration" { + series "ci/workspace-critical-path" + } + target "p95" within="13m" +} +objective "flakes" { + about "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" + indicator "ci-count" { + series "ci/flakes" + } + target "per-hour" max=0 +} diff --git a/slo/objectives.kdl.genie.ts b/slo/objectives.kdl.genie.ts new file mode 100644 index 00000000000..56b969560b5 --- /dev/null +++ b/slo/objectives.kdl.genie.ts @@ -0,0 +1,243 @@ +/** + * The objectives every Fractal daemon is built with (docs/st3/objectives.md). Each daemon shows + * each latency objective's live 1-minute, 5-minute and 1-hour windows on + * `GET /v1/client/request-latency` and in `fractal doctor`, and CI's daemon_load reads them too. + * An objective describes; it gates nothing by itself. daemon_load's budgets stay its gates. + * + * A latency path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time + * from a subscription on the client collections socket to its first snapshot or screen. A request + * that asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no + * objective. An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by + * design: it is the path `GET /v1/client/agents (fresh)` and counts toward its own objective, not + * the first page's. `publish COLLECTION` is a refresher's publication: the time from the first + * claim this node committed after the previous publication to the publication that carries it. + * + * This file imports only the package's dependency-free modules, which Genie bundles as they are. + */ +import { fractalKdl } from '../packages/fractal/src/kdl.ts' +import * as Fractal from '../packages/fractal/src/objective.ts' + +const { indicator } = Fractal + +/** The objectives, in the order `slo/objectives.kdl` lists them. */ +export const objectives: readonly (Fractal.ObjectiveInput & { readonly name: string })[] = [ + Fractal.objective({ + name: 'person-read', + about: + 'Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents', + indicator: indicator.latency({ + paths: [ + // Agents list + 'GET /v1/client/agents', + 'GET /v1/client/agents/{*id}', + 'stream agents', + // Missions list + 'GET /v1/client/missions', + 'GET /v1/client/missions/{*id}', + 'stream missions', + // Open a conversation, and its changes + 'GET /v1/client/sessions/{*id}', + 'stream conversation', + 'GET /v1/client/conversations/{id}/changes', + // Work list + 'GET /v1/client/work', + 'GET /v1/client/work/{*id}', + 'GET /v1/work', + 'stream work', + // Now + 'GET /v1/client/now', + 'GET /v1/client/attention', + 'stream attention', + // Why a run is stuck, and every blocked run + 'GET /v1/mission-runs/{run}/waiting-on', + 'GET /v1/blocked-runs', + // Documents + 'GET /v1/client/documents/content', + 'GET /v1/documents', + 'GET /v1/documents/content', + ], + }), + targets: [ + { stat: 'p99', within: '300ms' }, + // The same reads through this daemon from another machine that owns them. + { stat: 'p99', within: '300ms', scope: 'remote' }, + ], + }), + Fractal.objective({ + name: 'person-read-fresh', + about: + 'An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other', + indicator: indicator.latency({ paths: ['GET /v1/client/agents (fresh)'] }), + targets: [{ stat: 'p99', within: '300ms' }], + }), + Fractal.objective({ + name: 'projection-fresh', + about: + 'From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second', + indicator: indicator.latency({ + paths: ['publish missions', 'publish agents', 'publish attention'], + }), + targets: [{ stat: 'p99', within: '1500ms' }], + }), + Fractal.objective({ + name: 'write-ack', + about: 'Writes and acks: send, work actions, harness events', + indicator: indicator.latency({ + paths: [ + 'POST /v1/messages', + 'POST /v1/client/actions', + 'POST /v1/work/{action}/{*subject}', + 'POST /v1/work/extend/{*subject}', + 'POST /v1/work/done', + 'POST /v1/work/ask', + 'POST /v1/work/cancel-ask', + 'POST /v1/work/start', + 'POST /v1/work/handoff/{*subject}', + 'POST /v1/work/acknowledge/{*subject}', + 'POST /v1/harness-events', + 'POST /v1/mailbox/receipts', + 'POST /v1/messages/read/{*subject}', + ], + }), + targets: [{ stat: 'p99', within: '300ms' }], + }), + Fractal.objective({ + name: 'terminal-attach', + about: 'Terminal attach to first screen', + indicator: indicator.latency({ + paths: ['GET /v1/client/terminals/{id}/screen', 'stream terminal'], + }), + targets: [{ stat: 'p99', within: '200ms' }], + }), + Fractal.objective({ + name: 'sql-statement', + about: 'One SQL statement', + indicator: indicator.statement(), + targets: [ + { stat: 'p99', within: '10ms' }, + { stat: 'max', within: '100ms' }, + ], + }), + Fractal.objective({ + name: 'transaction', + about: "Any transaction, read or write, and any hold of the store's writer", + indicator: indicator.transaction(), + targets: [{ stat: 'max', within: '300ms' }], + }), + Fractal.objective({ + name: 'writer-hold', + about: + "Each hold of the store's single writer, and each read transaction past the same target, by who held it: a request route or background task, its client, and the code that asked", + indicator: indicator.writerHold(), + targets: [{ stat: 'max', within: '300ms' }], + }), + // The member's CPU is its daemon process and every replication worker process beside it, all + // their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC + // minutes. An idle minute is one known to be idle: no seat on this member working, no daemon + // start, deploy or replication catch-up in it, and every figure known. An hour with fewer than + // `idleMinMinutes` idle minutes, or that the daemon was not up for all of, is unknown. + Fractal.objective({ + name: 'cpu', + about: + 'Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour\'s idle minutes', + indicator: indicator.cpu({ + idleMinMinutes: 10, + // daemon_load's bound in CI under its synthetic load, which is not the production target. + loadMaxCores: 2.0, + }), + targets: [ + { stat: 'cores', max: 1.0 }, + { stat: 'idle-cores', max: 0.5 }, + ], + }), + // Closed UTC minutes reported by paired clients; distinct from daemon response timing. + Fractal.objective({ + name: 'ios-open-to-live', + about: 'Foreground to first live Home snapshot; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/open-to-live'] }), + targets: [{ stat: 'p99', within: '3000ms' }], + }), + Fractal.objective({ + name: 'ios-connect', + about: 'Client connection; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/connect'] }), + targets: [{ stat: 'p99', within: '1500ms' }], + }), + Fractal.objective({ + name: 'ios-message-ack', + about: 'Client message acknowledgement; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/message-ack'] }), + targets: [{ stat: 'p99', within: '500ms' }], + }), + Fractal.objective({ + name: 'ios-conversation-open', + about: 'Client conversation opening; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/conversation-open'] }), + targets: [{ stat: 'p99', within: '1000ms' }], + }), + Fractal.objective({ + name: 'ios-terminal-open', + about: 'Client terminal opening; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/terminal-open'] }), + targets: [{ stat: 'p99', within: '1500ms' }], + }), + Fractal.objective({ + name: 'ios-recover', + about: 'Client feed recovery; reported phone population only', + indicator: indicator.latency({ paths: ['client/ios/recover'] }), + targets: [{ stat: 'p99', within: '10000ms' }], + }), + Fractal.objective({ + name: 'ios-live-share', + about: 'Live foreground time / reported foreground time; disconnected foreground is over-target', + indicator: indicator.share({ path: 'client/ios/live-share' }), + targets: [{ stat: 'live-share', minPercent: 99 }], + }), + Fractal.objective({ + name: 'projection-divergence', + about: + "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer", + indicator: indicator.divergence(), + targets: [{ stat: 'open', max: 0 }], + }), + Fractal.objective({ + name: 'database', + about: + "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run", + indicator: indicator.database(), + targets: [ + { stat: 'live-gb', max: 8.0 }, + { stat: 'growth-mb-per-day', max: 300 }, + ], + }), + // CI, observed outside the daemon: the merge queue and the workspace's checks. A `ci-duration` + // target is one percentile of a duration, the row `NAME/pPERCENTILE`. A `ci-count` is the most + // events an hour, the row `NAME`. `series` names what feeds it, `ci/NAME`. They are objectives in + // `fractal slo` only, not in the request-latency read or `fractal doctor`. Workspace CI's runs, + // read by the repository observer that reads main_performance_failures and held in memory, feed + // them; other members show no-data. + Fractal.objective({ + name: 'merge-latency', + about: "A pull request's time in the merge queue, from entering it to merging", + indicator: indicator.ciDuration({ series: 'ci/merge-queue' }), + targets: [ + { stat: 'p50', within: '10m' }, + { stat: 'p95', within: '10m' }, + ], + }), + Fractal.objective({ + name: 'ci-critical-path', + about: "The workspace CI's critical path, from a change to all checks green", + indicator: indicator.ciDuration({ series: 'ci/workspace-critical-path' }), + targets: [{ stat: 'p95', within: '13m' }], + }), + Fractal.objective({ + name: 'flakes', + about: + 'Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue', + indicator: indicator.ciCount({ series: 'ci/flakes' }), + targets: [{ stat: 'per-hour', max: 0 }], + }), +] + +export default fractalKdl(objectives.map(Fractal.objectiveNode)) diff --git a/slo/targets.toml b/slo/targets.toml deleted file mode 100644 index 559aa61931d..00000000000 --- a/slo/targets.toml +++ /dev/null @@ -1,206 +0,0 @@ -# What the st daemon aims for. Every daemon builds this file in, shows each target's live -# 1-minute, 5-minute and 1-hour windows on `GET /v1/client/request-latency` and in `st doctor`, -# and CI's daemon_load reads it too. A latency is a 99th percentile unless it says max. -# -# A target describes; it gates nothing by itself. daemon_load's budgets stay its gates. -# -# A path is `METHOD /route` as the daemon routes it, or `stream COLLECTION` for the time from a -# subscription on the client collections socket to its first snapshot or screen. A request that -# asks to wait (`wait_ms` above zero, or `wait=true`) is a long poll and counts toward no target. -# An agents list asked to be fresh (`fresh=true`) waits for one roster refresh by design: it is -# the path `GET /v1/client/agents (fresh)` and counts toward its own target, not the first page's. -# `publish COLLECTION` is a refresher's publication: the time from the first claim this node -# committed after the previous publication to the publication that carries it. - -[[latency]] -name = "person-read" -about = "Reads a person waits on: agents list, missions list, open a conversation, conversation changes, work list, Now, what a run waits on, documents" -p99_ms = 300 -# The same reads through this daemon from another machine that owns them. -remote_p99_ms = 300 -paths = [ - # Agents list - "GET /v1/client/agents", - "GET /v1/client/agents/{*id}", - "stream agents", - # Missions list - "GET /v1/client/missions", - "GET /v1/client/missions/{*id}", - "stream missions", - # Open a conversation, and its changes - "GET /v1/client/sessions/{*id}", - "stream conversation", - "GET /v1/client/conversations/{id}/changes", - # Work list - "GET /v1/client/work", - "GET /v1/client/work/{*id}", - "GET /v1/work", - "stream work", - # Now - "GET /v1/client/now", - "GET /v1/client/attention", - "stream attention", - # Why a run is stuck, and every blocked run - "GET /v1/mission-runs/{run}/waiting-on", - "GET /v1/blocked-runs", - # Documents - "GET /v1/client/documents/content", - "GET /v1/documents", - "GET /v1/documents/content", -] - -[[latency]] -name = "person-read-fresh" -about = "An agents list with legacy fresh=true or an explicit min_cut asks to wait for publication; ordinary st agents ls and tree do not. Explicit waits have their own target and count toward no other" -p99_ms = 300 -paths = [ - "GET /v1/client/agents (fresh)", -] - -[[latency]] -name = "projection-fresh" -about = "From a commit to the published missions, agents or attention view that carries it, timed by each refresher; refreshers fold at most once a second" -p99_ms = 1500 -paths = [ - "publish missions", - "publish agents", - "publish attention", -] - -[[latency]] -name = "write-ack" -about = "Writes and acks: send, work actions, harness events" -p99_ms = 300 -paths = [ - "POST /v1/messages", - "POST /v1/client/actions", - "POST /v1/work/{action}/{*subject}", - "POST /v1/work/extend/{*subject}", - "POST /v1/work/done", - "POST /v1/work/ask", - "POST /v1/work/cancel-ask", - "POST /v1/work/start", - "POST /v1/work/handoff/{*subject}", - "POST /v1/work/acknowledge/{*subject}", - "POST /v1/harness-events", - "POST /v1/mailbox/receipts", - "POST /v1/messages/read/{*subject}", -] - -[[latency]] -name = "terminal-attach" -about = "Terminal attach to first screen" -p99_ms = 200 -paths = [ - "GET /v1/client/terminals/{id}/screen", - "stream terminal", -] - -[statement] -about = "One SQL statement" -p99_ms = 10 -max_ms = 100 - -[transaction] -about = "Any transaction, read or write, and any hold of the store's writer" -max_ms = 300 - -# The member's CPU is its daemon process and every replication worker process beside it, all -# their work included (maintenance, heartbeat, anti-entropy, replication), over closed UTC -# minutes. An idle minute is one known to be idle: no seat on this member working, no daemon -# start, deploy or replication catch-up in it, and every figure known. An hour with fewer than -# `idle_min_minutes` idle minutes, or that the daemon was not up for all of, is unknown. -[cpu] -about = "Member CPU, the daemon and its replication workers: at most 1 core over the hour, and 0.5 core over the hour's idle minutes" -max_cores = 1.0 -idle_max_cores = 0.5 -idle_min_minutes = 10 -# daemon_load's bound in CI under its synthetic load, which is not the production target. -load_max_cores = 2.0 - -# Closed UTC minutes reported by paired clients; distinct from daemon response timing. - -[[latency]] -name = "ios-open-to-live" -about = "Foreground to first live Home snapshot; reported phone population only" -p99_ms = 3000 -paths = ["client/ios/open-to-live"] - -[[latency]] -name = "ios-connect" -about = "Client connection; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/connect"] - -[[latency]] -name = "ios-message-ack" -about = "Client message acknowledgement; reported phone population only" -p99_ms = 500 -paths = ["client/ios/message-ack"] - -[[latency]] -name = "ios-conversation-open" -about = "Client conversation opening; reported phone population only" -p99_ms = 1000 -paths = ["client/ios/conversation-open"] - -[[latency]] -name = "ios-terminal-open" -about = "Client terminal opening; reported phone population only" -p99_ms = 1500 -paths = ["client/ios/terminal-open"] - -[[latency]] -name = "ios-recover" -about = "Client feed recovery; reported phone population only" -p99_ms = 10000 -paths = ["client/ios/recover"] - -[[share]] -name = "ios-live-share" -about = "Live foreground time / reported foreground time; disconnected foreground is over-target" -min_percent = 99 -path = "client/ios/live-share" - -[divergence] -about = "Open P1 projection bugs: two members that hold the same envelopes and project a table differently. Each is a fault for the fleet's fault agent with what a reproduction needs; nothing repairs it from a peer" -max_open = 0 - -[database] -about = "This member's store: the pages in use, and how much they grow a day once checkpoints and retention have run" -max_live_gb = 8.0 -max_growth_mb_per_day = 300 - -# CI, observed outside the daemon: the merge queue and the workspace's checks. A `[[duration]]` is -# one percentile of a duration, the objective `NAME/pPERCENTILE`; a name may declare several. A -# `[[count]]` is the most events an hour, the objective `NAME`. `path` names the series that feeds -# it, `ci/NAME`. They are objectives in `st slo` only, not in the request-latency read or `st -# doctor`. Workspace CI's runs, read by the repository observer that reads -# main_performance_failures and held in memory, feed them; other members show no-data. - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 50 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "merge-latency" -about = "A pull request's time in the merge queue, from entering it to merging" -percentile = 95 -target_ms = 600000 -path = "ci/merge-queue" - -[[duration]] -name = "ci-critical-path" -about = "The workspace CI's critical path, from a change to all checks green" -percentile = 95 -target_ms = 780000 -path = "ci/workspace-critical-path" - -[[count]] -name = "flakes" -about = "Flaky results: tests nextest passed only on a retry, and merge groups ejected from the queue" -max_per_hour = 0 -path = "ci/flakes"