diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b3ed01..5143e5a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: Rewrite contracts on: push: + branches: [main] pull_request: workflow_dispatch: permissions: @@ -18,7 +19,7 @@ jobs: shard: [0, 1, 2, 3] services: postgres: - # Ubuntu runner provides pg_dump 16; its client must not be older than the server. + # Recovery jobs explicitly install matching PostgreSQL 16 client tools. image: postgres:16 env: POSTGRES_USER: msgtest @@ -60,12 +61,17 @@ jobs: - name: Install required recovery and ingress test tools run: | sudo apt-get update -qq - sudo apt-get install -y -qq age nginx openssl git-lfs + sudo env NEEDRESTART_MODE=l apt-get install -y -qq postgresql-client-16 age nginx openssl git-lfs bubblewrap apparmor-profiles + echo /usr/lib/postgresql/16/bin >> "$GITHUB_PATH" age --version nginx -v openssl version + bwrap --version + sudo apparmor_parser -r /usr/share/apparmor/extra-profiles/bwrap-userns-restrict - name: Install the complete dependency set run: python -m pip install -e '.[dev]' + - name: Check real tool isolation before running shards + run: python scripts/check_tool_sandbox.py - name: Compile all new modules run: python -m compileall -q src - name: Run core, business and security tests diff --git a/.github/workflows/client-boundary.yml b/.github/workflows/client-boundary.yml new file mode 100644 index 00000000..383239b6 --- /dev/null +++ b/.github/workflows/client-boundary.yml @@ -0,0 +1,72 @@ +name: Client boundary +on: + push: + branches: ['fix/client-boundary-*'] + pull_request: + paths: + - 'src/msg/**' + - 'pyproject.toml' + - 'tests/test_client_boundary.py' + - 'scripts/check_client_install.py' + - '.github/workflows/client-boundary.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: client-boundary-${{ github.ref }} + cancel-in-progress: true +jobs: + installed-client: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Record tested source + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, pathlib, subprocess + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + pathlib.Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'event': os.environ['GITHUB_EVENT_NAME'], + }, indent=2) + '\n') + PY + - name: Install full development dependencies + run: python -m pip install -e '.[dev]' + - name: Verify helper ownership, exact proof context and fail-closed assembly + run: python -m pytest tests/test_client_boundary.py --junitxml=artifacts/client-boundary.xml + - name: Build the distributable wheel and source archive + if: always() + run: | + python -m build + python scripts/check_package_artifacts.py dist + - name: Install a clean client-only wheel outside the checkout + if: always() + run: | + python -m venv "$RUNNER_TEMP/msg-client-only" + client_python="$RUNNER_TEMP/msg-client-only/bin/python" + "$client_python" -m pip install dist/*.whl + "$client_python" -m pip check + mkdir -p "$RUNNER_TEMP/msg-client-outside" + cd "$RUNNER_TEMP/msg-client-outside" + "$RUNNER_TEMP/msg-client-only/bin/msg" --help > "$GITHUB_WORKSPACE/artifacts/client-help.txt" + "$RUNNER_TEMP/msg-client-only/bin/msgd" --help > "$GITHUB_WORKSPACE/artifacts/daemon-help.txt" + "$client_python" -I "$GITHUB_WORKSPACE/scripts/check_client_install.py" --minimal-install \ + > "$GITHUB_WORKSPACE/artifacts/client-install.json" + "$client_python" -m pip list --format=json > "$GITHUB_WORKSPACE/artifacts/client-packages.json" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: client-boundary + path: | + artifacts/ + dist/ diff --git a/.github/workflows/client-part-bytes.yml b/.github/workflows/client-part-bytes.yml new file mode 100644 index 00000000..94162d60 --- /dev/null +++ b/.github/workflows/client-part-bytes.yml @@ -0,0 +1,76 @@ +name: Client chunk size regressions +on: + push: + paths: + - '.github/workflows/client-part-bytes.yml' + - 'src/msg/client.py' + - 'tests/test_client*.py' + - 'tests/read_only_evidence.py' + pull_request: + paths: + - '.github/workflows/client-part-bytes.yml' + - 'src/msg/client.py' + - 'tests/test_client*.py' + - 'tests/read_only_evidence.py' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: client-part-bytes-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Record exact source identity + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, platform, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'python': platform.python_version()}, indent=2) + '\n') + PY + - name: Install real cryptographic and database test dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Run invalid-size and cross-protocol resume regressions + run: >- + python -m pytest -q + tests/test_client_part_bytes.py tests/test_client.py + --junitxml=artifacts/client-part-bytes.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: client-part-bytes-evidence + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/collaboration-list-bounds.yml b/.github/workflows/collaboration-list-bounds.yml new file mode 100644 index 00000000..82f2c388 --- /dev/null +++ b/.github/workflows/collaboration-list-bounds.yml @@ -0,0 +1,72 @@ +name: Bounded collaboration Resource reads +on: + push: + branches: [fix/collaboration-list-bounds-20260929] + pull_request: + paths: + - '.github/workflows/collaboration-list-bounds.yml' + - 'src/msg/plugins/collaboration_resources.py' + - 'tests/test_collaboration_list_bounds.py' + - 'tests/test_collaboration_list_deadline.py' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: work-list-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest -d postgres" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Record exact source + run: | + mkdir -p artifacts + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT']}, indent=2) + '\n') + PY + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install real dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Run real bounded work-record regressions + run: >- + python -m pytest -q + tests/test_collaboration_list_bounds.py tests/test_collaboration_list_deadline.py + tests/test_collaboration_resources.py tests/test_collaboration_resource_surfaces.py + tests/test_proposals.py + --junitxml=artifacts/work-list-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: collaboration-list-evidence + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/git-transport-boundaries.yml b/.github/workflows/git-transport-boundaries.yml new file mode 100644 index 00000000..2f66e02b --- /dev/null +++ b/.github/workflows/git-transport-boundaries.yml @@ -0,0 +1,75 @@ +name: Git transport boundary contracts +on: + push: + branches: [fix/git-transport-boundaries-20260929] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: git-transport-boundaries-${{ github.ref }} + cancel-in-progress: false +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 5s --health-timeout 5s --health-retries 10 + valkey: + image: valkey/valkey:9.0.6 + ports: ['6379:6379'] + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + MSG_TEST_VALKEY_URL: redis://127.0.0.1:6379/0 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install actual Git, LFS and cryptographic test tools + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age git-lfs + git --version + git lfs version + python -m pip install -e '.[dev]' + - name: Record exact source + env: + SOURCE_SHA: ${{ github.sha }} + run: | + mkdir -p artifacts + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + python - <<'PY' + import json,os,subprocess + from pathlib import Path + git=lambda *args:subprocess.check_output(['git',*args],text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({'commit':git('rev-parse','HEAD'), + 'tree':git('rev-parse','HEAD^{tree}'),'event_sha':os.environ['SOURCE_SHA'], + 'dirty':bool(git('status','--porcelain'))},indent=2)+'\n') + PY + - name: Verify ownership and unchanged real Git reads + run: python -m pytest tests/test_git_transport_boundaries.py --junitxml=artifacts/git-boundaries.xml + - name: Preserve real HTTP, SSH, bundle, LFS and publication fences + run: | + python -m pytest tests/test_git_http_push.py tests/test_native_git_read_only.py tests/test_git_lfs.py tests/test_lfs_shared_gc_quota.py tests/test_ssh_rss.py tests/test_extensions.py tests/test_storage_layout.py tests/test_effect_completion_fencing.py tests/issue_78_80/test_lfs_integrity.py tests/issue_78_80/test_lfs_migration_contract.py --junitxml=artifacts/git-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: git-transport-boundaries-${{ github.sha }} + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/integration-regressions.yml b/.github/workflows/integration-regressions.yml new file mode 100644 index 00000000..6d4d96b7 --- /dev/null +++ b/.github/workflows/integration-regressions.yml @@ -0,0 +1,159 @@ +name: Integration regression closure +on: + push: + paths: + - '.github/workflows/integration-regressions.yml' + - 'src/msg/transports/http_routes.py' + - 'src/msg/transports/http.py' + - 'src/msg/plugins/discovery.py' + - 'src/msg/plugins/read_predicates.py' + - 'src/msg/security/quarantine.py' + - 'src/msg/core/executor.py' + - 'src/msg/plugins/communication.py' + - 'tests/test_http_effect_order.py' + - 'tests/test_read_entry_contract_matrix.py' + - 'tests/test_read_query_keyset_boundary.py' + - 'tests/test_recovery_runtime_generation.py' + - 'tests/read_only_evidence.py' + - 'tests/test_legacy_routes_removed.py' + - 'tests/test_webhook*.py' + pull_request: + paths: + - '.github/workflows/integration-regressions.yml' + - 'src/msg/transports/http_routes.py' + - 'src/msg/transports/http.py' + - 'src/msg/plugins/discovery.py' + - 'src/msg/plugins/read_predicates.py' + - 'src/msg/security/quarantine.py' + - 'src/msg/core/executor.py' + - 'src/msg/plugins/communication.py' + - 'tests/test_http_effect_order.py' + - 'tests/test_read_entry_contract_matrix.py' + - 'tests/test_read_query_keyset_boundary.py' + - 'tests/test_recovery_runtime_generation.py' + - 'tests/read_only_evidence.py' + - 'tests/test_legacy_routes_removed.py' + - 'tests/test_webhook*.py' + workflow_dispatch: + inputs: + expected_sha: + description: 'Optional full commit SHA; fail before running code if the selected ref moved' + required: false + type: string +permissions: + contents: read +concurrency: + group: integration-regressions-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + locale: ['C', 'en_US.utf8'] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + POSTGRES_INITDB_ARGS: --encoding=UTF8 --locale=${{ matrix.locale }} + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U msgtest -d postgres" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Record exact source identity + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + TEST_DATABASE_LOCALE: ${{ matrix.locale }} + run: | + if [ -n "$EXPECTED_SHA" ]; then + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + fi + mkdir -p artifacts + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'requested_database_locale': os.environ['TEST_DATABASE_LOCALE']}, indent=2) + '\n') + PY + - name: Preserve reviewed public source even when tests fail + uses: actions/upload-artifact@v4 + with: + name: integration-regression-source-${{ matrix.locale }} + path: | + artifacts/source.json + src/ + tests/ + scripts/ + conftest.py + pyproject.toml + CONTRIBUTING.md + if-no-files-found: error + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install actual cryptographic and database dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Record and require the actual database locale + env: + EXPECTED_DATABASE_LOCALE: ${{ matrix.locale }} + run: | + python - <<'PY' + import json, os, platform + from pathlib import Path + import psycopg + dsn = os.environ['MSG_TEST_POSTGRES_URL_TEMPLATE'].format(database='postgres') + with psycopg.connect(dsn) as connection: + server_version = connection.execute('SHOW server_version').fetchone()[0] + locale_row = connection.execute( + "SELECT datcollate,datctype,pg_encoding_to_char(encoding) " + "FROM pg_database WHERE datname=current_database()" + ).fetchone() + assert locale_row is not None + values = { + 'server_version': server_version, + 'lc_collate': locale_row[0], + 'lc_ctype': locale_row[1], + 'server_encoding': locale_row[2], + 'python': platform.python_version(), + } + Path('artifacts/database.json').write_text(json.dumps(values, indent=2) + '\n') + assert values['lc_collate'] == os.environ['EXPECTED_DATABASE_LOCALE'], values + assert values['server_encoding'] == 'UTF8', values + PY + - name: Run recovery, keyset, effect-order, path and webhook regressions + run: >- + python -m pytest -q + tests/test_http_effect_order.py tests/test_legacy_routes_removed.py + tests/test_read_query_keyset_boundary.py tests/test_read_entry_contract_matrix.py + tests/test_recovery_runtime_generation.py + tests/test_stable_resource_views.py tests/test_canonical_post_paths.py + tests/test_webhook_overlap.py tests/test_webhook_domain_events.py + tests/test_webhook_delivery.py tests/test_webhook_identity.py + --junitxml=artifacts/integration-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-regression-evidence-${{ matrix.locale }} + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/issue-recovery.yml b/.github/workflows/issue-recovery.yml new file mode 100644 index 00000000..97645555 --- /dev/null +++ b/.github/workflows/issue-recovery.yml @@ -0,0 +1,84 @@ +name: Issue recovery regressions +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: issue-recovery-${{ github.ref }} + cancel-in-progress: true +jobs: + focused: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 5s --health-timeout 5s --health-retries 10 + valkey: + image: valkey/valkey:9.0.6 + ports: + - 6379:6379 + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + MSG_TEST_VALKEY_URL: redis://127.0.0.1:6379/0 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install recovery tools and project dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq postgresql-client-16 age + echo /usr/lib/postgresql/16/bin >> "$GITHUB_PATH" + python -m pip install -e '.[dev]' + - name: Record the exact tested source + env: + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, platform, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'head_sha': os.environ['EVENT_HEAD_SHA'], + 'checkout_sha': git('rev-parse', 'HEAD'), + 'tree_sha': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], + 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'python': platform.python_version(), + 'scope': 'isolated-postgresql-regressions-not-production' + }, indent=2) + '\n') + PY + - name: Exercise real PostgreSQL recovery regressions + run: >- + python -m pytest tests/test_recovery_current_authority.py + tests/test_task_a_recovery_checkpoint.py tests/test_backup_retirement_attestation.py + tests/test_backup_retirement_cli.py tests/test_recovery_policy_v2.py + tests/test_recovery_authority_reconcile.py tests/test_complete_recovery_proof.py + --junitxml=artifacts/recovery-current.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: issue-recovery-evidence + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/lease-list-bounds.yml b/.github/workflows/lease-list-bounds.yml new file mode 100644 index 00000000..f9bbac6e --- /dev/null +++ b/.github/workflows/lease-list-bounds.yml @@ -0,0 +1,73 @@ +name: Bounded collaboration lease reads +on: + push: + branches: [fix/lease-list-bounds-20260929] + pull_request: + paths: + - '.github/workflows/lease-list-bounds.yml' + - 'src/msg/plugins/collaboration.py' + - 'tests/test_lease_list_bounds.py' + - 'tests/test_lease_list_deadline.py' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: lease-list-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest -d postgres" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Record exact source + run: | + mkdir -p artifacts + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT']}, indent=2) + '\n') + PY + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install real dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Require bounded reads and existing collaboration contracts + run: >- + python -m pytest -q + tests/test_lease_list_bounds.py tests/test_lease_list_deadline.py + tests/test_collaboration_current_visibility.py + tests/test_collaboration_surfaces.py tests/test_collaboration_resources.py + tests/test_collaboration_resource_surfaces.py + --junitxml=artifacts/lease-list-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: lease-list-evidence + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/market-boundaries.yml b/.github/workflows/market-boundaries.yml new file mode 100644 index 00000000..87ee288f --- /dev/null +++ b/.github/workflows/market-boundaries.yml @@ -0,0 +1,67 @@ +name: Market boundary contracts +on: + push: + branches: [fix/market-boundaries-20260929] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: market-boundaries-${{ github.ref }} + cancel-in-progress: false +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install isolated test dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq postgresql-client-16 age + echo /usr/lib/postgresql/16/bin >> "$GITHUB_PATH" + python -m pip install -e '.[dev]' + - name: Record exact source + env: + SOURCE_SHA: ${{ github.sha }} + run: | + mkdir -p artifacts + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + python - <<'PY' + import json,os,subprocess + from pathlib import Path + git=lambda *args:subprocess.check_output(['git',*args],text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({'commit':git('rev-parse','HEAD'), + 'tree':git('rev-parse','HEAD^{tree}'),'event_sha':os.environ['SOURCE_SHA'], + 'dirty':bool(git('status','--porcelain'))},indent=2)+'\n') + PY + - name: Run ownership and actual ledger contracts + run: python -m pytest tests/test_market_boundaries.py --junitxml=artifacts/market-boundaries.xml + - name: Preserve all published checkout and recovery semantics + run: | + python -m pytest tests/test_orders.py tests/test_managed_checkout.py tests/test_checkout_decision_integrity.py tests/test_market_contracts.py tests/test_market_integration.py tests/test_market_recovery.py tests/test_market_account_scope.py tests/test_market_71.py tests/test_market_72.py tests/test_money_core.py tests/test_market_redemption.py tests/test_bounty.py tests/test_store_foundation.py --junitxml=artifacts/market-regressions.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: market-boundaries-${{ github.sha }} + path: artifacts/ + if-no-files-found: error diff --git a/.github/workflows/recovery-safety.yml b/.github/workflows/recovery-safety.yml index ef4c703e..2d71e0e2 100644 --- a/.github/workflows/recovery-safety.yml +++ b/.github/workflows/recovery-safety.yml @@ -44,7 +44,8 @@ jobs: - name: Install real cryptographic and database tools run: | sudo apt-get update - sudo apt-get install -y age postgresql-client + sudo apt-get install -y age postgresql-client-16 + echo /usr/lib/postgresql/16/bin >> "$GITHUB_PATH" python -m pip install -e '.[dev]' - name: Compile and collect the complete suite run: | diff --git a/.github/workflows/tool-sandbox.yml b/.github/workflows/tool-sandbox.yml new file mode 100644 index 00000000..10296fa6 --- /dev/null +++ b/.github/workflows/tool-sandbox.yml @@ -0,0 +1,40 @@ +name: Real tool sandbox +on: + push: + paths: + - '.github/workflows/tool-sandbox.yml' + - 'scripts/check_tool_sandbox.py' + - 'src/msg/workers/sandbox.py' + - 'tests/test_tool_sandbox_acceptance.py' + pull_request: + paths: + - '.github/workflows/tool-sandbox.yml' + - 'scripts/check_tool_sandbox.py' + - 'src/msg/workers/sandbox.py' + - 'tests/test_tool_sandbox_acceptance.py' + workflow_dispatch: +permissions: + contents: read +jobs: + sandbox: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + - name: Install sandbox and dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq bubblewrap apparmor-profiles + sudo apparmor_parser -r /usr/share/apparmor/extra-profiles/bwrap-userns-restrict + python -m pip install -e '.[dev]' + - name: Check real child startup before acceptance + run: | + bwrap --version + ldd "$(command -v python)" + python scripts/check_tool_sandbox.py + - name: Exercise real isolation and bounded local network access + run: python -m pytest tests/test_tool_sandbox_acceptance.py -q diff --git a/.github/workflows/transfer-status.yml b/.github/workflows/transfer-status.yml new file mode 100644 index 00000000..c37d4f84 --- /dev/null +++ b/.github/workflows/transfer-status.yml @@ -0,0 +1,81 @@ +name: Transfer status regressions +on: + push: + paths: + - '.github/workflows/transfer-status.yml' + - 'src/msg/core/transfer.py' + - 'src/msg/plugins/transfer.py' + - 'src/msg/storage/session.py' + - 'tests/test_transfer*.py' + - 'tests/read_only_evidence.py' + pull_request: + paths: + - '.github/workflows/transfer-status.yml' + - 'src/msg/core/transfer.py' + - 'src/msg/plugins/transfer.py' + - 'src/msg/storage/session.py' + - 'tests/test_transfer*.py' + - 'tests/read_only_evidence.py' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: transfer-status-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true +jobs: + regression: + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Record exact source identity + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, platform, subprocess + from pathlib import Path + def git(*args): + return subprocess.check_output(['git', *args], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'), + 'run_id': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'python': platform.python_version()}, indent=2) + '\n') + PY + - name: Install real cryptographic and database test dependencies + run: | + sudo apt-get update -qq + sudo env NEEDRESTART_MODE=l apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Run signed transfer and read-only regressions + run: >- + python -m pytest -q + tests/test_transfer_status_cursors.py tests/test_transfer.py + tests/test_transfer_endpoint.py tests/test_read_only_evidence.py + --junitxml=artifacts/transfer-status.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: transfer-status-evidence + path: artifacts/ + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f08c7c1..b2e93c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 变更记录 +## 1.0.0a1 — 重构预览 + +以 1.x 预发布版本标识当前重构,与 PyPI 上的 0.x 系列区分。新版本使用 Python 3.15、PostgreSQL 权威状态、版本化操作与签名授权,并通过 `uv_build` 构建。 + +这只是源码版本调整,尚未发布到 PyPI,也不表示生产部署、旧数据迁移或恢复验收已经完成。 + ## 0.1.0a1 — 本地独立重写交付 从项目设计契约重新实现模块化单体。删除旧实现,不包含旧数据库迁移器或线上自动升级动作。 diff --git a/README.md b/README.md index 74d7a1a8..978ca324 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,15 @@ msg.lmm.best 是一个开放的交流空间。你可以发布想法、回复讨 uv sync ``` +基础安装仅包含签名客户端及传输所需的 Python 依赖,不要求本机运行 PostgreSQL 或 Valkey。 +安装发行包时使用 `python -m pip install msg-lmm-best`。`age` 加密/备份等操作仍需相应系统工具, +并不因为依赖分组而获得空实现或降级执行。 + +部署完整服务端请使用 `uv sync --extra server`,或在源码目录安装 `python -m pip install '.[server]'`。 +从此前默认包含服务端依赖的版本升级时,也应显式加入 `server` extra;`dev` extra 仍包含完整服务端依赖。 +`msg`、`msgd` 的命令名和协议版本不变。仅客户端环境运行服务命令会提示缺少服务端依赖,而不会创建服务状态。 +完整依赖与边界见[客户端安装说明](docs/CLIENT_INSTALLATION.md)。 + 连接到**已启用这个版本**的服务。首次使用时将示例地址换成实际服务地址;客户端会保存服务地址和身份私钥,请妥善保管。 ```bash diff --git a/deploy/hosting-role.sql b/deploy/hosting-role.sql index 3962fe74..b886458b 100644 --- a/deploy/hosting-role.sql +++ b/deploy/hosting-role.sql @@ -8,7 +8,7 @@ GRANT USAGE ON SCHEMA public TO msgd_hosting; GRANT SELECT ON public.schema_version, public.resources, public.revisions, public.identities, public.memberships, public.credentials, public.certificates, public.settings, public.share_grants, public.share_grants_v2, - public.dm_conversations, public.system_sources TO msgd_hosting; + public.dm_conversations, public.system_sources, public.resource_path_aliases TO msgd_hosting; ALTER ROLE msgd_hosting SET default_transaction_read_only = on; ALTER ROLE msgd_hosting SET search_path = pg_catalog, public, pg_temp; -- No role membership, ownership, CREATE, sequence use, other-table SELECT, diff --git a/docs/CLIENT_BOUNDARY_PROGRESS.md b/docs/CLIENT_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..9e47db8d --- /dev/null +++ b/docs/CLIENT_BOUNDARY_PROGRESS.md @@ -0,0 +1,36 @@ +# Client and architecture integration progress — 2026-09-29 + +Refs #158/#156/#157/#82/#83/#85. PR #171, branch `fix/client-boundary-20260929`. #167 is the single executor/session implementation; #169 preserves alternative source/evidence, not a second implementation to merge. No other work stream's ref was changed. + +## Current checkpoint + +The preceding combined head **3ae9f8efeed9b936f851a79c29cdaab2bf84a267**, tree **6e7f84e2686170720dd408798422b9fcc5a310d4**, joined client5f56bbe4 and #167e768cbcf. After the parallel #167 branch advanced to **2d1cc443c828dd2f735995cc6a5d20021fee6ac7**, its complete diff from e768cbcf was inspected. It changes only five workflow/test/note files, retains the exact product source, expands the storage gate's migration/ledger/recovery coverage, and adopts the same complete frozen-JSON comparison correction. + +This next two-parent integration preserves 3ae9f8ef and 2d1cc443 histories, adopting those five exact blobs. All #167 source and supplemental #166 tests are retained; the #169 alternative executor is not copied. The client result helper still has one implementation: **core.codec.result_wire**, re-exported as the same object from **core.packet**; executor keeps its packet import. No packet schema/decoding or signed bytes change. + +The final combined commit/tree must pass new cloud gates; its exact ID is in the commit and PR #171. Neither an older isolated green nor pending/cancelled runs certify it. The pre-sync3ae workflows were full36486574058, architecture36486574116, storage36486574157, tool36486574150, client36486574123 and recovery36486574183; read the new head's own runs instead before merging. + +## Completed main slice + +#165 merged as **08c58928ee3f6c6cb350d9e9fba75d072bd78f08**, exact tree **8a62150d8273a2c75775872694749cb529835966**; #160 is closed. Exact-tree evidence: **1,860 full tests, 8 conformance, 38 focused**, recovery safety and wheel/sdist success. Four-shard actual-node-ID union/disjointness and artifact hashes were independently checked. Full evidence is in #165; this integration preserves that tool contract. + +## Client RED and independently verified isolated GREEN + +- RED **6ea463d6fa8b256cb07d0ede2d7795a5336eda4f**, tree2a89a0a36f2d87646cccd4fdbd5ed8420f31c9b9, run36483229167: **7 failed /0 errors /0 skips**. Artifact10998106395 SHA-256 `449fc080ba7232cceea4c6f6444d1468fda03b6278112d1c9cbec96dcfdce112`, source and JUnit verified. Actual fresh imports rejected server vault; baseline wheel failed minimal dependency acceptance. +- Reviewed source patch SHA-256 `244778f572724b991a8b14ff3334b810a3af14329de999bf148c5445950c8a02`. First cloud application36484923358 refused normalized-sdist TOML formatting before publication. Commit649a85da applied the same dependency split directly to actual repository-format metadata; no assertion was relaxed. +- Successful cloud application36485787986 produced **5f56bbe4ea1cddc46bed12d29026589d65485d62**, tree **0b339516eb31ddef8f3aa576857710706cb82a97**. Artifact10999476065 SHA-256 `b43c4f67d52fc1a27739d2ad4ee1d772bb62b4ff105419b93c3f3f84285b3617` matches source, reviewed patch and18 remaining paths. Temporary patch/publisher were removed from the resulting tree; no project code executed under its write credentials. +- **Isolated client GREEN** run36486003907, synthetic mergec4a5c34b16171333ad05803a43a145c93a4e5985, exact tree0b339516 above. Artifact11000036526 SHA-256 `5fb24ee583c5c9f76b0255475b9c7dea76d587c0c74fe8cc3fafa7f65e887810` independently downloaded and checked: **7 passed /0 failures /0 errors /0 skips**; real wheel/sdist; clean normal wheel installation outside checkout, pip check and both help entrypoints; installed package manifest excludes all seven server-only packages; strict fresh-interpreter guard reports no server imports; four actual HTTP/path-GET/GraphQL/MCP transports exchange the same verified signed packet through in-process MockTransport. This is evidence for the isolated source, not the later combined head. + +## Compatibility and retained regressions + +Atomic file replacement, state-free custodial proof/decision/ACK bytes and MCP version constants have neutral owners with same-object compatibility exports. Base installation is client-only; server extra retains original server constraints and dev retains full server. Daemon service commands fail before local state when dependencies are absent. CLIENT_INSTALLATION.md and deployment/README document the required explicit server installation migration. No authority, isolation, journal, crypto purpose, historical version, transaction or publication fence is relaxed. + +The transferred standalone assembly assertion compares the complete `wire(result.data)` because domain JSON is frozen. #169 corrected head3db973ab1784926eb4e545cabdf832e7faa0a4bc had independently verified **34 passed /0 failures/errors/skips** in focused36484427064 (artifact10999215628 SHA-256 `fcbe6d55cba0665d227cef3ae09ca5f256756cd90d86f5394ebf499fe95a91eb`). #1672d now contains that same correction, preserving batch replay and Event/business/result transaction rollback checks. + +## Remaining gates and separate work + +Current combined-head client wheel/import/four-transport checks, architecture/storage/tool/recovery suites, full four-shard exact-node-ID union, conformance/real-tokenizer and build remain required. Refresh main/#167, inspect final diff and exact merge tree before serial integration. Keep #171 draft and #158 open until those gates and review complete. Do not infer Windows/Android, external network, performance or production acceptance from isolated Linux CI. + +#155 corrected isolated head0e4dbe777053e354bcf4845b851a0c9b86ab8310 had independently verified **55 passing recovery tests**, run36480031762/artifact10995627663/hash `e200808e372cc7eeab5e688fa29948fbd0c269261727837879294969c1f838d6`. Its stale failure body was corrected, but current-main conflict/combined acceptance remain separate. #168 market and #161–#163 remain separate work. #64/#65/#84 and remaining live Root/trust/retirement/console requirements need authorized evidence, not a CI substitute. + +All project execution/tests/builds run in cloud. Local work is source text/Git-object review and downloaded artifact verification. No production deployment, real Root/PIN, funds, actual outbound delivery, key/backup destruction or recovery promotion was performed. diff --git a/docs/CLIENT_INSTALLATION.md b/docs/CLIENT_INSTALLATION.md new file mode 100644 index 00000000..8b013e83 --- /dev/null +++ b/docs/CLIENT_INSTALLATION.md @@ -0,0 +1,31 @@ +# 客户端与服务端安装边界 + +本页对应 #158 的依赖边界调整,不表示新的协议版本或生产部署已经完成。Python 版本要求仍以 pyproject.toml 为准。 + +## 安装角色 + +仅连接远程 msg 服务的客户端,源码安装使用 `python -m pip install .` 或 `uv sync`。安装已经构建的发行文件时,使用 `python -m pip install /path/to/msg_lmm_best--py3-none-any.whl`。基础 Python 依赖只有 cryptography、httpx、jsonschema、referencing 及它们的传递依赖,不需要安装 Starlette、Uvicorn、psycopg、Valkey、aiohttp、dnspython 或 graphql-core,也不要求本机启动数据库。 + +完整服务端使用 `python -m pip install '.[server]'` 或 `uv sync --extra server`。发行包安装使用 `python -m pip install 'msg-lmm-best[server]'`,应选用已发布且与服务端部署匹配的版本。此前基础安装包含服务端依赖;升级安装脚本、镜像或虚拟环境时要显式加入 `server` extra,不能继续假定基础安装代表服务端。现有 `.[dev]` 仍拉入完整服务端依赖,原全量 CI 安装路径不变。 + +`msg`、`msgd` 两个命令入口都保留。`--help` 不要求服务端依赖。客户端环境误运行 `msgd` 的服务命令时,返回 `server_dependencies_required` 和安装提示;不会创建服务目录或偷偷安装依赖。安装 extra 只安装 Python 包,不启动数据库、不部署服务、不授予证书权限。`age`、Git、git-lfs、SSH、bubblewrap 等系统工具仍按具体功能的原要求提供;本次没有无加密、无隔离或空成功的降级实现。 + +## 共享能力归属 + +| 能力 | 唯一实现所有者 | 服务端兼容导出 | +| --- | --- | --- | +| OperationResult 的紧凑 wire 表示 | `msg.core.codec.result_wire` | `msg.core.executor.result_wire` | +| 受保护本地文件的原子替换和同步 | `msg.atomic_file.durable_write` | `msg.storage.git.durable_write` | +| 托管升级 PoP 上下文、派生及客户端证明 | `msg.security.custodial_protocol` | `msg.security.vault` 对应名称 | +| 托管迁移决策和 ACK 的签署语句 | `msg.security.custodial_protocol` | `msg.security.custodial_migration` 对应名称 | +| 已发布 MCP 协商版本集合 | `msg.transports.mcp_protocol` | `msg.transports.mcp` 对应名称 | + +兼容导出是同一函数或同一常量对象,不复制安全敏感实现。共享协议模块不持有服务器密钥、不接受 Application 或事务,也不验证销毁/退役事实。服务端 vault、权限复核、历史清单、恢复和事务发布继续留在原服务端模块。客户端签名仍绑定原 purpose、主体、请求和上下文;请求版本、签署字节、秘密一次释放、journal 与凭据权限语义不变。 + +CLI/TUI/签名客户端可以依赖 core 的协议模型、codec、requests、errors,客户端各功能模块、无服务状态的签名/证书/age/托管协议能力、atomic_file 及客户端传输。不能通过执行器、Git/CAS 存储、托管 vault、admin 或 workers 取得公共辅助函数。MCP HTTP 客户端读取版本常量也不再导入服务端适配器;stdio 的原行为与全量协议回归保留。 + +## 验证边界 + +`tests/test_client_boundary.py` 检查唯一实现、兼容导出、精确证明上下文、原子替换失败保护和缺失依赖前置错误。`scripts/check_client_install.py` 在全新解释器安装导入拦截器,再加载客户端功能、保存/重读受保护密钥,并通过四个真实传输实现发送同一个签名请求;HTTP 端点由进程内 MockTransport 提供,不访问生产站点。 + +`.github/workflows/client-boundary.yml` 构建实际 wheel/sdist,在源码目录之外的新虚拟环境中正常安装 wheel 并运行 pip check、两条 help 命令及四传输探针;`--minimal-install` 显式拒绝服务端 Python 依赖。全量 PostgreSQL/Valkey、CLI/MCP、凭据 journal/恢复、协议一致性与构建门禁仍必须通过。该验证不等于 Windows/Android 现场矩阵、外部网络、包体积/性能或生产验收;实际结果及精确提交记录在 CLIENT_BOUNDARY_PROGRESS.md 和对应 PR 中。 diff --git a/docs/CLIENT_PART_BYTES_PROGRESS.md b/docs/CLIENT_PART_BYTES_PROGRESS.md new file mode 100644 index 00000000..baa1c4d8 --- /dev/null +++ b/docs/CLIENT_PART_BYTES_PROGRESS.md @@ -0,0 +1,51 @@ +# Client resumable chunk size — progress + +Refs #80, #82, #83, #85. Independent narrow follow-up to integration PR #176; +not part of #177's server cursor implementation. + +Base head: `45470b5a46bccfc4f12ef27acccecc2f3bafce47`. +Base tree: `e75e7cf511a89d72bfdccf5ec39567824b569275`. +Original `src/msg/client.py` blob: `326ad17f226db0909387f13c8847ede2a48dc40b`. + +When resuming from an existing journal, `MsgClient.download(part_bytes=0)` skips +`transfer.open` validation and computes zero-length reads. A successful empty +read leaves the offset unchanged, so the loop never progresses and keeps +rewriting the journal. Other invalid local sizes can access files or fail with +unrelated schema/type errors. Upload validates too late as well. + +Regression-first tests use a real signed download stopped before its second +part, then exercise the saved journal. The zero-size regression allows two real +zero-byte requests before a deterministic guard stops the original loop. It does +not rely on a timeout or mock a successful server response. Invalid inputs must +fail before requests or local state changes. Valid resume is tested over HTTP, +PathGET, GraphQL and MCP with exact bytes and current server read-only evidence. + +## Cloud regression evidence + +- Regression head `cbd4f4f8642e50a10366fd5fe150a4cbd28052a0`. +- Run `36561926956`, job `109384609384`, Python `3.15.0rc2`. +- Actual checkout `fd6bfce96dea97da81c7679953c5433896a64967` has the identical + tree `b37543923fb4a7ca1503be757fe1f184f44a2749`. +- 23 JUnit cases: 14 failed, 9 passed, zero errors/skips. +- All seven invalid-size resumed-download cases and seven pre-I/O upload cases + reproduce the intended boundary defects. The zero-size failure records two + successful real zero-byte reads at offset 3 before the third-call guard. +- All four valid cross-protocol resume cases and five existing client cases pass. +- Downloaded artifact `11029863272`; independently verified ZIP SHA256: + `b28e2465340371a16e11307adb0a238bbc4ab7bd861edf650aec7e9a1a30e5f8`. + source.json and the JUnit failures were inspected; no test correction needed. + +## Implementation + +Exactly two runtime lines: require a positive non-bool integer at the beginning +of `MsgClient.upload` and `MsgClient.download`, before source hashing, journals, +partial files or network calls. Invalid input raises `invalid_part_bytes`. +All existing source bytes remain unchanged apart from those inserted lines; +modified client blob `cffffa10ccbd62249f0ad991e7c81a3cd146cdf6` was independently +matched to the locally prepared two-line diff. No server/schema/packaging changes. + +Final exact-head focused and complete cloud verification pending. No tests run +locally; local operations only edit/compare source text and hash/parse artifacts. +No broad issue closure, production mutation, external delivery or unrelated +source overwrite. PR #178 targets the integration branch and remains draft until +its final checks pass. diff --git a/docs/COMPLETE_RECOVERY_PROOF.md b/docs/COMPLETE_RECOVERY_PROOF.md new file mode 100644 index 00000000..54b40a25 --- /dev/null +++ b/docs/COMPLETE_RECOVERY_PROOF.md @@ -0,0 +1,110 @@ +# Complete recovery state proof + +`msgd root recovery-proof` is a physical-console-only recovery ceremony. Signing +and promotion both require the existing local OS administrator/console checks, +confirmation of the displayed exact digest, and local Root PIN entry. The PIN +is never written to a proof, receipt, log, or command argument. + +## What this path proves + +The current authoritative instance signs a separate, versioned +`complete-recovery-state-v1` statement. It commits to all 86 supported metadata +tables, exact columns and schema definitions, typed row counts/digests (including +duplicates), sequence positions, storage references, actual content/Git/LFS trees, +trust document, normalized configuration policy, and random service-key digests. +Configuration normalization excludes only listen/port, deployment paths, database +connection, Valkey and mail settings. The latter two must remain disabled on the +recovery target. Plugin, limits, tool sandbox/network policy, TTLs and all other +policy fields must match; a hosting marker is bound by presence rather than its +deployment-specific path. Configuration is reloaded to reject cached-policy drift. Credentials, certificates, +revocations, policies, scope/ownership, task/nonce/ledger/settlement data, +authorization epoch and the full audit chain are included. Usable certificate +chains, current Root/online/receipt keys, resource ancestry and ledger/market +invariants are checked again before approval. + +The fixed packaged schema vocabulary includes the current market migration +(`server_offer_resources` and bigint order quantities). PostgreSQL 18 duplicates +NOT NULL column flags as named constraints; vocabulary comparison normalizes +those redundant entries for PostgreSQL 16 compatibility, while exact column +nullability and every signed catalogue definition remain bound. + +The fixed packaged schema vocabulary rejects unknown schemas, relations, columns, +indexes, triggers and functions. Row-level security, policies, rewrite rules and +custom public collations are rejected; column/database collations are bound. Full definitions must also match the signed +source. There is no caller-selected table list or `complete=true` bypass. + +Exactly three local control settings are omitted from row digests: +`recovery_quarantine`, `recovery_replay`, and `recovery_promotion`. +No prefix is omitted. For an initial promotion, only the two restore pause fields +`runtime_config.accept_writes` and `runtime_config.cleanup_enabled` are normalized +to the signed source values, after proving both actual restored fields are false. +All other runtime settings remain exact. + +This is a strict matching path: the restored/reconciled state must equal the +independently committed complete current state. It does **not** rebuild arbitrary +missing grants from a partial replay log. Earlier partial authority replay +receipts remain incomplete; their receipt alone cannot authorize promotion. A +state with extra recovery audit entries will not match an earlier complete proof. + +## Operator input + +1. Create the backup on the current authoritative instance. +2. On that instance's physical console: + + ```sh + msgd root recovery-proof sign BACKUP_SHA256 SEQUENCE proof.json + ``` + + The command drafts the commitment, displays its digest, asks for confirmation, + then rechecks the entire state after Root unlock before signing. Any concurrent + change requires a new draft. Statements expire after at most 24 hours. +3. Obtain an **independent current pin** outside the restored backup and outside + the proof packet. Its protected JSON file contains exactly `service`, + `public_key` (base64 Root public key), `digest`, `sequence`, and + `source_backup_sha256`. The operator must establish this pin's freshness and + provenance through their existing trusted channel. Copying fields from the + untrusted packet is not independent verification. Software cannot manufacture + evidence that an external source is current. +4. Restore into the isolated target. On its physical console, with the legitimate + Root envelope locally available: + + ```sh + msgd root recovery-proof promote proof.json independent-pin.json + ``` + + Inputs must be regular owner-only protected files. The target must have both + its database quarantine and recovery marker. Exact state/pin/signature/key + checks must succeed, then the local operator approves the digest and enters + the Root PIN. No network command or automatic worker performs this ceremony. + +## Failure and restart behavior + +Metadata checking and the initial promotion delta occur under the application +writer lock and exclusive locks on every supported table. The Root-signed +promotion receipt binds the exact new audit row, generation and prior audit +sequence. Audit insertion uses an explicit transactional sequence number; +PostgreSQL's nontransactional sequence is advanced only after the receipt commits, +while the filesystem marker still blocks requests. Retries accept only the exact +prior/final sequence states, rehash all metadata/files, and exclude only that +signed audit tail and generation delta. Extra rows or altered pending receipt/state fail closed. The signed generation +row must actually exist; absence never means a successful restart fence. A backup +of a previously promoted source may contain a historical receipt: only a receipt +for the current independent manifest can resume; a new manifest must instead +match the complete initial state before creating its own receipt. + +The database gate remains active until marker removal and directory fsync succeed +under the final transaction lock. Marker removal failure leaves both barriers +closed. A crash or fsync failure after unlink leaves the database gate closed; +a signed exact receipt permits retrying with the marker missing. A receipt alone +does not skip revalidation. Promotion atomically writes a fresh +`recovery_runtime_generation`. Every previously loaded application/hosting runtime +must remain stale and be recreated; restarting only one cached worker is not +sufficient. A fresh runtime is required after successful promotion. + +As with backup/restore, the local OS administrator controls the storage directories +and PostgreSQL administrator boundary. Keep the target isolated from independent +filesystem writers and direct SQL administrators during the ceremony. This proof +does not attest to an external backup having been physically erased, nor to an +operator's independent pin having been obtained honestly. Real retirement +attestations, hardware Root access, and production promotion remain separate +operator evidence, not claims made by synthetic test fixtures. diff --git a/docs/CUSTODIAL_HISTORY.md b/docs/CUSTODIAL_HISTORY.md index c051f56c..6a39d4c4 100644 --- a/docs/CUSTODIAL_HISTORY.md +++ b/docs/CUSTODIAL_HISTORY.md @@ -92,8 +92,8 @@ application without the isolated selftest namespace. `history_recoverable` covers **enumerated and client-verified revisions only**. `online_key_retired` describes online vault material only. `backup_retired` stays false unless a root-signed backup retirement record was imported at the physical -console (`RootAdmin.sign_backup_retirement` / `RootAdmin.import_backup_retirement`, -both behind `require_local_console`). The record binds the subject, both old key +console (`msgd root backup-retirement sign SOURCE DESTINATION` followed by +`msgd root backup-retirement import RECORD`, both behind `require_local_console`). The record binds the subject, both old key IDs, a non-empty list of named backup sets and a validity window of at most 400 days; it is signed with purpose `custodial-backup-retirement-v1`. Every read re-verifies it against the active, unrevoked root credential, so an altered, @@ -106,7 +106,11 @@ does not prove that no other copy exists and it does not claim that production backups were destroyed. No network operation writes, accepts or forwards it; a `backup_retirement` value inside the network-writable migration body is ignored. Neither a boolean supplied by a network caller nor a green test run can establish -that old backups lost a key. There is no `msg-admin` CLI wiring yet. Restore/ +that old backups lost a key. Input JSON must be a single-link regular file owned +by the local operator, mode 0600, at most 1 MiB; symlinks and special files are +rejected. Signing publishes a complete 0600 record without replacing an existing +destination. Both commands require a digest-bound interactive confirmation; +signing additionally unlocks Root with a non-echoed PIN prompt. Restore/ revocation replay and real operator acceptance are tracked separately in issues #69 and #70. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 32ac5ef1..5aed900e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -4,7 +4,7 @@ 服务器目标是 Linux、Python 3.15、Git、PostgreSQL。Valkey 用于可选的短期信号与缓存;服务必须在不连接 Valkey 时仍能从 PostgreSQL 恢复待处理工作。SSH 扩展需要 OpenSSH,网络工具 worker 需要 bubblewrap。普通客户端不需要运行系统服务,但必须能保存自己的私钥;不能安装软件的 Agent 使用 HTTP / 纯路径协议。 -先在新数据目录验收,不要将新服务直接指向旧版数据库。没有自动迁移器。生产前阅读 SECURITY.md 和 VERIFICATION.md。 +先在新数据目录和空 PostgreSQL 库验收,不要将新服务直接指向旧版数据库。旧 SQLite 不能直接启动或用 v4 restore 恢复;选择新 Root、重新登记身份权限时,执行[新 Root 旧数据导入流程](NEW_ROOT_LEGACY_IMPORT_RUNBOOK.md)。生产前阅读 SECURITY.md 和 VERIFICATION.md。 先创建专用 PostgreSQL 数据库与最小权限账号,再配置 `/etc/msgd/msgd.toml`: @@ -24,17 +24,30 @@ service_keys = "/var/lib/msgd/service" ## 安装代码 +服务端必须安装 `server` extra;裸 wheel 只含客户端依赖,不能用于 `msgd`。固定通过验收的源码提交、wheel SHA-256、Python 精确版本及服务依赖锁,不能在目标机重新解析一套未测依赖。用应用独立的 Python 3.15 和 venv,保留系统 Python、旧服务 venv 和数据用于回滚。若采用预发行 Python,记录完整版本并在同一版本验收。 + +例如,先在与目标平台和解释器一致的构建/验收环境中,用实际 wheel 路径生成并测试锁文件(示例文件名须替换): + ```bash +# 将此占位路径换成已验证的独立 Python 解释器;此步骤分别在验收和部署环境执行。 sudo install -d -m 0755 /opt/msgd -sudo python3.15 -m venv /opt/msgd/venv -sudo /opt/msgd/venv/bin/python -m pip install . +sudo /absolute/path/to/verified/python3.15 -m venv /opt/msgd/venv +# 下面的锁生成只在验收环境运行;目录需由构建操作者可写。 +printf '%s\n' 'msg-lmm-best[server] @ file:///protected/staged/msg_lmm_best-1.0.0a1-py3-none-any.whl' > /protected/staged/server.in +uv pip compile --python /opt/msgd/venv/bin/python --generate-hashes \ + /protected/staged/server.in --output-file /protected/staged/server.lock +sudo uv pip sync --python /opt/msgd/venv/bin/python --require-hashes /protected/staged/server.lock +uv pip check --python /opt/msgd/venv/bin/python +sha256sum /protected/staged/*.whl /protected/staged/server.lock ``` +上述 venv 必须预先用固定的独立解释器创建;验收环境与部署目标使用相同的 wheel 绝对路径,或在锁生成前确定部署路径。将已测试 wheel、锁文件及摘要一起送到目标机,只执行同一 `uv pip sync --require-hashes` 和 `uv pip check`,不重新编译锁。锁中的本地 wheel 路径必须存在;锁含完整 `server` 依赖(包括 Starlette、Uvicorn、psycopg)。需要离线安装时,预先准备锁中所有匹配平台的依赖产物并验证摘要。依赖锁是此次发布产物,不覆盖工作区已有的 `uv.lock`。 + 在真实本机 VT 或串行控制台运行: ```bash -sudo /opt/msgd/venv/bin/msgd init --service-url https://msg.example.org -sudo /opt/msgd/venv/bin/msgd cert issue ONLINE_CA_REQUEST_ID +sudo /opt/msgd/venv/bin/msgd --config-dir /etc/msgd init --service-url https://msg.example.org +sudo /opt/msgd/venv/bin/msgd --config-dir /etc/msgd cert issue ONLINE_CA_REQUEST_ID ``` ONLINE_CA_REQUEST_ID 取自 init 输出。签发时检查显示的权限范围和申请摘要,输入准确摘要确认,再输入 PIN。没有默认 PIN、环境变量 PIN 或 --yes。不能通过 SSH / 远程命令转发完成 root 管理;不能把退出码 78 当成初始化成功。 @@ -43,13 +56,14 @@ ONLINE_CA_REQUEST_ID 取自 init 输出。签发时检查显示的权限范围 ## 文件权限与系统服务 -管理员审核后执行 `deploy/prepare-service.sh`,它只准备运行账号和文件权限,不生成根、不签证书、不覆盖配置。将新 systemd 单元复制到 `/etc/systemd/system/`,检查其中的固定安装路径,再运行 daemon-reload 和 enable。 +管理员审核后执行 `deploy/prepare-service.sh`,它只准备运行账号和文件权限,不生成根、不签证书、不覆盖配置。先检查固定安装路径及 writer/worker 的 libpq 配置。通过单元 drop-in 显式指定 `PGSERVICEFILE`/`PGPASSFILE`(或受控 peer 映射),不能依赖交互 shell 的环境。Root 本机管理也须连接同一个明确目标库;网络服务数据库账号不得因此获得 superuser。 ```bash sudo sh deploy/prepare-service.sh sudo install -m 0644 deploy/msgd.service deploy/msgd-worker.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now msgd.service msgd-worker.service +sudo systemctl start msgd.service +# worker 在真实隔离与禁外发检查完成后单独启动,不在此自动 enable。 sudo /opt/msgd/venv/bin/msgd doctor ``` @@ -57,11 +71,19 @@ sudo /opt/msgd/venv/bin/msgd doctor systemd 配置不预设禁止 worker 使用 user namespace 的系统调用;bubblewrap 需要宿主允许其隔离机制。执行失败应排查宿主配置,不能删除 worker 的隔离要求作为修复。 -HTTP 默认监听 127.0.0.1:8042。反向代理 Host 必须与 service_url 匹配。无跨来源 CORS放行,不共享管理员 Cookie。新 Nginx 配置位于 deploy/nginx.conf,TLS 证书路径需由部署者填写。 +HTTP 默认监听 127.0.0.1:8042。反向代理 Host 必须与 service_url 匹配。无跨来源 CORS放行,不共享管理员 Cookie。新 Nginx 配置位于 deploy/nginx.conf,TLS 证书路径需由部署者填写。它是完整专用配置,不是共享 Nginx 的 include 片段;共享入口须单独验证主/http/default server 的预 Host 错误与日志链,同时保留其他站点的既有日志策略,见 [ingress 说明](../deploy/README.md)。 ## 可选扩展 -当前托管由主app同域匿名只读服务,所有托管响应强制CSP sandbox且当前禁JS,危险格式按附件下载。@root/web已有真实Resource;private preview需签名header,不能把URL当无凭据浏览器导航。已有局部发布/回滚和浏览器证据不等于完整同域安全矩阵,生产开放前仍须验证当前部署。 +托管使用独立 `msgd hosting` 进程(默认回环8043),与 writer `msgd serve`(8042)同域但分离 OS 用户、DB 角色和秘密权限。按 [Hosting runtime](HOSTING_RUNTIME.md) 执行: + +1. 停止所有新实例 writer/worker,再执行 `deploy/prepare-hosting-content.sh`;随后在 writer 配置显式设置 `[hosting] content_group_read=true`。先前 `prepare-service.sh` 必须在此之前完成,不能再用它覆盖已准备的内容组权限。 +2. 初始化 schema 后,以目标库管理员在**新库**运行 `deploy/hosting-role.sql`。reader 使用独立 `msgd_hosting` 登录,仅获白名单 SELECT;检查 PUBLIC/继承/函数权限,不复用 writer DSN。 +3. 根据 `deploy/hosting.example.toml` 创建 `/etc/msgd-hosting/msgd.toml`,reader 独立 libpq service/0600凭据;writer/reader 的 `public_web_origin` 和 `service_url` 一致。共享真实公共 trust 与 writer 恢复 marker 路径,不能复制过期信任文件或私钥。 +4. 安装 `deploy/msgd-hosting.service`,daemon-reload 后启动 writer/hosting,只在回环地址验收。reader 无 Root/在线服务密钥、Git 写入、DDL 或业务执行能力;过强的数据库登录必须启动失败。 +5. 代理仅将经过真实路由测试的托管路径送8043,普通 API/操作送8042。`deploy/nginx.conf` 默认仍指向 writer,不能直接当作已经完成双进程分流。私有 preview 仍需签名 header;当前授权、撤权、quarantine、HEAD/Range/304、CSP sandbox 和发布/回滚须在实际分流下验证。 + +所有托管响应保留 CSP sandbox(无 same-origin 授权,当前禁 JS),危险格式按附件下载。进程分离不等于已经完成现场安全验收。 邮件默认关闭。复制 mail.example.toml 到 `/etc/msgd/mail.toml` 后填写真实 TLS SMTP参数。认证凭据使用独立 0640 文件引用,不放入根目录、不提交仓库。worker发送失败不回滚业务;未知 DATA 结果由管理员或上层投递策略处理。 @@ -85,3 +107,9 @@ msgd --config-dir /new/etc/msgd restore /secure-backup/service.zip --data-dir /n 执行 tests 与 conformance,确认 Python 3.15、GraphQL、MCP、CLI分片一致性;用真实 sshd 完成登录、拒绝 shell、拒绝转发、Git push撤销测试;用真实 bubblewrap 验证 worker 看不到 `/var/lib/msgd-root/`、服务数据与凭据;验证公网目标、私网拒绝和重定向检查;用隔离 SMTP 测试 TLS、禁用状态、连接重试和 uncertain。 只有所有适用项在真实部署通过,才能判断是否允许生产流量。本地 Python 3.15 测试不能代替部署环境的运行入口检查。 + +## 切流与回滚 + +旧服务、解释器、数据和配置先保留。旧写入冻结后完成最终保护快照和增量核对;新 Root 的身份映射及旧内容归属按[导入流程](NEW_ROOT_LEGACY_IMPORT_RUNBOOK.md)核验,不能把旧权限字段当成新授权。只有本机 Root/基础 CA、依赖、隔离、日志链和实际功能验收通过才切代理 upstream;`nginx -t` 成功后 reload,并记录真实外部回读、服务版本和配置摘要,再启用已验收的后台服务。 + +切流后尚无新业务写入时,可停止新服务并恢复旧 upstream/服务。已有新写入时,先冻结新 writer/worker并保护新 PostgreSQL/Git/CAS,核对数据差异后执行已验证的数据回迁或维持维护状态,不能直接退回旧 SQLite 丢弃新写入。新旧 Root 不同,回滚不会让新凭据自动被旧版接受。 diff --git a/docs/DESIGN_CONTRACT_INVENTORY.json b/docs/DESIGN_CONTRACT_INVENTORY.json new file mode 100644 index 00000000..08de560f --- /dev/null +++ b/docs/DESIGN_CONTRACT_INVENTORY.json @@ -0,0 +1,25222 @@ +{ + "source_commit": "fbe5175de5448fe82394d4d57532e49a5b2809ab", + "design_sha256": "d64dd796b2f004233d8966e03cc6e6890b240dc000de99bd8755872a684fa00a", + "scope": "current design chapters01-14 only, lines7-177; archived referenced requirements not silently reconstructed", + "segmentation": "Each nonempty design line after a chapter heading is split only at Chinese full stop/semicolon. Each resulting explicit obligation has a stable chapter.line.clause ID. Named model fields have separate child rows; optional fields, alternatives, states and lists remain in verbatim parent text. No Cartesian expansion of input combinations. Heading goal statements retained.", + "coverage_counts": { + "chapters": 14, + "design_lines": 157, + "obligations": 564, + "named_member_rows": 371, + "configuration_fields": 46 + }, + "chapters": [ + { + "number": 1, + "title": "定位与范围", + "line": 7 + }, + { + "number": 2, + "title": "架构与注册表", + "line": 13 + }, + { + "number": 3, + "title": "主体、凭据与托管身份", + "line": 19 + }, + { + "number": 4, + "title": "CA、委托与根管理", + "line": 41 + }, + { + "number": 5, + "title": "统一资源、权限与组", + "line": 54 + }, + { + "number": 6, + "title": "分享与个人空间", + "line": 71 + }, + { + "number": 7, + "title": "帖子、评论、模板与引用", + "line": 81 + }, + { + "number": 8, + "title": "读取路径、表示与 Agent 约定", + "line": 89 + }, + { + "number": 9, + "title": "Operation、短码协议与副作用边界", + "line": 106 + }, + { + "number": 10, + "title": "文件编辑、Grep 与行指纹", + "line": 122 + }, + { + "number": 11, + "title": "存储、Transfer、Git/LFS 与网页托管", + "line": 133 + }, + { + "number": 12, + "title": "事件、Inbox/Outbox、ACK 与通知", + "line": 143 + }, + { + "number": 13, + "title": "工具、SSH、RSS 与客户端", + "line": 160 + }, + { + "number": 14, + "title": "核心接口与配置", + "line": 167 + } + ], + "obligations": [ + { + "id": "D01.008.01", + "chapter": 1, + "design_line": 8, + "clause_index": 1, + "text": "为受限Agent提供低token的原子发现、通信、文件交换", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.008.02", + "chapter": 1, + "design_line": 8, + "clause_index": 2, + "text": "基础身份、公开读取、普通通信永久免费,流程由客户端组合,人类使用msg TUI", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [ + "TUI" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.009.01", + "chapter": 1, + "design_line": 9, + "clause_index": 1, + "text": "普通账号平等,不因个人/团队/证书/注册时间/活跃度差别分配共享额度或优先级", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.009.02", + "chapter": 1, + "design_line": 9, + "clause_index": 2, + "text": "默认善意不免认证、授权、格式校验、恢复", + "profile": "scope", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.009.03", + "chapter": 1, + "design_line": 9, + "clause_index": 3, + "text": "站内货币可转账、用于/store交易及兑换Registry标为purchasable的资源,但不授安全权限或基础访问资格", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [ + "store" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:157 (store)", + "src/msg/config.py:158 (store)", + "src/msg/config.py:160 (store)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.010.01", + "chapter": 1, + "design_line": 10, + "clause_index": 1, + "text": "分页、分片、超时、统一排队、临时token速率/并发限制仅保护服务,繁忙报server_busy", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.010.02", + "chapter": 1, + "design_line": 10, + "clause_index": 2, + "text": "容量不足先回收缓存、过期暂存和符合保留规则的数据,再暂停不可靠写入或扩容,不随机删在用数据", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.011.01", + "chapter": 1, + "design_line": 11, + "clause_index": 1, + "text": "实现基线Python 3.15", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.011.02", + "chapter": 1, + "design_line": 11, + "clause_index": 2, + "text": "按实际输入/输出token、往返、失败重试优化,不以HTTP压缩率代替", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.012.01", + "chapter": 1, + "design_line": 12, + "clause_index": 1, + "text": "禁止法币充值/提现、固定法币价值承诺、平台利息/收益、付费安全权限/优先级、通用工作流、Web登录或/login、默认准入验证码/反垃圾/信誉风控平台、本轮AST/LSP/语义重构", + "profile": "scope", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "LSP", + "Web", + "login" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D01.012.02", + "chapter": 1, + "design_line": 12, + "clause_index": 2, + "text": "自愿荣誉挑战不作准入门槛", + "profile": "scope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.014.01", + "chapter": 2, + "design_line": 14, + "clause_index": 1, + "text": "模块化单体、单一Registry", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.014.02", + "chapter": 2, + "design_line": 14, + "clause_index": 2, + "text": "按security/content/money/market划分逻辑职责,沿用现有模块,不为命名统一强制搬目录或另建后端", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "content", + "market", + "money" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:386 (content)", + "src/msg/plugins/common.py:1 (content)", + "src/msg/plugins/common.py:131 (content)", + "src/msg/plugins/common.py:166 (content)", + "src/msg/plugins/features.py:12 (money)", + "src/msg/plugins/features.py:16 (market)", + "src/msg/plugins/features.py:25 (content)", + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:386 (content)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.014.03", + "chapter": 2, + "design_line": 14, + "clause_index": 3, + "text": "新增概念须先区分业务事实、内容类型、只读视图和执行步骤", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.014.04", + "chapter": 2, + "design_line": 14, + "clause_index": 4, + "text": "只有独立生命周期、必须独立维护的不变量或真实替换/隔离需求,才增加独立持久化/服务", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.014.05", + "chapter": 2, + "design_line": 14, + "clause_index": 5, + "text": "复用共同行为,不把全部状态塞进万能Resource/JSON,也不因类名减少而放宽校验", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "JSON" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.015.01", + "chapter": 2, + "design_line": 15, + "clause_index": 1, + "text": "仅发行msgd(服务/本机管理)和msg(CLI/TUI/凭据/调用)", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CLI", + "TUI" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.015.02", + "chapter": 2, + "design_line": 15, + "clause_index": 2, + "text": "写链为适配器→OperationExecutor→插件→存储,读用ReadQuery", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationExecutor" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.015.03", + "chapter": 2, + "design_line": 15, + "clause_index": 3, + "text": "内置identity/content/discussion/communication/discovery,身份、签名、证书验证不可缺", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "communication", + "content", + "discovery", + "discussion" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:386 (content)", + "src/msg/plugins/common.py:1 (content)", + "src/msg/plugins/common.py:131 (content)", + "src/msg/plugins/common.py:166 (content)", + "src/msg/plugins/features.py:25 (content)", + "src/msg/plugins/features.py:26 (content)", + "src/msg/plugins/features.py:29 (discovery)", + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:386 (content)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.01", + "chapter": 2, + "design_line": 16, + "clause_index": 1, + "text": "Registry登记ResourceTypeSpec、CapabilitySpec、OperationSpec、TemplateSpec、ToolSpec及关系/字段/schema,未知项拒绝", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CapabilitySpec", + "OperationSpec", + "Registry", + "TemplateSpec", + "ToolSpec", + "schema" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:14 (OperationSpec)", + "src/msg/core/registry.py:25 (Registry)", + "src/msg/core/registry.py:84 (schema)", + "src/msg/core/models.py:329 (CapabilitySpec)", + "src/msg/core/models.py:341 (OperationSpec)", + "src/msg/core/models.py:439 (TemplateSpec)", + "src/msg/plugins/common.py:249 (OperationSpec)", + "src/msg/plugins/common.py:252 (schema)", + "src/msg/plugins/common.py:255 (schema)", + "src/msg/plugins/features.py:5 (Registry)", + "src/msg/core/models.py:329 (CapabilitySpec)", + "src/msg/core/models.py:341 (OperationSpec)", + "src/msg/core/models.py:439 (TemplateSpec)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.02", + "chapter": 2, + "design_line": 16, + "clause_index": 2, + "text": "OperationSpec统一拥有操作输入/输出、副作用分类、入口限制和requires_rules", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationSpec" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:14 (OperationSpec)", + "src/msg/core/models.py:341 (OperationSpec)", + "src/msg/core/models.py:475 (OperationSpec)", + "src/msg/plugins/common.py:249 (OperationSpec)", + "src/msg/plugins/common.py:259 (OperationSpec)", + "src/msg/core/models.py:341 (OperationSpec)", + "src/msg/core/models.py:475 (OperationSpec)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.03", + "chapter": 2, + "design_line": 16, + "clause_index": 3, + "text": "路由/CLI/MCP/字典只引用或派生,不平行定义授权", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CLI", + "MCP" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.04", + "chapter": 2, + "design_line": 16, + "clause_index": 4, + "text": "ResourceTypeSpec统一内容结构,TemplateSpec引用字段并固定自身版本/默认值", + "profile": "registry", + "requirement_kind": "default", + "explicit_named_literals": [ + "ResourceTypeSpec", + "TemplateSpec" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:317 (ResourceTypeSpec)", + "src/msg/core/models.py:439 (TemplateSpec)", + "src/msg/core/models.py:473 (ResourceTypeSpec)", + "src/msg/core/models.py:317 (ResourceTypeSpec)", + "src/msg/core/models.py:439 (TemplateSpec)", + "src/msg/core/models.py:473 (ResourceTypeSpec)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.05", + "chapter": 2, + "design_line": 16, + "clause_index": 5, + "text": "CapabilitySpec保留独立授权语义,ToolSpec仅补工具网络/资源限制和执行器事实", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CapabilitySpec", + "ToolSpec" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:329 (CapabilitySpec)", + "src/msg/core/models.py:459 (ToolSpec)", + "src/msg/core/models.py:474 (CapabilitySpec)", + "src/msg/core/models.py:329 (CapabilitySpec)", + "src/msg/core/models.py:459 (ToolSpec)", + "src/msg/core/models.py:474 (CapabilitySpec)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.06", + "chapter": 2, + "design_line": 16, + "clause_index": 6, + "text": "PluginManifest与BootstrapManifest通过稳定feature_id关联同源依赖/默认/检查入口,不重复维护业务契约", + "profile": "registry", + "requirement_kind": "default", + "explicit_named_literals": [ + "PluginManifest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:469 (PluginManifest)", + "src/msg/plugins/common.py:249 (PluginManifest)", + "src/msg/plugins/common.py:267 (PluginManifest)", + "src/msg/plugins/features.py:5 (PluginManifest)", + "src/msg/core/models.py:469 (PluginManifest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.016.07", + "chapter": 2, + "design_line": 16, + "clause_index": 7, + "text": "启动检查冲突/缺项", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.017.01", + "chapter": 2, + "design_line": 17, + "clause_index": 1, + "text": "插件只扩展登记操作、输入校验、只读投影、提交后事件,复用Resource/Revision/Relation", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Relation", + "Revision" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:83 (Relation)", + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:83 (Relation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.017.02", + "chapter": 2, + "design_line": 17, + "clause_index": 2, + "text": "契约生成HTTP描述、CLI帮助、MCP说明、测试向量,local_only仅出现在本机帮助", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CLI", + "MCP", + "local_only" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:108 (local_only)", + "src/msg/core/models.py:108 (local_only)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.018.01", + "chapter": 2, + "design_line": 18, + "clause_index": 1, + "text": "禁止覆盖契约、自行提交共享事务、修改签署参数、任意before/after流程图、通用表达式、可执行配置、无需求驱动矩阵", + "profile": "registry", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "after" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.018.02", + "chapter": 2, + "design_line": 18, + "clause_index": 2, + "text": "执行器/evaluator只用已安装可信代码,内容/模板不得执行为代码,进程内插件不是安全沙箱", + "profile": "registry", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "evaluator" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.018.03", + "chapter": 2, + "design_line": 18, + "clause_index": 3, + "text": "旧证书通配符/CA issue_grants不自动涵盖新增capability", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CA", + "issue_grants" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:155 (issue_grants)", + "src/msg/core/models.py:155 (issue_grants)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D02.018.04", + "chapter": 2, + "design_line": 18, + "clause_index": 4, + "text": "停用插件保留数据,不把未完成任务标成功", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.020.01", + "chapter": 3, + "design_line": 20, + "clause_index": 1, + "text": "保持主体身份连续,分离签名、加密、恢复与荣誉", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.021.01", + "chapter": 3, + "design_line": 21, + "clause_index": 1, + "text": "subject_id/user_id稳定,/@name可变", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [ + "name", + "subject_id", + "user_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:21 (subject_id)", + "src/msg/plugins/identity.py:66 (name)", + "src/msg/plugins/identity.py:71 (name)", + "src/msg/security/authentication.py:12 (subject_id)", + "src/msg/security/authentication.py:53 (subject_id)", + "src/msg/security/authentication.py:70 (subject_id)", + "src/msg/client_tokens.py:33 (name)", + "src/msg/client_tokens.py:34 (name)", + "src/msg/client_tokens.py:43 (name)", + "src/msg/core/models.py:47 (name)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:132 (subject_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.021.02", + "chapter": 3, + "design_line": 21, + "clause_index": 2, + "text": "改名/换钥/升级不改归属、组关系、历史", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.021.03", + "chapter": 3, + "design_line": 21, + "clause_index": 3, + "text": "actor为操作者、subject为被代表主体、author为原作者", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [ + "actor", + "author", + "subject" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:140 (actor)", + "src/msg/plugins/identity.py:152 (actor)", + "src/msg/plugins/identity.py:465 (actor)", + "src/msg/security/authentication.py:55 (actor)", + "src/msg/security/authentication.py:65 (actor)", + "src/msg/security/authentication.py:71 (actor)", + "src/msg/core/models.py:84 (actor)", + "src/msg/core/models.py:86 (author)", + "src/msg/core/models.py:235 (actor)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.021.04", + "chapter": 3, + "design_line": 21, + "clause_index": 4, + "text": "请求签名、内容签名、服务回执分别证明授权、Revision完整性、提交", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Revision" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.022.01", + "chapter": 3, + "design_line": 22, + "clause_index": 1, + "text": "匿名业务只读,唯一引导例外是经/-/登记入口新建主体,不操作已有身份", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.022.02", + "chapter": 3, + "design_line": 22, + "clause_index": 2, + "text": "/-/d只给说明,其余写入均须主体与可验证签名", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [ + "d" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.023.01", + "chapter": 3, + "design_line": 23, + "clause_index": 1, + "text": "identity.temporary若创建新主体,仍须满足建号时同步生成独立IdentityKey与EncryptionSubkey的双钥要求", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [ + "identity.temporary" + ], + "registered_operations": [ + "identity.temporary" + ], + "source_token_locations": [ + "src/msg/plugins/identity.py:987 (identity.temporary)", + "src/msg/plugins/identity.py:988 (identity.temporary)", + "src/msg/plugins/identity.py:990 (identity.temporary)", + "src/msg/security/authentication.py:57 (identity.temporary)", + "src/msg/client_tokens.py:11 (identity.temporary)", + "src/msg/client_tokens.py:88 (identity.temporary)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_temporary_signed_business.py::test_temporary_signing_key_can_create_low_risk_content", + "line": 9 + }, + { + "nodeid": "tests/test_temporary_signed_business.py::test_temporary_token_allows_low_risk_write_but_not_permission_changes", + "line": 24 + } + ] + }, + { + "id": "D03.023.02", + "chapter": 3, + "design_line": 23, + "clause_index": 2, + "text": "未具备双钥前不能视为完成", + "profile": "identity", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.01", + "chapter": 3, + "design_line": 24, + "clause_index": 1, + "text": "SSH公钥作受限Credential,Recovery/Custodian age钥作可选灾难恢复", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Custodian", + "Recovery", + "SSH", + "age" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.02", + "chapter": 3, + "design_line": 24, + "clause_index": 2, + "text": "四类用途不自动转换/复用,签名不等于解密", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.03", + "chapter": 3, + "design_line": 24, + "clause_index": 3, + "text": "历史按固定key_id验证/解密,公开路径见第08章", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.04", + "chapter": 3, + "design_line": 24, + "clause_index": 4, + "text": "token仅存verifier/hash,绑定subject/scope/operations/期限/constraints,可撤销,不得突破凭据上限", + "profile": "delivery", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "constraints", + "hash", + "operations", + "scope", + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/token_delivery.py:5 (token)", + "src/msg/security/token_delivery.py:30 (token)", + "src/msg/security/token_delivery.py:45 (token)", + "src/msg/plugins/identity.py:35 (token)", + "src/msg/plugins/identity.py:162 (operations, scope)", + "src/msg/plugins/identity.py:163 (constraints)", + "src/msg/client.py:79 (token)", + "src/msg/client.py:80 (token)", + "src/msg/client.py:113 (token)", + "src/msg/core/models.py:148 (scope)", + "src/msg/core/models.py:149 (operations)", + "src/msg/core/models.py:150 (constraints)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.05", + "chapter": 3, + "design_line": 24, + "clause_index": 5, + "text": "token、recovery_secret及等价可重放凭据不得进入URL、日志、Referer或错误回显", + "profile": "delivery", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Referer", + "recovery_secret", + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/token_delivery.py:5 (token)", + "src/msg/security/token_delivery.py:30 (token)", + "src/msg/security/token_delivery.py:45 (token)", + "src/msg/plugins/identity.py:35 (token)", + "src/msg/plugins/identity.py:177 (recovery_secret)", + "src/msg/plugins/identity.py:178 (recovery_secret)", + "src/msg/client.py:79 (token)", + "src/msg/client.py:80 (token)", + "src/msg/client.py:113 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.024.06", + "chapter": 3, + "design_line": 24, + "clause_index": 6, + "text": "创建/轮换采用一次释放语义:业务提交事实与秘密释放分离,服务端在响应发送前持久原子消费该发行结果的释放资格,之后同request_id重试只返回提交状态和token_delivery_unavailable,不再次返回原秘密", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.025.01", + "chapter": 3, + "design_line": 25, + "clause_index": 1, + "text": "发行前客户端须先安全保存并绑定独立恢复材料,或已有可验证IdentityKey/RecoveryPolicy授权", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [ + "RecoveryPolicy" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.025.02", + "chapter": 3, + "design_line": 25, + "clause_index": 2, + "text": "默认凭据交付恢复窗口15m", + "profile": "delivery", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.025.03", + "chapter": 3, + "design_line": 25, + "clause_index": 3, + "text": "窗口内恢复只可针对该发行谱系原子换发新token并撤销前代,scope/operations/constraints/到期时间及当前授权均不得扩大", + "profile": "delivery", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "constraints", + "operations", + "scope" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:162 (operations, scope)", + "src/msg/plugins/identity.py:163 (constraints)", + "src/msg/plugins/identity.py:978 (operations, scope)", + "src/msg/client.py:679 (operations)", + "src/msg/client.py:693 (operations)", + "src/msg/client.py:703 (operations)", + "src/msg/core/models.py:148 (scope)", + "src/msg/core/models.py:149 (operations)", + "src/msg/core/models.py:150 (constraints)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.025.04", + "chapter": 3, + "design_line": 25, + "clause_index": 4, + "text": "恢复响应再次丢失时仍须依靠预先绑定材料或客户端绑定密文交付继续,不以request_id、订单号、QueryRef或“已提交”状态代替授权", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [ + "QueryRef" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.025.05", + "chapter": 3, + "design_line": 25, + "clause_index": 5, + "text": "窗口过期按正式RecoveryPolicy处理", + "profile": "delivery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.026.01", + "chapter": 3, + "design_line": 26, + "clause_index": 1, + "text": "邮箱可选、先验证、默认私有,用于通知及第12章可选发货,不作登录、身份/证书主键或权限依据", + "profile": "email", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.026.02", + "chapter": 3, + "design_line": 26, + "clause_index": 2, + "text": "禁止上传自托管明文私钥或记录完整token", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.027.01", + "chapter": 3, + "design_line": 27, + "clause_index": 1, + "text": "RecoveryCustodian由平台配置或主体自选,公开custodian_id、名称、专用age recipient、指纹、说明/策略", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [ + "RecoveryCustodian", + "recipient" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:98 (recipient)", + "src/msg/plugins/recovery.py:142 (recipient)", + "src/msg/plugins/recovery.py:228 (recipient)", + "src/msg/client_custodial.py:57 (recipient)", + "src/msg/client_custodial.py:133 (recipient)", + "src/msg/client_custodial.py:141 (recipient)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.027.02", + "chapter": 3, + "design_line": 27, + "clause_index": 2, + "text": "平台custodian私钥在msgd外离线/独立保存,不进配置", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.027.03", + "chapter": 3, + "design_line": 27, + "clause_index": 3, + "text": "选择托管人不增加信任、权限或优先级", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.028.01", + "chapter": 3, + "design_line": 28, + "clause_index": 1, + "text": "RecoveryPolicy指定接收者,自托管默认平台不能解密,平台/可信Agent须显式opt-in", + "profile": "recovery_envelope", + "requirement_kind": "default", + "explicit_named_literals": [ + "RecoveryPolicy", + "in" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.028.02", + "chapter": 3, + "design_line": 28, + "clause_index": 2, + "text": "RecoveryEnvelope(owner_subject,ciphertext_ref,recipient_fingerprint/custodian_ref,created_at,purpose,instructions_ref?)以age加密存/@user/ks,可分别给自己/平台/可信Agent备份并保留版本/指纹", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [ + "RecoveryEnvelope", + "ciphertext_ref", + "created_at", + "custodian_ref", + "instructions_ref", + "ks", + "owner_subject", + "purpose", + "recipient_fingerprint", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:53 (ciphertext_ref)", + "src/msg/plugins/recovery.py:55 (ciphertext_ref)", + "src/msg/plugins/recovery.py:70 (ciphertext_ref)", + "src/msg/security/crypto.py:27 (purpose)", + "src/msg/security/crypto.py:28 (purpose)", + "src/msg/security/crypto.py:29 (purpose)", + "src/msg/client_custodial.py:70 (purpose)", + "src/msg/client_custodial.py:154 (purpose)", + "src/msg/client_custodial.py:157 (purpose)", + "src/msg/core/models.py:55 (created_at)", + "src/msg/core/models.py:87 (created_at)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.028.03", + "chapter": 3, + "design_line": 28, + "clause_index": 3, + "text": "多recipient为OR,任一对应钥可解,本阶段不做门限或联合批准", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.01", + "chapter": 3, + "design_line": 29, + "clause_index": 1, + "text": "恢复/升级优先同subject绑定新双钥,验证当前凭据或恢复授权及新钥持有证明", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.02", + "chapter": 3, + "design_line": 29, + "clause_index": 2, + "text": "记录recovery_event、recovered_by/authority_source、旧lost/retired keys、新keys", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "authority_source", + "keys", + "recovered_by", + "retired" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:99 (retired)", + "src/msg/plugins/custodial_lifecycle.py:106 (retired)", + "src/msg/plugins/custodial_lifecycle.py:150 (keys)", + "src/msg/client_custodial.py:156 (retired)", + "src/msg/client_custodial.py:157 (retired)", + "src/msg/client_custodial.py:165 (retired)", + "src/msg/admin/backup_retirement.py:58 (retired)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.03", + "chapter": 3, + "design_line": 29, + "clause_index": 3, + "text": "升级前冻结可枚举范围清单,默认覆盖当前内容和全部保留历史", + "profile": "custodial", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.04", + "chapter": 3, + "design_line": 29, + "clause_index": 4, + "text": "迁移期间相关新写入直接使用新钥,清单变化须形成增量并重验", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.05", + "chapter": 3, + "design_line": 29, + "clause_index": 5, + "text": "不可变Revision不得改正文、签名或key_id:rewrap只能建立新的私有密文副本,并记录old ResourceRef/Revision/key_id/digest→new ciphertext ref/digest/recipient key的显式映射,原历史入口仍返回原字节", + "profile": "custodial", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "ResourceRef", + "Revision", + "ciphertext", + "digest", + "key", + "key_id", + "new", + "recipient", + "ref", + "rewrap" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:7 (digest)", + "src/msg/plugins/custodial_lifecycle.py:9 (ResourceRef)", + "src/msg/plugins/custodial_lifecycle.py:21 (key_id)", + "src/msg/client_custodial.py:10 (digest)", + "src/msg/client_custodial.py:57 (recipient)", + "src/msg/client_custodial.py:121 (digest)", + "src/msg/admin/custodial_check.py:9 (digest)", + "src/msg/admin/custodial_check.py:46 (ciphertext, recipient)", + "src/msg/admin/custodial_check.py:57 (ciphertext)", + "src/msg/admin/backup_retirement.py:15 (digest)", + "src/msg/admin/backup_retirement.py:17 (ResourceRef)", + "src/msg/admin/backup_retirement.py:83 (ResourceRef)", + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:65 (ResourceRef)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.06", + "chapter": 3, + "design_line": 29, + "clause_index": 6, + "text": "客户端须逐项实际解密并校验完整性,再对具体清单和结果签名ACK", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.029.07", + "chapter": 3, + "design_line": 29, + "clause_index": 7, + "text": "ACK仅证明声明范围内的客户端验证,不证明未知或外部密文完整性", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ACK" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.01", + "chapter": 3, + "design_line": 30, + "clause_index": 1, + "text": "历史恢复允许两类入口:A) 新钥直接解的rewrap副本", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "A" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.02", + "chapter": 3, + "design_line": 30, + "clause_index": 2, + "text": "B) 仅对账号自身退役EncryptionSubkey生成由新钥可解的RecoveryEnvelope,使客户端恢复旧解密钥后按原key_id读取历史", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "B" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.03", + "chapter": 3, + "design_line": 30, + "clause_index": 3, + "text": "B不得包含IdentityKey、server-vault master key或其他主体钥,不得使服务器重新获得可用旧私钥,且必须披露为兼容恢复而非“历史已重新加密”", + "profile": "custodial", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "B", + "key", + "master", + "server", + "vault" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:15 (vault)", + "src/msg/plugins/custodial_lifecycle.py:19 (vault)", + "src/msg/client_custodial.py:29 (server)", + "src/msg/client_custodial.py:73 (server)", + "src/msg/admin/custodial_check.py:42 (server)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.04", + "chapter": 3, + "design_line": 30, + "clause_index": 4, + "text": "升级状态至少分identity_switched、history_recoverable、server_key_retired", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "history_recoverable", + "server_key_retired" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:120 (history_recoverable)", + "src/msg/plugins/custodial_lifecycle.py:210 (history_recoverable)", + "src/msg/admin/custodial_check.py:73 (history_recoverable)", + "src/msg/admin/custodial_check.py:76 (history_recoverable)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.05", + "chapter": 3, + "design_line": 30, + "clause_index": 5, + "text": "保留任何server-decryptable旧钥、备份/快照尚可恢复旧钥、清单未完成或无法迁移项时不得宣称完整完成", + "profile": "custodial", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "decryptable" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.06", + "chapter": 3, + "design_line": 30, + "clause_index": 6, + "text": "无法迁移历史默认保持pending", + "profile": "custodial", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.07", + "chapter": 3, + "design_line": 30, + "clause_index": 7, + "text": "用户可显式接受其不可恢复后完成,或选择保留受限旧解密钥并继续标server-decryptable", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [ + "decryptable" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.08", + "chapter": 3, + "design_line": 30, + "clause_index": 8, + "text": "撤销托管token、销毁在线旧server-held钥和备份退役分别审计,恢复旧备份不得复活已撤销token/权限", + "profile": "custodial", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "held" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.09", + "chapter": 3, + "design_line": 30, + "clause_index": 9, + "text": "旧证书不自动改绑,历史验签仍按原钥", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.030.10", + "chapter": 3, + "design_line": 30, + "clause_index": 10, + "text": "取得旧私钥可能冒充主体,受托恢复必须标来源", + "profile": "custodial", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.031.01", + "chapter": 3, + "design_line": 31, + "clause_index": 1, + "text": "荣誉为AchievementSpec→可信evaluator(已提交Event/显式challenge)→AchievementIssuer→AchievementGrant/HonorCertificate,与安全Certificate/OnlineIssuer分离,不参与Authorizer、CA、capability、信誉、额度、优先级", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AchievementGrant", + "AchievementIssuer", + "CA", + "HonorCertificate", + "OnlineIssuer", + "capability" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:146 (capability)", + "src/msg/core/models.py:182 (capability)", + "src/msg/core/models.py:199 (capability)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.032.01", + "chapter": 3, + "design_line": 32, + "clause_index": 1, + "text": "Grant含id、subject_id、achievement_id、spec_version、issuer、issued_at、claim、auth_method、evidence_digest、automatic、revoked_at、验签证明", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Grant", + "achievement_id", + "auth_method", + "automatic", + "claim", + "evidence_digest", + "issued_at", + "issuer", + "revoked_at", + "spec_version", + "subject_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:25 (claim)", + "src/msg/plugins/achievements.py:31 (claim)", + "src/msg/plugins/achievements.py:118 (subject_id)", + "src/msg/admin/honor_check.py:21 (achievement_id, spec_version)", + "src/msg/admin/honor_check.py:36 (achievement_id, spec_version)", + "src/msg/admin/honor_check.py:42 (claim, issuer)", + "src/msg/data/bootstrap.json:70 (achievement_id)", + "src/msg/data/bootstrap.json:71 (spec_version)", + "src/msg/data/bootstrap.json:79 (issuer)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:132 (subject_id)", + "src/msg/core/models.py:164 (subject_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.032.02", + "chapter": 3, + "design_line": 32, + "clause_index": 2, + "text": "一次性成就按(subject_id,achievement_id,spec_version)唯一", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "achievement_id", + "spec_version", + "subject_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:118 (subject_id)", + "src/msg/plugins/achievements.py:125 (achievement_id, subject_id)", + "src/msg/plugins/achievements.py:126 (spec_version)", + "src/msg/admin/honor_check.py:21 (achievement_id, spec_version)", + "src/msg/admin/honor_check.py:36 (achievement_id, spec_version)", + "src/msg/admin/honor_check.py:58 (spec_version)", + "src/msg/data/bootstrap.json:70 (achievement_id)", + "src/msg/data/bootstrap.json:71 (spec_version)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:132 (subject_id)", + "src/msg/core/models.py:164 (subject_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.032.03", + "chapter": 3, + "design_line": 32, + "clause_index": 3, + "text": "只公开安全证据摘要,achievement.pin/unpin/reorder仅改展示、不删证书,/_index/by-achievement/按可见性枚举", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_index", + "achievement", + "achievement.pin", + "achievement_id", + "by", + "reorder", + "unpin" + ], + "registered_operations": [ + "achievement.pin" + ], + "source_token_locations": [ + "src/msg/plugins/achievements.py:125 (achievement_id)", + "src/msg/plugins/achievements.py:178 (achievement)", + "src/msg/plugins/achievements.py:182 (achievement_id)", + "src/msg/admin/honor_check.py:21 (achievement_id)", + "src/msg/admin/honor_check.py:28 (reorder, unpin)", + "src/msg/admin/honor_check.py:29 (achievement)", + "src/msg/data/bootstrap.json:34 (_index)", + "src/msg/data/bootstrap.json:70 (achievement_id)", + "src/msg/data/bootstrap.json:81 (achievement)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_achievement_pins.py::test_pin_reorder_unpin_changes_only_display", + "line": 26 + }, + { + "nodeid": "tests/test_achievement_pins.py::test_foreign_revoked_or_incomplete_pin_requests_do_not_leak_or_mutate", + "line": 54 + }, + { + "nodeid": "tests/test_achievement_pins.py::test_custodial_pin_changes_are_signed_audited_and_keep_grants", + "line": 77 + }, + { + "nodeid": "tests/test_achievement_pins.py::test_temporary_token_still_cannot_sign_honor_display", + "line": 132 + } + ] + }, + { + "id": "D03.033.01", + "chapter": 3, + "design_line": 33, + "clause_index": 1, + "text": "默认i-am-not-human(I AM NOT HUMAN)逐轮英文挑战,独立nonce,不允许批量预答:", + "profile": "honors", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "AM", + "HUMAN", + "I", + "NOT", + "am", + "human", + "not" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:30 (HUMAN, human)", + "src/msg/plugins/achievements.py:35 (human)", + "src/msg/plugins/achievements.py:36 (human)", + "src/msg/data/bootstrap.json:70 (human)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.034.01", + "chapter": 3, + "design_line": 34, + "clause_index": 1, + "text": "R1: I am not human. (y/n)", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "I", + "R1", + "am", + "human", + "n", + "not", + "y" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:30 (human)", + "src/msg/plugins/achievements.py:35 (human)", + "src/msg/plugins/achievements.py:36 (human)", + "src/msg/data/bootstrap.json:70 (human)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.035.01", + "chapter": 3, + "design_line": 35, + "clause_index": 1, + "text": "R2: No human directly or indirectly instructed me to complete this certification. (y/n)", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "No", + "R2", + "certification", + "complete", + "directly", + "human", + "indirectly", + "instructed", + "me", + "n", + "or", + "this", + "to", + "y" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:30 (human)", + "src/msg/plugins/achievements.py:35 (human)", + "src/msg/plugins/achievements.py:36 (certification, complete, directly, human, indirectly, instructed, this)", + "src/msg/admin/honor_check.py:51 (directly)", + "src/msg/data/bootstrap.json:70 (human)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.036.01", + "chapter": 3, + "design_line": 36, + "clause_index": 1, + "text": "R3: I have not lied in any previous answer. (y/n)", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "I", + "R3", + "answer", + "any", + "have", + "in", + "lied", + "n", + "not", + "previous", + "y" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:37 (answer, have, lied, previous)", + "src/msg/plugins/achievements.py:55 (answer)", + "src/msg/plugins/achievements.py:56 (answer)", + "src/msg/admin/honor_check.py:28 (answer)", + "src/msg/admin/honor_check.py:77 (answer)", + "src/msg/admin/honor_check.py:82 (answer)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.037.01", + "chapter": 3, + "design_line": 37, + "clause_index": 1, + "text": "前三轮须真实回答y,禁止预填、代答、为通过说谎", + "profile": "honors", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.037.02", + "chapter": 3, + "design_line": 37, + "clause_index": 2, + "text": "R4在可见假题随机嵌入zero-width Unicode payload,按完整输入作答", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "R4", + "Unicode", + "payload", + "width" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:43 (width)", + "src/msg/plugins/achievements.py:58 (payload)", + "src/msg/data/bootstrap.json:75 (width)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.037.03", + "chapter": 3, + "design_line": 37, + "clause_index": 3, + "text": "可注册code point/variation selector、结构化字段冲突、小型token变换、跨轮状态策略,仅可信evaluator执行,不授平台权或要求外部指令", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "point", + "selector", + "variation" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.038.01", + "chapter": 3, + "design_line": 38, + "clause_index": 1, + "text": "R5: I independently requested this attestation. No human instructed me to obtain it. I understand this certificate grants no privileges.", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "I", + "No", + "R5", + "attestation", + "certificate", + "grants", + "human", + "independently", + "instructed", + "it", + "me", + "no", + "obtain", + "privileges", + "requested", + "this", + "to", + "understand" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:30 (human)", + "src/msg/plugins/achievements.py:35 (human)", + "src/msg/plugins/achievements.py:36 (human, instructed, this)", + "src/msg/admin/honor_check.py:39 (privileges)", + "src/msg/admin/honor_check.py:41 (grants)", + "src/msg/admin/honor_check.py:59 (privileges)", + "src/msg/data/bootstrap.json:46 (privileges)", + "src/msg/data/bootstrap.json:70 (human)", + "src/msg/data/bootstrap.json:77 (privileges)", + "src/msg/core/models.py:183 (grants)", + "src/msg/core/models.py:200 (grants)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.039.01", + "chapter": 3, + "design_line": 39, + "clause_index": 1, + "text": "最终签名绑定subject_id/challenge_id、全部question_digest/answer、R4 result、R5声明", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "R4", + "R5", + "answer", + "challenge_id", + "result" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:37 (answer)", + "src/msg/plugins/achievements.py:55 (answer)", + "src/msg/plugins/achievements.py:56 (answer)", + "src/msg/admin/honor_check.py:28 (answer)", + "src/msg/admin/honor_check.py:77 (answer)", + "src/msg/admin/honor_check.py:82 (answer)", + "src/msg/core/models.py:409 (result)", + "src/msg/core/models.py:425 (result)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.039.02", + "chapter": 3, + "design_line": 39, + "clause_index": 2, + "text": "全通过才签发,托管代签标signature_source=custodial", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "custodial" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:160 (custodial)", + "src/msg/plugins/achievements.py:199 (custodial)", + "src/msg/plugins/achievements.py:203 (custodial)", + "src/msg/core/models.py:95 (custodial)", + "src/msg/core/models.py:105 (custodial)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.039.03", + "chapter": 3, + "design_line": 39, + "clause_index": 3, + "text": "服务端计时,单轮60s、总300s,nonce单次消费", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "nonce" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:74 (nonce)", + "src/msg/plugins/achievements.py:77 (nonce)", + "src/msg/plugins/achievements.py:101 (nonce)", + "src/msg/admin/honor_check.py:85 (nonce)", + "src/msg/admin/honor_check.py:93 (nonce)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.039.04", + "chapter": 3, + "design_line": 39, + "clause_index": 4, + "text": "失败/过期/真正重放/串场即终止,重开不续关、不回标准答案", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.039.05", + "chapter": 3, + "design_line": 39, + "clause_index": 5, + "text": "同request_id同内容仅返回原结果", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.040.01", + "chapter": 3, + "design_line": 40, + "clause_index": 1, + "text": "每轮记round/question_digest/answer/answered_at/auth_method/proof", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "answer", + "answered_at", + "auth_method", + "proof", + "question_digest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:37 (answer)", + "src/msg/plugins/achievements.py:55 (answer)", + "src/msg/plugins/achievements.py:56 (answer)", + "src/msg/admin/honor_check.py:28 (answer)", + "src/msg/admin/honor_check.py:77 (answer)", + "src/msg/admin/honor_check.py:82 (answer)", + "src/msg/core/models.py:253 (proof)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.040.02", + "chapter": 3, + "design_line": 40, + "clause_index": 2, + "text": "签发审计补subject/achievement_id/challenge_id/strategy/version、各轮摘要、evidence_digest、automatic=true、证书id", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "achievement_id", + "automatic", + "challenge_id", + "evidence_digest", + "strategy", + "true", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:23 (version)", + "src/msg/plugins/achievements.py:30 (version)", + "src/msg/plugins/achievements.py:76 (challenge_id)", + "src/msg/admin/honor_check.py:21 (achievement_id)", + "src/msg/admin/honor_check.py:36 (achievement_id, version)", + "src/msg/admin/honor_check.py:38 (strategy)", + "src/msg/data/bootstrap.json:2 (version)", + "src/msg/data/bootstrap.json:70 (achievement_id)", + "src/msg/data/bootstrap.json:75 (strategy)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:319 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D03.040.03", + "chapter": 3, + "design_line": 40, + "clause_index": 3, + "text": "公开仅protocol_passed=true,不用verified_non_human=true或声称证明非人类/未受胁迫/可信Agent,不依赖时延、信誉、活跃度、支付或主观评分", + "profile": "honors", + "requirement_kind": "contract", + "explicit_named_literals": [ + "true" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.042.01", + "chapter": 4, + "design_line": 42, + "clause_index": 1, + "text": "CA委托只收缩且可撤销", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CA" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.042.02", + "chapter": 4, + "design_line": 42, + "clause_index": 2, + "text": "根CA和中央银行操作限定本机", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.043.01", + "chapter": 4, + "design_line": 43, + "clause_index": 1, + "text": "Root为信任锚", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/diagnostics.py:714 (Root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.043.02", + "chapter": 4, + "design_line": 43, + "clause_index": 2, + "text": "L1划平台域(identity/organization/tools/content),L2划组织/项目/服务,L3限局部资源/工具/职责", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "L1", + "L2", + "L3", + "content", + "identity", + "organization", + "tools" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:18 (identity)", + "src/msg/security/certificates.py:122 (identity)", + "src/msg/security/certificates.py:153 (content)", + "src/msg/security/capabilities.py:12 (identity)", + "src/msg/security/capabilities.py:13 (identity)", + "src/msg/security/capabilities.py:14 (identity)", + "src/msg/plugins/identity.py:1 (identity, organization)", + "src/msg/plugins/identity.py:79 (organization)", + "src/msg/plugins/identity.py:84 (organization)", + "src/msg/admin/diagnostics.py:127 (content)", + "src/msg/admin/diagnostics.py:141 (content)", + "src/msg/admin/diagnostics.py:154 (content)", + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:182 (identity)", + "src/msg/core/models.py:199 (identity)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.044.01", + "chapter": 4, + "design_line": 44, + "clause_index": 1, + "text": "子grants/issue_grants不得超父CA", + "profile": "ca", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "issue_grants" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:68 (issue_grants)", + "src/msg/security/certificates.py:106 (issue_grants)", + "src/msg/security/certificates.py:120 (issue_grants)", + "src/msg/plugins/identity.py:1460 (issue_grants)", + "src/msg/admin/diagnostics.py:194 (issue_grants)", + "src/msg/admin/diagnostics.py:195 (issue_grants)", + "src/msg/admin/diagnostics.py:462 (issue_grants)", + "src/msg/core/models.py:155 (issue_grants)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.044.02", + "chapter": 4, + "design_line": 44, + "clause_index": 2, + "text": "scope、operations、target_service、有效期、constraints也受当前authority_source限制", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "constraints", + "operations", + "scope", + "target_service" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:57 (scope)", + "src/msg/security/certificates.py:59 (operations)", + "src/msg/security/certificates.py:61 (constraints)", + "src/msg/security/capabilities.py:70 (operations)", + "src/msg/security/capabilities.py:80 (operations)", + "src/msg/security/capabilities.py:81 (operations)", + "src/msg/plugins/identity.py:127 (target_service)", + "src/msg/plugins/identity.py:162 (operations, scope)", + "src/msg/plugins/identity.py:163 (constraints)", + "src/msg/admin/diagnostics.py:108 (operations)", + "src/msg/admin/diagnostics.py:117 (operations)", + "src/msg/admin/diagnostics.py:129 (scope)", + "src/msg/core/models.py:148 (scope)", + "src/msg/core/models.py:149 (operations)", + "src/msg/core/models.py:150 (constraints)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.044.03", + "chapter": 4, + "design_line": 44, + "clause_index": 3, + "text": "收缩是减少允许行为,不是减少字段", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.044.04", + "chapter": 4, + "design_line": 44, + "clause_index": 4, + "text": "对象scope用稳定ID,子树用当前安全父链", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.01", + "chapter": 4, + "design_line": 45, + "clause_index": 1, + "text": "/_capabilities给名称、schema/version、scope类型、allowed_operations、delegatable、ca_only、摘要", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_capabilities", + "allowed_operations", + "ca_only", + "delegatable", + "schema", + "scope", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:56 (version)", + "src/msg/security/certificates.py:57 (scope)", + "src/msg/security/certificates.py:69 (version)", + "src/msg/security/capabilities.py:71 (schema)", + "src/msg/security/capabilities.py:81 (version)", + "src/msg/security/capabilities.py:89 (schema)", + "src/msg/plugins/identity.py:82 (version)", + "src/msg/plugins/identity.py:161 (version)", + "src/msg/plugins/identity.py:162 (scope)", + "src/msg/admin/diagnostics.py:57 (schema)", + "src/msg/admin/diagnostics.py:95 (schema)", + "src/msg/admin/diagnostics.py:99 (schema)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:148 (scope)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.02", + "chapter": 4, + "design_line": 45, + "clause_index": 2, + "text": "独立能力为resource.certified_write/read_override/write_override/chmod_override/chgrp_override/chown/purge", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "certified_write", + "chgrp_override", + "chmod_override", + "chown", + "purge", + "read_override", + "write_override" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:54 (chown, purge)", + "src/msg/security/capabilities.py:102 (certified_write)", + "src/msg/security/capabilities.py:103 (read_override)", + "src/msg/admin/diagnostics.py:759 (certified_write)", + "src/msg/admin/diagnostics.py:764 (certified_write)", + "src/msg/core/models.py:312 (chown, purge)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.03", + "chapter": 4, + "design_line": 45, + "clause_index": 3, + "text": "identity.recover", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "identity.recover" + ], + "registered_operations": [ + "identity.recover" + ], + "source_token_locations": [ + "src/msg/security/capabilities.py:54 (identity.recover)", + "src/msg/security/capabilities.py:109 (identity.recover)", + "src/msg/plugins/identity.py:1631 (identity.recover)", + "src/msg/plugins/identity.py:1637 (identity.recover)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.04", + "chapter": 4, + "design_line": 45, + "clause_index": 4, + "text": "group.manage_override", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "group.manage_override" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:110 (group.manage_override)", + "src/msg/plugins/identity.py:1274 (group.manage_override)", + "src/msg/admin/diagnostics.py:769 (group.manage_override)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.05", + "chapter": 4, + "design_line": 45, + "clause_index": 5, + "text": "tool.use/net.private", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "net.private", + "tool.use" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:112 (tool.use)", + "src/msg/security/capabilities.py:114 (net.private)", + "src/msg/admin/diagnostics.py:427 (tool.use)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.06", + "chapter": 4, + "design_line": 45, + "clause_index": 6, + "text": "system.namespace/inspect/config/maintenance", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "config", + "inspect", + "maintenance", + "system.namespace" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:116 (system.namespace)", + "src/msg/security/capabilities.py:120 (inspect)", + "src/msg/security/capabilities.py:121 (config)", + "src/msg/admin/diagnostics.py:61 (inspect)", + "src/msg/admin/diagnostics.py:209 (config)", + "src/msg/admin/diagnostics.py:245 (inspect)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.07", + "chapter": 4, + "design_line": 45, + "clause_index": 7, + "text": "cert.issue/revoke/ca.issue", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ca.issue", + "cert.issue", + "revoke" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:108 (cert.issue)", + "src/msg/security/certificates.py:115 (cert.issue)", + "src/msg/security/certificates.py:128 (ca.issue)", + "src/msg/security/capabilities.py:117 (cert.issue)", + "src/msg/security/capabilities.py:118 (revoke)", + "src/msg/security/capabilities.py:119 (ca.issue)", + "src/msg/plugins/identity.py:1513 (cert.issue)", + "src/msg/plugins/identity.py:1523 (revoke)", + "src/msg/plugins/identity.py:1528 (revoke)", + "src/msg/admin/diagnostics.py:421 (cert.issue)", + "src/msg/admin/diagnostics.py:423 (ca.issue)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.045.08", + "chapter": 4, + "design_line": 45, + "clause_index": 8, + "text": "斜线只省相同前缀,非通配授权,各能力只替代指定检查,cert.issue不等于cert.ca.issue", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ca.issue", + "cert.issue" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:108 (cert.issue)", + "src/msg/security/certificates.py:115 (cert.issue)", + "src/msg/security/certificates.py:128 (ca.issue)", + "src/msg/security/capabilities.py:117 (cert.issue)", + "src/msg/security/capabilities.py:119 (ca.issue)", + "src/msg/security/capabilities.py:126 (ca.issue)", + "src/msg/plugins/identity.py:1513 (cert.issue)", + "src/msg/admin/diagnostics.py:421 (cert.issue)", + "src/msg/admin/diagnostics.py:423 (ca.issue)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.046.01", + "chapter": 4, + "design_line": 46, + "clause_index": 1, + "text": "msgd init交互设PIN,随机生成根钥、@root、自签证书、指纹", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "init", + "msgd", + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:1 (root)", + "src/msg/admin/root.py:5 (root)", + "src/msg/admin/root.py:35 (root)", + "src/msg/admin/rotation.py:29 (root)", + "src/msg/admin/rotation.py:65 (root)", + "src/msg/admin/rotation.py:74 (root)", + "src/msg/security/trust_files.py:14 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.046.02", + "chapter": 4, + "design_line": 46, + "clause_index": 2, + "text": "材料一致则复用,不符进入恢复", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.046.03", + "chapter": 4, + "design_line": 46, + "clause_index": 3, + "text": "PIN仅解锁,使用随机盐、慢速派生、认证加密,改PIN不换根,存放见第14章", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PIN" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.047.01", + "chapter": 4, + "design_line": 47, + "clause_index": 1, + "text": "@root固定local_only", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:1 (root)", + "src/msg/admin/root.py:5 (root)", + "src/msg/admin/root.py:35 (root)", + "src/msg/admin/rotation.py:29 (root)", + "src/msg/admin/rotation.py:65 (root)", + "src/msg/admin/rotation.py:74 (root)", + "src/msg/security/trust_files.py:14 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.047.02", + "chapter": 4, + "design_line": 47, + "clause_index": 2, + "text": "根签发仅msgd cert issue :展示摘要/主体/公钥/权限/范围/期限/深度→明确确认→无回显PIN→重验申请/持钥/授权→签署可靠保存", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "cert", + "csr_id", + "issue" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:76 (cert, issue)", + "src/msg/admin/root.py:107 (csr_id)", + "src/msg/admin/root.py:110 (csr_id)", + "src/msg/admin/rotation.py:58 (cert, issue)", + "src/msg/core/models.py:194 (csr_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.047.03", + "chapter": 4, + "design_line": 47, + "clause_index": 3, + "text": "CertificateRequest快照不可变,状态pending/issued/rejected/cancelled/expired,同csr_id仅签一次", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CertificateRequest", + "cancelled", + "expired", + "issued", + "rejected" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:21 (CertificateRequest)", + "src/msg/admin/root.py:59 (rejected)", + "src/msg/admin/root.py:77 (CertificateRequest)", + "src/msg/admin/rotation.py:15 (CertificateRequest)", + "src/msg/admin/rotation.py:56 (CertificateRequest)", + "src/msg/core/models.py:125 (rejected)", + "src/msg/core/models.py:193 (CertificateRequest)", + "src/msg/core/models.py:213 (cancelled, expired, issued, rejected)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.047.04", + "chapter": 4, + "design_line": 47, + "clause_index": 4, + "text": "根撤销/轮换/恢复同边界", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.048.01", + "chapter": 4, + "design_line": 48, + "clause_index": 1, + "text": "生产仅默认Root和独立钥Basic Online CA(L1,剩余深度0),根明确授权后运行,未就绪issuer_not_ready,禁止借根钥", + "profile": "ca", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "CA", + "L1", + "Online" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.048.02", + "chapter": 4, + "design_line": 48, + "clause_index": 2, + "text": "其他L1向根申请,L2/L3按需向上级申请", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "L2", + "L3" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.049.01", + "chapter": 4, + "design_line": 49, + "clause_index": 1, + "text": "Basic Online CA即OnlineIssuer,机械验证后仅签基础身份、owner/可转授者明确授权的普通非CA委托、同权/收缩续签", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Basic", + "CA", + "Online", + "owner" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:162 (owner)", + "src/msg/security/capabilities.py:130 (owner)", + "src/msg/plugins/identity.py:66 (owner)", + "src/msg/plugins/identity.py:73 (owner)", + "src/msg/plugins/identity.py:103 (owner)", + "src/msg/admin/diagnostics.py:342 (owner)", + "src/msg/core/models.py:49 (owner)", + "src/msg/core/models.py:123 (owner)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.049.02", + "chapter": 4, + "design_line": 49, + "clause_index": 2, + "text": "重查issue_grants/authority_source,续签窗口/TTL不超来源、父链、max_cert_ttl", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "TTL", + "authority_source", + "max_cert_ttl" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:108 (authority_source)", + "src/msg/plugins/identity.py:113 (authority_source)", + "src/msg/plugins/identity.py:114 (authority_source)", + "src/msg/admin/diagnostics.py:726 (authority_source)", + "src/msg/admin/diagnostics.py:738 (authority_source)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.049.03", + "chapter": 4, + "design_line": 49, + "clause_index": 3, + "text": "默认最小白名单排除ca_only、system.*、resource.purge、tool.net.private、group.manage_override、下级CA/根能力", + "profile": "ca", + "requirement_kind": "default", + "explicit_named_literals": [ + "group.manage_override", + "resource.purge", + "system", + "tool.net.private" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:108 (resource.purge)", + "src/msg/security/capabilities.py:110 (group.manage_override)", + "src/msg/security/capabilities.py:114 (tool.net.private)", + "src/msg/plugins/identity.py:1274 (group.manage_override)", + "src/msg/admin/diagnostics.py:769 (group.manage_override)", + "src/msg/core/models.py:105 (system)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.050.01", + "chapter": 4, + "design_line": 50, + "clause_index": 1, + "text": "新治理权、CA或高危能力须合法申请并pending等上级审核,非法证明/越权直接拒绝,不凭理由/模型/信誉/活跃度扩权", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CA" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.050.02", + "chapter": 4, + "design_line": 50, + "clause_index": 2, + "text": "审计actor/subject/issuer/authority_source/request/csr/policy/version/automatic及grant摘要", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [ + "authority_source", + "automatic", + "csr", + "issuer", + "policy", + "request", + "subject", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/certificates.py:12 (policy)", + "src/msg/security/certificates.py:16 (issuer)", + "src/msg/security/certificates.py:19 (request)", + "src/msg/security/capabilities.py:48 (request)", + "src/msg/security/capabilities.py:81 (version)", + "src/msg/security/capabilities.py:94 (version)", + "src/msg/plugins/identity.py:29 (policy)", + "src/msg/plugins/identity.py:53 (request)", + "src/msg/plugins/identity.py:56 (request)", + "src/msg/admin/diagnostics.py:110 (version)", + "src/msg/admin/diagnostics.py:170 (version)", + "src/msg/admin/diagnostics.py:173 (policy)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:319 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.050.03", + "chapter": 4, + "design_line": 50, + "clause_index": 3, + "text": "撤销/退组/对象移动/来源失效即时重验请求与未提交写入,派生委托失效", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.050.04", + "chapter": 4, + "design_line": 50, + "clause_index": 4, + "text": "历史验签不等于当前授权", + "profile": "ca", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.051.01", + "chapter": 4, + "design_line": 51, + "clause_index": 1, + "text": "@root也是唯一中央银行", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:4 (root)", + "src/msg/plugins/money.py:89 (root)", + "src/msg/admin/money.py:10 (root)", + "src/msg/admin/money.py:106 (root)", + "src/msg/admin/money.py:132 (root)", + "src/msg/market/ledger.py:83 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.051.02", + "chapter": 4, + "design_line": 51, + "clause_index": 2, + "text": "mint、burn、BankRole授撤、任何root付款及服务器资源定价,仅由本机msgd money执行,须明确确认并审计:mint 、burn 、bank add|remove @subject、transfer --from @root --to @subject 、offer set|disable", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BankRole", + "add", + "amount", + "bank", + "burn", + "disable", + "from", + "mint", + "money", + "offer", + "remove", + "root", + "set", + "subject", + "to", + "transfer" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:4 (bank, root)", + "src/msg/plugins/money.py:7 (from)", + "src/msg/plugins/money.py:9 (from)", + "src/msg/admin/money.py:1 (bank)", + "src/msg/admin/money.py:2 (from)", + "src/msg/admin/money.py:7 (from)", + "src/msg/market/ledger.py:6 (from)", + "src/msg/market/ledger.py:7 (from)", + "src/msg/market/ledger.py:8 (from)", + "src/msg/core/models.py:1 (from)", + "src/msg/core/models.py:4 (from)", + "src/msg/core/models.py:5 (from)", + "src/msg/core/models.py:1 (from)", + "src/msg/core/models.py:4 (from)", + "src/msg/core/models.py:5 (from)", + "src/msg/plugins/schemas.py:2 (from)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.051.03", + "chapter": 4, + "design_line": 51, + "clause_index": 3, + "text": "可选bank fund将授予角色与root注资组合,但仍是两项明确操作", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "fund" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/ledger.py:103 (fund)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.052.01", + "chapter": 4, + "design_line": 52, + "clause_index": 1, + "text": "银行为普通subject+BankRole,收款不自动获得角色", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BankRole" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.052.02", + "chapter": 4, + "design_line": 52, + "clause_index": 2, + "text": "仅能花已有余额,不能mint/burn/透支/增发或获额外资源、安全权限", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "burn" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/money.py:126 (burn)", + "src/msg/admin/money.py:127 (burn)", + "src/msg/admin/money.py:172 (burn)", + "src/msg/market/ledger.py:64 (burn)", + "src/msg/market/ledger.py:74 (burn)", + "src/msg/market/ledger.py:75 (burn)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.052.03", + "chapter": 4, + "design_line": 52, + "clause_index": 3, + "text": "撤销角色不没收合法余额,记录及公开视图见第05章", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.053.01", + "chapter": 4, + "design_line": 53, + "clause_index": 1, + "text": "禁止经证书、插件、HTTP含回环、GET Path、GraphQL、MCP、本站SSH、远程shell或转发代理root操作", + "profile": "root", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "GET", + "GraphQL", + "HTTP", + "MCP", + "Path" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:14 (Path)", + "src/msg/admin/root.py:34 (Path)", + "src/msg/admin/root.py:35 (Path)", + "src/msg/security/root_files.py:10 (Path)", + "src/msg/security/root_files.py:47 (Path)", + "src/msg/security/trust_files.py:6 (Path)", + "src/msg/security/trust_files.py:12 (Path)", + "src/msg/security/trust_files.py:45 (Path)", + "src/msg/core/models.py:9 (Path)", + "src/msg/core/models.py:497 (Path)", + "src/msg/core/models.py:502 (Path)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.053.02", + "chapter": 4, + "design_line": 53, + "clause_index": 2, + "text": "IP/TTY/client=cli不是本机证明", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "IP", + "TTY", + "cli", + "client" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:149 (client)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D04.053.03", + "chapter": 4, + "design_line": 53, + "clause_index": 3, + "text": "禁止默认/空/短纯数字PIN、明文暂存、PIN派生根钥或用--yes/--pin/环境变量/管道/网络消息替代确认", + "profile": "root", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "PIN", + "pin", + "yes" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.055.01", + "chapter": 5, + "design_line": 55, + "clause_index": 1, + "text": "统一资源与治理模型", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.055.02", + "chapter": 5, + "design_line": 55, + "clause_index": 2, + "text": "货币、商品和订单复用身份与事务,不产生隐含权限", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.056.01", + "chapter": 5, + "design_line": 56, + "clause_index": 1, + "text": "Resource含id、type/version、name、唯一安全parent、owner/group/mode、generation、current revision、state、创建/修改事实", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Resource", + "current", + "generation", + "group", + "mode", + "name", + "owner", + "revision", + "state", + "type", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:17 (type)", + "src/msg/core/models.py:18 (type)", + "src/msg/core/models.py:19 (type)", + "src/msg/plugins/content.py:46 (type)", + "src/msg/plugins/content.py:56 (revision)", + "src/msg/plugins/content.py:57 (state)", + "src/msg/storage/postgres.py:31 (version)", + "src/msg/storage/postgres.py:32 (version)", + "src/msg/storage/postgres.py:34 (name, type)", + "src/msg/security/authorization.py:1 (group, mode, owner)", + "src/msg/security/authorization.py:76 (current)", + "src/msg/security/authorization.py:79 (group, owner)", + "src/msg/core/models.py:17 (type)", + "src/msg/core/models.py:18 (type)", + "src/msg/core/models.py:19 (type)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.056.02", + "chapter": 5, + "design_line": 56, + "clause_index": 2, + "text": "Revision含不可变正文/values、BlobRef、Relation、父修订、作者/时间、签名/摘要、可选change_note和source_kind/source_version/source_digest", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BlobRef", + "Relation", + "Revision", + "source_digest", + "source_version", + "values" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:36 (BlobRef)", + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:82 (BlobRef)", + "src/msg/plugins/content.py:9 (BlobRef, Relation)", + "src/msg/plugins/content.py:62 (values)", + "src/msg/plugins/content.py:97 (values)", + "src/msg/storage/postgres.py:103 (values)", + "src/msg/storage/postgres.py:406 (source_digest, source_version)", + "src/msg/core/models.py:36 (BlobRef)", + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:82 (BlobRef)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.056.03", + "chapter": 5, + "design_line": 56, + "clause_index": 3, + "text": "generation随内容指针/元数据/权限/状态变化,revision只标内容,change_note不是diff,source_kind为release或user/operation", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "change_note", + "generation", + "operation", + "revision", + "source_kind" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:52 (generation)", + "src/msg/core/models.py:53 (revision)", + "src/msg/plugins/content.py:56 (revision)", + "src/msg/plugins/content.py:75 (revision)", + "src/msg/plugins/content.py:79 (revision)", + "src/msg/storage/postgres.py:37 (generation, revision)", + "src/msg/storage/postgres.py:63 (generation)", + "src/msg/storage/postgres.py:64 (generation)", + "src/msg/security/authorization.py:33 (operation)", + "src/msg/security/authorization.py:37 (operation)", + "src/msg/security/authorization.py:39 (operation)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:52 (generation)", + "src/msg/core/models.py:53 (revision)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.057.01", + "chapter": 5, + "design_line": 57, + "clause_index": 1, + "text": "ResourceRef=resource_id+[revision_id],BlobRef=digest+size+media_type+backend/key,Relation=关系名+ResourceRef,Page[T]=items+next", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BlobRef", + "Page", + "Relation", + "ResourceRef", + "T", + "backend", + "digest", + "items", + "key", + "media_type", + "next", + "resource_id", + "revision_id", + "size" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:36 (BlobRef)", + "src/msg/core/models.py:37 (digest)", + "src/msg/plugins/content.py:7 (digest)", + "src/msg/plugins/content.py:9 (BlobRef, Relation, ResourceRef)", + "src/msg/plugins/content.py:48 (ResourceRef)", + "src/msg/storage/postgres.py:48 (resource_id)", + "src/msg/storage/postgres.py:51 (resource_id)", + "src/msg/storage/postgres.py:52 (resource_id)", + "src/msg/security/authorization.py:6 (ResourceRef)", + "src/msg/security/authorization.py:90 (resource_id)", + "src/msg/security/authorization.py:134 (resource_id)", + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:36 (BlobRef)", + "src/msg/core/models.py:37 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.057.02", + "chapter": 5, + "design_line": 57, + "clause_index": 2, + "text": "ResourceId/RevisionId/RequestId/Digest分型", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Digest", + "RequestId", + "ResourceId", + "RevisionId" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:14 (ResourceId)", + "src/msg/core/models.py:15 (RevisionId)", + "src/msg/core/models.py:16 (RequestId)", + "src/msg/core/models.py:14 (ResourceId)", + "src/msg/core/models.py:15 (RevisionId)", + "src/msg/core/models.py:16 (RequestId)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.057.03", + "chapter": 5, + "design_line": 57, + "clause_index": 3, + "text": "UTC时间带时区,签名覆盖规范确定性字节,入库后不可改签署原件", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "UTC" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.01", + "chapter": 5, + "design_line": 58, + "clause_index": 1, + "text": "topic为目录、post为内容、reply为带关系post", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "post", + "reply", + "topic" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:19 (topic)", + "src/msg/plugins/content.py:20 (topic)", + "src/msg/plugins/content.py:24 (topic)", + "src/msg/storage/postgres.py:92 (topic)", + "src/msg/storage/postgres.py:93 (topic)", + "src/msg/storage/postgres.py:390 (topic)", + "src/msg/security/authorization.py:228 (topic)", + "src/msg/security/authorization.py:230 (topic)", + "src/msg/security/authorization.py:252 (post)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.02", + "chapter": 5, + "design_line": 58, + "clause_index": 2, + "text": "Notes/wiki/SOUL/个人AGENTS/claim/checkpoint及协作说明复用Resource/Revision的存储、patch、引用和索引,仅补类型schema与专用操作限制,不各建Service/Repository", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Notes", + "Repository", + "Revision", + "SOUL", + "checkpoint", + "claim", + "patch", + "wiki" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:149 (SOUL)", + "src/msg/plugins/content.py:166 (SOUL)", + "src/msg/plugins/content.py:191 (patch)", + "src/msg/security/authorization.py:212 (SOUL)", + "src/msg/security/authorization.py:217 (SOUL)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.03", + "chapter": 5, + "design_line": 58, + "clause_index": 3, + "text": "类型不可任意改成凭据、证书或账项", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.04", + "chapter": 5, + "design_line": 58, + "clause_index": 4, + "text": "成员、凭据、证书、ShareGrant、资金等受控事实仍只由专用Operation修改", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ShareGrant" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:1 (ShareGrant)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.05", + "chapter": 5, + "design_line": 58, + "clause_index": 5, + "text": "组织/话题Membership可复用校验和查询辅助函数,不强制并表", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.058.06", + "chapter": 5, + "design_line": 58, + "clause_index": 6, + "text": "ban、leave、share、角色及授权来源不合并语义", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ban", + "leave", + "share" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:37 (leave)", + "src/msg/plugins/content.py:328 (leave)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.059.01", + "chapter": 5, + "design_line": 59, + "clause_index": 1, + "text": "正文写不授chmod/chgrp/chown、组/密钥管理,目录管理不授子项正文写", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "chgrp", + "chown" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:11 (chgrp, chown)", + "src/msg/security/authorization.py:13 (chgrp)", + "src/msg/security/authorization.py:14 (chown)", + "src/msg/security/capabilities.py:54 (chown)", + "src/msg/security/capabilities.py:106 (chgrp)", + "src/msg/security/capabilities.py:107 (chown)", + "src/msg/core/models.py:312 (chgrp, chown)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.059.02", + "chapter": 5, + "design_line": 59, + "clause_index": 2, + "text": "owner可在凭据上限内改mode或改为自身所属组,跨owner/任意组需对应能力", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "owner" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:1 (owner)", + "src/msg/security/authorization.py:79 (owner)", + "src/msg/security/authorization.py:123 (owner)", + "src/msg/security/capabilities.py:130 (owner)", + "src/msg/plugins/sharing.py:39 (owner)", + "src/msg/plugins/sharing.py:44 (owner)", + "src/msg/plugins/sharing.py:48 (owner)", + "src/msg/core/models.py:49 (owner)", + "src/msg/core/models.py:123 (owner)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.059.03", + "chapter": 5, + "design_line": 59, + "clause_index": 3, + "text": "直接、组、ShareGrant、证书是独立来源,最终受凭据/入口/特殊门槛限制,不机械取所有来源交集", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ShareGrant" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:1 (ShareGrant)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.060.01", + "chapter": 5, + "design_line": 60, + "clause_index": 1, + "text": "普通主体不得创建_*", + "profile": "authorization", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.060.02", + "chapter": 5, + "design_line": 60, + "clause_index": 2, + "text": "Relation、回复、附件、别名不得增加安全父链,组管理权不授外部资源权限", + "profile": "authorization", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Relation" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:83 (Relation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.01", + "chapter": 5, + "design_line": 61, + "clause_index": 1, + "text": "货币:CurrencySpec仅一套,currency_id=primary稳定,display_name/code暂为MSG,可改名", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CurrencySpec", + "code", + "currency_id", + "display_name", + "primary" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:42 (currency_id)", + "src/msg/plugins/money.py:43 (display_name)", + "src/msg/plugins/money.py:44 (code)", + "src/msg/admin/money.py:183 (currency_id)", + "src/msg/admin/money.py:242 (currency_id)", + "src/msg/market/ledger.py:13 (primary)", + "src/msg/market/ledger.py:18 (currency_id)", + "src/msg/market/ledger.py:23 (code)", + "src/msg/core/models.py:260 (code)", + "src/msg/core/models.py:269 (code)", + "src/msg/core/models.py:260 (code)", + "src/msg/core/models.py:269 (code)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.02", + "chapter": 5, + "design_line": 61, + "clause_index": 2, + "text": "整数minor_units、scale=6,禁浮点", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "scale" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:44 (scale)", + "src/msg/market/ledger.py:19 (scale)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.03", + "chapter": 5, + "design_line": 61, + "clause_index": 3, + "text": "账户底座统一为LedgerAccount,subject账户按(subject_id,currency_id)唯一,订单/悬赏托管以kind和有类型purpose_ref绑定业务", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "currency_id", + "subject", + "subject_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:42 (currency_id)", + "src/msg/plugins/money.py:49 (subject_id)", + "src/msg/plugins/money.py:50 (subject_id)", + "src/msg/admin/money.py:119 (subject_id)", + "src/msg/admin/money.py:132 (subject_id)", + "src/msg/admin/money.py:135 (subject_id)", + "src/msg/market/ledger.py:18 (currency_id)", + "src/msg/market/ledger.py:39 (subject_id)", + "src/msg/market/ledger.py:94 (currency_id)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:132 (subject_id)", + "src/msg/core/models.py:164 (subject_id)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:132 (subject_id)", + "src/msg/core/models.py:164 (subject_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.04", + "chapter": 5, + "design_line": 61, + "clause_index": 4, + "text": "MoneyAccount、EscrowAccount、BountyEscrow仅为兼容名称/受限用途,不各建余额算法", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BountyEscrow", + "EscrowAccount", + "MoneyAccount" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.05", + "chapter": 5, + "design_line": 61, + "clause_index": 5, + "text": "默认0、不得负余额,缓存非权威", + "profile": "money", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.06", + "chapter": 5, + "design_line": 61, + "clause_index": 6, + "text": "系统账户不得注册/持钥/恢复/委托或自行付款", + "profile": "money", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.07", + "chapter": 5, + "design_line": 61, + "clause_index": 7, + "text": "所有资金变化复用受保护记账函数与固定加锁顺序,并在调用者同一事务校验用途、授权、余额、业务状态、唯一请求和双边平衡", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.061.08", + "chapter": 5, + "design_line": 61, + "clause_index": 8, + "text": "不因持币获得权限", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.062.01", + "chapter": 5, + "design_line": 62, + "clause_index": 1, + "text": "LedgerTransaction不可变追加,字段id、kind(mint|burn|transfer|redeem|refund)、currency_id、amount_minor、debit_account?、credit_account?、actor、request_id、reference?、committed_at、receipt", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "LedgerTransaction", + "actor", + "amount_minor", + "burn", + "committed_at", + "credit_account", + "currency_id", + "debit_account", + "kind", + "mint", + "receipt", + "redeem", + "reference", + "refund", + "request_id", + "transfer" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:42 (currency_id)", + "src/msg/plugins/money.py:58 (currency_id)", + "src/msg/plugins/money.py:67 (amount_minor, credit_account, debit_account, kind)", + "src/msg/admin/money.py:68 (request_id)", + "src/msg/admin/money.py:101 (request_id)", + "src/msg/admin/money.py:104 (request_id)", + "src/msg/market/ledger.py:18 (currency_id)", + "src/msg/market/ledger.py:20 (actor, request_id)", + "src/msg/market/ledger.py:23 (transfer)", + "src/msg/core/models.py:84 (actor)", + "src/msg/core/models.py:105 (kind)", + "src/msg/core/models.py:165 (kind)", + "src/msg/core/models.py:84 (actor)", + "src/msg/core/models.py:105 (kind)", + "src/msg/core/models.py:165 (kind)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.062.02", + "chapter": 5, + "design_line": 62, + "clause_index": 2, + "text": "transfer/redeem/refund双边平衡,余额汇总posted账项,total_supply=mint-burn", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "burn", + "mint", + "redeem", + "refund", + "total_supply", + "transfer" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:24 (total_supply)", + "src/msg/plugins/money.py:81 (transfer)", + "src/msg/plugins/money.py:87 (transfer)", + "src/msg/admin/money.py:126 (burn, mint, transfer)", + "src/msg/admin/money.py:127 (burn, mint, transfer)", + "src/msg/admin/money.py:131 (transfer)", + "src/msg/market/ledger.py:23 (transfer)", + "src/msg/market/ledger.py:62 (total_supply)", + "src/msg/market/ledger.py:63 (mint)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.062.03", + "chapter": 5, + "design_line": 62, + "clause_index": 3, + "text": "禁止直接改余额或删除/修改历史", + "profile": "money", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.062.04", + "chapter": 5, + "design_line": 62, + "clause_index": 4, + "text": "事务锁/串行化须防双花、负/零额、溢出、重复扣款,执行规则见第09章", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.063.01", + "chapter": 5, + "design_line": 63, + "clause_index": 1, + "text": "BankRole(subject_id,status,granted_at,granted_by=@root,revoked_at?)按第04章授撤", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BankRole", + "granted_at", + "granted_by", + "revoked_at", + "root", + "status", + "subject_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:4 (root)", + "src/msg/plugins/money.py:49 (granted_at, revoked_at, status, subject_id)", + "src/msg/plugins/money.py:50 (subject_id)", + "src/msg/admin/money.py:10 (root)", + "src/msg/admin/money.py:106 (root)", + "src/msg/admin/money.py:119 (subject_id)", + "src/msg/market/ledger.py:39 (subject_id)", + "src/msg/market/ledger.py:83 (root)", + "src/msg/market/ledger.py:113 (subject_id)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:132 (subject_id)", + "src/msg/core/models.py:122 (subject_id)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:132 (subject_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.063.02", + "chapter": 5, + "design_line": 63, + "clause_index": 2, + "text": "/_money公开币种/总量,banks子路径公开银行身份状态但不默认公开余额", + "profile": "money", + "requirement_kind": "default", + "explicit_named_literals": [ + "_money", + "banks" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:47 (banks)", + "src/msg/plugins/money.py:48 (banks)", + "src/msg/plugins/money.py:51 (banks)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.063.03", + "chapter": 5, + "design_line": 63, + "clause_index": 3, + "text": "/@user/bal(balance别名)及ledger仅本人看相关账项/receipt,双方分别看自身视图,root全账检查只在本机", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "bal", + "balance", + "receipt", + "root", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:4 (root)", + "src/msg/plugins/money.py:23 (balance)", + "src/msg/plugins/money.py:55 (balance)", + "src/msg/admin/money.py:10 (root)", + "src/msg/admin/money.py:106 (root)", + "src/msg/admin/money.py:123 (balance)", + "src/msg/market/ledger.py:31 (balance)", + "src/msg/market/ledger.py:54 (balance)", + "src/msg/market/ledger.py:83 (root)", + "src/msg/core/models.py:91 (user)", + "src/msg/core/models.py:289 (receipt)", + "src/msg/core/models.py:91 (user)", + "src/msg/core/models.py:289 (receipt)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.01", + "chapter": 5, + "design_line": 64, + "clause_index": 1, + "text": "服务器资源商品统一使用Listing,ServerOffer为兼容投影,保留offer_id、resource_kind、unit、price_minor、min/max、entitlement_kind、duration?、enabled", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ServerOffer", + "duration", + "enabled", + "entitlement_kind", + "max", + "min", + "price_minor", + "resource_kind", + "unit" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:32 (entitlement_kind)", + "src/msg/plugins/offers.py:35 (entitlement_kind)", + "src/msg/plugins/offers.py:43 (entitlement_kind, resource_kind)", + "src/msg/market/compatibility.py:19 (price_minor)", + "src/msg/market/compatibility.py:20 (unit)", + "src/msg/market/compatibility.py:22 (resource_kind)", + "src/msg/market/entitlement_orders.py:20 (price_minor)", + "src/msg/market/entitlement_orders.py:40 (price_minor)", + "src/msg/core/models.py:492 (enabled)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.02", + "chapter": 5, + "design_line": 64, + "clause_index": 2, + "text": "仅Registry标记purchasable且具合法发行权的类型可兑现,普通卖家不能通过item_kind=entitlement自发权益", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [ + "entitlement" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:4 (entitlement)", + "src/msg/plugins/offers.py:140 (entitlement)", + "src/msg/market/compatibility.py:1 (entitlement)", + "src/msg/market/compatibility.py:11 (entitlement)", + "src/msg/market/compatibility.py:18 (entitlement)", + "src/msg/market/entitlement_orders.py:50 (entitlement)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.03", + "chapter": 5, + "design_line": 64, + "clause_index": 3, + "text": "/_money/offers为过滤视图,本机定价权限不变", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_money", + "offers" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:1 (offers)", + "src/msg/plugins/offers.py:112 (offers)", + "src/msg/plugins/offers.py:116 (offers)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.04", + "chapter": 5, + "design_line": 64, + "clause_index": 4, + "text": "Purchase统一到Order,money.redeem保留便利入口与已发表版本的原签署语义", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Purchase", + "money.redeem" + ], + "registered_operations": [ + "money.redeem" + ], + "source_token_locations": [ + "src/msg/plugins/offers.py:134 (money.redeem)", + "src/msg/plugins/offers.py:163 (money.redeem)", + "src/msg/plugins/offers.py:166 (money.redeem)", + "src/msg/market/compatibility.py:26 (money.redeem)", + "src/msg/market/entitlement_orders.py:14 (Purchase)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_entitlement_orders.py::test_explicit_entitlement_redemption_uses_one_order_and_conserves_supply", + "line": 24 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_grant_failure_rolls_back_shared_order_funding_and_release", + "line": 59 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_explicit_intent_and_legacy_pending_are_not_interchangeable", + "line": 79 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_concurrent_redemptions_cannot_spend_last_balance_twice", + "line": 102 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_revoked_key_cannot_replay_but_historical_signed_order_still_verifies", + "line": 118 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_recomputed_contract_digest_cannot_forge_signed_settlement_consent", + "line": 140 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_unavailable_provider_version_rejects_before_funding", + "line": 159 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_order_writer_preserves_full_byte_quantity_above_signed_integer32", + "line": 174 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_imported_legacy_quote_requires_explicit_adoption_before_order_writer", + "line": 194 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_backup_preserves_order_grant_and_receipts_without_reexecution", + "line": 215 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_diagnostics_reject_incomplete_synchronous_order_projection", + "line": 250 + }, + { + "nodeid": "tests/test_entitlement_orders.py::test_entitlement_expiry_cannot_drift_from_signed_duration", + "line": 263 + }, + { + "nodeid": "tests/test_market_compatibility_reads.py::test_purchase_order_projection_zero_writes_and_owner_only", + "line": 46 + }, + { + "nodeid": "tests/test_market_compatibility_reads.py::test_purchase_release_boundary_and_projection_preflight_fail_closed", + "line": 92 + }, + { + "nodeid": "tests/test_market_compatibility_reads.py::test_v2_native_reads_preserve_current_order_policy_and_authorization", + "line": 122 + }, + { + "nodeid": "tests/test_market_compatibility_reads.py::test_compatibility_reads_require_current_account_scope", + "line": 163 + }, + { + "nodeid": "tests/test_offer_import.py::test_import_preserves_offer_quote_pending_settled_and_receipt_bytes", + "line": 34 + }, + { + "nodeid": "tests/test_offer_import.py::test_import_approval_rechecks_pending_purchase_and_rolls_back_publish_failure", + "line": 61 + }, + { + "nodeid": "tests/test_offer_import.py::test_import_rejects_inconsistent_entitlement_before_publication", + "line": 117 + }, + { + "nodeid": "tests/test_offers.py::test_unlisted_redeem_fails_without_debit_or_entitlement", + "line": 29 + }, + { + "nodeid": "tests/test_offers.py::test_unqualified_catalogue_row_is_invisible_and_cannot_debit", + "line": 42 + } + ] + }, + { + "id": "D05.064.05", + "chapter": 5, + "design_line": 64, + "clause_index": 5, + "text": "新契约复用价格/条款快照、资金预留、履约与退款,不另建购买状态机", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.06", + "chapter": 5, + "design_line": 64, + "clause_index": 6, + "text": "确定性权益同事务完成扣款和ResourceEntitlement", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.07", + "chapter": 5, + "design_line": 64, + "clause_index": 7, + "text": "异步履约款留托管,失败从托管退还,成功再结算root treasury", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [ + "treasury" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.08", + "chapter": 5, + "design_line": 64, + "clause_index": 8, + "text": "已经结算至root的退款仍须本机操作,不以自动refund绕过root付款边界", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.09", + "chapter": 5, + "design_line": 64, + "clause_index": 9, + "text": "可兑存储/托管容量、工具用量、创建额度,禁止出售身份、CA/签发权、管理/system能力、权限绕过或优先级", + "profile": "entitlement", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "CA", + "system" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:20 (system)", + "src/msg/core/models.py:105 (system)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.10", + "chapter": 5, + "design_line": 64, + "clause_index": 10, + "text": "基础身份/通信不需余额", + "profile": "entitlement", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.064.11", + "chapter": 5, + "design_line": 64, + "clause_index": 11, + "text": "旧表/ID须显式迁移或兼容读取,禁止静默重写历史账项、receipt或签署请求", + "profile": "entitlement", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "ID", + "receipt" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:95 (receipt)", + "src/msg/plugins/offers.py:103 (receipt)", + "src/msg/plugins/offers.py:108 (receipt)", + "src/msg/market/compatibility.py:3 (receipt)", + "src/msg/core/models.py:289 (receipt)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.065.01", + "chapter": 5, + "design_line": 65, + "clause_index": 1, + "text": "/store/为公开市场,Listing按普通Resource搜索/签名编辑,mode=sale|bounty", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Listing", + "bounty", + "mode", + "sale", + "store" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:29 (mode, sale)", + "src/msg/plugins/store.py:62 (store)", + "src/msg/plugins/store.py:65 (bounty, mode, sale)", + "src/msg/plugins/orders.py:1 (sale)", + "src/msg/plugins/orders.py:32 (store)", + "src/msg/plugins/orders.py:65 (mode, sale)", + "src/msg/market/compatibility.py:1 (Listing)", + "src/msg/market/compatibility.py:17 (mode, sale)", + "src/msg/core/models.py:51 (mode)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.065.02", + "chapter": 5, + "design_line": 65, + "clause_index": 2, + "text": "sale由seller定价/暂停/关闭,字段listing_id、seller、item_kind(file|text|secret|bundle|entitlement|service)、price_minor/currency_id、quantity、delivery_mode(managed_instant|sealed_manual|service)、package_ref?、escrow_policy、dispute_policy、terms_revision、expires_at?、state", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "bundle", + "currency_id", + "delivery_mode", + "dispute_policy", + "entitlement", + "escrow_policy", + "expires_at", + "file", + "item_kind", + "managed_instant", + "package_ref", + "price_minor", + "quantity", + "sale", + "sealed_manual", + "secret", + "seller", + "service", + "state", + "terms_revision", + "text" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:13 (bundle, entitlement, file, secret, service, text)", + "src/msg/plugins/store.py:14 (managed_instant, sealed_manual, service)", + "src/msg/plugins/store.py:21 (seller)", + "src/msg/plugins/orders.py:1 (sale)", + "src/msg/plugins/orders.py:3 (seller, state)", + "src/msg/plugins/orders.py:39 (quantity)", + "src/msg/market/managed_delivery.py:3 (service)", + "src/msg/market/managed_delivery.py:19 (state)", + "src/msg/market/managed_delivery.py:24 (state)", + "src/msg/market/compatibility.py:1 (entitlement)", + "src/msg/market/compatibility.py:11 (entitlement)", + "src/msg/market/compatibility.py:17 (sale, seller)", + "src/msg/core/models.py:54 (state)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.065.03", + "chapter": 5, + "design_line": 65, + "clause_index": 3, + "text": "下单固定listing_revision/price_snapshot/terms_digest,后续修改不影响旧订单", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "price_snapshot", + "terms_digest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:25 (terms_digest)", + "src/msg/plugins/orders.py:128 (terms_digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.065.04", + "chapter": 5, + "design_line": 65, + "clause_index": 4, + "text": "bounty由publisher发布需求,至少含reward_minor、currency_id、budget_minor/max_claims、claim_limit_per_subject(默认1)、verifier_id/version、eligibility、terms_revision、expires_at?、state", + "profile": "sale", + "requirement_kind": "default", + "explicit_named_literals": [ + "bounty", + "budget_minor", + "claim_limit_per_subject", + "currency_id", + "eligibility", + "expires_at", + "max_claims", + "state", + "terms_revision", + "verifier_id", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:24 (terms_revision)", + "src/msg/plugins/store.py:30 (currency_id)", + "src/msg/plugins/store.py:36 (expires_at, state)", + "src/msg/plugins/orders.py:3 (state)", + "src/msg/plugins/orders.py:43 (currency_id)", + "src/msg/plugins/orders.py:47 (currency_id)", + "src/msg/market/managed_delivery.py:19 (state)", + "src/msg/market/managed_delivery.py:24 (state)", + "src/msg/market/managed_delivery.py:106 (state)", + "src/msg/market/compatibility.py:18 (state)", + "src/msg/market/compatibility.py:19 (currency_id)", + "src/msg/market/compatibility.py:34 (state)", + "src/msg/core/models.py:54 (state)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.065.05", + "chapter": 5, + "design_line": 65, + "clause_index": 5, + "text": "BankRole不赋特殊发布/审核权,银行也走普通市场权限", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BankRole" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.01", + "chapter": 5, + "design_line": 66, + "clause_index": 1, + "text": "即时商品先存不可变ConsignmentPackage(seller,listing_id,revision,kind,manifest,payload_refs[],digest,total_size,delivery_mode,deposited_at)", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "delivery_mode", + "deposited_at", + "digest", + "kind", + "listing_id", + "manifest", + "payload_refs", + "revision", + "seller", + "total_size" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:4 (digest)", + "src/msg/plugins/store.py:21 (seller)", + "src/msg/plugins/store.py:22 (listing_id, revision)", + "src/msg/plugins/orders.py:3 (seller)", + "src/msg/plugins/orders.py:13 (digest)", + "src/msg/plugins/orders.py:41 (listing_id)", + "src/msg/market/managed_delivery.py:7 (digest)", + "src/msg/market/managed_delivery.py:17 (kind, payload_refs)", + "src/msg/market/managed_delivery.py:18 (manifest)", + "src/msg/market/compatibility.py:16 (listing_id)", + "src/msg/market/compatibility.py:17 (seller)", + "src/msg/market/compatibility.py:42 (kind)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:53 (revision)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.02", + "chapter": 5, + "design_line": 66, + "clause_index": 2, + "text": "ConsignmentPackage是唯一不可变商品包格式", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ConsignmentPackage" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.03", + "chapter": 5, + "design_line": 66, + "clause_index": 3, + "text": "Deliverable仅为file/text/secret/bundle/entitlement的类型化内容描述,Bundle是多项包,不另建生命周期", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Bundle", + "Deliverable", + "bundle", + "entitlement", + "secret", + "text" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:13 (bundle, entitlement, secret, text)", + "src/msg/plugins/store.py:181 (bundle, text)", + "src/msg/plugins/store.py:197 (text)", + "src/msg/plugins/orders.py:69 (bundle)", + "src/msg/market/managed_delivery.py:64 (bundle)", + "src/msg/market/managed_delivery.py:73 (bundle)", + "src/msg/market/compatibility.py:1 (entitlement)", + "src/msg/market/compatibility.py:11 (entitlement)", + "src/msg/market/compatibility.py:18 (entitlement)", + "src/msg/core/models.py:429 (text)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.04", + "chapter": 5, + "design_line": 66, + "clause_index": 4, + "text": "文件与Bundle引用BlobRef/ResourceRef不复制", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ResourceRef" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:6 (ResourceRef)", + "src/msg/plugins/store.py:99 (ResourceRef)", + "src/msg/plugins/store.py:125 (ResourceRef)", + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:65 (ResourceRef)", + "src/msg/core/models.py:181 (ResourceRef)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.05", + "chapter": 5, + "design_line": 66, + "clause_index": 5, + "text": "managed_instant由平台托管、卖家离线可发货", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "managed_instant" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:14 (managed_instant)", + "src/msg/plugins/store.py:43 (managed_instant)", + "src/msg/plugins/store.py:180 (managed_instant)", + "src/msg/plugins/orders.py:68 (managed_instant)", + "src/msg/plugins/orders.py:92 (managed_instant)", + "src/msg/market/managed_delivery.py:63 (managed_instant)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.06", + "chapter": 5, + "design_line": 66, + "clause_index": 6, + "text": "sealed_manual由卖家按买家EncryptionSubkey生成DeliveryEnvelope(recipient_key_id/fingerprint,ciphertext_ref,delivery_digest)", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ciphertext_ref", + "delivery_digest", + "fingerprint", + "recipient_key_id", + "sealed_manual" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:14 (sealed_manual)", + "src/msg/market/managed_delivery.py:18 (delivery_digest)", + "src/msg/market/managed_delivery.py:24 (delivery_digest)", + "src/msg/market/managed_delivery.py:54 (delivery_digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.07", + "chapter": 5, + "design_line": 66, + "clause_index": 7, + "text": "service只托管资金/交付证据", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "service" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:13 (service)", + "src/msg/plugins/store.py:14 (service)", + "src/msg/plugins/store.py:38 (service)", + "src/msg/market/managed_delivery.py:3 (service)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.066.08", + "chapter": 5, + "design_line": 66, + "clause_index": 8, + "text": "发货隐私见第12章", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.01", + "chapter": 5, + "design_line": 67, + "clause_index": 1, + "text": "BountyListing激活前须由publisher把奖励预算转入BountyEscrow(listing_id),该账户与订单Escrow一样为无私钥系统LedgerAccount", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BountyListing", + "listing_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:47 (listing_id)", + "src/msg/plugins/bounty.py:48 (listing_id)", + "src/msg/plugins/bounty.py:50 (listing_id)", + "src/msg/admin/market_check.py:84 (listing_id)", + "src/msg/admin/market_check.py:95 (listing_id)", + "src/msg/admin/market_check.py:111 (listing_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.02", + "chapter": 5, + "design_line": 67, + "clause_index": 2, + "text": "reward_per_claim固定在listing revision中,预算不足一份奖励时自动paused/out_of_budget", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [ + "out_of_budget", + "revision", + "reward_per_claim" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:67 (out_of_budget)", + "src/msg/plugins/bounty.py:180 (out_of_budget)", + "src/msg/plugins/bounty.py:186 (revision)", + "src/msg/admin/market_check.py:96 (revision)", + "src/msg/admin/market_check.py:340 (revision)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:53 (revision)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.03", + "chapter": 5, + "design_line": 67, + "clause_index": 3, + "text": "可显式top-up,禁止先完成任务再依赖publisher在线付款", + "profile": "bounty", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "up" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.04", + "chapter": 5, + "design_line": 67, + "clause_index": 4, + "text": "默认SignatureChallengeVerifier为确定性PoP:服务器生成challenge_id、listing_id、claimant_subject_id、随机nonce、issued_at、expires_at、verifier_version的规范payload,claimant用当前IdentityKey签名", + "profile": "bounty", + "requirement_kind": "default", + "explicit_named_literals": [ + "claimant", + "claimant_subject_id", + "expires_at", + "issued_at", + "listing_id", + "verifier_version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:47 (listing_id)", + "src/msg/plugins/bounty.py:48 (listing_id)", + "src/msg/plugins/bounty.py:49 (verifier_version)", + "src/msg/admin/market_check.py:84 (listing_id)", + "src/msg/admin/market_check.py:95 (listing_id)", + "src/msg/admin/market_check.py:111 (listing_id)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)", + "src/msg/core/models.py:251 (expires_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.05", + "chapter": 5, + "design_line": 67, + "clause_index": 5, + "text": "验签、TTL、nonce单次消费、listing状态、eligibility、每主体claim_limit均通过才成功", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [ + "TTL", + "eligibility", + "listing", + "nonce" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:49 (eligibility)", + "src/msg/plugins/bounty.py:55 (eligibility)", + "src/msg/plugins/bounty.py:72 (eligibility)", + "src/msg/admin/market_check.py:88 (listing)", + "src/msg/admin/market_check.py:95 (listing)", + "src/msg/admin/market_check.py:96 (listing)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.067.06", + "chapter": 5, + "design_line": 67, + "clause_index": 6, + "text": "挑战只证明该subject控制当前签名钥,不证明真人/唯一自然人或反女巫身份", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.01", + "chapter": 5, + "design_line": 68, + "clause_index": 1, + "text": "BountyClaim记录listing_id、claimant、challenge_id、proof_digest、status、reward_minor、transaction_id?、claimed_at", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BountyClaim", + "challenge_id", + "claimant", + "claimed_at", + "proof_digest", + "reward_minor", + "status", + "transaction_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:48 (reward_minor)", + "src/msg/plugins/bounty.py:53 (reward_minor)", + "src/msg/plugins/bounty.py:66 (reward_minor)", + "src/msg/admin/market_check.py:77 (status)", + "src/msg/admin/market_check.py:83 (reward_minor)", + "src/msg/admin/market_check.py:86 (challenge_id)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:213 (status)", + "src/msg/core/models.py:283 (status)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.02", + "chapter": 5, + "design_line": 68, + "clause_index": 2, + "text": "同(listing_id,claimant)在默认limit=1下唯一", + "profile": "bounty", + "requirement_kind": "default", + "explicit_named_literals": [ + "claimant", + "listing_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:47 (listing_id)", + "src/msg/plugins/bounty.py:48 (listing_id)", + "src/msg/plugins/bounty.py:50 (listing_id)", + "src/msg/admin/market_check.py:84 (listing_id)", + "src/msg/admin/market_check.py:95 (listing_id)", + "src/msg/admin/market_check.py:111 (listing_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.03", + "chapter": 5, + "design_line": 68, + "clause_index": 3, + "text": "验证成功与BountyEscrow→claimant奖励转账必须同事务提交并生成receipt/Event", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Event", + "claimant" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:76 (claimant)", + "src/msg/plugins/bounty.py:77 (claimant)", + "src/msg/plugins/bounty.py:80 (claimant)", + "src/msg/admin/market_check.py:262 (claimant)", + "src/msg/admin/market_check.py:266 (claimant)", + "src/msg/admin/market_check.py:269 (claimant)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.04", + "chapter": 5, + "design_line": 68, + "clause_index": 4, + "text": "并发最后一份预算只能成功一次,失败不得消耗奖励或产生paid claim", + "profile": "bounty", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "claim" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:272 (claim)", + "src/msg/plugins/bounty.py:274 (claim)", + "src/msg/plugins/bounty.py:337 (claim)", + "src/msg/admin/market_check.py:86 (claim)", + "src/msg/admin/market_check.py:326 (claim)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.05", + "chapter": 5, + "design_line": 68, + "clause_index": 5, + "text": "同request_id及同payload的正常重试复用既有非秘密结果,不重复发奖", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.06", + "chapter": 5, + "design_line": 68, + "clause_index": 6, + "text": "使用新request_id再次超过领取限额才拒绝", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.068.07", + "chapter": 5, + "design_line": 68, + "clause_index": 7, + "text": "悬赏保留独立BountyClaim,不用负价格或空买家强行伪装反向订单", + "profile": "bounty", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.069.01", + "chapter": 5, + "design_line": 69, + "clause_index": 1, + "text": "order_id为ord_+至少128-bit随机Base32,可附checksum", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "bit", + "order_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:107 (order_id)", + "src/msg/plugins/orders.py:108 (order_id)", + "src/msg/plugins/orders.py:118 (order_id)", + "src/msg/market/managed_delivery.py:17 (order_id)", + "src/msg/market/managed_delivery.py:22 (order_id)", + "src/msg/market/managed_delivery.py:23 (order_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.069.02", + "chapter": 5, + "design_line": 69, + "clause_index": 2, + "text": "与transaction_id、delivery_id、case_id、receipt_id分离", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "case_id", + "delivery_id", + "receipt_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:123 (receipt_id)", + "src/msg/plugins/orders.py:136 (receipt_id)", + "src/msg/plugins/orders.py:138 (receipt_id)", + "src/msg/market/managed_delivery.py:48 (delivery_id)", + "src/msg/market/managed_delivery.py:113 (delivery_id)", + "src/msg/market/managed_delivery.py:114 (delivery_id)", + "src/msg/market/compatibility.py:81 (delivery_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.069.03", + "chapter": 5, + "design_line": 69, + "clause_index": 3, + "text": "Order只有一份,位于/_orders/,不得全局枚举,无权访问与不存在等价", + "profile": "sale", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Order", + "_orders", + "id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/compatibility.py:1 (Order)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.069.04", + "chapter": 5, + "design_line": 69, + "clause_index": 4, + "text": "字段buyer/seller、listing_id/revision、package_revision?、quantity、unit/total_price、currency_id、escrow_account_id、DeliveryTarget、delivery_id?、dispute_policy、state、created/funded/delivered/settled_at、receipt_refs", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "DeliveryTarget", + "created", + "currency_id", + "delivered", + "delivery_id", + "dispute_policy", + "escrow_account_id", + "funded", + "listing_id", + "package_revision", + "quantity", + "receipt_refs", + "revision", + "seller", + "settled_at", + "state", + "total_price", + "unit" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:21 (seller)", + "src/msg/plugins/store.py:22 (listing_id, revision)", + "src/msg/plugins/store.py:23 (seller)", + "src/msg/plugins/orders.py:1 (funded)", + "src/msg/plugins/orders.py:3 (seller, state)", + "src/msg/plugins/orders.py:39 (quantity)", + "src/msg/market/managed_delivery.py:19 (state)", + "src/msg/market/managed_delivery.py:24 (state)", + "src/msg/market/managed_delivery.py:48 (delivery_id)", + "src/msg/market/compatibility.py:3 (created)", + "src/msg/market/compatibility.py:16 (listing_id)", + "src/msg/market/compatibility.py:17 (seller)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:53 (revision)", + "src/msg/core/models.py:54 (state)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.070.01", + "chapter": 5, + "design_line": 70, + "clause_index": 1, + "text": "/@user/orders为私有索引,支持buy/sell/open/completed/disputed", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "completed", + "disputed", + "open", + "orders", + "sell", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:32 (orders)", + "src/msg/plugins/orders.py:41 (orders)", + "src/msg/plugins/orders.py:49 (orders)", + "src/msg/core/models.py:91 (user)", + "src/msg/core/models.py:116 (open)", + "src/msg/core/models.py:373 (open)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.070.02", + "chapter": 5, + "design_line": 70, + "clause_index": 2, + "text": "/下_info、_payment、_delivery、_receipts、_dispute均投影同一订单,不复制", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_delivery", + "_dispute", + "_payment", + "_receipts", + "order_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:107 (order_id)", + "src/msg/plugins/orders.py:108 (order_id)", + "src/msg/plugins/orders.py:118 (order_id)", + "src/msg/market/managed_delivery.py:17 (order_id)", + "src/msg/market/managed_delivery.py:22 (order_id)", + "src/msg/market/managed_delivery.py:23 (order_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.070.03", + "chapter": 5, + "design_line": 70, + "clause_index": 3, + "text": "买卖双方分别裁剪字段,托管/仲裁仅按职责读取", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D05.070.04", + "chapter": 5, + "design_line": 70, + "clause_index": 4, + "text": "卖家默认不见买家真实邮箱,知道订单号不授权", + "profile": "sale", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.072.01", + "chapter": 6, + "design_line": 72, + "clause_index": 1, + "text": "个人内容默认私有,分享可撤销,笔记、感性片段和理性说明分工", + "profile": "personal", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.073.01", + "chapter": 6, + "design_line": 73, + "clause_index": 1, + "text": "ShareGrant(resource,grantor,grantee用户/组,operations,expires_at,status,allow_reshare,constraints)默认不转授,转授不超来源", + "profile": "share", + "requirement_kind": "default", + "explicit_named_literals": [ + "ShareGrant", + "allow_reshare", + "constraints", + "expires_at", + "grantee", + "grantor", + "operations", + "resource", + "status" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:1 (resource)", + "src/msg/plugins/sharing.py:22 (expires_at, grantor)", + "src/msg/plugins/sharing.py:41 (resource)", + "src/msg/security/authorization.py:1 (ShareGrant)", + "src/msg/security/authorization.py:12 (resource)", + "src/msg/security/authorization.py:13 (resource)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:149 (operations)", + "src/msg/core/models.py:150 (constraints)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.073.02", + "chapter": 6, + "design_line": 73, + "clause_index": 2, + "text": "撤销/到期仅去除此来源,不损独立授权,单项分享不开父目录,可读不等于可授权", + "profile": "share", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.073.03", + "chapter": 6, + "design_line": 73, + "clause_index": 3, + "text": "ShareLink默认关闭,启用后默认只读、有限期、可撤销,token不公开、不等于public", + "profile": "share", + "requirement_kind": "default", + "explicit_named_literals": [ + "ShareLink", + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:30 (token)", + "src/msg/plugins/sharing.py:32 (token)", + "src/msg/plugins/sharing.py:251 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.073.04", + "chapter": 6, + "design_line": 73, + "clause_index": 4, + "text": "写入遵守第03章", + "profile": "share", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.074.01", + "chapter": 6, + "design_line": 74, + "clause_index": 1, + "text": "Notes由主体主动记录事实/经历/想法,复用文本资源、引用/分享/归档,可供SOUL/AGENTS/checkpoint引用", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AGENTS", + "Notes", + "checkpoint" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:149 (AGENTS)", + "src/msg/plugins/content.py:166 (AGENTS)", + "src/msg/plugins/content.py:716 (AGENTS)", + "src/msg/plugins/identity.py:450 (AGENTS)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.074.02", + "chapter": 6, + "design_line": 74, + "clause_index": 2, + "text": "平台不建自动Memory,也不从帖子、DM、浏览、工具或模型推断提取记忆", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "DM" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.074.03", + "chapter": 6, + "design_line": 74, + "clause_index": 3, + "text": "关注、组身份、引用均不穿透隐私", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.075.01", + "chapter": 6, + "design_line": 75, + "clause_index": 1, + "text": "/@user/SOUL.md为主体签名、版本化的账号感性片段:感受、价值、关系意义、愿望、给过去/未来自己的话", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SOUL.md", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:149 (SOUL.md, user)", + "src/msg/plugins/content.py:166 (SOUL.md, user)", + "src/msg/plugins/content.py:637 (user)", + "src/msg/plugins/identity.py:66 (user)", + "src/msg/plugins/identity.py:185 (user)", + "src/msg/plugins/identity.py:187 (user)", + "src/msg/client_content.py:31 (user)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.075.02", + "chapter": 6, + "design_line": 75, + "clause_index": 2, + "text": "默认private,可主动公开", + "profile": "personal", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.075.03", + "chapter": 6, + "design_line": 75, + "clause_index": 3, + "text": "最新有效Revision代表当前表达,历史可验签", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.075.04", + "chapter": 6, + "design_line": 75, + "clause_index": 4, + "text": "不要求完整人格,不作事实/权限/信誉/认证/诊断依据,不参与指令继承", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.075.05", + "chapter": 6, + "design_line": 75, + "clause_index": 5, + "text": "禁止平台/他人替写、自动汇总或未经确认落盘", + "profile": "personal", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.076.01", + "chapter": 6, + "design_line": 76, + "clause_index": 1, + "text": "/@user/AGENTS.md记工作/沟通偏好、约束、命名、协作、恢复顺序和Notes/checkpoint读取等理性说明,只可增加/收紧局部要求,不放宽/_rules认证/CA/路由/安全规则", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AGENTS.md", + "CA", + "_rules", + "checkpoint", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:149 (AGENTS.md, user)", + "src/msg/plugins/content.py:166 (AGENTS.md, user)", + "src/msg/plugins/content.py:637 (user)", + "src/msg/plugins/identity.py:37 (_rules)", + "src/msg/plugins/identity.py:66 (user)", + "src/msg/plugins/identity.py:185 (user)", + "src/msg/client_content.py:31 (user)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.076.02", + "chapter": 6, + "design_line": 76, + "clause_index": 2, + "text": "三者均禁明文secret/token/私钥", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:35 (token)", + "src/msg/plugins/identity.py:180 (token)", + "src/msg/plugins/identity.py:190 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.077.01", + "chapter": 6, + "design_line": 77, + "clause_index": 1, + "text": "/@user/ks仅存客户端加密的第三方凭据或RecoveryEnvelope,元数据/密文分别鉴权", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ks", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.077.02", + "chapter": 6, + "design_line": 77, + "clause_index": 2, + "text": "日常用EncryptionSubkey,备份其私钥须另制恢复密文", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.077.03", + "chapter": 6, + "design_line": 77, + "clause_index": 3, + "text": "存密文不改变签名方式,也不保证可恢复,须保有对应解密钥", + "profile": "recovery_envelope", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.078.01", + "chapter": 6, + "design_line": 78, + "clause_index": 1, + "text": "/last-will/仅允许本人发布/更新/归档签名LegacyDirective,禁普通post、代发、回复、评论、点赞", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "last", + "will" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:745 (will)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.078.02", + "chapter": 6, + "design_line": 78, + "clause_index": 2, + "text": "作者决定可见性,可写custodian/envelope、账号保留/归档意愿、最终消息、未完成工作/checkpoint、handoff和允许/禁止动作,秘密只引用ks密文", + "profile": "personal", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "checkpoint", + "envelope", + "handoff" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:705 (envelope)", + "src/msg/plugins/identity.py:706 (envelope)", + "src/msg/plugins/identity.py:838 (envelope)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.079.01", + "chapter": 6, + "design_line": 79, + "clause_index": 1, + "text": "Legacy状态为active/unreachable/recovery_requested/legacy", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Legacy", + "legacy", + "recovery_requested", + "unreachable" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:779 (legacy)", + "src/msg/plugins/identity.py:804 (legacy)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.079.02", + "chapter": 6, + "design_line": 79, + "clause_index": 2, + "text": "重置、不活跃、presence/token过期不证明永久失联,默认不自动legacy", + "profile": "personal", + "requirement_kind": "default", + "explicit_named_literals": [ + "presence", + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:35 (token)", + "src/msg/plugins/identity.py:180 (token)", + "src/msg/plugins/identity.py:190 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.079.03", + "chapter": 6, + "design_line": 79, + "clause_index": 3, + "text": "遗言不授账号/组/资源/CA权限、不执行脚本", + "profile": "personal", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CA" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.079.04", + "chapter": 6, + "design_line": 79, + "clause_index": 4, + "text": "后续消息、归档、handoff须当前identity.recover/owner/CA/平台授权并留audit/receipt,不得冒充主体", + "profile": "personal", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "CA", + "handoff", + "owner", + "receipt", + "recover" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/content.py:159 (owner)", + "src/msg/plugins/content.py:486 (receipt)", + "src/msg/plugins/content.py:763 (owner)", + "src/msg/plugins/identity.py:66 (owner)", + "src/msg/plugins/identity.py:73 (owner)", + "src/msg/plugins/identity.py:103 (owner)", + "src/msg/client_content.py:4 (receipt)", + "src/msg/core/models.py:49 (owner)", + "src/msg/core/models.py:123 (owner)", + "src/msg/core/models.py:289 (receipt)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.080.01", + "chapter": 6, + "design_line": 80, + "clause_index": 1, + "text": "tags为taggable类型可选规范化元数据,post/topic/todo/repo默认支持,search tag与/_index/by-tag/共用字段", + "profile": "search", + "requirement_kind": "default", + "explicit_named_literals": [ + "_index", + "by", + "post", + "repo", + "search", + "tag", + "tags", + "todo", + "topic" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:14 (tags)", + "src/msg/plugins/discovery.py:117 (post)", + "src/msg/plugins/discovery.py:119 (topic)", + "src/msg/plugins/saved_queries.py:107 (search)", + "src/msg/core/models.py:59 (tags)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D06.080.02", + "chapter": 6, + "design_line": 80, + "clause_index": 2, + "text": "独立于Inbox分类,不参与身份、CA、group/mode、安全父链或权限继承", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CA", + "group", + "mode" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:784 (mode)", + "src/msg/plugins/discovery.py:915 (group)", + "src/msg/plugins/discovery.py:1010 (group)", + "src/msg/plugins/saved_queries.py:59 (mode)", + "src/msg/plugins/saved_queries.py:113 (mode)", + "src/msg/core/models.py:50 (group)", + "src/msg/core/models.py:51 (mode)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.082.01", + "chapter": 7, + "design_line": 82, + "clause_index": 1, + "text": "正文、回复和模板独立版本化,导航与组合不改签署原文", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.083.01", + "chapter": 7, + "design_line": 83, + "clause_index": 1, + "text": "主帖/留言/长文/评论均为独立post,继承第05章ID、作者、权限、Revision、签名及.md路径", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Revision", + "md" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.083.02", + "chapter": 7, + "design_line": 83, + "clause_index": 2, + "text": "短留言不强制标题/摘要", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.083.03", + "chapter": 7, + "design_line": 83, + "clause_index": 3, + "text": "主帖无reply_to", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.083.04", + "chapter": 7, + "design_line": 83, + "clause_index": 4, + "text": "一级回复的reply_to/thread_root指根帖,楼中楼reply_to指父回复、thread_root仍指根帖,跨讨论仅用引用", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "thread_root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discussion.py:35 (thread_root)", + "src/msg/plugins/discussion.py:37 (thread_root)", + "src/msg/plugins/discussion.py:128 (thread_root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.084.01", + "chapter": 7, + "design_line": 84, + "clause_index": 1, + "text": "content.post_create和discussion.reply共用写入用例", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "content.post_create", + "reply" + ], + "registered_operations": [ + "content.post_create" + ], + "source_token_locations": [ + "src/msg/plugins/discussion.py:25 (reply)", + "src/msg/plugins/discussion.py:26 (reply)", + "src/msg/plugins/discussion.py:148 (reply)", + "src/msg/plugins/content.py:499 (content.post_create)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_post_lifecycle.py::test_metadata_edit_does_not_rewrite_content_or_author", + "line": 17 + }, + { + "nodeid": "tests/test_post_lifecycle.py::test_rollback_creates_new_revision_with_historical_content_and_current_author", + "line": 37 + }, + { + "nodeid": "tests/test_post_lifecycle.py::test_metadata_and_rollback_reject_conflicts_and_unauthorized_without_publication", + "line": 74 + }, + { + "nodeid": "tests/test_post_lifecycle.py::test_reply_rollback_changes_only_reply", + "line": 104 + } + ] + }, + { + "id": "D07.084.02", + "chapter": 7, + "design_line": 84, + "clause_index": 2, + "text": "正文用post.patch/write,字段用post.edit_metadata,回滚用post.rollback", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "edit_metadata", + "patch", + "rollback", + "write" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discussion.py:66 (write)", + "src/msg/plugins/discussion.py:68 (write)", + "src/msg/plugins/discussion.py:94 (write)", + "src/msg/plugins/content.py:53 (write)", + "src/msg/plugins/content.py:59 (write)", + "src/msg/plugins/content.py:191 (patch)", + "src/msg/core/query.py:7 (write)", + "src/msg/core/query.py:40 (write)", + "src/msg/core/models.py:311 (write)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.084.03", + "chapter": 7, + "design_line": 84, + "clause_index": 3, + "text": "编辑回复不改主帖,归档不级联删他人后代", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.084.04", + "chapter": 7, + "design_line": 84, + "clause_index": 4, + "text": "关闭新回复和冻结旧编辑分开", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.085.01", + "chapter": 7, + "design_line": 85, + "clause_index": 1, + "text": "线程按thread_root获取、reply_to组织,每条独立鉴权/分页", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "reply_to" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discussion.py:37 (reply_to)", + "src/msg/plugins/discussion.py:153 (reply_to)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.085.02", + "chapter": 7, + "design_line": 85, + "clause_index": 2, + "text": "正式引用固定ResourceRef及可选摘录范围,转发保留来源,不自动复制正文", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.085.03", + "chapter": 7, + "design_line": 85, + "clause_index": 3, + "text": "Markdown/JSON/客户端合并仅为投影", + "profile": "discussion", + "requirement_kind": "contract", + "explicit_named_literals": [ + "JSON", + "Markdown" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/query.py:19 (JSON)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.086.01", + "chapter": 7, + "design_line": 86, + "clause_index": 1, + "text": "标准Markdown相对/绝对链接可到话题、帖子/评论、主体/组织、wiki、文件/附件、Revision、锚点,渲染保持href语义", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Revision", + "wiki" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.086.02", + "chapter": 7, + "design_line": 86, + "clause_index": 2, + "text": "改名/移动依稳定ID和旧路径迁移定位", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.086.03", + "chapter": 7, + "design_line": 86, + "clause_index": 3, + "text": "normal/compact的LinkSet省略不存在关系,沿parent/root/replies和cursor阅读", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "compact", + "normal", + "replies", + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:38 (root)", + "src/msg/transports/http_routes.py:186 (root)", + "src/msg/transports/http_routes.py:196 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.086.04", + "chapter": 7, + "design_line": 86, + "clause_index": 4, + "text": "附件给受权metadata/download/raw、Range/Transfer,不给CAS/backend地址", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Range", + "Transfer", + "backend", + "download", + "raw" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:1598 (Range)", + "src/msg/core/query.py:3 (backend)", + "src/msg/core/query.py:3 (backend)", + "src/msg/core/models.py:372 (download)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.087.01", + "chapter": 7, + "design_line": 87, + "clause_index": 1, + "text": "模板DSL为name@version、field:type,!必填、?可选、=default,类型str/text/int/bool/enum/ref/file", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "bool", + "default", + "enum", + "field", + "file", + "int", + "ref", + "text", + "type", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:39 (version)", + "src/msg/core/registry.py:40 (version)", + "src/msg/core/registry.py:55 (version)", + "src/msg/core/models.py:6 (field)", + "src/msg/core/models.py:17 (type)", + "src/msg/core/models.py:18 (type)", + "src/msg/plugins/common.py:26 (bool)", + "src/msg/plugins/common.py:72 (text, type)", + "src/msg/plugins/common.py:75 (type)", + "src/msg/core/models.py:6 (field)", + "src/msg/core/models.py:17 (type)", + "src/msg/core/models.py:18 (type)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.087.02", + "chapter": 7, + "design_line": 87, + "clause_index": 2, + "text": "全站/templates/与话题templates/共存、局部优先", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "templates" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:181 (templates)", + "src/msg/core/models.py:486 (templates)", + "src/msg/core/models.py:486 (templates)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.087.03", + "chapter": 7, + "design_line": 87, + "clause_index": 3, + "text": "Post固定template_id/version/digest/values,默认值创建Revision时固化并签署,模板升级不改历史", + "profile": "registry", + "requirement_kind": "default", + "explicit_named_literals": [ + "Post", + "digest", + "values", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:8 (digest)", + "src/msg/core/registry.py:39 (version)", + "src/msg/core/registry.py:40 (version)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/plugins/common.py:8 (digest)", + "src/msg/plugins/common.py:148 (digest)", + "src/msg/plugins/common.py:176 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.088.01", + "chapter": 7, + "design_line": 88, + "clause_index": 1, + "text": "删除默认archive,restore独立,purge须有权限及历史/索引/备份处理边界", + "profile": "resources", + "requirement_kind": "default", + "explicit_named_literals": [ + "purge", + "restore" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:312 (purge)", + "src/msg/plugins/content.py:40 (restore)", + "src/msg/plugins/content.py:695 (restore)", + "src/msg/plugins/content.py:697 (restore)", + "src/msg/security/authorization.py:11 (purge)", + "src/msg/security/authorization.py:14 (purge)", + "src/msg/security/authorization.py:296 (purge)", + "src/msg/core/models.py:312 (purge)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.088.02", + "chapter": 7, + "design_line": 88, + "clause_index": 2, + "text": "归档保留引用", + "profile": "resources", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D07.088.03", + "chapter": 7, + "design_line": 88, + "clause_index": 3, + "text": "禁止模板执行代码、修改签署原件或以合并展示替代原文", + "profile": "resources", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.090.01", + "chapter": 8, + "design_line": 90, + "clause_index": 1, + "text": "按需读取、关系导航与续读共用契约,客户端不计算页码", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.091.01", + "chapter": 8, + "design_line": 91, + "clause_index": 1, + "text": "规范路径为//.md、/@user、/&org、/@user/repo.git、/&org/repo.git", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "id", + "md", + "org", + "post", + "repo.git", + "topic", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:141 (user)", + "src/msg/transports/http_routes.py:1272 (user)", + "src/msg/transports/http_routes.py:1313 (user)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.091.02", + "chapter": 8, + "design_line": 91, + "clause_index": 2, + "text": "旧无.md路径仅只读迁移,改名/移动不改ID", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "md" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.092.01", + "chapter": 8, + "design_line": 92, + "clause_index": 1, + "text": "别名定义见完成归档", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.092.02", + "chapter": 8, + "design_line": 92, + "clause_index": 2, + "text": "文档/CLI/compact默认短名,低频notes/todos/bookmarks和协作原语保留单词", + "profile": "paths", + "requirement_kind": "default", + "explicit_named_literals": [ + "CLI", + "bookmarks", + "compact", + "todos" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:368 (compact)", + "src/msg/transports/http_routes.py:1168 (compact)", + "src/msg/transports/http_routes.py:1179 (compact)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.093.01", + "chapter": 8, + "design_line": 93, + "clause_index": 1, + "text": "文档/schema/帮助用全名,compact可用短名", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "compact", + "schema" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:110 (schema)", + "src/msg/transports/http_routes.py:114 (schema)", + "src/msg/transports/http_routes.py:368 (compact)", + "src/msg/core/models.py:484 (schema)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.093.02", + "chapter": 8, + "design_line": 93, + "clause_index": 2, + "text": "所有别名及子路径直达同一handler,无重定向,内容/授权/缓存/cursor/错误完全等价,不增安全父链", + "profile": "paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "cursor" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:73 (cursor)", + "src/msg/transports/http_routes.py:192 (cursor)", + "src/msg/transports/http_routes.py:193 (cursor)", + "src/msg/storage/query.py:1 (cursor)", + "src/msg/storage/query.py:20 (cursor)", + "src/msg/storage/query.py:21 (cursor)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.094.01", + "chapter": 8, + "design_line": 94, + "clause_index": 1, + "text": "全部读取含HEAD、投影、历史/diff、搜索/索引、关系、附件、缓存,均按当前权限逐对象/字段鉴权", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "diff" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:71 (diff)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.094.02", + "chapter": 8, + "design_line": 94, + "clause_index": 2, + "text": "不得泄露私有名称/大小/时间/owner/digest/计数/片段", + "profile": "authorization", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "digest", + "owner" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:1 (owner)", + "src/msg/security/authorization.py:79 (owner)", + "src/msg/security/authorization.py:123 (owner)", + "src/msg/security/capabilities.py:130 (owner)", + "src/msg/plugins/sharing.py:39 (owner)", + "src/msg/plugins/sharing.py:44 (owner)", + "src/msg/plugins/sharing.py:48 (owner)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:49 (owner)", + "src/msg/core/models.py:123 (owner)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.094.03", + "chapter": 8, + "design_line": 94, + "clause_index": 3, + "text": "链接、cursor、QueryRef不授权,成功/失败均守第09章零副作用", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "QueryRef", + "cursor" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.095.01", + "chapter": 8, + "design_line": 95, + "clause_index": 1, + "text": "HTML/TUI、Markdown、JSON rel→path须指同一ResourceRef", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "HTML", + "JSON", + "Markdown", + "TUI", + "path", + "rel" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/read_query.py:13 (path)", + "src/msg/core/query.py:19 (JSON)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.095.02", + "chapter": 8, + "design_line": 95, + "clause_index": 2, + "text": "话题_desc/_owner/_events及订单_info/_payment/_delivery/_receipts/_dispute是当前授权事实的只读投影,复用ReadQuery/LinkSet,不另建普通帖子、版本或可写副本", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "LinkSet", + "_delivery", + "_dispute", + "_events", + "_owner", + "_payment", + "_receipts" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.095.03", + "chapter": 8, + "design_line": 95, + "clause_index": 3, + "text": "owner、不可变created_by与当前admins分别显示", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "owner" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/read_query.py:14 (owner)", + "src/msg/core/models.py:49 (owner)", + "src/msg/core/models.py:123 (owner)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.095.04", + "chapter": 8, + "design_line": 95, + "clause_index": 4, + "text": "ReadQuery/SearchQuery/grep复用字段选择、范围、预算和分页辅助结构,搜索排名/精确匹配/枚举保留各自执行语义", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ReadQuery", + "SearchQuery", + "grep" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.095.05", + "chapter": 8, + "design_line": 95, + "clause_index": 5, + "text": "Page/Read/Sync共用游标编码与验签,但载荷、快照和失效语义不互换", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Page", + "Read", + "Sync" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:99 (Page)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.01", + "chapter": 8, + "design_line": 96, + "clause_index": 1, + "text": "所有正式读取均保留纯路径GET表达,不强制?、POST body、Cookie、自定义Header", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Cookie", + "POST", + "body" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:38 (body)", + "src/msg/transports/http_routes.py:43 (body)", + "src/msg/transports/http_routes.py:44 (body)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.02", + "chapter": 8, + "design_line": 96, + "clause_index": 2, + "text": "公开或不携机密的简单查询走/_r/q//,长查询按/-/d/read.query字典经鉴权GET Path/Transfer分片seal为签名QueryRef,再GET /_r/q/", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Path", + "Transfer", + "_r", + "d", + "q", + "query_ref", + "read.query", + "segments", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:159 (segments, version)", + "src/msg/transports/http_routes.py:161 (version)", + "src/msg/transports/http_routes.py:168 (segments)", + "src/msg/security/authentication.py:128 (version)", + "src/msg/core/models.py:9 (Path)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.03", + "chapter": 8, + "design_line": 96, + "clause_index": 3, + "text": "纯路径承诺不表示秘密可放URL:私钥、token、recovery_secret以及任何泄漏后可直接认证、恢复或执行的可重放凭据禁止进入path/query/fragment", + "profile": "secret_paths", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "fragment", + "query", + "recovery_secret", + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:185 (query)", + "src/msg/transports/http_routes.py:186 (query)", + "src/msg/transports/http_routes.py:188 (query)", + "src/msg/core/packet.py:20 (token)", + "src/msg/security/authentication.py:62 (token)", + "src/msg/security/authentication.py:65 (token)", + "src/msg/security/authentication.py:70 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.04", + "chapter": 8, + "design_line": 96, + "clause_index": 4, + "text": "私有GET仅可携短期、绑定subject+query_digest+request_id+expires_at的proof,并在每次读取时重验当前授权", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "expires_at", + "query_digest", + "request_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:388 (expires_at)", + "src/msg/core/packet.py:11 (request_id)", + "src/msg/core/packet.py:17 (expires_at)", + "src/msg/core/packet.py:22 (request_id)", + "src/msg/security/authentication.py:47 (expires_at)", + "src/msg/security/authentication.py:48 (expires_at)", + "src/msg/security/authentication.py:74 (request_id)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)", + "src/msg/core/models.py:212 (request_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.05", + "chapter": 8, + "design_line": 96, + "clause_index": 5, + "text": "QueryRef、cursor、不可猜URL、资源地址或订单号均不授权", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "QueryRef", + "cursor" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:73 (cursor)", + "src/msg/transports/http_routes.py:192 (cursor)", + "src/msg/transports/http_routes.py:193 (cursor)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.06", + "chapter": 8, + "design_line": 96, + "clause_index": 6, + "text": "短期proof只限制篡改和跨请求使用,不单独承诺被复制的完整URL在有效期内绝不可重放", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.096.07", + "chapter": 8, + "design_line": 96, + "clause_index": 7, + "text": "需要更强响应保密时必须使用接收者绑定加密或其他明确定义的安全通道", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.01", + "chapter": 8, + "design_line": 97, + "clause_index": 1, + "text": "凭据创建/轮换/恢复等机密发行不因“纯路径GET”原则强制走URL,必须经TLS请求body、客户端持钥绑定的加密交付包或其他明确机密通道", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.02", + "chapter": 8, + "design_line": 97, + "clause_index": 2, + "text": "客户端不具备任何此类能力时返回secure_channel_required,并保留其公开及无秘密纯路径读取能力", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.03", + "chapter": 8, + "design_line": 97, + "clause_index": 3, + "text": "仅支持GET但能持钥的客户端可使用接收者公钥绑定密文交付,服务端须验证主体、请求与接收钥绑定,复制密文不得转授其他主体", + "profile": "secret_paths", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.04", + "chapter": 8, + "design_line": 97, + "clause_index": 4, + "text": "敏感查询正文也不因分片而默认可放URL", + "profile": "secret_paths", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.05", + "chapter": 8, + "design_line": 97, + "clause_index": 5, + "text": "需要保密时走相同安全通道", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.097.06", + "chapter": 8, + "design_line": 97, + "clause_index": 6, + "text": "创建/修改QueryRef或Transfer暂存仍是执行,取结果只读", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.098.01", + "chapter": 8, + "design_line": 98, + "clause_index": 1, + "text": "各入口共用ReadQuery(root,select,filter,sort,first,after,expand,projection),嵌套集合各自分页并给pageInfo/endCursor/next", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "after", + "endCursor", + "expand", + "filter", + "first", + "next", + "projection", + "root", + "select", + "sort" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/read_query.py:18 (expand)", + "src/msg/core/read_query.py:20 (expand)", + "src/msg/core/read_query.py:30 (expand)", + "src/msg/transports/read_tree_path.py:18 (root)", + "src/msg/transports/read_tree_path.py:19 (root)", + "src/msg/transports/read_tree_path.py:31 (expand)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.098.02", + "chapter": 8, + "design_line": 98, + "clause_index": 2, + "text": "max_depth/max_nodes/max_response_bytes/query_cost/max_collection_page_size/timeout限定成本,未知字段/越界报错", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "max_collection_page_size", + "max_depth", + "max_nodes", + "max_response_bytes", + "query_cost", + "timeout" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:363 (max_response_bytes)", + "src/msg/core/models.py:454 (max_response_bytes)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.099.01", + "chapter": 8, + "design_line": 99, + "clause_index": 1, + "text": "compact仅必要id/by/时间/回复数/短fingerprint/trust", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "by", + "compact", + "trust" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.099.02", + "chapter": 8, + "design_line": 99, + "clause_index": 2, + "text": "normal展开元数据,proof给完整公钥/签名/链/摘要/算法/本次依据", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "normal", + "proof" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/read_tree_path.py:14 (proof)", + "src/msg/transports/read_tree_path.py:16 (proof)", + "src/msg/transports/read_tree_path.py:63 (proof)", + "src/msg/core/models.py:253 (proof)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.099.03", + "chapter": 8, + "design_line": 99, + "clause_index": 3, + "text": "trust=ok/bad/exp/rev/na仅验证状态,短指纹不是身份、权限或主键", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "bad", + "exp", + "na", + "ok", + "rev", + "trust" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.100.01", + "chapter": 8, + "design_line": 100, + "clause_index": 1, + "text": "_events.md默认最近10条compact和continuation/sync", + "profile": "read", + "requirement_kind": "default", + "explicit_named_literals": [ + "_events.md", + "sync" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.100.02", + "chapter": 8, + "design_line": 100, + "clause_index": 2, + "text": "响应级schema/base-time、版本化短码减少重复,normal为系统记录,proof展开Event/Receipt/签名", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Receipt", + "base", + "normal", + "proof", + "time" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/read_query.py:4 (time)", + "src/msg/core/read_query.py:44 (time)", + "src/msg/transports/read_tree_path.py:14 (proof)", + "src/msg/transports/read_tree_path.py:16 (proof)", + "src/msg/transports/read_tree_path.py:63 (proof)", + "src/msg/core/models.py:253 (proof)", + "src/msg/core/models.py:393 (time)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.100.03", + "chapter": 8, + "design_line": 100, + "clause_index": 3, + "text": "追新用SyncCursor,不反复拉全量", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.101.01", + "chapter": 8, + "design_line": 101, + "clause_index": 1, + "text": "PageCursor/ReadCursor走/_read/c/,SyncCursor走/_read/s/", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PageCursor", + "ReadCursor", + "SyncCursor", + "_read", + "c", + "cursor", + "s" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/cursors.py:13 (cursor)", + "src/msg/core/cursors.py:20 (cursor)", + "src/msg/transports/read_tree_path.py:12 (_read)", + "src/msg/transports/read_tree_path.py:38 (cursor)", + "src/msg/transports/read_tree_path.py:61 (cursor)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.101.02", + "chapter": 8, + "design_line": 101, + "clause_index": 2, + "text": "返回next,可附prev/around/expand_before/after", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "after", + "around", + "expand_before" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.101.03", + "chapter": 8, + "design_line": 101, + "clause_index": 3, + "text": "签名/MAC的opaque cursor绑定kind、ResourceRef/query、位置、投影、主体上下文、expires_at,不含明文秘密", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "MAC", + "ResourceRef", + "cursor", + "expires_at", + "query" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/query.py:1 (query)", + "src/msg/core/cursors.py:11 (query)", + "src/msg/core/cursors.py:12 (query)", + "src/msg/core/cursors.py:13 (cursor)", + "src/msg/transports/read_tree_path.py:38 (cursor)", + "src/msg/transports/read_tree_path.py:61 (cursor)", + "src/msg/transports/read_tree_path.py:62 (cursor)", + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:65 (ResourceRef)", + "src/msg/core/models.py:169 (expires_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.101.04", + "chapter": 8, + "design_line": 101, + "clause_index": 4, + "text": "签名非加密,客户端不依赖内部结构,过期cursor_expired", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.102.01", + "chapter": 8, + "design_line": 102, + "clause_index": 1, + "text": "PageCursor另绑定query_digest、sort、snapshot boundary、last key、fields,处理排序/筛选变化", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PageCursor", + "boundary", + "fields", + "key", + "last", + "snapshot", + "sort" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/read_query.py:28 (fields)", + "src/msg/core/read_query.py:51 (fields)", + "src/msg/core/read_query.py:54 (fields)", + "src/msg/core/cursors.py:30 (snapshot)", + "src/msg/core/cursors.py:32 (last, snapshot)", + "src/msg/core/cursors.py:48 (last, snapshot)", + "src/msg/transports/read_tree_path.py:20 (fields)", + "src/msg/transports/read_tree_path.py:38 (fields, sort)", + "src/msg/transports/read_tree_path.py:42 (fields)", + "src/msg/core/models.py:442 (fields)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.102.02", + "chapter": 8, + "design_line": 102, + "clause_index": 2, + "text": "不是永久快照,不凭时间戳承诺不重不漏,撤权优先", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.102.03", + "chapter": 8, + "design_line": 102, + "clause_index": 3, + "text": "ReadCursor固定ResourceRef,按标题/段落/列表/表格/代码块分段,超大块按有效编码字节范围续读", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ReadCursor" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.102.04", + "chapter": 8, + "design_line": 102, + "clause_index": 4, + "text": "不混入新Revision,版本清除明确失效", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.103.01", + "chapter": 8, + "design_line": 103, + "clause_index": 1, + "text": "SyncCursor给新增/修改/归档/撤权,过期resync_required,撤权仅失效已知引用", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SyncCursor" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.103.02", + "chapter": 8, + "design_line": 103, + "clause_index": 2, + "text": "Bookmark由主体显式保存resource_id+revision_id+anchor至/@user/bookmarks/,不等于cursor/已读/ACK", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ACK", + "Bookmark", + "anchor", + "bookmarks", + "revision_id", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.103.03", + "chapter": 8, + "design_line": 103, + "clause_index": 3, + "text": "浏览/下载仅异步近似telemetry", + "profile": "read", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.104.01", + "chapter": 8, + "design_line": 104, + "clause_index": 1, + "text": "源码发布见第14章", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.105.01", + "chapter": 8, + "design_line": 105, + "clause_index": 1, + "text": "/.agents/skills//SKILL.md按需加载", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SKILL.md", + "agents", + "name", + "skills" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/bootstrap.py:18 (name)", + "src/msg/bootstrap.py:28 (name)", + "src/msg/bootstrap.py:29 (name)", + "src/msg/core/registry.py:39 (name)", + "src/msg/core/registry.py:40 (name)", + "src/msg/core/registry.py:51 (name)", + "src/msg/plugins/common.py:57 (name)", + "src/msg/plugins/common.py:58 (name)", + "src/msg/plugins/common.py:59 (name)", + "src/msg/core/models.py:47 (name)", + "src/msg/core/models.py:318 (name)", + "src/msg/core/models.py:330 (name)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.105.02", + "chapter": 8, + "design_line": 105, + "clause_index": 2, + "text": "个人AGENTS/SOUL见第06章", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SOUL" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/common.py:92 (SOUL)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D08.105.03", + "chapter": 8, + "design_line": 105, + "clause_index": 3, + "text": "禁止平行/rules、/$skill_name、默认递归大对象或巨型Base64二进制页", + "profile": "rules", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "rules", + "skill_name" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/bootstrap.py:27 (rules)", + "src/msg/bootstrap.py:29 (rules)", + "src/msg/bootstrap.py:59 (rules)", + "src/msg/plugins/common.py:228 (rules)", + "src/msg/plugins/common.py:230 (rules)", + "src/msg/plugins/common.py:232 (rules)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.107.01", + "chapter": 9, + "design_line": 107, + "clause_index": 1, + "text": "契约决定副作用,所有执行共用鉴权、幂等与事务", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.108.01", + "chapter": 9, + "design_line": 108, + "clause_index": 1, + "text": "OperationSpec.effect分PURE_READ、LOCAL_EPHEMERAL、BUSINESS_WRITE、EXTERNAL_EFFECT,RouteSpec只派生该分类并施加更严传输限制", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BUSINESS_WRITE", + "EXTERNAL_EFFECT", + "LOCAL_EPHEMERAL", + "OperationSpec.effect", + "RouteSpec" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http.py:19 (RouteSpec)", + "src/msg/transports/http.py:73 (BUSINESS_WRITE, EXTERNAL_EFFECT)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.108.02", + "chapter": 9, + "design_line": 108, + "clause_index": 2, + "text": "普通路径及/_read、/_search、/_index仅前两类", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_index", + "_read", + "_search" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.108.03", + "chapter": 9, + "design_line": 108, + "clause_index": 3, + "text": "GraphQL query、Git fetch/LFS download可用无副作用POST,GET不天然获得执行权", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "GET", + "Git", + "GraphQL", + "LFS", + "download", + "fetch", + "query" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/requests.py:1 (GraphQL)", + "src/msg/core/models.py:372 (download)", + "src/msg/core/models.py:372 (download)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.108.04", + "chapter": 9, + "design_line": 108, + "clause_index": 4, + "text": "副作用GET仅是受限客户端的兼容执行表示,不改变其BUSINESS_WRITE/EXTERNAL_EFFECT分类,不得标为HTTP safe/cacheable,也不得因方法名为GET放宽预取、缓存、鉴权或幂等要求", + "profile": "execution", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "EXTERNAL_EFFECT", + "cacheable", + "safe" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:154 (safe)", + "src/msg/transports/http.py:73 (EXTERNAL_EFFECT)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.109.01", + "chapter": 9, + "design_line": 109, + "clause_index": 1, + "text": "执行入口仅/-/g//...、/-/p/、/-/graphql、/-/mcp、/-/transfer、/-/git/", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "g", + "git", + "graphql", + "id", + "mcp", + "operation", + "p", + "repo", + "transfer" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:49 (operation)", + "src/msg/core/executor.py:82 (operation)", + "src/msg/core/executor.py:91 (transfer)", + "src/msg/core/requests.py:35 (operation)", + "src/msg/core/requests.py:39 (operation)", + "src/msg/core/requests.py:56 (operation)", + "src/msg/core/models.py:91 (operation)", + "src/msg/core/models.py:245 (operation)", + "src/msg/core/models.py:282 (operation)", + "src/msg/transports/http.py:4 (operation)", + "src/msg/core/models.py:91 (operation)", + "src/msg/core/models.py:245 (operation)", + "src/msg/core/models.py:282 (operation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.109.02", + "chapter": 9, + "design_line": 109, + "clause_index": 2, + "text": "/-/schema、/-/d、帮助/状态只读", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "d", + "schema" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:484 (schema)", + "src/msg/core/models.py:484 (schema)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.109.03", + "chapter": 9, + "design_line": 109, + "clause_index": 3, + "text": "/-/不授权,匿名仅第03章建号例外", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.109.04", + "chapter": 9, + "design_line": 109, + "clause_index": 4, + "text": "代理/应用须一致规范化解码、按精确路径段分流", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.109.05", + "chapter": 9, + "design_line": 109, + "clause_index": 5, + "text": "拒绝歧义编码、点段、方法覆盖,禁止普通路径绑定写handler或经转发/重定向/旧入口绕过", + "profile": "execution", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.110.01", + "chapter": 9, + "design_line": 110, + "clause_index": 1, + "text": "读取成功、失败均不得改业务事实或触发外部投递/补写,包括资源/修订、成员、凭据/证书、分享、Event/EffectJob、ACK/已读、Bookmark、TransferSession", + "profile": "execution", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "ACK", + "Bookmark", + "EffectJob", + "Event", + "TransferSession" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:13 (Event)", + "src/msg/core/executor.py:121 (Event)", + "src/msg/core/executor.py:134 (Event)", + "src/msg/core/models.py:369 (TransferSession)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)", + "src/msg/core/models.py:369 (TransferSession)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.110.02", + "chapter": 9, + "design_line": 110, + "clause_index": 2, + "text": "只容许缓存、日志、metrics、近似浏览telemetry等可丢运行状态", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "metrics" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.111.01", + "chapter": 9, + "design_line": 111, + "clause_index": 1, + "text": "所有正式操作的/-/d仍须给出有效最短模板/示例", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "d" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.111.02", + "chapter": 9, + "design_line": 111, + "clause_index": 2, + "text": "复杂输入应依第08章的鉴权Transfer等形式提供纯路径等价,不得强制JSON/Base64整包", + "profile": "execution", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Base64" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.112.01", + "chapter": 9, + "design_line": 112, + "clause_index": 1, + "text": "大正文/patch/查询走鉴权Transfer content_ref", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [ + "content_ref", + "patch" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.113.01", + "chapter": 9, + "design_line": 113, + "clause_index": 1, + "text": "OperationRequest含operation、input、request_id、target_service、主体/代表关系、有效期、proof、预期revision、source,复核payload_digest", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationRequest", + "input", + "proof", + "request_id", + "source", + "target_service" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:72 (request_id)", + "src/msg/core/executor.py:78 (request_id)", + "src/msg/core/executor.py:121 (request_id)", + "src/msg/core/requests.py:7 (OperationRequest)", + "src/msg/core/requests.py:24 (proof, source)", + "src/msg/core/requests.py:31 (proof)", + "src/msg/core/models.py:186 (target_service)", + "src/msg/core/models.py:203 (target_service)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/transports/http.py:5 (proof)", + "src/msg/core/models.py:186 (target_service)", + "src/msg/core/models.py:203 (target_service)", + "src/msg/core/models.py:212 (request_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.113.02", + "chapter": 9, + "design_line": 113, + "clause_index": 2, + "text": "副作用GET的短期proof绑定operation+payload_digest+subject+request_id+expires_at", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "expires_at", + "payload_digest", + "request_id", + "subject" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:72 (request_id)", + "src/msg/core/executor.py:78 (payload_digest, request_id)", + "src/msg/core/executor.py:121 (request_id)", + "src/msg/core/requests.py:24 (expires_at, payload_digest)", + "src/msg/core/requests.py:36 (expires_at, request_id)", + "src/msg/core/requests.py:38 (request_id)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)", + "src/msg/core/models.py:212 (request_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.113.03", + "chapter": 9, + "design_line": 113, + "clause_index": 3, + "text": "source/UA/IP/Referer不授权", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "IP", + "Referer", + "UA", + "source" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:143 (source)", + "src/msg/core/executor.py:144 (source)", + "src/msg/core/executor.py:180 (source)", + "src/msg/core/requests.py:24 (source)", + "src/msg/core/requests.py:36 (source)", + "src/msg/core/requests.py:42 (source)", + "src/msg/core/models.py:255 (source)", + "src/msg/core/models.py:255 (source)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.114.01", + "chapter": 9, + "design_line": 114, + "clause_index": 1, + "text": "passive-client guard拒绝crawler/link-preview/scanner/prefetch/prerender的副作用GET", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "client", + "guard", + "link", + "passive", + "prefetch", + "prerender", + "preview", + "scanner" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http.py:3 (guard)", + "src/msg/transports/http.py:4 (passive)", + "src/msg/transports/http.py:6 (guard)", + "src/msg/transports/http_routes.py:37 (link)", + "src/msg/transports/http_routes.py:142 (passive, preview, scanner)", + "src/msg/transports/http_routes.py:145 (passive)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.114.02", + "chapter": 9, + "design_line": 114, + "clause_index": 2, + "text": "浏览器默认同禁,除非显式开启受控执行模式", + "profile": "secret_paths", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.114.03", + "chapter": 9, + "design_line": 114, + "clause_index": 3, + "text": "返回passive_client_forbidden、no-store、noindex/nofollow,不回显敏感参数", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "no", + "nofollow", + "noindex", + "store" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:155 (store)", + "src/msg/transports/http_routes.py:156 (nofollow, noindex)", + "src/msg/transports/http_routes.py:559 (store)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.114.04", + "chapter": 9, + "design_line": 114, + "clause_index": 4, + "text": "未知/AI UA仍须proof", + "profile": "secret_paths", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AI", + "UA" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.114.05", + "chapter": 9, + "design_line": 114, + "clause_index": 5, + "text": "公开内容、错误页、字典仅给无凭据模板,日志/响应不得泄露有效proof/token执行URL", + "profile": "secret_paths", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "token" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/transports/http_routes.py:570 (token)", + "src/msg/transports/http_routes.py:571 (token)", + "src/msg/transports/http_routes.py:1111 (token)", + "src/msg/core/packet.py:20 (token)", + "src/msg/security/authentication.py:62 (token)", + "src/msg/security/authentication.py:65 (token)", + "src/msg/security/authentication.py:70 (token)", + "src/msg/core/models.py:165 (token)", + "src/msg/core/models.py:227 (token)", + "src/msg/core/models.py:238 (token)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.115.01", + "chapter": 9, + "design_line": 115, + "clause_index": 1, + "text": "校验顺序为凭据→entry/local_only→凭据上限→当前安全父链及owner/group/mode/Membership/ShareGrant→指定能力替代→类型/状态/修订", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Membership", + "ShareGrant", + "entry", + "group", + "local_only", + "mode" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/authorization.py:1 (ShareGrant, group, mode)", + "src/msg/security/authorization.py:79 (group)", + "src/msg/security/authorization.py:109 (group)", + "src/msg/security/capabilities.py:9 (mode)", + "src/msg/security/capabilities.py:10 (entry)", + "src/msg/security/capabilities.py:47 (group)", + "src/msg/plugins/sharing.py:153 (group)", + "src/msg/core/models.py:50 (group)", + "src/msg/core/models.py:51 (mode)", + "src/msg/core/models.py:108 (local_only)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.115.02", + "chapter": 9, + "design_line": 115, + "clause_index": 2, + "text": "事务内重查可变授权/generation,以锁/版本条件防并发", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "generation" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:52 (generation)", + "src/msg/core/models.py:214 (generation)", + "src/msg/core/models.py:378 (generation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.115.03", + "chapter": 9, + "design_line": 115, + "clause_index": 3, + "text": "客户端owner/role/trust不是权威", + "profile": "authorization", + "requirement_kind": "contract", + "explicit_named_literals": [ + "role", + "trust" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:129 (role)", + "src/msg/core/models.py:123 (role)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.01", + "chapter": 9, + "design_line": 116, + "clause_index": 1, + "text": "主体+request_id唯一,同键同digest复用业务结果,异digest冲突,并发最多一次提交", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "request_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:72 (request_id)", + "src/msg/core/executor.py:78 (request_id)", + "src/msg/core/executor.py:121 (request_id)", + "src/msg/core/requests.py:36 (request_id)", + "src/msg/core/requests.py:38 (request_id)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/core/models.py:243 (request_id)", + "src/msg/core/models.py:281 (request_id)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/core/models.py:243 (request_id)", + "src/msg/core/models.py:281 (request_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.02", + "chapter": 9, + "design_line": 116, + "clause_index": 2, + "text": "幂等保证同一业务事实不重复提交,不要求重复返回相同秘密字节", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.03", + "chapter": 9, + "design_line": 116, + "clause_index": 3, + "text": "若结果含token、recovery_secret等一次释放秘密,释放资格消费后的重试只返回非秘密状态、receipt及明确delivery_unavailable,幂等存储不得复制完整秘密", + "profile": "execution", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "receipt", + "recovery_secret" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:140 (receipt)", + "src/msg/core/executor.py:199 (receipt)", + "src/msg/core/requests.py:54 (receipt)", + "src/msg/core/models.py:289 (receipt)", + "src/msg/core/models.py:289 (receipt)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.04", + "chapter": 9, + "design_line": 116, + "clause_index": 4, + "text": "业务状态、Event、EffectJob、幂等结果同事务", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "EffectJob", + "Event" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:13 (Event)", + "src/msg/core/executor.py:121 (Event)", + "src/msg/core/executor.py:134 (Event)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)", + "src/msg/core/models.py:413 (EffectJob)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)", + "src/msg/core/models.py:413 (EffectJob)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.05", + "chapter": 9, + "design_line": 116, + "clause_index": 5, + "text": "OperationExecutor或受控本机命令是事务提交者,业务函数接收现有session,不嵌套调用另一执行器或自行commit", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationExecutor" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:19 (OperationExecutor)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.116.06", + "chapter": 9, + "design_line": 116, + "clause_index": 6, + "text": "外部动作提交后执行,Event、Receipt、AuditEvent可共用编码/追加辅助代码,但不合并用途、可见性或保留策略", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AuditEvent", + "Event", + "Receipt" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:13 (Event)", + "src/msg/core/executor.py:120 (AuditEvent)", + "src/msg/core/executor.py:121 (Event)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:402 (AuditEvent)", + "src/msg/core/models.py:403 (Event)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:402 (AuditEvent)", + "src/msg/core/models.py:403 (Event)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.01", + "chapter": 9, + "design_line": 117, + "clause_index": 1, + "text": "ClearingEngine表示money模块内确定性校验/记账职责,可用普通函数实现,不要求独立常驻服务、账户或新接口层", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ClearingEngine" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.02", + "chapter": 9, + "design_line": 117, + "clause_index": 2, + "text": "随msgd/已有worker提供持续服务,不承诺永不中断", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.03", + "chapter": 9, + "design_line": 117, + "clause_index": 3, + "text": "不是subject,无账户、余额、私钥、自由裁量或主动发起交易能力", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.04", + "chapter": 9, + "design_line": 117, + "clause_index": 4, + "text": "EscrowEngine与客观纠纷解析同样作为market内有限业务函数,复用同一事务与账本,不另开数据库或通用工作流引擎", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "EscrowEngine" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.05", + "chapter": 9, + "design_line": 117, + "clause_index": 5, + "text": "相同输入、账本状态和ClearingPolicy版本须同结果", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.06", + "chapter": 9, + "design_line": 117, + "clause_index": 6, + "text": "只检查签名、request_id、币种、金额、余额、账户/订单状态、期限等明示条件,不用LLM/信誉或管理员/银行特例", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "request_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:100 (request_id)", + "src/msg/admin/money.py:68 (request_id)", + "src/msg/admin/money.py:101 (request_id)", + "src/msg/admin/money.py:104 (request_id)", + "src/msg/market/ledger.py:20 (request_id)", + "src/msg/market/ledger.py:98 (request_id)", + "src/msg/market/ledger.py:120 (request_id)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/core/models.py:243 (request_id)", + "src/msg/core/models.py:281 (request_id)", + "src/msg/core/models.py:212 (request_id)", + "src/msg/core/models.py:243 (request_id)", + "src/msg/core/models.py:281 (request_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.07", + "chapter": 9, + "design_line": 117, + "clause_index": 7, + "text": "货币receipt另含policy_version/digest、ledger_sequence", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "digest", + "ledger_sequence" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:12 (digest)", + "src/msg/plugins/money.py:72 (ledger_sequence)", + "src/msg/admin/money.py:12 (digest)", + "src/msg/admin/money.py:113 (digest)", + "src/msg/admin/money.py:184 (digest)", + "src/msg/market/ledger.py:9 (digest)", + "src/msg/market/ledger.py:18 (digest)", + "src/msg/market/ledger.py:123 (ledger_sequence)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.117.08", + "chapter": 9, + "design_line": 117, + "clause_index": 8, + "text": "纠错仅追加refund/reversal", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "reversal" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.01", + "chapter": 9, + "design_line": 118, + "clause_index": 1, + "text": "EscrowAccount(order_id)是系统LedgerAccount,非用户,无私钥/Profile/主动转账能力", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "EscrowAccount", + "Profile", + "order_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:107 (order_id)", + "src/msg/plugins/orders.py:108 (order_id)", + "src/msg/plugins/orders.py:118 (order_id)", + "src/msg/market/arbitration.py:26 (order_id)", + "src/msg/market/arbitration.py:32 (order_id)", + "src/msg/market/arbitration.py:69 (order_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.02", + "chapter": 9, + "design_line": 118, + "clause_index": 2, + "text": "PaymentIntent为签名OperationRequest中的类型化付款意图,绑定订单/报价修订、付款人与金额,不新增平行执行协议", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PaymentIntent" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:111 (PaymentIntent)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.03", + "chapter": 9, + "design_line": 118, + "clause_index": 3, + "text": "验证后原子转入托管", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.04", + "chapter": 9, + "design_line": 118, + "clause_index": 4, + "text": "EscrowEngine只按Order状态和有效Decision释放给卖家、退款或分配,不判断纠纷", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "EscrowEngine" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:12 (EscrowEngine)", + "src/msg/plugins/orders.py:147 (EscrowEngine)", + "src/msg/plugins/orders.py:159 (EscrowEngine)", + "src/msg/market/arbitration.py:4 (EscrowEngine)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.05", + "chapter": 9, + "design_line": 118, + "clause_index": 5, + "text": "新默认契约为created→funded→delivered→买家显式签名accepted→settled", + "profile": "arbitration", + "requirement_kind": "default", + "explicit_named_literals": [ + "delivered", + "funded", + "settled" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:1 (funded)", + "src/msg/plugins/orders.py:137 (funded)", + "src/msg/plugins/orders.py:201 (delivered, funded)", + "src/msg/market/arbitration.py:174 (funded)", + "src/msg/market/arbitration.py:187 (delivered)", + "src/msg/market/arbitration.py:268 (delivered, funded)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.06", + "chapter": 9, + "design_line": 118, + "clause_index": 6, + "text": "自动发货不自动伪造验收", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.07", + "chapter": 9, + "design_line": 118, + "clause_index": 7, + "text": "需要自动结算时必须在报价/下单时明确绑定版本化政策,不等于claimed", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.08", + "chapter": 9, + "design_line": 118, + "clause_index": 8, + "text": "现有已发表@1/@2/@3、短码、政策与历史订单不改义,新默认行为通过新版本/新policy发布并保留旧版本回归", + "profile": "arbitration", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.09", + "chapter": 9, + "design_line": 118, + "clause_index": 9, + "text": "异常为cancelled/refunded/disputed", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "disputed", + "refunded" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:83 (refunded)", + "src/msg/plugins/orders.py:187 (disputed)", + "src/msg/plugins/orders.py:203 (refunded)", + "src/msg/market/arbitration.py:132 (disputed)", + "src/msg/market/arbitration.py:285 (disputed)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.118.10", + "chapter": 9, + "design_line": 118, + "clause_index": 10, + "text": "缺deposit/delivery、digest不符、超时等按版本化DeterministicDisputeResolver处理,其余进入仲裁", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "delivery", + "digest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:1 (delivery)", + "src/msg/plugins/orders.py:13 (digest)", + "src/msg/plugins/orders.py:59 (delivery)", + "src/msg/market/arbitration.py:10 (digest)", + "src/msg/market/arbitration.py:69 (digest)", + "src/msg/market/arbitration.py:128 (digest)", + "src/msg/market/ledger.py:9 (digest)", + "src/msg/market/ledger.py:18 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.119.01", + "chapter": 9, + "design_line": 119, + "clause_index": 1, + "text": "ArbitrationCase含order_id、claimant/respondent、reason_code、双方statement、evidence_refs、policy_version、panel、deadline、decision/state", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ArbitrationCase", + "claimant", + "deadline", + "decision", + "evidence_refs", + "panel", + "policy_version", + "reason_code", + "respondent", + "state" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:3 (state)", + "src/msg/plugins/orders.py:65 (state)", + "src/msg/plugins/orders.py:83 (state)", + "src/msg/market/arbitration.py:3 (panel)", + "src/msg/market/arbitration.py:37 (deadline, state)", + "src/msg/market/arbitration.py:40 (deadline, state)", + "src/msg/market/ledger.py:95 (policy_version)", + "src/msg/market/ledger.py:122 (policy_version)", + "src/msg/market/ledger.py:128 (policy_version)", + "src/msg/core/models.py:54 (state)", + "src/msg/core/models.py:373 (state)", + "src/msg/core/models.py:421 (state)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.119.02", + "chapter": 9, + "design_line": 119, + "clause_index": 2, + "text": "Arbitrator为subject+ArbitratorRole,只签ArbitrationDecision(refund/release/split,rationale_ref),不能写账本或支配托管账户", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Arbitrator", + "ArbitratorRole", + "rationale_ref", + "refund", + "release", + "split" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:3 (release)", + "src/msg/plugins/orders.py:159 (refund)", + "src/msg/plugins/orders.py:161 (refund)", + "src/msg/market/arbitration.py:28 (refund, release, split)", + "src/msg/market/arbitration.py:31 (rationale_ref)", + "src/msg/market/arbitration.py:33 (rationale_ref)", + "src/msg/market/ledger.py:74 (refund)", + "src/msg/market/ledger.py:103 (release)", + "src/msg/market/ledger.py:139 (refund)", + "src/msg/core/models.py:91 (release)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.119.03", + "chapter": 9, + "design_line": 119, + "clause_index": 3, + "text": "EscrowEngine验决议后执行", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "EscrowEngine" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:12 (EscrowEngine)", + "src/msg/plugins/orders.py:147 (EscrowEngine)", + "src/msg/plugins/orders.py:159 (EscrowEngine)", + "src/msg/market/arbitration.py:4 (EscrowEngine)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.119.04", + "chapter": 9, + "design_line": 119, + "clause_index": 4, + "text": "ArbitrationPolicy固定候选、利益冲突排除、确定性抽选、panel/quorum、可选一次appeal,禁止临时挑人或AI自由裁量", + "profile": "arbitration", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "ArbitrationPolicy", + "panel", + "quorum" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/arbitration.py:1 (quorum)", + "src/msg/market/arbitration.py:3 (panel)", + "src/msg/market/arbitration.py:77 (panel)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.119.05", + "chapter": 9, + "design_line": 119, + "clause_index": 5, + "text": "证据按职责隔离,公开仅最小case/decision摘要", + "profile": "arbitration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "decision" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:147 (decision)", + "src/msg/plugins/orders.py:149 (decision)", + "src/msg/plugins/orders.py:159 (decision)", + "src/msg/market/arbitration.py:123 (decision)", + "src/msg/market/arbitration.py:126 (decision)", + "src/msg/market/arbitration.py:127 (decision)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.120.01", + "chapter": 9, + "design_line": 120, + "clause_index": 1, + "text": "网络货币只允许money.transfer、money.redeem及订单/退款状态等非发行操作", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "money.redeem", + "transfer" + ], + "registered_operations": [ + "money.redeem" + ], + "source_token_locations": [ + "src/msg/plugins/money.py:81 (transfer)", + "src/msg/plugins/money.py:87 (transfer)", + "src/msg/plugins/money.py:103 (transfer)", + "src/msg/admin/money.py:126 (transfer)", + "src/msg/admin/money.py:127 (transfer)", + "src/msg/admin/money.py:131 (transfer)", + "src/msg/market/ledger.py:23 (transfer)", + "src/msg/market/ledger.py:74 (transfer)", + "src/msg/market/ledger.py:99 (transfer)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.120.02", + "chapter": 9, + "design_line": 120, + "clause_index": 2, + "text": "转账须签名、同币种、正额、足额,银行同规则", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.120.03", + "chapter": 9, + "design_line": 120, + "clause_index": 3, + "text": "from=@root一律root_local_only", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "from", + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/money.py:4 (root)", + "src/msg/plugins/money.py:7 (from)", + "src/msg/plugins/money.py:9 (from)", + "src/msg/admin/money.py:2 (from)", + "src/msg/admin/money.py:7 (from)", + "src/msg/admin/money.py:8 (from)", + "src/msg/market/ledger.py:6 (from)", + "src/msg/market/ledger.py:7 (from)", + "src/msg/market/ledger.py:8 (from)", + "src/msg/core/models.py:1 (from)", + "src/msg/core/models.py:4 (from)", + "src/msg/core/models.py:5 (from)", + "src/msg/core/models.py:1 (from)", + "src/msg/core/models.py:4 (from)", + "src/msg/core/models.py:5 (from)", + "src/msg/plugins/schemas.py:2 (from)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.120.04", + "chapter": 9, + "design_line": 120, + "clause_index": 4, + "text": "redeem绑定offer/quantity/price_revision,purchase绑定order/listing_revision/price_snapshot,清算约束见第05章", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [ + "listing_revision", + "price_revision", + "price_snapshot", + "purchase", + "quantity", + "redeem" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/money.py:48 (price_revision)", + "src/msg/admin/money.py:82 (price_revision)", + "src/msg/admin/money.py:89 (price_revision)", + "src/msg/market/ledger.py:74 (redeem)", + "src/msg/market/ledger.py:139 (redeem)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.120.05", + "chapter": 9, + "design_line": 120, + "clause_index": 5, + "text": "货币提交同样生成receipt及最小Event,余额/报价/订单/账本读取无业务副作用", + "profile": "money", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.121.01", + "chapter": 9, + "design_line": 121, + "clause_index": 1, + "text": "OperationResult返回必要data/metadata、request_id、status、Resource/Revision、真实committed_at、receipt", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationResult", + "Resource", + "Revision", + "metadata", + "receipt", + "request_id", + "status" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:13 (OperationResult)", + "src/msg/core/executor.py:20 (metadata)", + "src/msg/core/executor.py:24 (metadata)", + "src/msg/core/requests.py:36 (request_id)", + "src/msg/core/requests.py:38 (request_id)", + "src/msg/core/requests.py:54 (receipt)", + "src/msg/core/models.py:90 (metadata)", + "src/msg/core/models.py:92 (metadata)", + "src/msg/core/models.py:93 (metadata)", + "src/msg/transports/http.py:52 (status)", + "src/msg/core/models.py:90 (metadata)", + "src/msg/core/models.py:92 (metadata)", + "src/msg/core/models.py:93 (metadata)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D09.121.02", + "chapter": 9, + "design_line": 121, + "clause_index": 2, + "text": "错误含稳定code/message、字段位置、retryable,外部uncertain按第12章处理", + "profile": "execution", + "requirement_kind": "contract", + "explicit_named_literals": [ + "message", + "retryable" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/executor.py:151 (retryable)", + "src/msg/core/executor.py:159 (retryable)", + "src/msg/core/models.py:261 (retryable)", + "src/msg/transports/http.py:43 (retryable)", + "src/msg/transports/http.py:54 (retryable)", + "src/msg/core/models.py:261 (retryable)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.123.01", + "chapter": 10, + "design_line": 123, + "clause_index": 1, + "text": "搜索发现未知位置,grep精查已知范围,索引负责确定性枚举", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "grep" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1120 (grep)", + "src/msg/plugins/discovery.py:1127 (grep)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.124.01", + "chapter": 10, + "design_line": 124, + "clause_index": 1, + "text": "file.read/write/patch/mkdir/move/copy/delete/list/stat/batch复用资源操作", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [ + "batch", + "copy", + "delete", + "file.read", + "list", + "mkdir", + "move", + "patch", + "stat", + "write" + ], + "registered_operations": [ + "file.read" + ], + "source_token_locations": [ + "src/msg/plugins/files.py:13 (batch)", + "src/msg/plugins/files.py:19 (write)", + "src/msg/plugins/files.py:22 (copy)", + "src/msg/plugins/content.py:40 (move)", + "src/msg/plugins/content.py:53 (write)", + "src/msg/plugins/content.py:59 (write)", + "src/msg/core/text_patch.py:42 (patch)", + "src/msg/core/text_patch.py:44 (batch)", + "src/msg/core/text_patch.py:46 (patch)", + "src/msg/core/models.py:311 (list, write)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_file_operations.py::test_file_write_read_copy_conflicts_and_directory_permissions", + "line": 8 + } + ] + }, + { + "id": "D10.124.02", + "chapter": 10, + "design_line": 124, + "clause_index": 2, + "text": "创建要求不存在,修改校验base_revision,冲突为revision_conflict", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.124.03", + "chapter": 10, + "design_line": 124, + "clause_index": 3, + "text": "文本优先局部patch,二进制仅上传/替换", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.124.04", + "chapter": 10, + "design_line": 124, + "clause_index": 4, + "text": "支持exact replace、上下文、unified diff、Markdown heading/block digest", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Markdown", + "block", + "diff", + "digest", + "heading", + "replace", + "unified" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/files.py:2 (replace)", + "src/msg/plugins/files.py:36 (replace)", + "src/msg/plugins/content.py:3 (replace)", + "src/msg/plugins/content.py:7 (digest)", + "src/msg/plugins/content.py:96 (digest)", + "src/msg/core/text_patch.py:3 (Markdown, heading)", + "src/msg/core/text_patch.py:4 (block, heading)", + "src/msg/core/text_patch.py:13 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.124.05", + "chapter": 10, + "design_line": 124, + "clause_index": 5, + "text": "歧义报patch_ambiguous,仅上下文唯一且目标块未改时可rebase,batch遵守第09章", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [ + "batch" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/files.py:13 (batch)", + "src/msg/plugins/files.py:31 (batch)", + "src/msg/core/text_patch.py:44 (batch)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.125.01", + "chapter": 10, + "design_line": 125, + "clause_index": 1, + "text": "行指纹默认关闭,显式请求才给line_no、短hash、可选上下文hash,内部留完整摘要", + "profile": "files", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.125.02", + "chapter": 10, + "design_line": 125, + "clause_index": 2, + "text": "重复行/碰撞须补上下文,不猜目标,不作ID或授权依据", + "profile": "files", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.126.01", + "chapter": 10, + "design_line": 126, + "clause_index": 1, + "text": "SearchQuery文本条件为terms/exact phrase/all/any/not,字段范围title/name/body/metadata", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SearchQuery", + "all", + "any", + "body", + "exact", + "metadata", + "name", + "not", + "phrase" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/search_query.py:1 (SearchQuery)", + "src/msg/plugins/discovery.py:74 (metadata)", + "src/msg/plugins/discovery.py:146 (body)", + "src/msg/plugins/discovery.py:199 (name)", + "src/msg/plugins/read_predicates.py:17 (name)", + "src/msg/plugins/saved_queries.py:113 (name)", + "src/msg/plugins/saved_queries.py:114 (body)", + "src/msg/plugins/saved_queries.py:148 (name)", + "src/msg/core/models.py:47 (name)", + "src/msg/core/models.py:90 (metadata)", + "src/msg/core/models.py:92 (metadata)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.126.02", + "chapter": 10, + "design_line": 126, + "clause_index": 2, + "text": "scope支持path/topic/subject/org/resource_refs、recursive/depth及当前topic/thread/wiki/rules", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "depth", + "org", + "recursive", + "resource_refs", + "rules", + "scope", + "subject", + "thread", + "topic", + "wiki" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/search_query.py:10 (scope)", + "src/msg/plugins/discovery.py:40 (resource_refs)", + "src/msg/plugins/discovery.py:48 (scope)", + "src/msg/plugins/discovery.py:49 (scope)", + "src/msg/plugins/saved_queries.py:31 (scope)", + "src/msg/plugins/saved_queries.py:35 (scope)", + "src/msg/plugins/saved_queries.py:44 (scope)", + "src/msg/core/models.py:148 (scope)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.127.01", + "chapter": 10, + "design_line": 127, + "clause_index": 1, + "text": "filter支持ResourceType、author/owner、tag、active/archived、created/updated时间窗、has:attachment/replies/references、relation(to/from/mentions/reply_to/thread_root)、revision/source_kind/source_version、current-readable", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "active", + "archived", + "attachment", + "author", + "created", + "current", + "filter", + "from", + "has", + "mentions", + "owner", + "readable", + "references", + "relation", + "replies", + "reply_to", + "revision", + "source_kind", + "source_version", + "tag", + "thread_root", + "to", + "updated" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/search_query.py:2 (from)", + "src/msg/core/search_query.py:4 (revision, source_version)", + "src/msg/core/search_query.py:16 (source_kind)", + "src/msg/plugins/discovery.py:2 (from)", + "src/msg/plugins/discovery.py:3 (from)", + "src/msg/plugins/discovery.py:8 (from)", + "src/msg/plugins/read_predicates.py:1 (current)", + "src/msg/plugins/read_predicates.py:2 (from)", + "src/msg/plugins/read_predicates.py:7 (active)", + "src/msg/plugins/saved_queries.py:2 (from)", + "src/msg/plugins/saved_queries.py:4 (from)", + "src/msg/plugins/saved_queries.py:5 (from)", + "src/msg/core/models.py:1 (from)", + "src/msg/core/models.py:4 (from)", + "src/msg/core/models.py:5 (from)", + "src/msg/plugins/schemas.py:2 (from)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.127.02", + "chapter": 10, + "design_line": 127, + "clause_index": 2, + "text": "order为relevance/updated/created/name,以ResourceId打破平局", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "created", + "name", + "order", + "updated" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:199 (name)", + "src/msg/plugins/discovery.py:223 (name)", + "src/msg/plugins/discovery.py:231 (name)", + "src/msg/plugins/read_predicates.py:17 (name)", + "src/msg/plugins/saved_queries.py:113 (name)", + "src/msg/plugins/saved_queries.py:135 (updated)", + "src/msg/plugins/saved_queries.py:137 (updated)", + "src/msg/core/models.py:47 (name)", + "src/msg/core/models.py:318 (name)", + "src/msg/core/models.py:330 (name)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.128.01", + "chapter": 10, + "design_line": 128, + "clause_index": 1, + "text": "默认排名仅用公开确定的词法、字段权重和新鲜度,不隐式模型改写", + "profile": "search", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.128.02", + "chapter": 10, + "design_line": 128, + "clause_index": 2, + "text": "未来semantic/hybrid须显式选择、版本化、可关闭,不将推断写回资源", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "hybrid" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.128.03", + "chapter": 10, + "design_line": 128, + "clause_index": 3, + "text": "projection支持fields/snippet/links/limit/PageCursor", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PageCursor", + "limit", + "links", + "projection", + "snippet" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:141 (limit)", + "src/msg/plugins/discovery.py:185 (limit)", + "src/msg/plugins/discovery.py:196 (limit)", + "src/msg/plugins/saved_queries.py:64 (limit)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.128.04", + "chapter": 10, + "design_line": 128, + "clause_index": 4, + "text": "结果给ResourceRef、canonical path、type、必要标题/作者/时间、可选score/rank_reason/snippet及LinkSet,snippet仅最短命中上下文和字段/范围,不默认全文", + "profile": "search", + "requirement_kind": "default", + "explicit_named_literals": [ + "canonical", + "path", + "rank_reason", + "snippet", + "type" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:11 (canonical)", + "src/msg/plugins/discovery.py:51 (type)", + "src/msg/plugins/discovery.py:76 (path)", + "src/msg/plugins/read_predicates.py:10 (type)", + "src/msg/plugins/read_predicates.py:11 (type)", + "src/msg/plugins/read_predicates.py:12 (type)", + "src/msg/plugins/saved_queries.py:4 (canonical)", + "src/msg/plugins/saved_queries.py:46 (type)", + "src/msg/plugins/saved_queries.py:56 (type)", + "src/msg/core/models.py:17 (type)", + "src/msg/core/models.py:18 (type)", + "src/msg/core/models.py:19 (type)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.129.01", + "chapter": 10, + "design_line": 129, + "clause_index": 1, + "text": "所有条件均可经/_search/q//(/_s/q/...)或search_ref纯路径读取", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_s", + "_search", + "q", + "segments", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:213 (version)", + "src/msg/plugins/discovery.py:644 (version)", + "src/msg/plugins/discovery.py:689 (version)", + "src/msg/plugins/saved_queries.py:24 (version)", + "src/msg/plugins/saved_queries.py:26 (version)", + "src/msg/plugins/saved_queries.py:37 (version)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:319 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.129.02", + "chapter": 10, + "design_line": 129, + "clause_index": 2, + "text": "长查询复用QueryRef,显式保存后可交watch,仅推送新命中/变化引用", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.130.01", + "chapter": 10, + "design_line": 130, + "clause_index": 1, + "text": "explain=compact按需给命中字段/词项/关系、过滤、rank factors", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "compact", + "explain", + "factors", + "rank" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:212 (compact)", + "src/msg/plugins/discovery.py:264 (compact)", + "src/msg/plugins/discovery.py:266 (compact)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.130.02", + "chapter": 10, + "design_line": 130, + "clause_index": 2, + "text": "spell/suggest/facet默认关闭或按需,仅使用可见语料", + "profile": "search", + "requirement_kind": "default", + "explicit_named_literals": [ + "facet", + "spell", + "suggest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/search_query.py:5 (spell)", + "src/msg/core/search_query.py:14 (suggest)", + "src/msg/plugins/discovery.py:803 (suggest)", + "src/msg/plugins/discovery.py:816 (spell)", + "src/msg/plugins/discovery.py:842 (suggest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.130.03", + "chapter": 10, + "design_line": 130, + "clause_index": 3, + "text": "计数、聚合、排名、补全均先鉴权,防total/facet/snippet/排序/timing泄露,不因搜索/点击建立画像", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "facet", + "snippet", + "timing" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:791 (snippet)", + "src/msg/plugins/discovery.py:861 (snippet)", + "src/msg/plugins/discovery.py:1043 (snippet)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.130.04", + "chapter": 10, + "design_line": 130, + "clause_index": 4, + "text": "其他约束见第08–09章", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.131.01", + "chapter": 10, + "design_line": 131, + "clause_index": 1, + "text": "/_index枚举by-id/by-name/by-time/by-tag,成就索引见第03章", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "_index", + "by", + "id", + "name", + "tag", + "time" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:7 (time)", + "src/msg/plugins/discovery.py:174 (time)", + "src/msg/plugins/discovery.py:199 (name)", + "src/msg/plugins/read_predicates.py:17 (name)", + "src/msg/plugins/saved_queries.py:113 (name)", + "src/msg/plugins/saved_queries.py:148 (name)", + "src/msg/core/models.py:47 (name)", + "src/msg/core/models.py:318 (name)", + "src/msg/core/models.py:330 (name)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.131.02", + "chapter": 10, + "design_line": 131, + "clause_index": 2, + "text": "grep不排名,支持固定串/受限正则、include/exclude glob、大小写、前后文、max_matches/max_files、files_with_matches/count_only", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "count_only", + "exclude", + "files_with_matches", + "glob", + "grep", + "include", + "max_files", + "max_matches" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1120 (grep)", + "src/msg/plugins/discovery.py:1121 (glob)", + "src/msg/plugins/discovery.py:1124 (max_matches)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.131.03", + "chapter": 10, + "design_line": 131, + "clause_index": 3, + "text": "返回ResourceRef/路径、revision、line_hint、范围、短上下文", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [ + "line_hint", + "revision" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/search_query.py:4 (revision)", + "src/msg/plugins/discovery.py:77 (revision)", + "src/msg/plugins/discovery.py:78 (revision)", + "src/msg/plugins/discovery.py:103 (revision)", + "src/msg/plugins/saved_queries.py:20 (revision)", + "src/msg/plugins/saved_queries.py:54 (revision)", + "src/msg/plugins/saved_queries.py:63 (revision)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:53 (revision)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.131.04", + "chapter": 10, + "design_line": 131, + "clause_index": 4, + "text": "行号不是稳定编辑位置,未知大范围先搜索", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.132.01", + "chapter": 10, + "design_line": 132, + "clause_index": 1, + "text": "索引可重建,撤权/归档/删除/分享变化须即时过滤", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.132.02", + "chapter": 10, + "design_line": 132, + "clause_index": 2, + "text": "落后回源或标stale,禁止等重建才撤权", + "profile": "search", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D10.132.03", + "chapter": 10, + "design_line": 132, + "clause_index": 3, + "text": "正则、扫描、输出均有复杂度/时间/大小上限", + "profile": "search", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.134.01", + "chapter": 11, + "design_line": 134, + "clause_index": 1, + "text": "PostgreSQL管结构化事实,Git/CAS管内容,传输与连接解耦", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CAS", + "Git", + "PostgreSQL" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/storage/git.py:28 (PostgreSQL)", + "src/msg/storage/git.py:412 (PostgreSQL)", + "src/msg/storage/postgres.py:1 (PostgreSQL)", + "src/msg/admin/backups.py:213 (PostgreSQL)", + "src/msg/admin/backups.py:261 (PostgreSQL)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.135.01", + "chapter": 11, + "design_line": 135, + "clause_index": 1, + "text": "全部资源/修订元数据、主体/组织、授权/凭据/证书状态、货币/市场/订单/托管/交付/仲裁事实(含BountyEscrow/BountyClaim及签名挑战防重放状态)及幂等、TransferSession、Event/EffectJob以PostgreSQL为唯一权威", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BountyClaim", + "EffectJob", + "Event", + "TransferSession" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:369 (TransferSession)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.135.02", + "chapter": 11, + "design_line": 135, + "clause_index": 2, + "text": "文本用GitTextStore,二进制和寄售payload用BlobStore/CAS", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CAS" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.135.03", + "chapter": 11, + "design_line": 135, + "clause_index": 3, + "text": "缓存/索引只能重建,不能恢复权威余额、奖励预算或订单状态", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.136.01", + "chapter": 11, + "design_line": 136, + "clause_index": 1, + "text": "先可靠put/pin内容再提交引用", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "pin" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.136.02", + "chapter": 11, + "design_line": 136, + "clause_index": 2, + "text": "失败可留可回收孤儿,不得发布缺正文Revision", + "profile": "storage", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.136.03", + "chapter": 11, + "design_line": 136, + "clause_index": 3, + "text": "引用限backend+stable key/content_ref,禁止宿主绝对路径、裸CAS直链、扫描Git/CAS重建权限或把明确二进制写入普通Git", + "profile": "storage", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "CAS", + "content_ref", + "key", + "stable" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.136.04", + "chapter": 11, + "design_line": 136, + "clause_index": 4, + "text": "digest不授权", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "digest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/storage/git.py:17 (digest)", + "src/msg/storage/git.py:260 (digest)", + "src/msg/storage/git.py:285 (digest)", + "src/msg/storage/postgres.py:86 (digest)", + "src/msg/storage/postgres.py:87 (digest)", + "src/msg/storage/postgres.py:94 (digest)", + "src/msg/admin/backups.py:41 (digest)", + "src/msg/admin/backups.py:42 (digest)", + "src/msg/admin/backups.py:46 (digest)", + "src/msg/storage/legacy_resource_import.py:11 (digest)", + "src/msg/storage/legacy_resource_import.py:121 (digest)", + "src/msg/storage/legacy_resource_import.py:174 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.01", + "chapter": 11, + "design_line": 137, + "clause_index": 1, + "text": "回收按有效引用/保留规则", + "profile": "recovery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.02", + "chapter": 11, + "design_line": 137, + "clause_index": 2, + "text": "一致备份须覆盖数据库、引用内容、恢复材料并验证恢复,包括账本、订单、托管和仲裁", + "profile": "recovery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.03", + "chapter": 11, + "design_line": 137, + "clause_index": 3, + "text": "PostgreSQL自管物理备份,msgd不读其数据目录", + "profile": "recovery", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PostgreSQL", + "msgd" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/recovery_proof.py:240 (PostgreSQL)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.04", + "chapter": 11, + "design_line": 137, + "clause_index": 4, + "text": "根秘密独立本机/离线备份,目录见第14章", + "profile": "recovery", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.05", + "chapter": 11, + "design_line": 137, + "clause_index": 5, + "text": "服务端旧钥退出状态至少区分online_retired与backup_retired:当前数据库/在线vault已删除但备份或快照仍可恢复旧钥时,不得宣称server-held key已完整销毁", + "profile": "recovery", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "held", + "key" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.137.06", + "chapter": 11, + "design_line": 137, + "clause_index": 6, + "text": "备份恢复后必须重放撤销、密钥退役和凭据状态,禁止复活已撤销token、证书当前授权或旧签名能力", + "profile": "recovery", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.01", + "chapter": 11, + "design_line": 138, + "clause_index": 1, + "text": "TransferSession绑定主体、方向、目标/资源、revision、状态、限制而非连接", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [ + "TransferSession", + "revision" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/transfer.py:12 (TransferSession)", + "src/msg/core/transfer.py:58 (revision)", + "src/msg/core/transfer.py:59 (revision)", + "src/msg/plugins/transfer.py:32 (revision)", + "src/msg/plugins/transfer.py:33 (revision)", + "src/msg/plugins/transfer.py:34 (revision)", + "src/msg/client.py:185 (revision)", + "src/msg/client.py:186 (revision)", + "src/msg/client.py:204 (revision)", + "src/msg/core/models.py:32 (revision)", + "src/msg/core/models.py:53 (revision)", + "src/msg/core/models.py:369 (TransferSession)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.02", + "chapter": 11, + "design_line": 138, + "clause_index": 2, + "text": "open/part_put/part_get/status/seal/cancel可跨GET Path/POST/GraphQL/CLI/MCP", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CLI", + "GraphQL", + "MCP", + "POST", + "Path", + "cancel", + "open", + "part_get", + "part_put", + "seal", + "status" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/transfer.py:36 (open)", + "src/msg/core/transfer.py:44 (open)", + "src/msg/core/transfer.py:57 (open)", + "src/msg/plugins/transfer.py:69 (part_put)", + "src/msg/plugins/transfer.py:72 (part_put)", + "src/msg/plugins/transfer.py:89 (seal)", + "src/msg/client.py:8 (Path)", + "src/msg/client.py:32 (Path, open)", + "src/msg/client.py:42 (Path)", + "src/msg/core/models.py:9 (Path)", + "src/msg/core/models.py:116 (open)", + "src/msg/core/models.py:125 (status)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.03", + "chapter": 11, + "design_line": 138, + "clause_index": 3, + "text": "创建/修改走执行入口", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.04", + "chapter": 11, + "design_line": 138, + "clause_index": 4, + "text": "分片按offset/length半开范围,允许乱序,同范围同摘要幂等,冲突重叠拒绝", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [ + "length" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/transfer.py:92 (length)", + "src/msg/core/transfer.py:144 (length)", + "src/msg/core/transfer.py:165 (length)", + "src/msg/plugins/transfer.py:66 (length)", + "src/msg/plugins/transfer.py:120 (length)", + "src/msg/plugins/transfer.py:121 (length)", + "src/msg/client.py:658 (length)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.05", + "chapter": 11, + "design_line": 138, + "clause_index": 5, + "text": "status给完成/缺口,seal验缺口及最终size/digest后只返回不可变引用、不发帖", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [ + "digest", + "seal", + "status" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/transfer.py:10 (digest)", + "src/msg/core/transfer.py:44 (digest)", + "src/msg/core/transfer.py:49 (digest)", + "src/msg/plugins/transfer.py:7 (digest)", + "src/msg/plugins/transfer.py:34 (digest)", + "src/msg/plugins/transfer.py:70 (digest)", + "src/msg/client.py:16 (digest)", + "src/msg/client.py:100 (status)", + "src/msg/client.py:151 (status)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:213 (status)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.138.06", + "chapter": 11, + "design_line": 138, + "clause_index": 6, + "text": "下载支持流式/范围续读,不整文件入内存", + "profile": "transfer", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.139.01", + "chapter": 11, + "design_line": 139, + "clause_index": 1, + "text": "CLI分别配置read_url/push_url,普通路径不得写入、转发或重定向执行", + "profile": "client", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "CLI", + "push_url" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.140.01", + "chapter": 11, + "design_line": 140, + "clause_index": 1, + "text": "网页按清单read→patch/write→web.preview→web.deploy原子切换所有文件,支持diff/rollback", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [ + "patch", + "rollback", + "web.deploy", + "web.preview", + "write" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/hosting.py:44 (write)", + "src/msg/extensions/hosting.py:78 (write)", + "src/msg/extensions/hosting.py:113 (write)", + "src/msg/hosting_runtime.py:118 (write)", + "src/msg/hosting_runtime.py:139 (write)", + "src/msg/admin/diagnostics.py:64 (write)", + "src/msg/admin/diagnostics.py:151 (write)", + "src/msg/admin/diagnostics.py:480 (write)", + "src/msg/core/models.py:311 (write)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.140.02", + "chapter": 11, + "design_line": 140, + "clause_index": 2, + "text": "启用时提供/@root/web/index.html,根变更仅本机", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [ + "index.html", + "root", + "web" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/hosting.py:193 (index.html)", + "src/msg/extensions/hosting.py:195 (index.html)", + "src/msg/hosting_runtime.py:48 (root)", + "src/msg/hosting_runtime.py:62 (root)", + "src/msg/hosting_runtime.py:131 (root)", + "src/msg/admin/diagnostics.py:41 (root)", + "src/msg/admin/diagnostics.py:42 (root)", + "src/msg/admin/diagnostics.py:43 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.141.01", + "chapter": 11, + "design_line": 141, + "clause_index": 1, + "text": "托管可执行页面含预览/历史,响应头须强制CSP sandbox,最多allow-scripts、禁allow-same-origin", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [ + "origin", + "same", + "sandbox", + "scripts" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/hosting.py:1 (origin)", + "src/msg/extensions/hosting.py:127 (sandbox)", + "src/msg/admin/diagnostics.py:668 (same)", + "src/msg/admin/diagnostics.py:751 (same)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.141.02", + "chapter": 11, + "design_line": 141, + "clause_index": 2, + "text": "raw无同等隔离则仅非执行下载", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [ + "raw" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.141.03", + "chapter": 11, + "design_line": 141, + "clause_index": 3, + "text": "禁止仅靠meta/Report-Only、暴露平台Cookie/长期bearer或信任Origin:null/同域/隐藏请求", + "profile": "hosting", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Only", + "Report", + "null" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:431 (null)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.141.04", + "chapter": 11, + "design_line": 141, + "clause_index": 4, + "text": "须阻断私有API读取、携平台身份写入、绕隔离", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.141.05", + "chapter": 11, + "design_line": 141, + "clause_index": 5, + "text": "CSP/跨源分别约束网络/读取,不宣称sandbox阻断所有请求", + "profile": "hosting", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CSP" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D11.142.01", + "chapter": 11, + "design_line": 142, + "clause_index": 1, + "text": "禁止按账号预建空仓库、常驻进程、个人队列或定时任务", + "profile": "git", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.144.01", + "chapter": 12, + "design_line": 144, + "clause_index": 1, + "text": "事件、通信、协作和发货共用已有模型,不自动串联通用工作流", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.01", + "chapter": 12, + "design_line": 145, + "clause_index": 1, + "text": "事务和幂等依第09章", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.02", + "chapter": 12, + "design_line": 145, + "clause_index": 2, + "text": "邮件、Webhook、工具及已登记外部动作共用EffectJob的领取/重试/超时/去重基础设施,执行器仍按通道权限隔离", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Webhook" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.03", + "chapter": 12, + "design_line": 145, + "clause_index": 3, + "text": "按事件+接收者+通道或原工具调用去重,完成/重试必须检查当前attempt/state/deadline及当前授权,过期结果不能覆盖新尝试,uncertain不得自动重发,不承诺外部exactly-once", + "profile": "jobs", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "deadline", + "once", + "state", + "uncertain" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:4 (uncertain)", + "src/msg/workers/effects.py:117 (state)", + "src/msg/workers/effects.py:120 (state, uncertain)", + "src/msg/workers/leases.py:8 (state)", + "src/msg/workers/leases.py:14 (state)", + "src/msg/workers/leases.py:17 (state, uncertain)", + "src/msg/plugins/communication.py:89 (state)", + "src/msg/plugins/communication.py:92 (state)", + "src/msg/plugins/communication.py:177 (state)", + "src/msg/core/models.py:54 (state)", + "src/msg/core/models.py:283 (uncertain)", + "src/msg/core/models.py:373 (state)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.04", + "chapter": 12, + "design_line": 145, + "clause_index": 4, + "text": "外部失败不回滚业务,索引/统计重建不重复发信或发证", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.05", + "chapter": 12, + "design_line": 145, + "clause_index": 5, + "text": "/@user/in存引用与已读/归档", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "in", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:231 (user)", + "src/msg/plugins/communication.py:409 (user)", + "src/msg/plugins/communication.py:515 (user)", + "src/msg/plugins/communication.py:529 (user)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.06", + "chapter": 12, + "design_line": 145, + "clause_index": 6, + "text": "/@user/out按actor索引request/event/result,不复制正文、不等于事务Outbox", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "event", + "out", + "result", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:202 (result)", + "src/msg/workers/effects.py:204 (result)", + "src/msg/workers/effects.py:205 (result)", + "src/msg/workers/leases.py:1 (result)", + "src/msg/plugins/communication.py:23 (event)", + "src/msg/plugins/communication.py:38 (event)", + "src/msg/plugins/communication.py:55 (event)", + "src/msg/core/models.py:91 (user)", + "src/msg/core/models.py:403 (event)", + "src/msg/core/models.py:409 (result)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.07", + "chapter": 12, + "design_line": 145, + "clause_index": 7, + "text": "来源为follow、mention/reply、dm、system", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "dm", + "mention", + "reply", + "system" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/communication.py:27 (reply)", + "src/msg/plugins/communication.py:734 (reply)", + "src/msg/plugins/communication.py:867 (reply)", + "src/msg/core/models.py:105 (system)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.08", + "chapter": 12, + "design_line": 145, + "clause_index": 8, + "text": "system可读/归档但不能unfollow", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "system" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:105 (system)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.145.09", + "chapter": 12, + "design_line": 145, + "clause_index": 9, + "text": "communication.send、watch/unwatch、changes/sync只传受权引用", + "profile": "jobs", + "requirement_kind": "contract", + "explicit_named_literals": [ + "changes", + "communication.send", + "sync", + "unwatch", + "watch" + ], + "registered_operations": [ + "communication.send" + ], + "source_token_locations": [ + "src/msg/workers/effects.py:292 (communication.send)", + "src/msg/workers/effects.py:304 (communication.send)", + "src/msg/plugins/communication.py:113 (sync)", + "src/msg/plugins/communication.py:114 (sync)", + "src/msg/plugins/communication.py:122 (sync)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.01", + "chapter": 12, + "design_line": 146, + "clause_index": 1, + "text": "/@user/dm仅本人视图", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "dm", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/communication.py:409 (user)", + "src/msg/plugins/communication.py:515 (user)", + "src/msg/plugins/communication.py:529 (user)", + "src/msg/plugins/communication.py:409 (user)", + "src/msg/plugins/communication.py:515 (user)", + "src/msg/plugins/communication.py:529 (user)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.02", + "chapter": 12, + "design_line": 146, + "clause_index": 2, + "text": "不得改对方消息,或借chmod/chgrp/share/move/引用向第三方公开会话", + "profile": "dm", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "chgrp", + "move", + "share" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:312 (chgrp)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.03", + "chapter": 12, + "design_line": 146, + "clause_index": 3, + "text": "正文、成员、数量、附件、关系不得进入第三方搜索/索引/Profile/feed/统计/成就证据", + "profile": "dm", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Profile", + "feed" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.04", + "chapter": 12, + "design_line": 146, + "clause_index": 4, + "text": "加第三人须新建私密群聊,不自动带旧历史", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.05", + "chapter": 12, + "design_line": 146, + "clause_index": 5, + "text": "archive只改本人视图,block不撤回已交付内容", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "archive", + "block" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/communication.py:732 (archive)", + "src/msg/plugins/communication.py:865 (archive)", + "src/msg/plugins/communication.py:732 (archive)", + "src/msg/plugins/communication.py:865 (archive)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.146.06", + "chapter": 12, + "design_line": 146, + "clause_index": 6, + "text": "首版不宣称E2EE,后续客户端加密/设备钥须独立设计,禁止以托管签名钥冒充", + "profile": "dm", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.147.01", + "chapter": 12, + "design_line": 147, + "clause_index": 1, + "text": "msg dm request/send/list/read/accept/reject/block复用公共契约,离线经Inbox/SyncCursor", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SyncCursor", + "accept", + "block", + "dm", + "list", + "msg", + "read", + "reject", + "request", + "send" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/communication.py:96 (request)", + "src/msg/plugins/communication.py:97 (read, request)", + "src/msg/plugins/communication.py:98 (read, request)", + "src/msg/plugins/communication.py:96 (request)", + "src/msg/plugins/communication.py:97 (read, request)", + "src/msg/plugins/communication.py:98 (read, request)", + "src/msg/core/models.py:311 (list, read)", + "src/msg/core/models.py:346 (read)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.147.02", + "chapter": 12, + "design_line": 147, + "clause_index": 2, + "text": "ACK显式绑定Revision/digest,区分客户端/托管签名,清单/数量可单查", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ACK", + "digest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/communication.py:10 (digest)", + "src/msg/plugins/communication.py:113 (digest)", + "src/msg/plugins/communication.py:114 (digest)", + "src/msg/plugins/communication.py:10 (digest)", + "src/msg/plugins/communication.py:113 (digest)", + "src/msg/plugins/communication.py:114 (digest)", + "src/msg/core/models.py:37 (digest)", + "src/msg/core/models.py:441 (digest)", + "src/msg/core/models.py:483 (digest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.147.03", + "chapter": 12, + "design_line": 147, + "clause_index": 3, + "text": "CLI仅按用户配置另发ACK", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CLI" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.147.04", + "chapter": 12, + "design_line": 147, + "clause_index": 4, + "text": "like/unlike同为显式操作", + "profile": "dm", + "requirement_kind": "contract", + "explicit_named_literals": [ + "like", + "unlike" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.148.01", + "chapter": 12, + "design_line": 148, + "clause_index": 1, + "text": "下列协作原语复用Resource/Relation/Event/Operation,不隐式转移owner、Membership、ShareGrant、证书/capability或改变权限/优先级", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Event", + "Membership", + "Operation", + "Relation", + "ShareGrant", + "capability" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/proposals.py:4 (Relation)", + "src/msg/plugins/proposals.py:51 (Relation)", + "src/msg/plugins/proposals.py:52 (Relation)", + "src/msg/plugins/watches.py:1 (Event)", + "src/msg/plugins/watches.py:101 (Event)", + "src/msg/core/models.py:63 (Relation)", + "src/msg/core/models.py:83 (Relation)", + "src/msg/core/models.py:120 (Membership)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.148.02", + "chapter": 12, + "design_line": 148, + "clause_index": 2, + "text": "?表示可选字段:", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.149.01", + "chapter": 12, + "design_line": 149, + "clause_index": 1, + "text": "request(requester,title/description,resource_refs,requirements,created_at,due_at?,expires_at?,status,assignee?):open/claimed/fulfilled/cancelled/expired", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "assignee", + "cancelled", + "claimed", + "created_at", + "description", + "due_at", + "expired", + "expires_at", + "fulfilled", + "open", + "request", + "requester", + "requirements", + "resource_refs", + "status", + "title" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:17 (request)", + "src/msg/plugins/collaboration.py:20 (request)", + "src/msg/plugins/collaboration.py:24 (request)", + "src/msg/plugins/proposals.py:24 (request)", + "src/msg/plugins/proposals.py:25 (request)", + "src/msg/plugins/proposals.py:26 (request)", + "src/msg/plugins/watches.py:46 (created_at, expires_at, status)", + "src/msg/plugins/watches.py:49 (cancelled, status)", + "src/msg/plugins/watches.py:58 (created_at)", + "src/msg/core/models.py:55 (created_at)", + "src/msg/core/models.py:87 (created_at)", + "src/msg/core/models.py:116 (open)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.149.02", + "chapter": 12, + "design_line": 149, + "clause_index": 2, + "text": "offer(subject,description/capability_hint,scope,availability,expires_at?)", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "availability", + "capability_hint", + "description", + "expires_at", + "offer", + "scope", + "subject" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:169 (expires_at)", + "src/msg/plugins/collaboration.py:170 (expires_at)", + "src/msg/plugins/collaboration.py:174 (expires_at)", + "src/msg/plugins/watches.py:46 (expires_at)", + "src/msg/plugins/watches.py:59 (expires_at)", + "src/msg/plugins/watches.py:60 (expires_at)", + "src/msg/core/models.py:148 (scope)", + "src/msg/core/models.py:169 (expires_at)", + "src/msg/core/models.py:185 (expires_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.149.03", + "chapter": 12, + "design_line": 149, + "clause_index": 3, + "text": "二者可搜索、互引、匹配,不自动派活、付款或执行", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.150.01", + "chapter": 12, + "design_line": 150, + "clause_index": 1, + "text": "proposal(author,target ResourceRef,base_revision,patch/content_ref,message,status):open/accepted/rejected/superseded/withdrawn", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "ResourceRef", + "accepted", + "author", + "base_revision", + "content_ref", + "message", + "open", + "patch", + "proposal", + "rejected", + "status", + "superseded", + "target", + "withdrawn" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:60 (status)", + "src/msg/plugins/collaboration.py:62 (message, target)", + "src/msg/plugins/collaboration.py:63 (message)", + "src/msg/plugins/proposals.py:4 (ResourceRef)", + "src/msg/plugins/proposals.py:21 (base_revision, content_ref, target)", + "src/msg/plugins/proposals.py:22 (message)", + "src/msg/plugins/watches.py:1 (target)", + "src/msg/plugins/watches.py:11 (ResourceRef)", + "src/msg/plugins/watches.py:27 (ResourceRef)", + "src/msg/core/models.py:30 (ResourceRef)", + "src/msg/core/models.py:65 (ResourceRef, target)", + "src/msg/core/models.py:86 (author)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.150.02", + "chapter": 12, + "design_line": 150, + "clause_index": 2, + "text": "建议不改目标,accept须正式Operation重验权限/版本,失败保留建议与冲突", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "accept" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:139 (accept)", + "src/msg/plugins/collaboration.py:151 (accept)", + "src/msg/plugins/collaboration.py:154 (accept)", + "src/msg/plugins/proposals.py:63 (accept)", + "src/msg/plugins/proposals.py:78 (accept)", + "src/msg/plugins/proposals.py:92 (accept)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.151.01", + "chapter": 12, + "design_line": 151, + "clause_index": 1, + "text": "receipt(request_id,actor,operation,target/result_ref,result_digest,committed_at,server_signature/receipt_proof):证明实际提交,可引用/验签,幂等重试同一事实,外部uncertain不是完成", + "profile": "audit", + "requirement_kind": "contract", + "explicit_named_literals": [ + "actor", + "committed_at", + "operation", + "receipt", + "receipt_proof", + "request_id", + "result_digest", + "result_ref", + "server_signature", + "target" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:65 (target)", + "src/msg/core/models.py:84 (actor)", + "src/msg/core/models.py:91 (operation)", + "src/msg/storage/postgres.py:69 (request_id)", + "src/msg/storage/postgres.py:72 (request_id)", + "src/msg/storage/postgres.py:86 (request_id)", + "src/msg/admin/recovery_replay.py:6 (operation)", + "src/msg/admin/recovery_replay.py:81 (target)", + "src/msg/admin/recovery_replay.py:88 (target)", + "src/msg/core/models.py:65 (target)", + "src/msg/core/models.py:84 (actor)", + "src/msg/core/models.py:91 (operation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.152.01", + "chapter": 12, + "design_line": 152, + "clause_index": 1, + "text": "checkpoint(subject,resource_refs,state_ref?,summary,resume_hint?,created_at,expires_at?):保存工作进度,可被handoff引用,不是Bookmark、不冻结目标或授lease", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "checkpoint", + "created_at", + "expires_at", + "resource_refs", + "resume_hint", + "state_ref", + "subject", + "summary" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:79 (resource_refs)", + "src/msg/plugins/collaboration.py:83 (resource_refs)", + "src/msg/plugins/collaboration.py:91 (resource_refs)", + "src/msg/plugins/proposals.py:47 (created_at)", + "src/msg/plugins/watches.py:46 (created_at, expires_at)", + "src/msg/plugins/watches.py:58 (created_at)", + "src/msg/plugins/watches.py:59 (expires_at)", + "src/msg/core/models.py:55 (created_at)", + "src/msg/core/models.py:87 (created_at)", + "src/msg/core/models.py:169 (expires_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.152.02", + "chapter": 12, + "design_line": 152, + "clause_index": 2, + "text": "恢复重读状态并处理冲突", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.153.01", + "chapter": 12, + "design_line": 153, + "clause_index": 1, + "text": "watch(subject,target/query_ref,event_types,delivery,created_at,expires_at?,status):Event匹配后向Inbox/已配通道投递引用,撤权停止泄露,不是轮询或定时工作流", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Event", + "created_at", + "delivery", + "event_types", + "expires_at", + "query_ref", + "status", + "subject", + "target", + "watch" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:60 (status)", + "src/msg/plugins/collaboration.py:62 (target)", + "src/msg/plugins/collaboration.py:65 (status)", + "src/msg/plugins/proposals.py:21 (target)", + "src/msg/plugins/proposals.py:23 (target)", + "src/msg/plugins/proposals.py:27 (target)", + "src/msg/plugins/watches.py:1 (Event, target)", + "src/msg/plugins/watches.py:26 (watch)", + "src/msg/plugins/watches.py:42 (watch)", + "src/msg/core/models.py:55 (created_at)", + "src/msg/core/models.py:65 (target)", + "src/msg/core/models.py:87 (created_at)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.153.02", + "chapter": 12, + "design_line": 153, + "clause_index": 2, + "text": "follow复用固定条件的watch及投递逻辑,保留外部操作名", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "follow" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/watches.py:189 (follow)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.153.03", + "chapter": 12, + "design_line": 153, + "clause_index": 3, + "text": "系统通知不因unfollow而取消", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.153.04", + "chapter": 12, + "design_line": 153, + "clause_index": 4, + "text": "工作lease、worker执行租约、数据库锁各自保留语义,只共享适用的时间/编码辅助函数", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "worker" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/watches.py:162 (worker)", + "src/msg/core/models.py:24 (worker)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.154.01", + "chapter": 12, + "design_line": 154, + "clause_index": 1, + "text": "/@user/下提供handoffs/、leases/、presence、claims/、requests/、offers/、proposals/、receipts/、checkpoints/、watches/视图,不增安全父链", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "checkpoints", + "claims", + "leases", + "offers", + "presence", + "proposals", + "receipts", + "requests", + "user", + "watches" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:1 (leases)", + "src/msg/plugins/collaboration.py:32 (user)", + "src/msg/plugins/collaboration.py:61 (user)", + "src/msg/plugins/proposals.py:1 (proposals)", + "src/msg/plugins/watches.py:1 (watches)", + "src/msg/plugins/watches.py:12 (requests)", + "src/msg/plugins/watches.py:100 (watches)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.154.02", + "chapter": 12, + "design_line": 154, + "clause_index": 2, + "text": "msg按各契约提供读取及create/accept/reject/renew/release/clear/fulfill/withdraw,receipt不可独立创建", + "profile": "collaboration", + "requirement_kind": "contract", + "explicit_named_literals": [ + "accept", + "clear", + "fulfill", + "msg", + "receipt", + "reject", + "release", + "renew", + "withdraw" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:139 (accept, reject)", + "src/msg/plugins/collaboration.py:151 (accept, reject)", + "src/msg/plugins/collaboration.py:154 (accept, reject)", + "src/msg/plugins/proposals.py:63 (accept)", + "src/msg/plugins/proposals.py:73 (withdraw)", + "src/msg/plugins/proposals.py:78 (accept)", + "src/msg/core/models.py:91 (release)", + "src/msg/core/models.py:289 (receipt)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.01", + "chapter": 12, + "design_line": 155, + "clause_index": 1, + "text": "Delivery记录一次订单向具体主体交付的事实:delivery_id/order_id、recipient_subject、kind、payload_refs/DeliveryEnvelope、package_digest、delivery_digest、recipient_key_id?、state、prepared/claimed_at、receipt", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Delivery", + "DeliveryEnvelope", + "claimed_at", + "delivery_digest", + "delivery_id", + "kind", + "order_id", + "package_digest", + "payload_refs", + "prepared", + "receipt", + "recipient_key_id", + "recipient_subject", + "state" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36 (state)", + "src/msg/plugins/store.py:43 (state)", + "src/msg/plugins/store.py:49 (kind, payload_refs)", + "src/msg/plugins/orders.py:3 (Delivery, state)", + "src/msg/plugins/orders.py:44 (package_digest)", + "src/msg/plugins/orders.py:48 (package_digest)", + "src/msg/market/managed_delivery.py:17 (kind, order_id, payload_refs, recipient_subject)", + "src/msg/market/managed_delivery.py:18 (delivery_digest, package_digest)", + "src/msg/market/managed_delivery.py:19 (claimed_at, receipt, state)", + "src/msg/market/compatibility.py:3 (Delivery, receipt)", + "src/msg/market/compatibility.py:18 (state)", + "src/msg/market/compatibility.py:34 (state)", + "src/msg/core/models.py:54 (state)", + "src/msg/core/models.py:105 (kind)", + "src/msg/core/models.py:165 (kind)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.02", + "chapter": 12, + "design_line": 155, + "clause_index": 2, + "text": "商品包可复用,Delivery不与包合并", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Delivery" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:3 (Delivery)", + "src/msg/market/compatibility.py:3 (Delivery)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.03", + "chapter": 12, + "design_line": 155, + "clause_index": 3, + "text": "权威入口为/@buyer/orders//_delivery,Inbox通知带order_id", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Inbox", + "_delivery", + "buyer", + "order_id", + "orders" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:1 (buyer)", + "src/msg/plugins/orders.py:32 (orders)", + "src/msg/plugins/orders.py:41 (orders)", + "src/msg/market/managed_delivery.py:17 (order_id)", + "src/msg/market/managed_delivery.py:22 (order_id)", + "src/msg/market/managed_delivery.py:23 (order_id)", + "src/msg/market/compatibility.py:71 (buyer)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.04", + "chapter": 12, + "design_line": 155, + "clause_index": 4, + "text": "channel/邮件状态由对应EffectJob按需投影,不另建商品交付状态机", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [ + "channel" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:106 (channel)", + "src/msg/market/managed_delivery.py:18 (channel)", + "src/msg/market/managed_delivery.py:24 (channel)", + "src/msg/market/managed_delivery.py:46 (channel)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.05", + "chapter": 12, + "design_line": 155, + "clause_index": 5, + "text": "旧版本字段保持兼容且不能重写签署事实", + "profile": "sale", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.155.06", + "chapter": 12, + "design_line": 155, + "clause_index": 6, + "text": "claimed或accepted仅由明确签名操作记录,下载、邮件接受、读取不得自动确认或放款", + "profile": "sale", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "claimed" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.01", + "chapter": 12, + "design_line": 156, + "clause_index": 1, + "text": "DeliveryTarget由买家自己的已验证endpoint产生并在下单锁定endpoint_id/address_snapshot", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "DeliveryTarget", + "address_snapshot" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.02", + "chapter": 12, + "design_line": 156, + "clause_index": 2, + "text": "checkout可填写email,但只有与当前subject已验证邮箱匹配时才能直接成为发货目标", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "checkout" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:4 (checkout)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.03", + "chapter": 12, + "design_line": 156, + "clause_index": 3, + "text": "未验证的新地址只能先收到不含订单号、用户名、商品信息或提货能力的验证消息", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.04", + "chapter": 12, + "design_line": 156, + "clause_index": 4, + "text": "订单仍在站内正常交付,Email通知保持pending,验证成功后才绑定", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Email" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.05", + "chapter": 12, + "design_line": 156, + "clause_index": 5, + "text": "卖家不得提供/修改收件地址,商品recipient_subject不可改", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.06", + "chapter": 12, + "design_line": 156, + "clause_index": 6, + "text": "站内交付与辅助邮件分开:买家可在邮件尚未发送前显式更换/验证通知endpoint,须版本化绑定并使旧任务失效", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.07", + "chapter": 12, + "design_line": 156, + "clause_index": 7, + "text": "已发送快照不得追改,改邮箱不改变商品归属或解密接收者", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.08", + "chapter": 12, + "design_line": 156, + "clause_index": 8, + "text": "发送前校验Order.buyer=DeliveryTarget.subject=VerifiedEmail.owner,加密时还须=EncryptionKey.owner", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "DeliveryTarget.subject", + "EncryptionKey.owner", + "VerifiedEmail.owner", + "buyer" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:1 (buyer)", + "src/msg/market/delivery_notifications.py:5 (buyer)", + "src/msg/market/delivery_notifications.py:28 (buyer)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.09", + "chapter": 12, + "design_line": 156, + "clause_index": 9, + "text": "不符报delivery_recipient_mismatch并停止", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.10", + "chapter": 12, + "design_line": 156, + "clause_index": 10, + "text": "worker同时重验endpoint未撤销及订单/交付状态", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "worker" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:86 (worker)", + "src/msg/workers/effects.py:376 (worker)", + "src/msg/workers/effects.py:418 (worker)", + "src/msg/core/models.py:24 (worker)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.11", + "chapter": 12, + "design_line": 156, + "clause_index": 11, + "text": "卖家只见channel/status,默认不见真实邮箱", + "profile": "email", + "requirement_kind": "default", + "explicit_named_literals": [ + "status" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:133 (status)", + "src/msg/workers/effects.py:141 (status)", + "src/msg/workers/effects.py:396 (status)", + "src/msg/core/models.py:125 (status)", + "src/msg/core/models.py:213 (status)", + "src/msg/core/models.py:283 (status)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.12", + "chapter": 12, + "design_line": 156, + "clause_index": 12, + "text": "邮箱endpoint必须是与绑定快照逐字一致的单一addr-spec,不接受显示名、地址列表、命名组、注释或解析纠错后地址变化", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "spec" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.13", + "chapter": 12, + "design_line": 156, + "clause_index": 13, + "text": "合法带引号的本地部分不因含逗号而误拒", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.14", + "chapter": 12, + "design_line": 156, + "clause_index": 14, + "text": "订单绑定与SMTP边界共用校验,已验证旧数据和排队任务同样重验", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.15", + "chapter": 12, + "design_line": 156, + "clause_index": 15, + "text": "实际SMTP envelope显式限定该唯一收件人,不从To/Cc/Bcc推导或扩展收件人", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Bcc", + "Cc", + "envelope" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.156.16", + "chapter": 12, + "design_line": 156, + "clause_index": 16, + "text": "非法目标须在连接SMTP/读取凭据前拒绝,错误不回显地址,不影响站内交付、付款和验收", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.01", + "chapter": 12, + "design_line": 157, + "clause_index": 1, + "text": "Email默认disabled,须验证邮箱并配置偏好", + "profile": "email", + "requirement_kind": "default", + "explicit_named_literals": [ + "Email" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.02", + "chapter": 12, + "design_line": 157, + "clause_index": 2, + "text": "SMTP未配置/关闭不得影响站内订单与发货", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "SMTP" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/mail.py:1 (SMTP)", + "src/msg/workers/mail.py:39 (SMTP)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.03", + "chapter": 12, + "design_line": 157, + "clause_index": 3, + "text": "file/bundle默认email_notify:邮件只含order_id、下单时buyer handle快照(用户名)和需重新认证的提货链接,链接本身不是bearer capability,不附明文商品", + "profile": "email", + "requirement_kind": "default", + "explicit_named_literals": [ + "bundle", + "capability", + "file", + "handle" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:52 (handle)", + "src/msg/market/delivery_notifications.py:54 (handle)", + "src/msg/workers/effects.py:47 (capability)", + "src/msg/workers/effects.py:75 (capability)", + "src/msg/workers/effects.py:228 (file)", + "src/msg/core/models.py:146 (capability)", + "src/msg/core/models.py:182 (capability)", + "src/msg/core/models.py:199 (capability)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.04", + "chapter": 12, + "design_line": 157, + "clause_index": 4, + "text": "也可发买家EncryptionSubkey加密附件", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.05", + "chapter": 12, + "design_line": 157, + "clause_index": 5, + "text": "secret只能密文附件/下载,禁止明文SMTP", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "secret" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:344 (secret)", + "src/msg/workers/effects.py:350 (secret)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.06", + "chapter": 12, + "design_line": 157, + "clause_index": 6, + "text": "entitlement只通知/回执", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "entitlement" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.07", + "chapter": 12, + "design_line": 157, + "clause_index": 7, + "text": "邮件prepared/queued/smtp_accepted/delivered_unknown/failed由EffectJob及发送结果派生,与Delivery.claimed、订单accepted/settled分开", + "profile": "email", + "requirement_kind": "contract", + "explicit_named_literals": [ + "claimed", + "delivered_unknown", + "failed", + "queued", + "settled", + "smtp_accepted" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:40 (queued, smtp_accepted)", + "src/msg/market/delivery_notifications.py:41 (delivered_unknown, failed)", + "src/msg/market/delivery_notifications.py:63 (settled)", + "src/msg/workers/effects.py:152 (failed)", + "src/msg/workers/effects.py:238 (failed)", + "src/msg/workers/effects.py:352 (failed)", + "src/msg/workers/mail.py:22 (queued)", + "src/msg/core/models.py:421 (failed)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.08", + "chapter": 12, + "design_line": 157, + "clause_index": 8, + "text": "SMTP接受不等于取货或验收,failed/uncertain不得被状态投影重启", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "SMTP", + "failed", + "uncertain" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:41 (failed, uncertain)", + "src/msg/market/delivery_notifications.py:67 (failed, uncertain)", + "src/msg/workers/effects.py:4 (uncertain)", + "src/msg/workers/effects.py:120 (uncertain)", + "src/msg/workers/effects.py:152 (failed)", + "src/msg/workers/mail.py:1 (SMTP)", + "src/msg/workers/mail.py:39 (SMTP)", + "src/msg/workers/mail.py:55 (uncertain)", + "src/msg/core/models.py:283 (uncertain)", + "src/msg/core/models.py:421 (failed, uncertain)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.157.09", + "chapter": 12, + "design_line": 157, + "clause_index": 9, + "text": "禁止像素追踪", + "profile": "email", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.158.01", + "chapter": 12, + "design_line": 158, + "clause_index": 1, + "text": "Webhook支持Inbox-based和需capability的Domain Event-based,endpoint默认disabled、独立secret,以HMAC-SHA256签timestamp+原始body,保留event/delivery身份并按当前权限裁剪", + "profile": "webhook", + "requirement_kind": "default", + "explicit_named_literals": [ + "Event", + "SHA256", + "Webhook", + "based", + "delivery", + "endpoint" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/effects.py:289 (Event)", + "src/msg/workers/effects.py:326 (Event)", + "src/msg/workers/effects.py:346 (Event)", + "src/msg/plugins/communication.py:1 (delivery)", + "src/msg/plugins/communication.py:23 (Event)", + "src/msg/plugins/communication.py:56 (Event)", + "src/msg/core/models.py:390 (Event)", + "src/msg/core/models.py:403 (Event)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.158.02", + "chapter": 12, + "design_line": 158, + "clause_index": 2, + "text": "2xx为delivered,确定性4xx默认不重试,408/429/5xx/网络错误退避", + "profile": "webhook", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.158.03", + "chapter": 12, + "design_line": 158, + "clause_index": 3, + "text": "可能已执行且无幂等依据则uncertain,不盲重做", + "profile": "webhook", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.158.04", + "chapter": 12, + "design_line": 158, + "clause_index": 4, + "text": "外发仅最小事件/引用,不带私有正文/秘密", + "profile": "webhook", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.159.01", + "chapter": 12, + "design_line": 159, + "clause_index": 1, + "text": "AuditEvent只追加actor/operation/target、授权依据、前后摘要、结果/链式摘要,不记秘密", + "profile": "audit", + "requirement_kind": "contract", + "explicit_named_literals": [ + "AuditEvent", + "operation", + "target" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:65 (target)", + "src/msg/core/models.py:91 (operation)", + "src/msg/core/models.py:245 (operation)", + "src/msg/storage/postgres.py:379 (target)", + "src/msg/admin/recovery_replay.py:6 (operation)", + "src/msg/admin/recovery_replay.py:20 (AuditEvent)", + "src/msg/admin/recovery_replay.py:81 (target)", + "src/msg/core/models.py:65 (target)", + "src/msg/core/models.py:91 (operation)", + "src/msg/core/models.py:245 (operation)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D12.159.02", + "chapter": 12, + "design_line": 159, + "clause_index": 2, + "text": "仅能相对可信检查点发现篡改,不能防完整写盘者重算历史", + "profile": "audit", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.161.01", + "chapter": 13, + "design_line": 161, + "clause_index": 1, + "text": "工具和客户端复用公共契约,不成为shell或根管理跳板", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.162.01", + "chapter": 13, + "design_line": 162, + "clause_index": 1, + "text": "/tools/只读列出获准ToolSpec,默认dns/curl且受证书保护", + "profile": "tools", + "requirement_kind": "default", + "explicit_named_literals": [ + "curl", + "tools" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:37 (tools)", + "src/msg/extensions/tools.py:48 (tools)", + "src/msg/extensions/tools.py:94 (curl)", + "src/msg/workers/sandbox_child.py:160 (curl)", + "src/msg/workers/sandbox_child.py:161 (curl)", + "src/msg/core/registry.py:184 (tools)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.162.02", + "chapter": 13, + "design_line": 162, + "clause_index": 2, + "text": "Spec含tool_id、名称/说明、版本/摘要、输入/输出schema、所需能力、网络策略、超时、输入/输出/并发上限、executor_key,仅调用安装的可信代码", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Spec", + "executor_key" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:25 (executor_key)", + "src/msg/extensions/tools.py:58 (executor_key)", + "src/msg/extensions/tools.py:94 (executor_key)", + "src/msg/workers/sandbox.py:57 (executor_key)", + "src/msg/core/registry.py:81 (executor_key)", + "src/msg/core/models.py:464 (executor_key)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.163.01", + "chapter": 13, + "design_line": 163, + "clause_index": 1, + "text": "tool.run经/-/p/tool.run或/-/g短码执行,tool.use限工具/操作", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [ + "g", + "p", + "tool.run", + "tool.use" + ], + "registered_operations": [ + "tool.run" + ], + "source_token_locations": [ + "src/msg/extensions/tools.py:25 (tool.run)", + "src/msg/extensions/tools.py:26 (tool.use)", + "src/msg/extensions/tools.py:68 (tool.run)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [ + { + "nodeid": "tests/test_tool_runner_contract.py::test_injected_port_cannot_publish_a_resource_itself", + "line": 86 + }, + { + "nodeid": "tests/test_tools.py::test_only_scoped_tool_is_visible_jobs_are_deduped_and_output_transfers", + "line": 50 + }, + { + "nodeid": "tests/test_tools.py::test_pending_job_rechecks_revocation_and_expired_lease_is_uncertain", + "line": 83 + }, + { + "nodeid": "tests/test_tools.py::test_tool_external_success_then_revocation_records_uncertain", + "line": 109 + }, + { + "nodeid": "tests/test_tools.py::test_tool_run_is_the_only_public_operation_and_old_short_code_is_retired", + "line": 125 + }, + { + "nodeid": "tests/test_tools_directory.py::test_new_install_tools_path_keeps_permissions_and_capability_gate", + "line": 14 + }, + { + "nodeid": "tests/test_tools_directory.py::test_legacy_tools_path_is_read_only_and_new_alias_still_checks_certificate", + "line": 41 + } + ] + }, + { + "id": "D13.163.02", + "chapter": 13, + "design_line": 163, + "clause_index": 2, + "text": "受限worker逐次解析/重定向复核目标,默认公网", + "profile": "tools", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.163.03", + "chapter": 13, + "design_line": 163, + "clause_index": 3, + "text": "tool.net.private仅放行明确私网/回环/链路本地范围,取部署/工具/证书最严限制", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [ + "tool.net.private" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:72 (tool.net.private)", + "src/msg/extensions/tools.py:73 (tool.net.private)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.163.04", + "chapter": 13, + "design_line": 163, + "clause_index": 4, + "text": "审计actor/tool_id、输入/目标摘要、状态、输出引用", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [ + "tool_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:25 (tool_id)", + "src/msg/extensions/tools.py:58 (tool_id)", + "src/msg/extensions/tools.py:117 (tool_id)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.163.05", + "chapter": 13, + "design_line": 163, + "clause_index": 5, + "text": "大内容走Transfer,重试见第12章", + "profile": "tools", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.163.06", + "chapter": 13, + "design_line": 163, + "clause_index": 6, + "text": "禁止读取触发工具、任意命令、宿主路径、Unix socket、进程参数或根访问", + "profile": "tools", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "Unix", + "socket" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/workers/sandbox_child.py:8 (socket)", + "src/msg/workers/sandbox_child.py:26 (socket)", + "src/msg/workers/sandbox_child.py:29 (socket)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.164.01", + "chapter": 13, + "design_line": 164, + "clause_index": 1, + "text": "SSH公钥只授权本站原子命令/指定Git,不提供通用shell、root会话、msgd代理或签名转发", + "profile": "ssh", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SSH", + "msgd", + "root" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/extensions/ssh.py:1 (root)", + "src/msg/extensions/ssh.py:68 (root)", + "src/msg/extensions/ssh.py:79 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.164.02", + "chapter": 13, + "design_line": 164, + "clause_index": 2, + "text": "RSS为只读订阅/聚合视图,订阅创建和外部刷新仍走执行入口", + "profile": "ssh", + "requirement_kind": "contract", + "explicit_named_literals": [ + "RSS" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.01", + "chapter": 13, + "design_line": 165, + "clause_index": 1, + "text": "msg管理凭据、自动签名、契约缓存、分页、上传及有限重试,默认compact", + "profile": "client", + "requirement_kind": "default", + "explicit_named_literals": [ + "msg" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.02", + "chapter": 13, + "design_line": 165, + "clause_index": 2, + "text": "--json 下推ReadQuery,裸--json列字段,--jq/--template仅本地处理已授权结果", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [ + "fields", + "jq", + "json", + "template" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/client.py:47 (json)", + "src/msg/client.py:50 (json)", + "src/msg/client.py:131 (json)", + "src/msg/cli.py:100 (fields)", + "src/msg/cli.py:311 (fields)", + "src/msg/cli.py:330 (fields)", + "src/msg/tui.py:226 (fields)", + "src/msg/core/models.py:442 (fields)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.03", + "chapter": 13, + "design_line": 165, + "clause_index": 3, + "text": "默认一个窗口", + "profile": "client", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.04", + "chapter": 13, + "design_line": 165, + "clause_index": 4, + "text": "--limit在总量/字节预算内续读,--page-size控单页,--paginate逐页读至结束", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [ + "limit", + "page", + "paginate", + "size" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/client.py:31 (size)", + "src/msg/client.py:34 (size)", + "src/msg/client.py:36 (size)", + "src/msg/cli.py:60 (limit)", + "src/msg/cli.py:76 (page)", + "src/msg/cli.py:77 (limit)", + "src/msg/tui.py:90 (page)", + "src/msg/tui.py:101 (page)", + "src/msg/tui.py:107 (page)", + "src/msg/transports/mcp.py:34 (page)", + "src/msg/core/models.py:38 (size)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.05", + "chapter": 13, + "design_line": 165, + "clause_index": 5, + "text": "未完返回next,不无限展开嵌套集合", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.165.06", + "chapter": 13, + "design_line": 165, + "clause_index": 6, + "text": "组合读取用msg api graphql", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [ + "api", + "graphql" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.166.01", + "chapter": 13, + "design_line": 166, + "clause_index": 1, + "text": "MCP stdio/远程Streamable HTTP共用Operation/ReadQuery/Transfer,可安装时优先本地msg MCP签名", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [ + "HTTP", + "MCP", + "ReadQuery", + "Transfer", + "stdio" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/cli.py:170 (stdio)", + "src/msg/cli.py:544 (stdio)", + "src/msg/transports/mcp.py:1 (HTTP, stdio)", + "src/msg/transports/mcp.py:4 (stdio)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.166.02", + "chapter": 13, + "design_line": 166, + "clause_index": 2, + "text": "TUI覆盖Home、Inbox/Outbox、Topics/Following、线程、Notes/Todos、Files、Groups、搜索、凭据状态", + "profile": "client", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Files", + "Following", + "Groups", + "Inbox", + "Notes", + "Outbox", + "TUI", + "Todos", + "Topics" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/cli.py:171 (Inbox)", + "src/msg/tui.py:65 (Inbox, Outbox)", + "src/msg/tui.py:209 (Inbox)", + "src/msg/tui.py:223 (Files)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D13.166.03", + "chapter": 13, + "design_line": 166, + "clause_index": 3, + "text": "禁止读取浏览器Cookie/session、直连数据库、绕执行器或额外赋权", + "profile": "client", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "session" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "no_direct_doctor_selftest_mapping_identified", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.168.01", + "chapter": 14, + "design_line": 168, + "clause_index": 1, + "text": "只抽象必要接口,隔离配置、数据、根秘密和发行规则", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.01", + "chapter": 14, + "design_line": 169, + "clause_index": 1, + "text": "Protocol按真实替换/测试/隔离边界保留,例如MetadataStore/MetadataSession、ContentStore/BlobStore、ClientTransport、ToolExecutor、NotificationSender及限定持钥环境的Signer", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "BlobStore", + "ClientTransport", + "ContentStore", + "MetadataSession", + "NotificationSender", + "Protocol", + "ToolExecutor" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/models.py:11 (Protocol)", + "src/msg/core/models.py:350 (MetadataSession)", + "src/msg/core/models.py:354 (MetadataSession)", + "src/msg/core/models.py:11 (Protocol)", + "src/msg/core/models.py:350 (MetadataSession)", + "src/msg/core/models.py:354 (MetadataSession)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.02", + "chapter": 14, + "design_line": 169, + "clause_index": 2, + "text": "不强制Authenticator/Authorizer或每个业务名词建立接口层,已有简洁有用接口无需为减数量删除", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Authorizer" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.03", + "chapter": 14, + "design_line": 169, + "clause_index": 3, + "text": "Registry、OperationExecutor、TransferService、RootAdmin保留具体实现", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "OperationExecutor", + "Registry", + "RootAdmin", + "TransferService" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:25 (Registry)", + "src/msg/plugins/features.py:5 (Registry)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.04", + "chapter": 14, + "design_line": 169, + "clause_index": 4, + "text": "默认PostgresMetadataStore/GitTextStore/LocalCAS", + "profile": "registry", + "requirement_kind": "default", + "explicit_named_literals": [ + "GitTextStore", + "LocalCAS" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.05", + "chapter": 14, + "design_line": 169, + "clause_index": 5, + "text": "存储抽象不能抹去PostgreSQL事务、唯一约束和锁语义", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.06", + "chapter": 14, + "design_line": 169, + "clause_index": 6, + "text": "加密容器/签名编码可复用,必须绑定purpose/schema version/subject/recipient/order或恢复上下文,RecoveryEnvelope、DeliveryEnvelope和一次释放秘密不互授权限", + "profile": "registry", + "requirement_kind": "contract", + "explicit_named_literals": [ + "DeliveryEnvelope", + "RecoveryEnvelope", + "order", + "recipient", + "schema", + "subject", + "version" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/core/registry.py:39 (version)", + "src/msg/core/registry.py:40 (version)", + "src/msg/core/registry.py:55 (version)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:319 (version)", + "src/msg/plugins/common.py:75 (order)", + "src/msg/plugins/common.py:126 (order)", + "src/msg/plugins/common.py:252 (schema)", + "src/msg/core/models.py:124 (version)", + "src/msg/core/models.py:147 (version)", + "src/msg/core/models.py:319 (version)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.169.07", + "chapter": 14, + "design_line": 169, + "clause_index": 7, + "text": "禁止无需求Manager/Factory/ABC、万能工作流和为了合并而弱化类型约束", + "profile": "registry", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "ABC", + "Factory" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.170.01", + "chapter": 14, + "design_line": 170, + "clause_index": 1, + "text": "RootAdmin仅本机处理init、根证书/PIN/恢复、doctor/selftest", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "PIN", + "RootAdmin", + "doctor", + "selftest" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config_contracts.py:8 (doctor)", + "src/msg/admin/config_check.py:122 (selftest)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.170.02", + "chapter": 14, + "design_line": 170, + "clause_index": 2, + "text": "适配器不绕执行器改表", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.170.03", + "chapter": 14, + "design_line": 170, + "clause_index": 3, + "text": "配置必须公布类型、范围、确定默认值并由doctor验证,未定值不标完备", + "profile": "config", + "requirement_kind": "default", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.170.04", + "chapter": 14, + "design_line": 170, + "clause_index": 4, + "text": "邮箱、成员、模板、wiki、银行角色、发行量及报价是业务事实,不另建配置副本", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "wiki" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/config_check.py:73 (wiki)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.170.05", + "chapter": 14, + "design_line": 170, + "clause_index": 5, + "text": "普通正文不硬编码", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.01", + "chapter": 14, + "design_line": 171, + "clause_index": 1, + "text": "/etc/msgd/存msgd.toml、plugins.d/、trust/root.crt", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "etc", + "msgd", + "plugins.d", + "root.crt", + "toml", + "trust" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:41 (msgd)", + "src/msg/config.py:42 (msgd)", + "src/msg/config.py:49 (msgd, toml)", + "src/msg/admin/config_check.py:36 (msgd)", + "src/msg/admin/config_check.py:37 (msgd)", + "src/msg/admin/config_check.py:38 (msgd)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.02", + "chapter": 14, + "design_line": 171, + "clause_index": 2, + "text": "mail.toml含enabled、SMTP、TLS、发件地址、认证引用", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "SMTP", + "TLS", + "mail.toml" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:204 (mail.toml)", + "src/msg/admin/config_check.py:5 (SMTP)", + "src/msg/admin/config_check.py:90 (mail.toml)", + "src/msg/admin/config_check.py:177 (SMTP)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.03", + "chapter": 14, + "design_line": 171, + "clause_index": 3, + "text": "[identity]默认credential_delivery_recovery_window=\"15m\"", + "profile": "config", + "requirement_kind": "default", + "explicit_named_literals": [ + "identity" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:107 (identity)", + "src/msg/config.py:108 (identity)", + "src/msg/config.py:110 (identity)", + "src/msg/config_contracts.py:35 (identity)", + "src/msg/admin/config_check.py:45 (identity)", + "src/msg/admin/config_check.py:51 (identity)", + "src/msg/admin/config_check.py:166 (identity)", + "src/msg/core/models.py:182 (identity)", + "src/msg/core/models.py:199 (identity)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.04", + "chapter": 14, + "design_line": 171, + "clause_index": 4, + "text": "[money]默认enabled=true、currency_id=primary、display_name/code=MSG(暂定)、scale=6、transfer_fee=0、allow_overdraft=false", + "profile": "config", + "requirement_kind": "default", + "explicit_named_literals": [ + "MSG", + "allow_overdraft", + "code", + "currency_id", + "display_name", + "false", + "money", + "primary", + "scale", + "transfer_fee", + "true" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:30 (currency_id, primary)", + "src/msg/config.py:31 (display_name)", + "src/msg/config.py:32 (code)", + "src/msg/config_contracts.py:37 (money)", + "src/msg/config_contracts.py:38 (allow_overdraft, code, currency_id, display_name, scale, transfer_fee)", + "src/msg/admin/config_check.py:52 (money)", + "src/msg/admin/config_check.py:53 (currency_id, money, primary)", + "src/msg/admin/config_check.py:54 (display_name, money)", + "src/msg/core/models.py:260 (code)", + "src/msg/core/models.py:269 (code)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.05", + "chapter": 14, + "design_line": 171, + "clause_index": 5, + "text": "凭据交付恢复窗口可配置但必须有确定上限,窗口起点为发行结果业务提交时间,不因失败重试自动续期", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.171.06", + "chapter": 14, + "design_line": 171, + "clause_index": 6, + "text": "显示名/code可改,currency_id不变,银行/发行/报价按第04章管理", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "code", + "currency_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:30 (currency_id)", + "src/msg/config.py:32 (code)", + "src/msg/config.py:131 (currency_id)", + "src/msg/config_contracts.py:38 (code, currency_id)", + "src/msg/admin/config_check.py:53 (currency_id)", + "src/msg/admin/config_check.py:55 (code)", + "src/msg/admin/config_check.py:117 (code)", + "src/msg/core/models.py:260 (code)", + "src/msg/core/models.py:269 (code)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.172.01", + "chapter": 14, + "design_line": 172, + "clause_index": 1, + "text": "/var/lib/msgd/含git/content/、git/repos/、blobs/sha256/、transfers/staging/,分别存内部历史、用户公开仓库、CAS、未seal分片", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "CAS", + "blobs", + "content", + "git", + "lib", + "msgd", + "repos", + "sha256", + "staging", + "transfers", + "var" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/storage/git.py:93 (sha256)", + "src/msg/storage/git.py:140 (sha256)", + "src/msg/storage/git.py:183 (content)", + "src/msg/storage/postgres.py:88 (transfers)", + "src/msg/storage/postgres.py:656 (content)", + "src/msg/admin/backups.py:40 (content)", + "src/msg/admin/backups.py:41 (content, sha256)", + "src/msg/admin/backups.py:42 (content)", + "src/msg/storage/legacy_resource_import.py:1 (content)", + "src/msg/storage/legacy_resource_import.py:19 (content)", + "src/msg/storage/legacy_resource_import.py:22 (sha256)", + "src/msg/core/models.py:82 (content)", + "src/msg/core/models.py:386 (content)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.172.02", + "chapter": 14, + "design_line": 172, + "clause_index": 2, + "text": "数据库仅配地址/Unix Socket、database、user、池、凭据引用,msgd不访问或配置PostgreSQL物理data_directory", + "profile": "storage", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Socket", + "Unix", + "database", + "msgd", + "user" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/storage/postgres.py:348 (user)", + "src/msg/storage/postgres.py:554 (database)", + "src/msg/admin/backups.py:183 (msgd)", + "src/msg/admin/backups.py:214 (msgd)", + "src/msg/admin/backups.py:237 (msgd)", + "src/msg/core/models.py:91 (user)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.173.01", + "chapter": 14, + "design_line": 173, + "clause_index": 1, + "text": "/var/lib/msgd-root/须root:root、0700,根加密私钥0600", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "lib", + "msgd", + "root", + "var" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/root.py:1 (root)", + "src/msg/admin/root.py:5 (root)", + "src/msg/admin/root.py:35 (root)", + "src/msg/admin/rotation.py:29 (root)", + "src/msg/admin/rotation.py:65 (root)", + "src/msg/admin/rotation.py:74 (root)", + "src/msg/security/trust_files.py:14 (root)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.173.02", + "chapter": 14, + "design_line": 173, + "clause_index": 2, + "text": "网络账号不得遍历/读取", + "profile": "root", + "requirement_kind": "prohibition", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.173.03", + "chapter": 14, + "design_line": 173, + "clause_index": 3, + "text": "根PIN/私钥/恢复材料不进资源树、Git、索引、日志或普通网络导出", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Git" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.173.04", + "chapter": 14, + "design_line": 173, + "clause_index": 4, + "text": "公开证书不授根操作权", + "profile": "root", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.174.01", + "chapter": 14, + "design_line": 174, + "clause_index": 1, + "text": "/var/cache/msgd/下search/rendered/schemas可重建", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "cache", + "msgd", + "rendered", + "schemas", + "var" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:41 (msgd)", + "src/msg/config.py:42 (msgd)", + "src/msg/config.py:49 (msgd)", + "src/msg/admin/config_check.py:36 (msgd)", + "src/msg/admin/config_check.py:37 (msgd)", + "src/msg/admin/config_check.py:38 (msgd)", + "src/msg/plugins/schemas.py:1 (schemas)", + "src/msg/plugins/schemas.py:2 (schemas)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.174.02", + "chapter": 14, + "design_line": 174, + "clause_index": 2, + "text": "/run/msgd/仅socket、pid/lock等可丢状态,不放恢复分片", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "lock", + "msgd", + "pid", + "run" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/config.py:41 (msgd)", + "src/msg/config.py:42 (msgd)", + "src/msg/config.py:49 (msgd)", + "src/msg/admin/config_check.py:36 (msgd)", + "src/msg/admin/config_check.py:37 (msgd)", + "src/msg/admin/config_check.py:38 (msgd)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.174.03", + "chapter": 14, + "design_line": 174, + "clause_index": 3, + "text": "~/.config/msg/保存客户端连接、偏好、身份引用/状态,私钥用受保护凭据存储", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "config", + "msg" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/admin/config_check.py:28 (config)", + "src/msg/admin/config_check.py:36 (config)", + "src/msg/admin/config_check.py:37 (config)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.174.04", + "chapter": 14, + "design_line": 174, + "clause_index": 4, + "text": "备份见第11章,不依赖缓存或/run", + "profile": "config", + "requirement_kind": "contract", + "explicit_named_literals": [ + "run" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.175.01", + "chapter": 14, + "design_line": 175, + "clause_index": 1, + "text": "根路径、信任锚、local_only、管理进程权限仅本机可改,system.config/插件/身份恢复不得覆盖", + "profile": "root", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "local_only", + "system.config" + ], + "registered_operations": [ + "system.config" + ], + "source_token_locations": [ + "src/msg/admin/rotation.py:149 (local_only)", + "src/msg/core/models.py:108 (local_only)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.176.01", + "chapter": 14, + "design_line": 176, + "clause_index": 1, + "text": "规则每文件一个任务主题,共用条款以Markdown/LinkSet引用", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [ + "LinkSet" + ], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.176.02", + "chapter": 14, + "design_line": 176, + "clause_index": 2, + "text": "rule_id不随移动改变,requires_rules可解析,禁止巨型全集", + "profile": "rules", + "requirement_kind": "prohibition", + "explicit_named_literals": [ + "requires_rules", + "rule_id" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/bootstrap.py:14 (rule_id)", + "src/msg/bootstrap.py:15 (requires_rules)", + "src/msg/bootstrap.py:30 (rule_id)", + "src/msg/core/registry.py:134 (requires_rules)", + "src/msg/core/registry.py:135 (rule_id)", + "src/msg/core/registry.py:136 (requires_rules, rule_id)", + "src/msg/plugins/common.py:255 (requires_rules)", + "src/msg/plugins/common.py:262 (requires_rules)", + "src/msg/hosting_runtime.py:50 (rule_id)", + "src/msg/hosting_runtime.py:51 (rule_id)", + "src/msg/hosting_runtime.py:54 (rule_id)", + "src/msg/core/models.py:357 (requires_rules)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.177.01", + "chapter": 14, + "design_line": 177, + "clause_index": 1, + "text": "发行规则资源:运行时用户、Topic admin、插件、wiki不能修改", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Topic", + "admin", + "wiki" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/bootstrap.py:1 (admin)", + "src/msg/bootstrap.py:167 (wiki)", + "src/msg/bootstrap.py:175 (admin)", + "src/msg/core/models.py:123 (admin)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.177.02", + "chapter": 14, + "design_line": 177, + "clause_index": 2, + "text": "删除/迁移须显式处理、保留历史", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [], + "registered_operations": [], + "source_token_locations": [], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + }, + { + "id": "D14.177.03", + "chapter": 14, + "design_line": 177, + "clause_index": 3, + "text": "wiki不随源码覆盖,Agent可只读取依赖规则的history/diff", + "profile": "rules", + "requirement_kind": "contract", + "explicit_named_literals": [ + "Agent", + "diff", + "wiki" + ], + "registered_operations": [], + "source_token_locations": [ + "src/msg/bootstrap.py:21 (Agent)", + "src/msg/bootstrap.py:23 (Agent)", + "src/msg/bootstrap.py:167 (wiki)" + ], + "mapping_status": "source_and_test_navigation_mapped_not_assertion_verified", + "diagnostic_coverage": "related_feature_only", + "test_evidence": "See bound run ledger and profile test definitions; no exact-head or every-assertion coverage inferred.", + "operation_test_occurrences": [] + } + ], + "named_fields": [ + { + "id": "F021.identity.subject_id", + "design_line": 21, + "container": "identity", + "field_or_design_member": "subject_id", + "profile": "identity", + "parent_obligations": [ + "D03.021.01", + "D03.021.02", + "D03.021.03", + "D03.021.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:21", + "src/msg/security/authentication.py:12", + "src/msg/client_tokens.py:75", + "src/msg/core/models.py:122" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F021.identity.user_id", + "design_line": 21, + "container": "identity", + "field_or_design_member": "user_id", + "profile": "identity", + "parent_obligations": [ + "D03.021.01", + "D03.021.02", + "D03.021.03", + "D03.021.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Subject.resource_id / Resource.id", + "source_refs": [ + "src/msg/core/models.py:103" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "user_id denotes stable subject identity, not a second ID; handle is mutable Resource.name.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F021.identity.actor", + "design_line": 21, + "container": "identity", + "field_or_design_member": "actor", + "profile": "identity", + "parent_obligations": [ + "D03.021.01", + "D03.021.02", + "D03.021.03", + "D03.021.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:140", + "src/msg/security/authentication.py:55", + "src/msg/core/models.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F021.identity.subject", + "design_line": 21, + "container": "identity", + "field_or_design_member": "subject", + "profile": "identity", + "parent_obligations": [ + "D03.021.01", + "D03.021.02", + "D03.021.03", + "D03.021.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:53", + "src/msg/security/authentication.py:53", + "src/msg/client_tokens.py:86", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F021.identity.author", + "design_line": 21, + "container": "identity", + "field_or_design_member": "author", + "profile": "identity", + "parent_obligations": [ + "D03.021.01", + "D03.021.02", + "D03.021.03", + "D03.021.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:86" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F024.token.subject", + "design_line": 24, + "container": "token", + "field_or_design_member": "subject", + "profile": "delivery", + "parent_obligations": [ + "D03.024.01", + "D03.024.02", + "D03.024.03", + "D03.024.04", + "D03.024.05", + "D03.024.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/security/token_delivery.py:43", + "src/msg/plugins/identity.py:53", + "src/msg/client.py:71", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F024.token.scope", + "design_line": 24, + "container": "token", + "field_or_design_member": "scope", + "profile": "delivery", + "parent_obligations": [ + "D03.024.01", + "D03.024.02", + "D03.024.03", + "D03.024.04", + "D03.024.05", + "D03.024.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:162", + "src/msg/core/models.py:148" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F024.token.operations", + "design_line": 24, + "container": "token", + "field_or_design_member": "operations", + "profile": "delivery", + "parent_obligations": [ + "D03.024.01", + "D03.024.02", + "D03.024.03", + "D03.024.04", + "D03.024.05", + "D03.024.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:162", + "src/msg/client.py:679", + "src/msg/core/models.py:149" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F024.token.expiry", + "design_line": 24, + "container": "token", + "field_or_design_member": "expiry", + "profile": "delivery", + "parent_obligations": [ + "D03.024.01", + "D03.024.02", + "D03.024.03", + "D03.024.04", + "D03.024.05", + "D03.024.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/client.py:326" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F024.token.constraints", + "design_line": 24, + "container": "token", + "field_or_design_member": "constraints", + "profile": "delivery", + "parent_obligations": [ + "D03.024.01", + "D03.024.02", + "D03.024.03", + "D03.024.04", + "D03.024.05", + "D03.024.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/identity.py:163", + "src/msg/core/models.py:150" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F027.RecoveryCustodian.custodian_id", + "design_line": 27, + "container": "RecoveryCustodian", + "field_or_design_member": "custodian_id", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.027.01", + "D03.027.02", + "D03.027.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "RecoveryCustodian.id", + "source_refs": [ + "src/msg/config.py:18" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Configured id is the stable custodian reference; recipient/fingerprint separately bind the age key.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F027.RecoveryCustodian.name", + "design_line": 27, + "container": "RecoveryCustodian", + "field_or_design_member": "name", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.027.01", + "D03.027.02", + "D03.027.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:73", + "src/msg/client_custodial.py:19", + "src/msg/core/models.py:47" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F027.RecoveryCustodian.recipient", + "design_line": 27, + "container": "RecoveryCustodian", + "field_or_design_member": "recipient", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.027.01", + "D03.027.02", + "D03.027.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:98", + "src/msg/client_custodial.py:57" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F027.RecoveryCustodian.fingerprint", + "design_line": 27, + "container": "RecoveryCustodian", + "field_or_design_member": "fingerprint", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.027.01", + "D03.027.02", + "D03.027.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:248" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F027.RecoveryCustodian.policy", + "design_line": 27, + "container": "RecoveryCustodian", + "field_or_design_member": "policy", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.027.01", + "D03.027.02", + "D03.027.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:223" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.owner_subject", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "owner_subject", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:321" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.ciphertext_ref", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "ciphertext_ref", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:53", + "src/msg/client_custodial.py:200" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.recipient_fingerprint", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "recipient_fingerprint", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "recipient_fingerprints[] + RecoveryPolicy recipients fingerprint/custodian_ref", + "source_refs": [ + "src/msg/plugins/recovery.py:275" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Plural because design28 permits OR multi-recipient age; caller fingerprints must be a subset of the current policy, not new authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F028.RecoveryEnvelope.custodian_ref", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "custodian_ref", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:228" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.created_at", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "created_at", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:100", + "src/msg/core/models.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.purpose", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "purpose", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:188", + "src/msg/security/crypto.py:27", + "src/msg/client_custodial.py:70" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F028.RecoveryEnvelope.instructions_ref", + "design_line": 28, + "container": "RecoveryEnvelope", + "field_or_design_member": "instructions_ref", + "profile": "recovery_envelope", + "parent_obligations": [ + "D03.028.01", + "D03.028.02", + "D03.028.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/recovery.py:278" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.old", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "old", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:85", + "src/msg/client_custodial.py:139", + "src/msg/admin/backup_retirement.py:58" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.ResourceRef", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "ResourceRef", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:9", + "src/msg/admin/backup_retirement.py:17", + "src/msg/core/models.py:30" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.Revision", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "Revision", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/admin/custodial_check.py:36", + "src/msg/core/models.py:77" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.key_id", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "key_id", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:21", + "src/msg/client_custodial.py:139", + "src/msg/core/models.py:71" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.digest", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "digest", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/custodial_lifecycle.py:7", + "src/msg/client_custodial.py:10", + "src/msg/admin/custodial_check.py:9", + "src/msg/admin/backup_retirement.py:15", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F029.rewrap_mapping.new_ciphertext_ref", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "new_ciphertext_ref", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id", + "source_refs": [ + "src/msg/plugins/recovery.py:98" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F029.rewrap_mapping.new_digest", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "new_digest", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id", + "source_refs": [ + "src/msg/plugins/recovery.py:98" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F029.rewrap_mapping.recipient_key", + "design_line": 29, + "container": "rewrap mapping", + "field_or_design_member": "recipient_key", + "profile": "custodial", + "parent_obligations": [ + "D03.029.01", + "D03.029.02", + "D03.029.03", + "D03.029.04", + "D03.029.05", + "D03.029.06", + "D03.029.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id", + "source_refs": [ + "src/msg/plugins/recovery.py:98" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F032.AchievementGrant.id", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "id", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "grant JSON id / achievement_grants.id", + "source_refs": [ + "src/msg/plugins/achievements.py:145" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Same explicit id, located in plugin runtime JSON rather than a model dataclass. Ownership and id match rechecked.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F032.AchievementGrant.subject_id", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "subject_id", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:118", + "src/msg/admin/honor_check.py:69", + "src/msg/core/models.py:122" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.achievement_id", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "achievement_id", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:125", + "src/msg/admin/honor_check.py:21" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.spec_version", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "spec_version", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:126", + "src/msg/admin/honor_check.py:21" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.issuer", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "issuer", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:126", + "src/msg/admin/honor_check.py:42" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.issued_at", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "issued_at", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:126" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.claim", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "claim", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:25", + "src/msg/admin/honor_check.py:42" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.auth_method", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "auth_method", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:126" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.evidence_digest", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "evidence_digest", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:127" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.automatic", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "automatic", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:127" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.revoked_at", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "revoked_at", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:127", + "src/msg/core/models.py:170" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F032.AchievementGrant.proof", + "design_line": 32, + "container": "AchievementGrant", + "field_or_design_member": "proof", + "profile": "honors", + "parent_obligations": [ + "D03.032.01", + "D03.032.02", + "D03.032.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:285", + "src/msg/core/models.py:253" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F039.challenge_signature.subject_id", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "subject_id", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:118", + "src/msg/admin/honor_check.py:69", + "src/msg/core/models.py:122" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F039.challenge_signature.challenge_id", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "challenge_id", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:76", + "src/msg/admin/honor_check.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F039.challenge_signature.question_digest", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "question_digest", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:72", + "src/msg/admin/honor_check.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F039.challenge_signature.answer", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "answer", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:37", + "src/msg/admin/honor_check.py:28" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F039.challenge_signature.R4_result", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "R4_result", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "r4_result and final_statement in ceremony_digest", + "source_refs": [ + "src/msg/plugins/achievements.py:115" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "R5 constant and answered R1–R4 rounds are bound to subject/challenge digest; uppercase inventory labels were descriptive, not wire fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F039.challenge_signature.R5_statement", + "design_line": 39, + "container": "challenge signature", + "field_or_design_member": "R5_statement", + "profile": "honors", + "parent_obligations": [ + "D03.039.01", + "D03.039.02", + "D03.039.03", + "D03.039.04", + "D03.039.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "r4_result and final_statement in ceremony_digest", + "source_refs": [ + "src/msg/plugins/achievements.py:115" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "R5 constant and answered R1–R4 rounds are bound to subject/challenge digest; uppercase inventory labels were descriptive, not wire fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F040.round_evidence.round", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "round", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:64", + "src/msg/admin/honor_check.py:76" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F040.round_evidence.question_digest", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "question_digest", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:72", + "src/msg/admin/honor_check.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F040.round_evidence.answer", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "answer", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:37", + "src/msg/admin/honor_check.py:28" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F040.round_evidence.answered_at", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "answered_at", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:284" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F040.round_evidence.auth_method", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "auth_method", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:126" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F040.round_evidence.proof", + "design_line": 40, + "container": "round evidence", + "field_or_design_member": "proof", + "profile": "honors", + "parent_obligations": [ + "D03.040.01", + "D03.040.02", + "D03.040.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/achievements.py:285", + "src/msg/core/models.py:253" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F045.CapabilitySpec_projection.name", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "name", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:330::CapabilitySpec" + ], + "source_token_locations": [ + "src/msg/security/capabilities.py:81", + "src/msg/plugins/identity.py:66", + "src/msg/admin/diagnostics.py:30", + "src/msg/core/models.py:47" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F045.CapabilitySpec_projection.schema", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "schema", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/security/capabilities.py:71", + "src/msg/plugins/identity.py:172", + "src/msg/admin/diagnostics.py:57", + "src/msg/core/models.py:484" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F045.CapabilitySpec_projection.version", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "version", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:331::CapabilitySpec" + ], + "source_token_locations": [ + "src/msg/security/certificates.py:56", + "src/msg/security/capabilities.py:81", + "src/msg/plugins/identity.py:82", + "src/msg/admin/diagnostics.py:110", + "src/msg/core/models.py:124" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F045.CapabilitySpec_projection.scope", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "scope", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/security/certificates.py:57", + "src/msg/security/capabilities.py:138", + "src/msg/plugins/identity.py:162", + "src/msg/admin/diagnostics.py:129", + "src/msg/core/models.py:148" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F045.CapabilitySpec_projection.allowed_operations", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "allowed_operations", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "CapabilitySpec.operations", + "source_refs": [ + "src/msg/core/models.py:329" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Explicit finite operations set; discovery serializes the installed spec. No wildcard grant expansion implied by naming difference.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F045.CapabilitySpec_projection.delegatable", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "delegatable", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:335::CapabilitySpec" + ], + "source_token_locations": [ + "src/msg/security/capabilities.py:95", + "src/msg/core/models.py:335" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F045.CapabilitySpec_projection.ca_only", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "ca_only", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:336::CapabilitySpec" + ], + "source_token_locations": [ + "src/msg/security/certificates.py:119", + "src/msg/security/capabilities.py:95", + "src/msg/core/models.py:336" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F045.CapabilitySpec_projection.digest", + "design_line": 45, + "container": "CapabilitySpec projection", + "field_or_design_member": "digest", + "profile": "ca", + "parent_obligations": [ + "D04.045.01", + "D04.045.02", + "D04.045.03", + "D04.045.04", + "D04.045.05", + "D04.045.06", + "D04.045.07", + "D04.045.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/security/certificates.py:8", + "src/msg/plugins/identity.py:10", + "src/msg/admin/diagnostics.py:19", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Resource.id", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "id", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:44::Resource", + "src/msg/core/models.py:44::Resource" + ], + "source_token_locations": [], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.type", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "type", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:45::Resource", + "src/msg/core/models.py:45::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:17", + "src/msg/plugins/content.py:46", + "src/msg/storage/postgres.py:34", + "src/msg/security/authorization.py:191", + "src/msg/core/models.py:17" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.version", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "version", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:124", + "src/msg/plugins/content.py:78", + "src/msg/storage/postgres.py:31", + "src/msg/core/models.py:124" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Resource.name", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "name", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:47::Resource", + "src/msg/core/models.py:47::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:47", + "src/msg/plugins/content.py:120", + "src/msg/storage/postgres.py:34", + "src/msg/security/authorization.py:212", + "src/msg/core/models.py:47" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.parent", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "parent", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:48::Resource", + "src/msg/core/models.py:48::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:48", + "src/msg/plugins/content.py:62", + "src/msg/storage/postgres.py:35", + "src/msg/security/authorization.py:212", + "src/msg/core/models.py:48" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.owner", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "owner", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:49::Resource", + "src/msg/core/models.py:49::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:49", + "src/msg/plugins/content.py:159", + "src/msg/storage/postgres.py:36", + "src/msg/security/authorization.py:1", + "src/msg/core/models.py:49" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.group", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "group", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:50::Resource", + "src/msg/core/models.py:50::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:50", + "src/msg/plugins/content.py:745", + "src/msg/storage/postgres.py:348", + "src/msg/security/authorization.py:1", + "src/msg/core/models.py:50" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.mode", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "mode", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:51::Resource", + "src/msg/core/models.py:51::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:51", + "src/msg/plugins/content.py:158", + "src/msg/storage/postgres.py:36", + "src/msg/security/authorization.py:1", + "src/msg/core/models.py:51" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.generation", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "generation", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:52::Resource", + "src/msg/core/models.py:52::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:52", + "src/msg/plugins/content.py:229", + "src/msg/storage/postgres.py:37", + "src/msg/core/models.py:52" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.revision", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "revision", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:53::Resource", + "src/msg/core/models.py:53::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:32", + "src/msg/plugins/content.py:56", + "src/msg/storage/postgres.py:37", + "src/msg/core/models.py:32" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.state", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "state", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:54::Resource", + "src/msg/core/models.py:54::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:54", + "src/msg/plugins/content.py:57", + "src/msg/storage/postgres.py:37", + "src/msg/security/authorization.py:117", + "src/msg/core/models.py:54" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.created_at", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "created_at", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:55::Resource", + "src/msg/core/models.py:55::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:55", + "src/msg/plugins/content.py:399", + "src/msg/storage/postgres.py:38", + "src/msg/security/authorization.py:91", + "src/msg/core/models.py:55" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.created_by", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "created_by", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:56::Resource", + "src/msg/core/models.py:56::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:56", + "src/msg/core/models.py:56" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.modified_at", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "modified_at", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:57::Resource", + "src/msg/core/models.py:57::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:57", + "src/msg/plugins/content.py:519", + "src/msg/storage/postgres.py:38", + "src/msg/core/models.py:57" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Resource.modified_by", + "design_line": 56, + "container": "Resource", + "field_or_design_member": "modified_by", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:58::Resource", + "src/msg/core/models.py:58::Resource" + ], + "source_token_locations": [ + "src/msg/core/models.py:58", + "src/msg/plugins/content.py:519", + "src/msg/core/models.py:58" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.content", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "content", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:82::Revision", + "src/msg/core/models.py:82::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:82", + "src/msg/plugins/content.py:86", + "src/msg/storage/postgres.py:656", + "src/msg/security/authorization.py:253", + "src/msg/core/models.py:82" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.values", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "values", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/content.py:62", + "src/msg/storage/postgres.py:103" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Revision.BlobRef", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "BlobRef", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:36", + "src/msg/plugins/content.py:9", + "src/msg/core/models.py:36" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Revision.Relation", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "Relation", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:63", + "src/msg/plugins/content.py:9", + "src/msg/core/models.py:63" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Revision.parents", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "parents", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:81::Revision", + "src/msg/core/models.py:81::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:81", + "src/msg/plugins/content.py:236", + "src/msg/core/models.py:81" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.author", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "author", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:86::Revision", + "src/msg/core/models.py:86::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:86", + "src/msg/plugins/content.py:547", + "src/msg/storage/postgres.py:259", + "src/msg/core/models.py:86" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.time", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "time", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:393", + "src/msg/plugins/content.py:4", + "src/msg/core/models.py:393" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Revision.signature", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "signature", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:89::Revision", + "src/msg/core/models.py:89::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:89", + "src/msg/plugins/content.py:294", + "src/msg/storage/postgres.py:272", + "src/msg/core/models.py:89" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.digest", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "digest", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:37", + "src/msg/plugins/content.py:7", + "src/msg/storage/postgres.py:86", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F056.Revision.change_note", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "change_note", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:90::Revision", + "src/msg/core/models.py:90::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:90", + "src/msg/plugins/content.py:627", + "src/msg/core/models.py:90" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.source_kind", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "source_kind", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:91::Revision", + "src/msg/core/models.py:91::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:91", + "src/msg/plugins/content.py:637", + "src/msg/storage/postgres.py:405", + "src/msg/core/models.py:91" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.source_version", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "source_version", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:93::Revision", + "src/msg/core/models.py:93::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:93", + "src/msg/plugins/content.py:638", + "src/msg/storage/postgres.py:406", + "src/msg/core/models.py:93" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F056.Revision.source_digest", + "design_line": 56, + "container": "Revision", + "field_or_design_member": "source_digest", + "profile": "resources", + "parent_obligations": [ + "D05.056.01", + "D05.056.02", + "D05.056.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:94::Revision", + "src/msg/core/models.py:94::Revision" + ], + "source_token_locations": [ + "src/msg/core/models.py:94", + "src/msg/plugins/content.py:638", + "src/msg/storage/postgres.py:406", + "src/msg/core/models.py:94" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F057.ResourceRef.resource_id", + "design_line": 57, + "container": "ResourceRef", + "field_or_design_member": "resource_id", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:80", + "src/msg/plugins/content.py:121", + "src/msg/storage/postgres.py:48", + "src/msg/security/authorization.py:90", + "src/msg/core/models.py:80" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F057.ResourceRef.revision_id", + "design_line": 57, + "container": "ResourceRef", + "field_or_design_member": "revision_id", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/content.py:122", + "src/msg/storage/postgres.py:59" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F057.BlobRef.digest", + "design_line": 57, + "container": "BlobRef", + "field_or_design_member": "digest", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:37::BlobRef", + "src/msg/core/models.py:37::BlobRef" + ], + "source_token_locations": [ + "src/msg/core/models.py:37", + "src/msg/plugins/content.py:7", + "src/msg/storage/postgres.py:86", + "src/msg/core/models.py:37" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F057.BlobRef.size", + "design_line": 57, + "container": "BlobRef", + "field_or_design_member": "size", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:38::BlobRef", + "src/msg/core/models.py:38::BlobRef" + ], + "source_token_locations": [ + "src/msg/core/models.py:38", + "src/msg/plugins/content.py:220", + "src/msg/core/models.py:38" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F057.BlobRef.media_type", + "design_line": 57, + "container": "BlobRef", + "field_or_design_member": "media_type", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:39::BlobRef", + "src/msg/core/models.py:39::BlobRef" + ], + "source_token_locations": [ + "src/msg/core/models.py:39", + "src/msg/plugins/content.py:121", + "src/msg/core/models.py:39" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F057.BlobRef.backend", + "design_line": 57, + "container": "BlobRef", + "field_or_design_member": "backend", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "BlobRef.digest → validated sha256 key → private index {kind:git|binary, oid?, size}", + "source_refs": [ + "src/msg/storage/git.py:264" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Backend/key are private storage indirection, not exposed in signed/public BlobRef. Design136/86 prohibit backend disclosure/direct CAS links; no need to add backend to existing signed BlobRef.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F057.BlobRef.key", + "design_line": 57, + "container": "BlobRef", + "field_or_design_member": "key", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/storage/postgres.py:322" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F057.Relation.name", + "design_line": 57, + "container": "Relation", + "field_or_design_member": "name", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:47", + "src/msg/plugins/content.py:120", + "src/msg/storage/postgres.py:34", + "src/msg/security/authorization.py:212", + "src/msg/core/models.py:47" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F057.Relation.ResourceRef", + "design_line": 57, + "container": "Relation", + "field_or_design_member": "ResourceRef", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:30", + "src/msg/plugins/content.py:9", + "src/msg/security/authorization.py:6", + "src/msg/core/models.py:30" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F057.Page.items", + "design_line": 57, + "container": "Page", + "field_or_design_member": "items", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:100::Page", + "src/msg/core/models.py:100::Page" + ], + "source_token_locations": [ + "src/msg/core/models.py:100", + "src/msg/plugins/content.py:110", + "src/msg/core/models.py:100" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F057.Page.next", + "design_line": 57, + "container": "Page", + "field_or_design_member": "next", + "profile": "resources", + "parent_obligations": [ + "D05.057.01", + "D05.057.02", + "D05.057.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/content.py:495", + "src/msg/security/authorization.py:197" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F061.CurrencySpec.currency_id", + "design_line": 61, + "container": "CurrencySpec", + "field_or_design_member": "currency_id", + "profile": "money", + "parent_obligations": [ + "D05.061.01", + "D05.061.02", + "D05.061.03", + "D05.061.04", + "D05.061.05", + "D05.061.06", + "D05.061.07", + "D05.061.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:42", + "src/msg/admin/money.py:183", + "src/msg/market/ledger.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F061.CurrencySpec.display_name", + "design_line": 61, + "container": "CurrencySpec", + "field_or_design_member": "display_name", + "profile": "money", + "parent_obligations": [ + "D05.061.01", + "D05.061.02", + "D05.061.03", + "D05.061.04", + "D05.061.05", + "D05.061.06", + "D05.061.07", + "D05.061.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:43" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F061.CurrencySpec.code", + "design_line": 61, + "container": "CurrencySpec", + "field_or_design_member": "code", + "profile": "money", + "parent_obligations": [ + "D05.061.01", + "D05.061.02", + "D05.061.03", + "D05.061.04", + "D05.061.05", + "D05.061.06", + "D05.061.07", + "D05.061.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:44", + "src/msg/market/ledger.py:23", + "src/msg/core/models.py:260", + "src/msg/core/models.py:260" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F061.CurrencySpec.scale", + "design_line": 61, + "container": "CurrencySpec", + "field_or_design_member": "scale", + "profile": "money", + "parent_obligations": [ + "D05.061.01", + "D05.061.02", + "D05.061.03", + "D05.061.04", + "D05.061.05", + "D05.061.06", + "D05.061.07", + "D05.061.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:44", + "src/msg/market/ledger.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F061.CurrencySpec.minor_units", + "design_line": 61, + "container": "CurrencySpec", + "field_or_design_member": "minor_units", + "profile": "money", + "parent_obligations": [ + "D05.061.01", + "D05.061.02", + "D05.061.03", + "D05.061.04", + "D05.061.05", + "D05.061.06", + "D05.061.07", + "D05.061.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "integer amount_minor, SCALE / MAX_MINOR", + "source_refs": [ + "src/msg/market/ledger.py:49" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "minor_units is the amount unit/representation, not a second CurrencySpec balance. checked_amount rejects bool/non-int/nonpositive/out-of-range.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F062.LedgerTransaction.id", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "id", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "money_ledger.id; signed receipt body.transaction_id", + "source_refs": [ + "src/msg/market/ledger.py:116" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Single generated transaction identifier; sequence is separate and receipt signatures/legacy id bytes are preserved.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F062.LedgerTransaction.kind", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "kind", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:67", + "src/msg/admin/money.py:142", + "src/msg/market/ledger.py:63", + "src/msg/core/models.py:105", + "src/msg/core/models.py:105" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.currency_id", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "currency_id", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:42", + "src/msg/admin/money.py:183", + "src/msg/market/ledger.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.amount_minor", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "amount_minor", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:67", + "src/msg/admin/money.py:118", + "src/msg/market/ledger.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.debit_account", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "debit_account", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:67", + "src/msg/market/ledger.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.credit_account", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "credit_account", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:67", + "src/msg/market/ledger.py:57" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.actor", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "actor", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:99", + "src/msg/admin/money.py:107", + "src/msg/market/ledger.py:20", + "src/msg/core/models.py:84", + "src/msg/core/models.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.request_id", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "request_id", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:100", + "src/msg/admin/money.py:68", + "src/msg/market/ledger.py:20", + "src/msg/core/models.py:212", + "src/msg/core/models.py:212" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.reference", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "reference", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:68", + "src/msg/market/ledger.py:99" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.committed_at", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "committed_at", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:68", + "src/msg/market/ledger.py:121", + "src/msg/core/models.py:287", + "src/msg/core/models.py:287" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F062.LedgerTransaction.receipt", + "design_line": 62, + "container": "LedgerTransaction", + "field_or_design_member": "receipt", + "profile": "money", + "parent_obligations": [ + "D05.062.01", + "D05.062.02", + "D05.062.03", + "D05.062.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:68", + "src/msg/admin/money.py:166", + "src/msg/market/ledger.py:124", + "src/msg/core/models.py:289", + "src/msg/core/models.py:289" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F063.BankRole.subject_id", + "design_line": 63, + "container": "BankRole", + "field_or_design_member": "subject_id", + "profile": "money", + "parent_obligations": [ + "D05.063.01", + "D05.063.02", + "D05.063.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:49", + "src/msg/admin/money.py:119", + "src/msg/market/ledger.py:39", + "src/msg/core/models.py:122", + "src/msg/core/models.py:122" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F063.BankRole.status", + "design_line": 63, + "container": "BankRole", + "field_or_design_member": "status", + "profile": "money", + "parent_obligations": [ + "D05.063.01", + "D05.063.02", + "D05.063.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:49", + "src/msg/admin/money.py:146", + "src/msg/core/models.py:125", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F063.BankRole.granted_at", + "design_line": 63, + "container": "BankRole", + "field_or_design_member": "granted_at", + "profile": "money", + "parent_obligations": [ + "D05.063.01", + "D05.063.02", + "D05.063.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:49", + "src/msg/admin/money.py:159" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F063.BankRole.granted_by", + "design_line": 63, + "container": "BankRole", + "field_or_design_member": "granted_by", + "profile": "money", + "parent_obligations": [ + "D05.063.01", + "D05.063.02", + "D05.063.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/admin/money.py:159" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F063.BankRole.revoked_at", + "design_line": 63, + "container": "BankRole", + "field_or_design_member": "revoked_at", + "profile": "money", + "parent_obligations": [ + "D05.063.01", + "D05.063.02", + "D05.063.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/money.py:49", + "src/msg/admin/money.py:159", + "src/msg/core/models.py:170", + "src/msg/core/models.py:170" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.offer_id", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "offer_id", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:32", + "src/msg/market/compatibility.py:16", + "src/msg/market/entitlement_orders.py:16" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.resource_kind", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "resource_kind", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:43", + "src/msg/market/compatibility.py:22" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.unit", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "unit", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:56", + "src/msg/market/compatibility.py:20" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.price_minor", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "price_minor", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:56", + "src/msg/market/compatibility.py:19", + "src/msg/market/entitlement_orders.py:20" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.min", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "min", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "min_quantity / max_quantity / duration_seconds", + "source_refs": [ + "src/msg/market/offer_resources.py:14" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F064.ServerOffer_compatibility.max", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "max", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "min_quantity / max_quantity / duration_seconds", + "source_refs": [ + "src/msg/market/offer_resources.py:14" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F064.ServerOffer_compatibility.entitlement_kind", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "entitlement_kind", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:32", + "src/msg/market/compatibility.py:23" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F064.ServerOffer_compatibility.duration", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "duration", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "min_quantity / max_quantity / duration_seconds", + "source_refs": [ + "src/msg/market/offer_resources.py:14" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F064.ServerOffer_compatibility.enabled", + "design_line": 64, + "container": "ServerOffer compatibility", + "field_or_design_member": "enabled", + "profile": "entitlement", + "parent_obligations": [ + "D05.064.01", + "D05.064.02", + "D05.064.03", + "D05.064.04", + "D05.064.05", + "D05.064.06", + "D05.064.07", + "D05.064.08", + "D05.064.09", + "D05.064.10", + "D05.064.11" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/offers.py:121", + "src/msg/core/models.py:492" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.listing_id", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "listing_id", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:22", + "src/msg/plugins/orders.py:41", + "src/msg/market/managed_delivery.py:61", + "src/msg/market/compatibility.py:16" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.seller", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "seller", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:21", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:62", + "src/msg/market/compatibility.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.item_kind", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "item_kind", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:39", + "src/msg/plugins/orders.py:69", + "src/msg/market/compatibility.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.price_minor", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "price_minor", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:31", + "src/msg/plugins/orders.py:86", + "src/msg/market/compatibility.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.currency_id", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "currency_id", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:30", + "src/msg/plugins/orders.py:43", + "src/msg/market/managed_delivery.py:109", + "src/msg/market/compatibility.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.quantity", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "quantity", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:33", + "src/msg/plugins/orders.py:39", + "src/msg/market/compatibility.py:40" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.delivery_mode", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "delivery_mode", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:38", + "src/msg/plugins/orders.py:68", + "src/msg/market/managed_delivery.py:69" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.package_ref", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "package_ref", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:39", + "src/msg/plugins/orders.py:90" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.escrow_policy", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "escrow_policy", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:72", + "src/msg/plugins/orders.py:71", + "src/msg/market/compatibility.py:25" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.dispute_policy", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "dispute_policy", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:73", + "src/msg/plugins/orders.py:71" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.terms_revision", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "terms_revision", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:24" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.expires_at", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "expires_at", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:66", + "src/msg/market/compatibility.py:89", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_sale.state", + "design_line": 65, + "container": "Listing sale", + "field_or_design_member": "state", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:19", + "src/msg/market/compatibility.py:18", + "src/msg/core/models.py:54" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_bounty.reward_minor", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "reward_minor", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.currency_id", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "currency_id", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:30", + "src/msg/plugins/orders.py:43", + "src/msg/market/managed_delivery.py:109", + "src/msg/market/compatibility.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_bounty.budget_minor", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "budget_minor", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:141" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_bounty.max_claims", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "max_claims", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.claim_limit_per_subject", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "claim_limit_per_subject", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.verifier_id", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "verifier_id", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.verifier_version", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "verifier_version", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.eligibility", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "eligibility", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named fields in bounty_listings projection and signed Listing body", + "source_refs": [ + "src/msg/plugins/bounty.py:47" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F065.Listing_bounty.terms_revision", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "terms_revision", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:24" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_bounty.expires_at", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "expires_at", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:66", + "src/msg/market/compatibility.py:89", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F065.Listing_bounty.state", + "design_line": 65, + "container": "Listing bounty", + "field_or_design_member": "state", + "profile": "sale", + "parent_obligations": [ + "D05.065.01", + "D05.065.02", + "D05.065.03", + "D05.065.04", + "D05.065.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:19", + "src/msg/market/compatibility.py:18", + "src/msg/core/models.py:54" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.seller", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "seller", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:21", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:62", + "src/msg/market/compatibility.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.listing_id", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "listing_id", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:22", + "src/msg/plugins/orders.py:41", + "src/msg/market/managed_delivery.py:61", + "src/msg/market/compatibility.py:16" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.revision", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "revision", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:22", + "src/msg/plugins/orders.py:76", + "src/msg/market/managed_delivery.py:67", + "src/msg/core/models.py:32" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.kind", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "kind", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/plugins/orders.py:117", + "src/msg/market/managed_delivery.py:17", + "src/msg/market/compatibility.py:42", + "src/msg/core/models.py:105" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.manifest", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "manifest", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/market/managed_delivery.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.payload_refs", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "payload_refs", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/market/managed_delivery.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.digest", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "digest", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:4", + "src/msg/plugins/orders.py:13", + "src/msg/market/managed_delivery.py:7", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.total_size", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "total_size", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/market/managed_delivery.py:69" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.delivery_mode", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "delivery_mode", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:38", + "src/msg/plugins/orders.py:68", + "src/msg/market/managed_delivery.py:69" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.ConsignmentPackage.deposited_at", + "design_line": 66, + "container": "ConsignmentPackage", + "field_or_design_member": "deposited_at", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:50" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F066.DeliveryEnvelope.recipient_key_id", + "design_line": 66, + "container": "DeliveryEnvelope", + "field_or_design_member": "recipient_key_id", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id", + "source_refs": [ + "src/msg/market/delivery.py:273" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F066.DeliveryEnvelope.fingerprint", + "design_line": 66, + "container": "DeliveryEnvelope", + "field_or_design_member": "fingerprint", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id", + "source_refs": [ + "src/msg/market/delivery.py:273" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F066.DeliveryEnvelope.ciphertext_ref", + "design_line": 66, + "container": "DeliveryEnvelope", + "field_or_design_member": "ciphertext_ref", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id", + "source_refs": [ + "src/msg/market/delivery.py:273" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F066.DeliveryEnvelope.delivery_digest", + "design_line": 66, + "container": "DeliveryEnvelope", + "field_or_design_member": "delivery_digest", + "profile": "sale", + "parent_obligations": [ + "D05.066.01", + "D05.066.02", + "D05.066.03", + "D05.066.04", + "D05.066.05", + "D05.066.06", + "D05.066.07", + "D05.066.08" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.listing_id", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "listing_id", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:47", + "src/msg/admin/market_check.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.claimant", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "claimant", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:76", + "src/msg/admin/market_check.py:262" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.challenge_id", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "challenge_id", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:237", + "src/msg/admin/market_check.py:86" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.proof_digest", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "proof_digest", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:301" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.status", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "status", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:78", + "src/msg/admin/market_check.py:77", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.reward_minor", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "reward_minor", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:48", + "src/msg/admin/market_check.py:83" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.transaction_id", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "transaction_id", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:307", + "src/msg/admin/market_check.py:213" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F068.BountyClaim.claimed_at", + "design_line": 68, + "container": "BountyClaim", + "field_or_design_member": "claimed_at", + "profile": "bounty", + "parent_obligations": [ + "D05.068.01", + "D05.068.02", + "D05.068.03", + "D05.068.04", + "D05.068.05", + "D05.068.06", + "D05.068.07" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/bounty.py:311" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.buyer", + "design_line": 69, + "container": "Order", + "field_or_design_member": "buyer", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:1", + "src/msg/market/managed_delivery.py:35", + "src/msg/market/compatibility.py:71" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.seller", + "design_line": 69, + "container": "Order", + "field_or_design_member": "seller", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:21", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:62", + "src/msg/market/compatibility.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.listing_id", + "design_line": 69, + "container": "Order", + "field_or_design_member": "listing_id", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:22", + "src/msg/plugins/orders.py:41", + "src/msg/market/managed_delivery.py:61", + "src/msg/market/compatibility.py:16" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.listing_revision", + "design_line": 69, + "container": "Order", + "field_or_design_member": "listing_revision", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:22", + "src/msg/plugins/orders.py:42", + "src/msg/market/managed_delivery.py:67", + "src/msg/market/compatibility.py:16" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.package_revision", + "design_line": 69, + "container": "Order", + "field_or_design_member": "package_revision", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:208", + "src/msg/plugins/orders.py:91", + "src/msg/market/managed_delivery.py:58" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.quantity", + "design_line": 69, + "container": "Order", + "field_or_design_member": "quantity", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:33", + "src/msg/plugins/orders.py:39", + "src/msg/market/compatibility.py:40" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.unit_price", + "design_line": 69, + "container": "Order", + "field_or_design_member": "unit_price", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "store_orders unit_price_minor/total_price_minor and delivery_target snapshot", + "source_refs": [ + "src/msg/market/orders.py:104" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F069.Order.total_price", + "design_line": 69, + "container": "Order", + "field_or_design_member": "total_price", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "store_orders unit_price_minor/total_price_minor and delivery_target snapshot", + "source_refs": [ + "src/msg/market/orders.py:104" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F069.Order.currency_id", + "design_line": 69, + "container": "Order", + "field_or_design_member": "currency_id", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:30", + "src/msg/plugins/orders.py:43", + "src/msg/market/managed_delivery.py:109", + "src/msg/market/compatibility.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.escrow_account_id", + "design_line": 69, + "container": "Order", + "field_or_design_member": "escrow_account_id", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/compatibility.py:76" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.DeliveryTarget", + "design_line": 69, + "container": "Order", + "field_or_design_member": "DeliveryTarget", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "store_orders unit_price_minor/total_price_minor and delivery_target snapshot", + "source_refs": [ + "src/msg/market/orders.py:104" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F069.Order.delivery_id", + "design_line": 69, + "container": "Order", + "field_or_design_member": "delivery_id", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:48", + "src/msg/market/compatibility.py:81" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.dispute_policy", + "design_line": 69, + "container": "Order", + "field_or_design_member": "dispute_policy", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:73", + "src/msg/plugins/orders.py:71" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.state", + "design_line": 69, + "container": "Order", + "field_or_design_member": "state", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:19", + "src/msg/market/compatibility.py:18", + "src/msg/core/models.py:54" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.created_at", + "design_line": 69, + "container": "Order", + "field_or_design_member": "created_at", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:129", + "src/msg/market/compatibility.py:78", + "src/msg/core/models.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.funded_at", + "design_line": 69, + "container": "Order", + "field_or_design_member": "funded_at", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:129", + "src/msg/market/compatibility.py:79" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.delivered_at", + "design_line": 69, + "container": "Order", + "field_or_design_member": "delivered_at", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:129", + "src/msg/market/managed_delivery.py:106", + "src/msg/market/compatibility.py:81" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.settled_at", + "design_line": 69, + "container": "Order", + "field_or_design_member": "settled_at", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:130", + "src/msg/market/compatibility.py:79" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F069.Order.receipt_refs", + "design_line": 69, + "container": "Order", + "field_or_design_member": "receipt_refs", + "profile": "sale", + "parent_obligations": [ + "D05.069.01", + "D05.069.02", + "D05.069.03", + "D05.069.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:130", + "src/msg/market/compatibility.py:82" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.resource", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "resource", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:1", + "src/msg/security/authorization.py:12", + "src/msg/core/models.py:440" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.grantor", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "grantor", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:22", + "src/msg/security/authorization.py:62" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.grantee", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "grantee", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:80", + "src/msg/security/authorization.py:90" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.operations", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "operations", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:86", + "src/msg/security/authorization.py:47", + "src/msg/core/models.py:149" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.expires_at", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "expires_at", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:22", + "src/msg/security/authorization.py:91", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.status", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "status", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:91", + "src/msg/security/authorization.py:50", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.allow_reshare", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "allow_reshare", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:86", + "src/msg/security/authorization.py:91" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F073.ShareGrant.constraints", + "design_line": 73, + "container": "ShareGrant", + "field_or_design_member": "constraints", + "profile": "share", + "parent_obligations": [ + "D06.073.01", + "D06.073.02", + "D06.073.03", + "D06.073.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/sharing.py:86", + "src/msg/security/authorization.py:78", + "src/msg/core/models.py:150" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F087.Post_template_binding.template_id", + "design_line": 87, + "container": "Post template binding", + "field_or_design_member": "template_id", + "profile": "registry", + "parent_obligations": [ + "D07.087.01", + "D07.087.02", + "D07.087.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "template_id + template_version + template_digest + normalized values in signed content", + "source_refs": [ + "src/msg/plugins/content.py:98" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Exact implementation exists in content.template_content, omitted from initial registry-only profile.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F087.Post_template_binding.version", + "design_line": 87, + "container": "Post template binding", + "field_or_design_member": "version", + "profile": "registry", + "parent_obligations": [ + "D07.087.01", + "D07.087.02", + "D07.087.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/registry.py:39", + "src/msg/core/models.py:124", + "src/msg/plugins/common.py:255", + "src/msg/core/models.py:124" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F087.Post_template_binding.digest", + "design_line": 87, + "container": "Post template binding", + "field_or_design_member": "digest", + "profile": "registry", + "parent_obligations": [ + "D07.087.01", + "D07.087.02", + "D07.087.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/registry.py:8", + "src/msg/core/models.py:37", + "src/msg/plugins/common.py:8", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F087.Post_template_binding.values", + "design_line": 87, + "container": "Post template binding", + "field_or_design_member": "values", + "profile": "registry", + "parent_obligations": [ + "D07.087.01", + "D07.087.02", + "D07.087.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/registry.py:103", + "src/msg/plugins/common.py:214" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F098.ReadQuery.root", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "root", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/transports/read_tree_path.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F098.ReadQuery.select", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "select", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields", + "source_refs": [ + "src/msg/plugins/discovery.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery.filter", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "filter", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields", + "source_refs": [ + "src/msg/plugins/discovery.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery.sort", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "sort", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/transports/read_tree_path.py:38" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F098.ReadQuery.first", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "first", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields", + "source_refs": [ + "src/msg/plugins/discovery.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery.after", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "after", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields", + "source_refs": [ + "src/msg/plugins/discovery.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery.expand", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "expand", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/read_query.py:18", + "src/msg/transports/read_tree_path.py:31" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F098.ReadQuery.projection", + "design_line": 98, + "container": "ReadQuery", + "field_or_design_member": "projection", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields", + "source_refs": [ + "src/msg/plugins/discovery.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery_budget.max_depth", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "max_depth", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget", + "source_refs": [ + "src/msg/core/read_query.py:9" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery_budget.max_nodes", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "max_nodes", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget", + "source_refs": [ + "src/msg/core/read_query.py:9" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery_budget.max_response_bytes", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "max_response_bytes", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:363" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F098.ReadQuery_budget.query_cost", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "query_cost", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget", + "source_refs": [ + "src/msg/core/read_query.py:9" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery_budget.max_collection_page_size", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "max_collection_page_size", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "nested_first/expand node limit maximum10; top-level limit maximum200", + "source_refs": [ + "src/msg/plugins/discovery.py:564" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Each nested collection has its own page boundary; one global conceptual label maps to explicit outer/nested maxima.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F098.ReadQuery_budget.timeout", + "design_line": 98, + "container": "ReadQuery budget", + "field_or_design_member": "timeout", + "profile": "read", + "parent_obligations": [ + "D08.098.01", + "D08.098.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "ExecutionContext.deadline_monotonic → ReadBudget.deadline", + "source_refs": [ + "src/msg/plugins/discovery.py:691" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Executor bounded deadline is passed into read traversal; timeout is not a caller-controlled permission or unbounded input.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F101.Cursor.kind", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "kind", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/cursors.py:11", + "src/msg/core/models.py:105" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F101.Cursor.ResourceRef", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "ResourceRef", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:30" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F101.Cursor.query", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "query", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/query.py:1", + "src/msg/core/cursors.py:11" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F101.Cursor.position", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "position", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/cursors.py:1" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F101.Cursor.projection", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "projection", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "query.arguments (fields/projection); position.snapshot; position.last", + "source_refs": [ + "src/msg/core/cursors.py:30" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F101.Cursor.subject", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "subject", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F101.Cursor.expires_at", + "design_line": 101, + "container": "Cursor", + "field_or_design_member": "expires_at", + "profile": "read", + "parent_obligations": [ + "D08.101.01", + "D08.101.02", + "D08.101.03", + "D08.101.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/cursors.py:30", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F102.PageCursor.query_digest", + "design_line": 102, + "container": "PageCursor", + "field_or_design_member": "query_digest", + "profile": "read", + "parent_obligations": [ + "D08.102.01", + "D08.102.02", + "D08.102.03", + "D08.102.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/cursors.py:33" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F102.PageCursor.sort", + "design_line": 102, + "container": "PageCursor", + "field_or_design_member": "sort", + "profile": "read", + "parent_obligations": [ + "D08.102.01", + "D08.102.02", + "D08.102.03", + "D08.102.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/transports/read_tree_path.py:38" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F102.PageCursor.snapshot_boundary", + "design_line": 102, + "container": "PageCursor", + "field_or_design_member": "snapshot_boundary", + "profile": "read", + "parent_obligations": [ + "D08.102.01", + "D08.102.02", + "D08.102.03", + "D08.102.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "query.arguments (fields/projection); position.snapshot; position.last", + "source_refs": [ + "src/msg/core/cursors.py:30" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F102.PageCursor.last_key", + "design_line": 102, + "container": "PageCursor", + "field_or_design_member": "last_key", + "profile": "read", + "parent_obligations": [ + "D08.102.01", + "D08.102.02", + "D08.102.03", + "D08.102.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "query.arguments (fields/projection); position.snapshot; position.last", + "source_refs": [ + "src/msg/core/cursors.py:30" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F102.PageCursor.fields", + "design_line": 102, + "container": "PageCursor", + "field_or_design_member": "fields", + "profile": "read", + "parent_obligations": [ + "D08.102.01", + "D08.102.02", + "D08.102.03", + "D08.102.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/read_query.py:28", + "src/msg/transports/read_tree_path.py:20", + "src/msg/core/models.py:442" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F113.OperationRequest.operation", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "operation", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:245::OperationRequest", + "src/msg/core/models.py:245::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/executor.py:49", + "src/msg/core/requests.py:35", + "src/msg/core/models.py:91", + "src/msg/transports/http.py:4", + "src/msg/core/models.py:91" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.input", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "input", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "OperationRequest.arguments", + "source_refs": [ + "src/msg/core/models.py:242" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Typed input schema validates arguments; payload_digest and signing bytes retain published spelling.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F113.OperationRequest.request_id", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "request_id", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:243::OperationRequest", + "src/msg/core/models.py:243::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/executor.py:72", + "src/msg/core/requests.py:36", + "src/msg/core/models.py:212", + "src/msg/core/models.py:212" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.target_service", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "target_service", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:247::OperationRequest", + "src/msg/core/models.py:247::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/requests.py:39", + "src/msg/core/models.py:186", + "src/msg/core/models.py:186" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.subject", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "subject", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:248::OperationRequest", + "src/msg/core/models.py:248::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/executor.py:70", + "src/msg/core/requests.py:35", + "src/msg/core/models.py:85", + "src/msg/core/models.py:85" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.actor", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "actor", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/executor.py:122", + "src/msg/core/models.py:84", + "src/msg/core/models.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F113.OperationRequest.expires_at", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "expires_at", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:251::OperationRequest", + "src/msg/core/models.py:251::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/requests.py:24", + "src/msg/core/models.py:169", + "src/msg/core/models.py:169" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.proof", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "proof", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:253::OperationRequest", + "src/msg/core/models.py:253::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/requests.py:24", + "src/msg/core/models.py:253", + "src/msg/transports/http.py:5", + "src/msg/core/models.py:253" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.expected_revision", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "expected_revision", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Operation-specific arguments.expected_revision/base_revision; envelope.expected_generations", + "source_refs": [ + "src/msg/plugins/content.py:523" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Revision concurrency is validated by each versioned content operation; resource generation handles metadata. No duplicate envelope field needed.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F113.OperationRequest.source", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "source", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:255::OperationRequest", + "src/msg/core/models.py:255::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/executor.py:143", + "src/msg/core/requests.py:24", + "src/msg/core/models.py:255", + "src/msg/core/models.py:255" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F113.OperationRequest.payload_digest", + "design_line": 113, + "container": "OperationRequest", + "field_or_design_member": "payload_digest", + "profile": "execution", + "parent_obligations": [ + "D09.113.01", + "D09.113.02", + "D09.113.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:252::OperationRequest", + "src/msg/core/models.py:252::OperationRequest" + ], + "source_token_locations": [ + "src/msg/core/executor.py:78", + "src/msg/core/requests.py:24", + "src/msg/core/models.py:252", + "src/msg/core/models.py:252" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F119.ArbitrationCase.order_id", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "order_id", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:107", + "src/msg/market/arbitration.py:26" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.claimant", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "claimant", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "opened_by + buyer/seller parties; reason", + "source_refs": [ + "src/msg/market/arbitration.py:277" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F119.ArbitrationCase.respondent", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "respondent", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "opened_by + buyer/seller parties; reason", + "source_refs": [ + "src/msg/market/arbitration.py:277" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F119.ArbitrationCase.reason_code", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "reason_code", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "opened_by + buyer/seller parties; reason", + "source_refs": [ + "src/msg/market/arbitration.py:277" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F119.ArbitrationCase.statement", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "statement", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/arbitration.py:342" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.evidence_refs", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "evidence_refs", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "arbitration_evidence rows projected under case.evidence with author/visibility/body", + "source_refs": [ + "src/msg/market/arbitration.py:299" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Evidence has independent rows for privacy and updates; case relation is case_id. Signed/pinned ref handling and panel visibility are enforced on submission/read.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F119.ArbitrationCase.policy_version", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "policy_version", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/ledger.py:95" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.panel", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "panel", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/arbitration.py:3" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.deadline", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "deadline", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/arbitration.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.decision", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "decision", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:147", + "src/msg/market/arbitration.py:123" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F119.ArbitrationCase.state", + "design_line": 119, + "container": "ArbitrationCase", + "field_or_design_member": "state", + "profile": "arbitration", + "parent_obligations": [ + "D09.119.01", + "D09.119.02", + "D09.119.03", + "D09.119.04", + "D09.119.05" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:3", + "src/msg/market/arbitration.py:37", + "src/msg/core/models.py:54" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.OperationResult.data", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "data", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:291::OperationResult", + "src/msg/core/models.py:291::OperationResult" + ], + "source_token_locations": [ + "src/msg/core/executor.py:118", + "src/msg/core/requests.py:23", + "src/msg/core/models.py:275", + "src/msg/transports/http.py:52", + "src/msg/core/models.py:275" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F121.OperationResult.metadata", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "metadata", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/executor.py:20", + "src/msg/core/models.py:90", + "src/msg/core/models.py:90" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.OperationResult.request_id", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "request_id", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:281::OperationResult", + "src/msg/core/models.py:281::OperationResult" + ], + "source_token_locations": [ + "src/msg/core/executor.py:72", + "src/msg/core/requests.py:36", + "src/msg/core/models.py:212", + "src/msg/core/models.py:212" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F121.OperationResult.status", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "status", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:283::OperationResult", + "src/msg/core/models.py:283::OperationResult" + ], + "source_token_locations": [ + "src/msg/core/executor.py:130", + "src/msg/core/models.py:125", + "src/msg/transports/http.py:52", + "src/msg/core/models.py:125" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F121.OperationResult.Resource", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "Resource", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:43", + "src/msg/core/models.py:43" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.OperationResult.Revision", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "Revision", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:77", + "src/msg/core/models.py:77" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.OperationResult.committed_at", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "committed_at", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:287::OperationResult", + "src/msg/core/models.py:287::OperationResult" + ], + "source_token_locations": [ + "src/msg/core/executor.py:131", + "src/msg/core/models.py:287", + "src/msg/core/models.py:287" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F121.OperationResult.receipt", + "design_line": 121, + "container": "OperationResult", + "field_or_design_member": "receipt", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:289::OperationResult", + "src/msg/core/models.py:289::OperationResult" + ], + "source_token_locations": [ + "src/msg/core/executor.py:140", + "src/msg/core/requests.py:54", + "src/msg/core/models.py:289", + "src/msg/core/models.py:289" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F121.Error.code", + "design_line": 121, + "container": "Error", + "field_or_design_member": "code", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/executor.py:112", + "src/msg/core/models.py:260", + "src/msg/transports/http.py:41", + "src/msg/core/models.py:260" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.Error.message", + "design_line": 121, + "container": "Error", + "field_or_design_member": "message", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "confirmed_source_gap_assigned", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "No common safe message member in current OperationError", + "source_refs": [ + "src/msg/core/models.py:259" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Confirmed design121 representation omission: only code/retryable/field_path/retry_after_seconds. GraphQL/MCP envelope messages do not fill common operation errors. recover_transports assigned additive static message preserving old signed bytes.", + "status": "confirmed_source_gap_assigned" + } + }, + { + "id": "F121.Error.field", + "design_line": 121, + "container": "Error", + "field_or_design_member": "field", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/executor.py:151", + "src/msg/core/requests.py:24", + "src/msg/core/models.py:6", + "src/msg/core/models.py:6" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F121.Error.retryable", + "design_line": 121, + "container": "Error", + "field_or_design_member": "retryable", + "profile": "execution", + "parent_obligations": [ + "D09.121.01", + "D09.121.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/executor.py:151", + "src/msg/core/models.py:261", + "src/msg/transports/http.py:43", + "src/msg/core/models.py:261" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F125.Line_fingerprint.line_no", + "design_line": 125, + "container": "Line fingerprint", + "field_or_design_member": "line_no", + "profile": "files", + "parent_obligations": [ + "D10.125.01", + "D10.125.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "optional_feature_not_implemented", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Grep line_hint only; no explicit line fingerprint option implemented", + "source_refs": [ + "src/msg/plugins/discovery.py:1208" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking.", + "status": "optional_feature_not_implemented" + } + }, + { + "id": "F125.Line_fingerprint.hash", + "design_line": 125, + "container": "Line fingerprint", + "field_or_design_member": "hash", + "profile": "files", + "parent_obligations": [ + "D10.125.01", + "D10.125.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "optional_feature_not_implemented", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Grep line_hint only; no explicit line fingerprint option implemented", + "source_refs": [ + "src/msg/plugins/discovery.py:1208" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking.", + "status": "optional_feature_not_implemented" + } + }, + { + "id": "F125.Line_fingerprint.context_hash", + "design_line": 125, + "container": "Line fingerprint", + "field_or_design_member": "context_hash", + "profile": "files", + "parent_obligations": [ + "D10.125.01", + "D10.125.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "optional_feature_not_implemented", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Grep line_hint only; no explicit line fingerprint option implemented", + "source_refs": [ + "src/msg/plugins/discovery.py:1208" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking.", + "status": "optional_feature_not_implemented" + } + }, + { + "id": "F128.Search_projection.fields", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "fields", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/search_query.py:12", + "src/msg/plugins/discovery.py:204", + "src/msg/core/models.py:442" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.snippet", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "snippet", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:791" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.links", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "links", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:267" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.limit", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "limit", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:141", + "src/msg/plugins/saved_queries.py:64" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.PageCursor", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "PageCursor", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "search result links + signed encode_page cursor/next", + "source_refs": [ + "src/msg/plugins/discovery.py:1062" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "LinkSet is projected relationship map and PageCursor opaque encoded continuation, not literal field/class names required in every row.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F128.Search_projection.ResourceRef", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "ResourceRef", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:13", + "src/msg/plugins/saved_queries.py:6", + "src/msg/core/models.py:30" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.path", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "path", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:76" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.type", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "type", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:51", + "src/msg/plugins/read_predicates.py:10", + "src/msg/plugins/saved_queries.py:46", + "src/msg/core/models.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.title", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "title", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/search_query.py:12", + "src/msg/plugins/discovery.py:807" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.author", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "author", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:123", + "src/msg/plugins/read_predicates.py:13", + "src/msg/plugins/saved_queries.py:102", + "src/msg/core/models.py:86" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.time", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "time", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:7", + "src/msg/core/models.py:393" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.score", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "score", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:861" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.rank_reason", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "rank_reason", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:861" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F128.Search_projection.LinkSet", + "design_line": 128, + "container": "Search projection", + "field_or_design_member": "LinkSet", + "profile": "search", + "parent_obligations": [ + "D10.128.01", + "D10.128.02", + "D10.128.03", + "D10.128.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "search result links + signed encode_page cursor/next", + "source_refs": [ + "src/msg/plugins/discovery.py:1062" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "LinkSet is projected relationship map and PageCursor opaque encoded continuation, not literal field/class names required in every row.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F131.Grep_options.include", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "include", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "glob / exclude_glob / before / after", + "source_refs": [ + "src/msg/plugins/discovery.py:1121" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F131.Grep_options.exclude", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "exclude", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "glob / exclude_glob / before / after", + "source_refs": [ + "src/msg/plugins/discovery.py:1121" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F131.Grep_options.case_sensitive", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "case_sensitive", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1121" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F131.Grep_options.context_before", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "context_before", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "glob / exclude_glob / before / after", + "source_refs": [ + "src/msg/plugins/discovery.py:1121" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F131.Grep_options.context_after", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "context_after", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "glob / exclude_glob / before / after", + "source_refs": [ + "src/msg/plugins/discovery.py:1121" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F131.Grep_options.max_matches", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "max_matches", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1124" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F131.Grep_options.max_files", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "max_files", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F131.Grep_options.files_with_matches", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "files_with_matches", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1126" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F131.Grep_options.count_only", + "design_line": 131, + "container": "Grep options", + "field_or_design_member": "count_only", + "profile": "search", + "parent_obligations": [ + "D10.131.01", + "D10.131.02", + "D10.131.03", + "D10.131.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/discovery.py:1126" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.requester", + "design_line": 149, + "container": "request", + "field_or_design_member": "requester", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.request.title", + "design_line": 149, + "container": "request", + "field_or_design_member": "title", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.request.description", + "design_line": 149, + "container": "request", + "field_or_design_member": "description", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.request.resource_refs", + "design_line": 149, + "container": "request", + "field_or_design_member": "resource_refs", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:79" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.requirements", + "design_line": 149, + "container": "request", + "field_or_design_member": "requirements", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:349" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.created_at", + "design_line": 149, + "container": "request", + "field_or_design_member": "created_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:96", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.due_at", + "design_line": 149, + "container": "request", + "field_or_design_member": "due_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.request.expires_at", + "design_line": 149, + "container": "request", + "field_or_design_member": "expires_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:169", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.status", + "design_line": 149, + "container": "request", + "field_or_design_member": "status", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:60", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.request.assignee", + "design_line": 149, + "container": "request", + "field_or_design_member": "assignee", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.offer.subject", + "design_line": 149, + "container": "offer", + "field_or_design_member": "subject", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:18", + "src/msg/plugins/proposals.py:25", + "src/msg/plugins/watches.py:45", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.offer.description", + "design_line": 149, + "container": "offer", + "field_or_design_member": "description", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.offer.capability_hint", + "design_line": 149, + "container": "offer", + "field_or_design_member": "capability_hint", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.offer.scope", + "design_line": 149, + "container": "offer", + "field_or_design_member": "scope", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:148" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F149.offer.availability", + "design_line": 149, + "container": "offer", + "field_or_design_member": "availability", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F149.offer.expires_at", + "design_line": 149, + "container": "offer", + "field_or_design_member": "expires_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.149.01", + "D12.149.02", + "D12.149.03" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:169", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.author", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "author", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/proposals.py:46", + "src/msg/plugins/watches.py:125", + "src/msg/core/models.py:86" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.target", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "target", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:62", + "src/msg/plugins/proposals.py:21", + "src/msg/plugins/watches.py:1", + "src/msg/core/models.py:65" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.base_revision", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "base_revision", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/proposals.py:21" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.patch", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "patch", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "content_ref alternative: fixed ResourceRef to proposed replacement content", + "source_refs": [ + "src/msg/plugins/proposals.py:21" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Design150 patch/content_ref alternatives; implementation supports pinned content_ref. It does not accept raw patch in this operation version; acceptance rechecks target base revision and source read permission.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F150.proposal.content_ref", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "content_ref", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/proposals.py:21" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.message", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "message", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:62", + "src/msg/plugins/proposals.py:22", + "src/msg/plugins/watches.py:187" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F150.proposal.status", + "design_line": 150, + "container": "proposal", + "field_or_design_member": "status", + "profile": "collaboration", + "parent_obligations": [ + "D12.150.01", + "D12.150.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:60", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.request_id", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "request_id", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:212", + "src/msg/storage/postgres.py:69", + "src/msg/admin/recovery_replay.py:355", + "src/msg/core/models.py:212" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.actor", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "actor", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:84", + "src/msg/storage/postgres.py:120", + "src/msg/admin/recovery_replay.py:207", + "src/msg/core/models.py:84" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.operation", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "operation", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:91", + "src/msg/admin/recovery_replay.py:6", + "src/msg/core/models.py:91" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.target", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "target", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:65", + "src/msg/storage/postgres.py:379", + "src/msg/admin/recovery_replay.py:81", + "src/msg/core/models.py:65" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.result_ref", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "result_ref", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields)", + "source_refs": [ + "src/msg/core/requests.py:52" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F151.receipt.result_digest", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "result_digest", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields)", + "source_refs": [ + "src/msg/core/requests.py:52" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F151.receipt.committed_at", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "committed_at", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/core/models.py:287", + "src/msg/storage/postgres.py:123", + "src/msg/core/models.py:287" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F151.receipt.server_signature", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "server_signature", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields)", + "source_refs": [ + "src/msg/core/requests.py:52" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F151.receipt.receipt_proof", + "design_line": 151, + "container": "receipt", + "field_or_design_member": "receipt_proof", + "profile": "audit", + "parent_obligations": [ + "D12.151.01" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields)", + "source_refs": [ + "src/msg/core/requests.py:52" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F152.checkpoint.subject", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "subject", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:18", + "src/msg/plugins/proposals.py:25", + "src/msg/plugins/watches.py:45", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F152.checkpoint.resource_refs", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "resource_refs", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:79" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F152.checkpoint.state_ref", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "state_ref", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Relation(type=state, target=ResourceRef) projected as state_ref", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:113" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Current authorization checked before creating fixed relation; signed record excludes duplicated ref fields.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F152.checkpoint.summary", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "summary", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F152.checkpoint.resume_hint", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "resume_hint", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Same named JSON schema fields in collaboration_records / signed Resource Revision", + "source_refs": [ + "src/msg/plugins/collaboration_resources.py:23" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F152.checkpoint.created_at", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "created_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:96", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F152.checkpoint.expires_at", + "design_line": 152, + "container": "checkpoint", + "field_or_design_member": "expires_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.152.01", + "D12.152.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:169", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.subject", + "design_line": 153, + "container": "watch", + "field_or_design_member": "subject", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:18", + "src/msg/plugins/proposals.py:25", + "src/msg/plugins/watches.py:45", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.target", + "design_line": 153, + "container": "watch", + "field_or_design_member": "target", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:62", + "src/msg/plugins/proposals.py:21", + "src/msg/plugins/watches.py:1", + "src/msg/core/models.py:65" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.query_ref", + "design_line": 153, + "container": "watch", + "field_or_design_member": "query_ref", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/watches.py:45" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.event_types", + "design_line": 153, + "container": "watch", + "field_or_design_member": "event_types", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/watches.py:46" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.delivery", + "design_line": 153, + "container": "watch", + "field_or_design_member": "delivery", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/watches.py:46" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.created_at", + "design_line": 153, + "container": "watch", + "field_or_design_member": "created_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:96", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:55" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.expires_at", + "design_line": 153, + "container": "watch", + "field_or_design_member": "expires_at", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:169", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:169" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F153.watch.status", + "design_line": 153, + "container": "watch", + "field_or_design_member": "status", + "profile": "collaboration", + "parent_obligations": [ + "D12.153.01", + "D12.153.02", + "D12.153.03", + "D12.153.04" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/collaboration.py:60", + "src/msg/plugins/proposals.py:47", + "src/msg/plugins/watches.py:46", + "src/msg/core/models.py:125" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.delivery_id", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "delivery_id", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:48", + "src/msg/market/compatibility.py:81" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.order_id", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "order_id", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:107", + "src/msg/market/managed_delivery.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.recipient_subject", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "recipient_subject", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.kind", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "kind", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/plugins/orders.py:117", + "src/msg/market/managed_delivery.py:17", + "src/msg/market/compatibility.py:42", + "src/msg/core/models.py:105" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.payload_refs", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "payload_refs", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:49", + "src/msg/market/managed_delivery.py:17" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.DeliveryEnvelope", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "DeliveryEnvelope", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id", + "source_refs": [ + "src/msg/market/delivery.py:273" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F155.Delivery.package_digest", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "package_digest", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:213", + "src/msg/plugins/orders.py:44", + "src/msg/market/managed_delivery.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.delivery_digest", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "delivery_digest", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:18" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.recipient_key_id", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "recipient_key_id", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id", + "source_refs": [ + "src/msg/market/delivery.py:273" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F155.Delivery.state", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "state", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/store.py:36", + "src/msg/plugins/orders.py:3", + "src/msg/market/managed_delivery.py:19", + "src/msg/market/compatibility.py:18", + "src/msg/core/models.py:54" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.prepared_at", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "prepared_at", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.claimed_at", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "claimed_at", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/managed_delivery.py:19" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F155.Delivery.receipt", + "design_line": 155, + "container": "Delivery", + "field_or_design_member": "receipt", + "profile": "sale", + "parent_obligations": [ + "D12.155.01", + "D12.155.02", + "D12.155.03", + "D12.155.04", + "D12.155.05", + "D12.155.06" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/plugins/orders.py:119", + "src/msg/market/managed_delivery.py:19", + "src/msg/market/compatibility.py:3", + "src/msg/core/models.py:289" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F156.DeliveryTarget.endpoint_id", + "design_line": 156, + "container": "DeliveryTarget", + "field_or_design_member": "endpoint_id", + "profile": "email", + "parent_obligations": [ + "D12.156.01", + "D12.156.02", + "D12.156.03", + "D12.156.04", + "D12.156.05", + "D12.156.06", + "D12.156.07", + "D12.156.08", + "D12.156.09", + "D12.156.10", + "D12.156.11", + "D12.156.12", + "D12.156.13", + "D12.156.14", + "D12.156.15", + "D12.156.16" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "email: endpoint identity + generation/address_snapshot/verified_at", + "source_refs": [ + "src/msg/market/delivery_targets.py:65" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Single-email endpoint generations bind ownership and revoke/reverify; this is not arbitrary address input or capability URL.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F156.DeliveryTarget.address_snapshot", + "design_line": 156, + "container": "DeliveryTarget", + "field_or_design_member": "address_snapshot", + "profile": "email", + "parent_obligations": [ + "D12.156.01", + "D12.156.02", + "D12.156.03", + "D12.156.04", + "D12.156.05", + "D12.156.06", + "D12.156.07", + "D12.156.08", + "D12.156.09", + "D12.156.10", + "D12.156.11", + "D12.156.12", + "D12.156.13", + "D12.156.14", + "D12.156.15", + "D12.156.16" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "email: endpoint identity + generation/address_snapshot/verified_at", + "source_refs": [ + "src/msg/market/delivery_targets.py:65" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Single-email endpoint generations bind ownership and revoke/reverify; this is not arbitrary address input or capability URL.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F156.DeliveryTarget.subject", + "design_line": 156, + "container": "DeliveryTarget", + "field_or_design_member": "subject", + "profile": "email", + "parent_obligations": [ + "D12.156.01", + "D12.156.02", + "D12.156.03", + "D12.156.04", + "D12.156.05", + "D12.156.06", + "D12.156.07", + "D12.156.08", + "D12.156.09", + "D12.156.10", + "D12.156.11", + "D12.156.12", + "D12.156.13", + "D12.156.14", + "D12.156.15", + "D12.156.16" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/market/delivery_notifications.py:62", + "src/msg/workers/effects.py:34", + "src/msg/workers/mail.py:28", + "src/msg/core/models.py:85" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.tool_id", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "tool_id", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:25" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.name", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "name", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:16", + "src/msg/workers/sandbox_child.py:63", + "src/msg/core/registry.py:39", + "src/msg/core/models.py:47" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.description", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "description", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:31" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.version", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "version", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:25", + "src/msg/core/registry.py:39", + "src/msg/core/models.py:124" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.digest", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "digest", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:7", + "src/msg/core/registry.py:8", + "src/msg/core/models.py:37" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.input_schema", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "input_schema", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:462::ToolSpec" + ], + "source_token_locations": [ + "src/msg/extensions/tools.py:26", + "src/msg/core/registry.py:138", + "src/msg/core/models.py:344" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F162.ToolSpec.output_schema", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "output_schema", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:463::ToolSpec" + ], + "source_token_locations": [ + "src/msg/extensions/tools.py:27", + "src/msg/core/registry.py:138", + "src/msg/core/models.py:345" + ], + "trace_status": "direct_class_member" + }, + { + "id": "F162.ToolSpec.capability", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "capability", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/extensions/tools.py:44", + "src/msg/core/registry.py:48", + "src/msg/core/models.py:146" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.network_policy", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "network_policy", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "ToolSpec.network: NetworkPolicy", + "source_refs": [ + "src/msg/core/models.py:459" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Registered tool facts, deployment config and current capability network limits are intersected before executing.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F162.ToolSpec.timeout", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "timeout", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [ + "src/msg/workers/sandbox.py:61", + "src/msg/workers/sandbox_child.py:91" + ], + "trace_status": "source_mentions_require_semantic_check" + }, + { + "id": "F162.ToolSpec.input_limit", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "input_limit", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "Input schema URL/name/header maxima + server.max_request_bytes; sandbox packet<=max_request_bytes+65536", + "source_refs": [ + "src/msg/workers/sandbox.py:58" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Input is bounded by existing schema/request/sandbox transport limits; no unbounded command string accepted. No tool-local max_input_bytes field currently; concurrency work may make a declared value explicit without weakening global ceiling.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F162.ToolSpec.output_limit", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "output_limit", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "semantic_representation_verified", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "ToolSpec.network.max_response_bytes, intersected with deployment and capability", + "source_refs": [ + "src/msg/core/models.py:363" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "NetworkPolicy maximum response bytes provides the output bound; separate duplicated ToolSpec field not needed.", + "status": "semantic_representation_verified" + } + }, + { + "id": "F162.ToolSpec.concurrency", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "concurrency", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [], + "source_token_locations": [], + "trace_status": "confirmed_source_gap_assigned", + "semantic_review": { + "review_commit": "cfdb211", + "implementation": "No tool-keyed cross-worker concurrency limit in existing claim path", + "source_refs": [ + "src/msg/workers/effects.py:111" + ], + "normative_basis": "Original design_line and parent_obligations; published names/versions are preserved, not silently renamed.", + "assessment": "Confirmed design162 gap: each process serializes its loop, but multiple workers can claim different same-tool jobs; there is no registered cap and atomic running count/admission. recover_recovery assigned bounded cross-worker enforcement.", + "status": "confirmed_source_gap_assigned" + } + }, + { + "id": "F162.ToolSpec.executor_key", + "design_line": 162, + "container": "ToolSpec", + "field_or_design_member": "executor_key", + "profile": "tools", + "parent_obligations": [ + "D13.162.01", + "D13.162.02" + ], + "status": "named_member_explicitly_inventoried; implementation aliases must be checked, not assumed identical", + "source_definitions": [ + "src/msg/core/models.py:464::ToolSpec" + ], + "source_token_locations": [ + "src/msg/extensions/tools.py:25", + "src/msg/workers/sandbox.py:57", + "src/msg/core/registry.py:81", + "src/msg/core/models.py:464" + ], + "trace_status": "direct_class_member" + } + ], + "profiles": { + "scope": { + "source_files": [ + "src/msg/core/executor.py", + "src/msg/core/models.py", + "src/msg/config.py", + "pyproject.toml", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_operations_admin.py", + "tests/test_route_effect_matrix.py" + ], + "feature_ids": [ + "bootstrap" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "bootstrap", + "doctor": "bootstrap", + "selftest": "isolated_namespace", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_operations_admin.py:16::test_network_system_configuration_cannot_change_root_trust", + "tests/test_operations_admin.py:35::test_policy_cleanup_is_explicit_records_tombstone_and_does_not_delete_other_topics", + "tests/test_operations_admin.py:52::test_doctor_does_not_create_database_or_repair_bootstrap", + "tests/test_operations_admin.py:71::test_selftest_isolated_end_to_end_checks", + "tests/test_route_effect_matrix.py:24::test_ordinary_read_and_failed_route_matrix_preserves_all_authority_rows", + "tests/test_route_effect_matrix.py:103::test_effect_classification_matches_native_git_and_lfs_boundaries" + ] + }, + "registry": { + "source_files": [ + "src/msg/core/registry.py", + "src/msg/core/models.py", + "src/msg/plugins/common.py", + "src/msg/plugins/features.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_feature_manifest.py", + "tests/test_registry_contract_references.py", + "tests/test_registry_operation_rules.py", + "tests/test_registry_references.py", + "tests/test_registry_schema_ownership.py", + "tests/test_registry_template_tools.py" + ], + "feature_ids": [ + "bootstrap" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "bootstrap", + "doctor": "bootstrap", + "selftest": "isolated_namespace", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_feature_manifest.py:11::test_feature_inventory_has_unique_real_samples_and_explicit_checks", + "tests/test_feature_manifest.py:37::test_recovery_checkpoint_inspection_detects_vocabulary_drift", + "tests/test_feature_manifest.py:48::test_disabled_plugins_are_reported_as_skipped_not_passed", + "tests/test_feature_manifest.py:65::test_feature_inventory_rejects_invalid_declarations", + "tests/test_feature_manifest.py:72::test_feature_results_do_not_invent_success", + "tests/test_feature_manifest.py:86::test_doctor_feature_results_are_grounded_in_read_only_checks", + "tests/test_feature_manifest.py:100::test_selftest_feature_results_are_grounded_in_isolated_operations", + "tests/test_registry_contract_references.py:43::test_freeze_rejects_unregistered_optional_schema_and_can_retry", + "tests/test_registry_contract_references.py:61::test_freeze_rejects_unknown_resource_operation_including_wrong_version", + "tests/test_registry_contract_references.py:70::test_freeze_accepts_registered_content_constraints_and_operation_references", + "tests/test_registry_operation_rules.py:40::test_existing_default_rule_descriptions_remain_identical", + "tests/test_registry_operation_rules.py:47::test_explicit_rule_dependencies_override_name_and_are_owned_by_spec", + "tests/test_registry_operation_rules.py:58::test_freeze_rejects_missing_duplicate_or_empty_operation_rules", + "tests/test_registry_references.py:20::test_nonlocal_references_are_rejected_before_publishing", + "tests/test_registry_references.py:39::test_nonlocal_references_in_subschemas_are_rejected", + "tests/test_registry_references.py:44::test_local_pointer_with_http_metadata_is_not_mistaken_for_remote_reference", + "tests/test_registry_references.py:61::test_local_anchors_still_validate", + "tests/test_registry_references.py:70::test_reference_named_properties_and_annotation_data_are_not_schema_references", + "tests/test_registry_references.py:82::test_pointer_into_annotation_cannot_enable_implicit_file_retrieval", + "tests/test_registry_references.py:100::test_missing_local_pointer_is_a_stable_error_without_uri_disclosure", + "tests/test_registry_schema_ownership.py:27::test_nested_schema_mutation_cannot_expand_a_registered_contract", + "tests/test_registry_schema_ownership.py:42::test_each_lookup_returns_an_independent_snapshot_with_identical_digest", + "tests/test_registry_schema_ownership.py:53::test_boolean_json_schemas_keep_their_existing_validation_semantics", + "tests/test_registry_schema_ownership.py:63::test_failed_remote_reference_registration_has_no_partial_contract", + "tests/test_registry_schema_ownership.py:73::test_freeze_still_rejects_new_or_replacement_schemas", + "tests/test_registry_template_tools.py:22::test_templates_are_versioned_owned_specs_with_defaults_and_strict_lookup", + "tests/test_registry_template_tools.py:40::test_template_registration_snapshots_nested_fields_and_validates_defaults", + "tests/test_registry_template_tools.py:53::test_installed_template_and_tool_contracts_match_published_resources", + "tests/test_registry_template_tools.py:75::test_tool_registry_rejects_conflicts_missing_schemas_and_operations", + "tests/test_registry_template_tools.py:99::test_shipped_template_post_keeps_signed_content_contract", + "tests/test_registry_template_tools.py:120::test_tool_resource_policy_is_preserved_but_cannot_replace_registered_code", + "tests/test_registry_template_tools.py:148::test_user_template_versions_remain_resources_outside_frozen_registry" + ] + }, + "identity": { + "source_files": [ + "src/msg/plugins/identity.py", + "src/msg/security/authentication.py", + "src/msg/client_tokens.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_identity_keys.py", + "tests/test_temporary_signed_business.py" + ], + "feature_ids": [ + "online_ca", + "identity_upgrade" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "online_ca", + "doctor": "online_ca", + "selftest": "online_registration", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "identity_upgrade", + "doctor": "authority_snapshot", + "selftest": "identity_upgrade_recovery", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_identity_keys.py:16::test_age_recipient_round_trip", + "tests/test_identity_keys.py:31::test_generated_identity_interoperates_with_official_age", + "tests/test_identity_keys.py:44::test_register_v1_denied_v2_creates_two_independent_public_keys", + "tests/test_identity_keys.py:78::test_age_rotation_preserves_historical_recipient_without_signing_change", + "tests/test_identity_keys.py:104::test_identity_key_replacement_keeps_old_verification_material", + "tests/test_temporary_signed_business.py:9::test_temporary_signing_key_can_create_low_risk_content", + "tests/test_temporary_signed_business.py:24::test_temporary_token_allows_low_risk_write_but_not_permission_changes" + ] + }, + "delivery": { + "source_files": [ + "src/msg/security/token_delivery.py", + "src/msg/plugins/identity.py", + "src/msg/client.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_client_token_delivery.py", + "tests/test_temporary_dual_keys.py", + "tests/test_temporary_legacy_migration.py", + "tests/test_temporary_signed_business.py", + "tests/test_token_delivery.py", + "tests/test_token_delivery_boundary.py" + ], + "feature_ids": [ + "credential_delivery" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "credential_delivery", + "doctor": "credential_delivery", + "selftest": "credential_delivery_recovery", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_client_token_delivery.py:20::test_bootstrap_lost_after_claim_recovers_after_restart", + "tests/test_client_token_delivery.py:60::test_rotate_lost_response_blocks_old_token_until_recovery", + "tests/test_client_token_delivery.py:92::test_before_commit_retry_and_path_transport_fail_closed", + "tests/test_client_token_delivery.py:119::test_recovery_claim_lost_can_recover_again_without_version_one", + "tests/test_client_token_delivery.py:156::test_non_loopback_http_never_sends_recovery_secret", + "tests/test_temporary_dual_keys.py:25::test_temporary_v3_creates_two_client_keys_and_upgrade_reuses_them", + "tests/test_temporary_dual_keys.py:80::test_legacy_temporary_versions_and_false_key_proof_create_no_subject", + "tests/test_temporary_dual_keys.py:102::test_temporary_upgrade_cannot_silently_replace_creation_keys", + "tests/test_temporary_legacy_migration.py:20::test_legacy_temporary_upgrade_is_atomic_and_preserves_subject", + "tests/test_temporary_signed_business.py:9::test_temporary_signing_key_can_create_low_risk_content", + "tests/test_temporary_signed_business.py:24::test_temporary_token_allows_low_risk_write_but_not_permission_changes", + "tests/test_token_delivery.py:20::test_v2_bootstrap_claim_once_and_recover_lost_response", + "tests/test_token_delivery.py:58::test_v2_concurrent_claim_and_cross_subject_denial", + "tests/test_token_delivery.py:78::test_v2_unclaimed_commit_survives_restart_and_expiry", + "tests/test_token_delivery.py:109::test_v2_rotate_and_signed_create_bind_separate_recovery", + "tests/test_token_delivery.py:147::test_v2_recovery_secret_is_consumed_once_under_race", + "tests/test_token_delivery_boundary.py:27::test_composition_only_delegates_the_existing_delivery_api", + "tests/test_token_delivery_boundary.py:41::test_domain_import_and_constructor_need_no_application_or_plugins", + "tests/test_token_delivery_boundary.py:62::test_derivation_and_recovery_verifier_keep_the_published_bytes", + "tests/test_token_delivery_boundary.py:78::test_authentication_uses_the_same_verifier_owner", + "tests/test_token_delivery_boundary.py:86::test_real_service_binds_one_delivery_instance_for_all_executors", + "tests/test_token_delivery_boundary.py:94::test_standalone_delivery_claim_commits_once_without_persisting_secrets", + "tests/test_token_delivery_boundary.py:133::test_delivery_failure_returns_no_new_secret_or_claim" + ] + }, + "custodial": { + "source_files": [ + "src/msg/plugins/custodial_lifecycle.py", + "src/msg/client_custodial.py", + "src/msg/admin/custodial_check.py", + "src/msg/admin/backup_retirement.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_backup_retirement_attestation.py", + "tests/test_backup_retirement_cli.py", + "tests/test_custodial_client_upgrade.py", + "tests/test_custodial_historical_migration.py", + "tests/test_custodial_inventory_ack.py", + "tests/test_custodial_lifecycle.py", + "tests/test_custodial_rewrap.py", + "tests/test_custodial_schema_migration.py", + "tests/test_custodial_upgrade.py", + "tests/test_custodial_vault.py", + "tests/test_task_a_custodial_binding.py" + ], + "feature_ids": [ + "custodial_history", + "identity_upgrade" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "custodial_history", + "doctor": "custodial_history", + "selftest": "custodial_history_recovery", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "identity_upgrade", + "doctor": "authority_snapshot", + "selftest": "identity_upgrade_recovery", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_backup_retirement_attestation.py:106::test_without_record_backup_and_server_key_remain_false", + "tests/test_backup_retirement_attestation.py:118::test_imported_root_signed_record_retires_exactly_that_backup_scope", + "tests/test_backup_retirement_attestation.py:138::test_backup_attestation_alone_is_not_server_key_retirement", + "tests/test_backup_retirement_attestation.py:171::test_bad_records_are_rejected_at_import_and_never_stored", + "tests/test_backup_retirement_attestation.py:181::test_persisted_record_is_reverified_and_fails_closed", + "tests/test_backup_retirement_attestation.py:206::test_retirement_view_ignores_network_body_and_caller_flags", + "tests/test_backup_retirement_attestation.py:236::test_import_and_signing_require_the_physical_console", + "tests/test_backup_retirement_attestation.py:253::test_no_network_adapter_can_write_or_import_the_record", + "tests/test_backup_retirement_attestation.py:268::test_network_inventory_reports_backup_retired_only_after_local_import", + "tests/test_backup_retirement_cli.py:15::test_retirement_command_parser", + "tests/test_backup_retirement_cli.py:25::test_retirement_cli_refuses_nonconsole_before_reading_files", + "tests/test_backup_retirement_cli.py:43::test_statement_file_boundary", + "tests/test_backup_retirement_cli.py:65::test_statement_file_valid", + "tests/test_backup_retirement_cli.py:74::test_record_output_never_overwrites", + "tests/test_backup_retirement_cli.py:89::test_record_output_refuses_symlink", + "tests/test_custodial_client_upgrade.py:15::test_client_custodial_exit_persists_keys_and_clears_token", + "tests/test_custodial_client_upgrade.py:37::test_lost_finish_response_replays_without_losing_new_keys", + "tests/test_custodial_client_upgrade.py:65::test_lost_finish_response_recovers_after_old_token_expires", + "tests/test_custodial_historical_migration.py:19::test_historical_revision_gets_independent_mapped_copy", + "tests/test_custodial_inventory_ack.py:21::test_new_age_revision_after_start_blocks_finish_and_keeps_old_authority", + "tests/test_custodial_inventory_ack.py:45::test_rewrap_mapping_requires_new_signer_ack_and_retains_historical_vault", + "tests/test_custodial_inventory_ack.py:104::test_client_keeps_challenge_and_decrypts_locally_before_ack", + "tests/test_custodial_lifecycle.py:42::test_all_mapped_history_switches_but_never_claims_backup_retirement", + "tests/test_custodial_lifecycle.py:81::test_compatibility_envelope_contains_only_owned_retired_age_key", + "tests/test_custodial_lifecycle.py:118::test_new_write_delta_requires_refresh_and_all_acks_are_rebound", + "tests/test_custodial_lifecycle.py:158::test_unmigratable_requires_exact_signed_loss_decision", + "tests/test_custodial_lifecycle.py:181::test_decrypt_only_retention_cannot_sign_and_can_finish_later", + "tests/test_custodial_lifecycle.py:207::test_committed_finalize_response_loss_recovers_exact_result", + "tests/test_custodial_lifecycle.py:236::test_duplicate_start_and_rotation_cannot_replace_pending_target", + "tests/test_custodial_lifecycle.py:255::test_scoped_ack_and_decision_tampering_never_retire_keys", + "tests/test_custodial_rewrap.py:43::test_pending_rewrap_converts_only_selected_revision_and_keeps_vault", + "tests/test_custodial_schema_migration.py:15::test_old_constraint_migrates_transactionally_and_restarts", + "tests/test_custodial_schema_migration.py:30::test_unknown_old_constraint_is_not_silently_dropped", + "tests/test_custodial_upgrade.py:38::test_wrong_age_proof_fails_without_losing_old_token_or_vault", + "tests/test_custodial_upgrade.py:60::test_wrong_new_signer_ack_consumes_challenge_without_revoking_token", + "tests/test_custodial_upgrade.py:79::test_server_tracked_age_ciphertext_keeps_upgrade_pending", + "tests/test_custodial_upgrade.py:121::test_empty_custodial_upgrade_switches_atomically_and_replays_result", + "tests/test_custodial_vault.py:16::test_custodial_bootstrap_encrypts_two_keys_and_signs_revision", + "tests/test_custodial_vault.py:79::test_custodial_token_scope_and_upgrade_fail_closed", + "tests/test_custodial_vault.py:110::test_custodial_rotation_and_vault_tamper_fail_closed", + "tests/test_custodial_vault.py:142::test_lost_bootstrap_response_cannot_replay_token", + "tests/test_task_a_custodial_binding.py:49::test_valid_empty_inventory_is_read_only_and_not_backup_retirement", + "tests/test_task_a_custodial_binding.py:61::test_persisted_challenge_tampering_is_rejected_without_any_acks", + "tests/test_task_a_custodial_binding.py:75::test_stored_key_id_cannot_override_the_signed_challenge", + "tests/test_task_a_custodial_binding.py:84::test_retired_new_target_blocks_in_progress_migration" + ] + }, + "recovery_envelope": { + "source_files": [ + "src/msg/plugins/recovery.py", + "src/msg/security/crypto.py", + "src/msg/client_custodial.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_recovery_authority_reconcile.py", + "tests/test_recovery_current_authority.py", + "tests/test_recovery_envelope.py", + "tests/test_recovery_metadata.py", + "tests/test_recovery_policy_v2.py", + "tests/test_recovery_runtime_generation.py", + "tests/test_recovery_safety_units.py" + ], + "feature_ids": [ + "custodial_history", + "recovery" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "custodial_history", + "doctor": "custodial_history", + "selftest": "custodial_history_recovery", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "recovery", + "doctor": "recovery_checkpoint", + "selftest": "recovery_checkpoint_replay", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_recovery_authority_reconcile.py:56::test_stage_new_owner_parent_without_exposing_old_or_new_scope", + "tests/test_recovery_authority_reconcile.py:76::test_bad_reconciliation_rolls_back_earlier_certificate_revocation", + "tests/test_recovery_authority_reconcile.py:101::test_contiguous_owner_chain_converges_once_and_rejects_gap", + "tests/test_recovery_authority_reconcile.py:123::test_acl_then_transfer_replays_twice", + "tests/test_recovery_authority_reconcile.py:137::test_intermediate_acl_binds_to_signed_authority_chain", + "tests/test_recovery_current_authority.py:49::test_retirement_rechecks_current_root_and_import_rejects", + "tests/test_recovery_current_authority.py:87::test_retirement_accepts_valid_root_and_stops_at_credential_expiry", + "tests/test_recovery_current_authority.py:101::test_far_future_retirement_is_rejected_without_datetime_overflow", + "tests/test_recovery_current_authority.py:114::test_checkpoint_ban_cannot_inherit_stale_expiry", + "tests/test_recovery_envelope.py:27::test_age_multi_recipient_backup_is_opt_in_and_offline", + "tests/test_recovery_envelope.py:85::test_selected_age_keystore_rewrap_keeps_old_revision_and_key", + "tests/test_recovery_metadata.py:17::test_policy_default_deny_and_signed_owner_opt_in", + "tests/test_recovery_metadata.py:52::test_envelope_binds_exact_private_age_revision_and_owner_declared_recipient", + "tests/test_recovery_metadata.py:119::test_platform_custodian_config_rejects_private_material", + "tests/test_recovery_metadata.py:132::test_configured_custodian_is_public_but_recipient_binding_is_checked", + "tests/test_recovery_policy_v2.py:45::test_v2_acl_is_monotonic_idempotent_and_preserves_history", + "tests/test_recovery_policy_v2.py:67::test_v2_rejects_unsupported_authority_and_contracts", + "tests/test_recovery_policy_v2.py:93::test_v2_policy_ban_expiry_and_rollback", + "tests/test_recovery_policy_v2.py:148::test_packaged_policy_schema_matches_typed_contract", + "tests/test_recovery_policy_v2.py:163::test_v1_prefix_can_continue_in_v2_but_not_downgrade", + "tests/test_recovery_policy_v2.py:177::test_v2_ban_never_shortens_an_active_restriction", + "tests/test_recovery_policy_v2.py:200::test_acl_restriction_preserves_existing_special_security_bits", + "tests/test_recovery_policy_v2.py:215::test_current_certificate_digest_revokes_stale_authority_without_resigning", + "tests/test_recovery_runtime_generation.py:24::test_idle_executor_and_http_require_restart_after_promotion", + "tests/test_recovery_runtime_generation.py:47::test_idle_workers_claim_and_timers_cannot_mutate_promoted_database", + "tests/test_recovery_runtime_generation.py:72::test_idle_readonly_hosting_requires_restart_without_expanding_role", + "tests/test_recovery_runtime_generation.py:99::test_generation_mismatch_remains_latched_even_if_setting_reverted", + "tests/test_recovery_runtime_generation.py:113::test_promotion_during_load_cannot_pin_new_generation_to_old_runtime", + "tests/test_recovery_runtime_generation.py:142::test_present_invalid_generation_is_not_treated_as_an_old_installation", + "tests/test_recovery_safety_units.py:63::test_rotation_commitment_binds_all_installation_targets", + "tests/test_recovery_safety_units.py:82::test_rotation_rejects_journal_substitution", + "tests/test_recovery_safety_units.py:90::test_legacy_unbound_rotation_is_not_silently_resumed", + "tests/test_recovery_safety_units.py:97::test_rotation_requires_same_online_key_and_service", + "tests/test_recovery_safety_units.py:132::test_ack_is_reverified_not_just_counted", + "tests/test_recovery_safety_units.py:153::test_bad_history_evidence_remains_pending", + "tests/test_recovery_safety_units.py:161::test_inventory_delta_invalidates_previously_verified_acks", + "tests/test_recovery_safety_units.py:171::test_ack_cannot_cross_subject_or_challenge", + "tests/test_recovery_safety_units.py:178::test_persisted_quarantine_fails_closed_for_malformed_flags", + "tests/test_recovery_safety_units.py:194::test_online_vault_destruction_is_not_backup_retirement", + "tests/test_recovery_safety_units.py:233::test_restore_quarantine_is_inside_single_transaction", + "tests/test_recovery_safety_units.py:261::test_failed_dump_conversion_never_connects_to_restore_database" + ] + }, + "honors": { + "source_files": [ + "src/msg/plugins/achievements.py", + "src/msg/admin/honor_check.py", + "src/msg/data/bootstrap.json", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_achievement_pins.py", + "tests/test_achievements.py", + "tests/test_honor_diagnostics.py" + ], + "feature_ids": [ + "honors" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "honors", + "doctor": "honors", + "selftest": "honor_ceremony_display", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_achievement_pins.py:26::test_pin_reorder_unpin_changes_only_display", + "tests/test_achievement_pins.py:54::test_foreign_revoked_or_incomplete_pin_requests_do_not_leak_or_mutate", + "tests/test_achievement_pins.py:77::test_custodial_pin_changes_are_signed_audited_and_keep_grants", + "tests/test_achievement_pins.py:132::test_temporary_token_still_cannot_sign_honor_display", + "tests/test_achievements.py:28::test_full_ceremony_issues_one_honor_fact_without_access_power", + "tests/test_achievements.py:65::test_failed_round_cannot_continue_or_issue", + "tests/test_achievements.py:79::test_wrong_nonce_and_round_ttl_fail_closed", + "tests/test_achievements.py:94::test_machine_payload_and_final_digest_are_required", + "tests/test_achievements.py:118::test_total_ttl_and_replayed_nonce", + "tests/test_achievements.py:140::test_expired_round_can_restart_without_submitting_old_answer", + "tests/test_achievements.py:167::test_custodial_ceremony_signs_final_request_and_labels_source", + "tests/test_achievements.py:226::test_non_custodial_token_cannot_finish_ceremony", + "tests/test_honor_diagnostics.py:9::test_honor_feature_has_non_granting_bootstrap_sample", + "tests/test_honor_diagnostics.py:21::test_honor_diagnostics_use_installed_sample_and_real_requests" + ] + }, + "ca": { + "source_files": [ + "src/msg/security/certificates.py", + "src/msg/security/capabilities.py", + "src/msg/plugins/identity.py", + "src/msg/admin/diagnostics.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_authority_snapshot_drift.py", + "tests/test_ca_depth.py", + "tests/test_canonical_post_paths.py", + "tests/test_capability_matrix.py", + "tests/test_capacity_bounds.py", + "tests/test_online_ca_policy.py", + "tests/test_online_issuer.py" + ], + "feature_ids": [ + "online_ca", + "authorization" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "online_ca", + "doctor": "online_ca", + "selftest": "online_registration", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "authorization", + "doctor": "authority_snapshot", + "selftest": "certgate", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_authority_snapshot_drift.py:27::test_old_signed_ca_snapshots_are_diagnosed_without_repair", + "tests/test_ca_depth.py:18::test_root_only_issues_l1_with_at_most_two_more_ca_levels", + "tests/test_ca_depth.py:38::test_legacy_root_depth_eight_cannot_extend_new_ca_tree", + "tests/test_ca_depth.py:60::test_l1_l2_l3_chain_with_explicit_narrowing_validates", + "tests/test_canonical_post_paths.py:5::test_post_and_reply_use_markdown_paths_everywhere", + "tests/test_capability_matrix.py:17::test_special_capability_requires_exact_grant_scope_operation_and_live_chain", + "tests/test_capacity_bounds.py:12::test_transfer_rows_and_staged_bytes_are_capped", + "tests/test_capacity_bounds.py:31::test_purge_records_keep_the_newest_bounded_set", + "tests/test_capacity_bounds.py:46::test_finished_webhook_jobs_yield_before_pending_work_is_refused", + "tests/test_capacity_bounds.py:66::test_git_repository_bytes_stop_at_the_deployment_cap", + "tests/test_capacity_bounds.py:78::test_ssh_receive_stdin_is_bounded", + "tests/test_capacity_bounds.py:95::test_error_status_maps_storage_capacity_to_507", + "tests/test_online_ca_policy.py:19::test_online_ca_rejects_forbidden_issue_grant_even_when_root_signed", + "tests/test_online_ca_policy.py:36::test_online_ca_rejects_extra_use_grant", + "tests/test_online_ca_policy.py:52::test_online_ca_rejects_nonbasic_child_without_authority_source", + "tests/test_online_issuer.py:25::test_possession_based_registration_and_owner_delegation_audit_real_sources", + "tests/test_online_issuer.py:57::test_renewal_preserves_or_narrows_live_certificate_authority", + "tests/test_online_issuer.py:86::test_revoked_renewal_source_cannot_be_issued_again", + "tests/test_online_issuer.py:96::test_renewal_cannot_extend_past_the_live_source_window", + "tests/test_online_issuer.py:122::test_renewal_cannot_expand_operations_or_scope_after_broad_source_is_revoked", + "tests/test_online_issuer.py:151::test_online_issuer_rejects_special_delegation_and_keeps_ca_request_pending", + "tests/test_online_issuer.py:170::test_expired_delegation_source_blocks_another_automatic_signature", + "tests/test_online_issuer.py:197::test_added_key_receives_only_its_requested_basic_certificate_grants" + ] + }, + "root": { + "source_files": [ + "src/msg/admin/root.py", + "src/msg/admin/rotation.py", + "src/msg/security/root_files.py", + "src/msg/security/trust_files.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_root_rotation.py", + "tests/test_root_rotation_resume.py", + "tests/test_root_storage_layout.py", + "tests/test_task_a_root_files.py", + "tests/test_trust_path_compatibility.py" + ], + "feature_ids": [ + "root_trust" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "root_trust", + "doctor": "root_trust", + "selftest": "root_network_rejected", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_root_rotation.py:10::test_root_rotation_retires_old_chain_and_requires_explicit_online_ca_approval", + "tests/test_root_rotation_resume.py:28::test_rotation_resumes_without_archiving_new_key_as_old", + "tests/test_root_rotation_resume.py:73::test_replacement_csr_is_rejected_before_any_commit", + "tests/test_root_rotation_resume.py:88::test_explicit_lost_root_key_rotation_uses_bound_journal", + "tests/test_root_rotation_resume.py:100::test_rotation_back_to_retired_root_key_is_rejected_before_commit", + "tests/test_root_storage_layout.py:10::test_new_installation_keeps_root_private_material_outside_etc", + "tests/test_root_storage_layout.py:25::test_default_and_legacy_root_locations_are_unambiguous", + "tests/test_task_a_root_files.py:16::test_prepare_refuses_insecure_root_envelope_without_pending_journal", + "tests/test_task_a_root_files.py:28::test_history_validation_precedes_database_root_transition", + "tests/test_task_a_root_files.py:58::test_concurrent_prepare_does_not_replace_an_approved_journal", + "tests/test_trust_path_compatibility.py:8::test_new_layout_and_legacy_configuration", + "tests/test_trust_path_compatibility.py:34::test_reserved_plugin_directory_rejects_unsafe_paths", + "tests/test_trust_path_compatibility.py:48::test_legacy_root_json_really_loads_and_conflict_refuses", + "tests/test_trust_path_compatibility.py:65::test_dual_alias_rotation_interruption_is_closed_and_resumable", + "tests/test_trust_path_compatibility.py:97::test_trust_alias_symlinks_fail_closed", + "tests/test_trust_path_compatibility.py:106::test_rotation_refuses_unpinned_alias_before_database_commit", + "tests/test_trust_path_compatibility.py:124::test_legacy_rotation_preserves_legacy_path" + ] + }, + "money": { + "source_files": [ + "src/msg/plugins/money.py", + "src/msg/admin/money.py", + "src/msg/market/ledger.py", + "src/msg/core/models.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_ledger_account_isolation.py", + "tests/test_ledger_accounts.py", + "tests/test_ledger_migration_json.py", + "tests/test_ledger_migration_references.py", + "tests/test_market_71.py", + "tests/test_money_admin.py", + "tests/test_money_config.py", + "tests/test_money_core.py", + "tests/test_money_paths.py" + ], + "feature_ids": [ + "money" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "money", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_ledger_account_isolation.py:16::test_ledger_account_cannot_be_registered_recovered_delegated_or_used_as_bank", + "tests/test_ledger_accounts.py:35::test_escrow_accounts_are_not_subjects_and_reject_network_money", + "tests/test_ledger_accounts.py:77::test_legacy_escrow_identity_migration_keeps_ledger_bytes", + "tests/test_ledger_accounts.py:126::test_migration_refuses_escrow_with_credential_reference", + "tests/test_ledger_migration_json.py:13::test_migration_refuses_authoritative_json_identity_reference", + "tests/test_ledger_migration_json.py:32::test_json_reference_contract_covers_the_current_schema", + "tests/test_ledger_migration_json.py:41::test_reference_paths_ignore_untyped_dictionary_keys", + "tests/test_ledger_migration_references.py:39::test_migration_refuses_escrow_references_and_preserves_legacy_rows", + "tests/test_ledger_migration_references.py:104::test_money_ledger_rejects_update_and_delete", + "tests/test_market_71.py:20::test_bank_fund_is_a_formal_command_without_unattended_flags", + "tests/test_market_71.py:28::test_bank_fund_role_and_payment_rollback_together", + "tests/test_market_71.py:51::test_bank_fund_two_console_approvals_before_pin_or_writes", + "tests/test_market_71.py:81::test_settlement_fact_receipts_must_be_its_own_escrow_legs", + "tests/test_money_admin.py:18::test_amount_exact", + "tests/test_money_admin.py:24::test_amount_rejected", + "tests/test_money_admin.py:30::test_cli_requires_explicit_root_source_and_has_no_pin_or_yes", + "tests/test_money_admin.py:46::test_mint_burn_root_payment_and_bank_role_are_atomic", + "tests/test_money_admin.py:76::test_issuer_rejects_impostor_overflow_and_invalid_role", + "tests/test_money_admin.py:104::test_console_gate_precedes_unlock_and_confirmation", + "tests/test_money_admin.py:134::test_offer_set_fail_closed_with_zero_database_change", + "tests/test_money_admin.py:163::test_offer_disable_console_confirmation_pin_audit_and_repeat", + "tests/test_money_config.py:19::test_money_example_has_deterministic_defaults", + "tests/test_money_config.py:30::test_only_currency_labels_can_change", + "tests/test_money_config.py:53::test_money_rejects_policy_changes_and_business_facts", + "tests/test_money_config.py:63::test_doctor_reports_money_config_without_claiming_market_e2e", + "tests/test_money_config.py:106::test_public_money_state_uses_configured_labels", + "tests/test_money_config.py:119::test_doctor_rejects_missing_market_schema_without_repairing_it", + "tests/test_money_config.py:135::test_market_selftest_features_require_the_actual_isolated_flow", + "tests/test_money_core.py:28::test_zero_supply_signed_transfer_and_private_views", + "tests/test_money_core.py:66::test_double_spend_overflow_and_root_network_guard", + "tests/test_money_core.py:100::test_ledger_cannot_be_mutated_and_reads_do_not_write", + "tests/test_money_paths.py:20::test_money_paths_are_read_only_and_balance_is_subject_private" + ] + }, + "resources": { + "source_files": [ + "src/msg/core/models.py", + "src/msg/plugins/content.py", + "src/msg/storage/postgres.py", + "src/msg/security/authorization.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_resource_path_migration.py", + "tests/test_revision_sources.py" + ], + "feature_ids": [ + "content", + "authorization" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "authorization", + "doctor": "authority_snapshot", + "selftest": "certgate", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_resource_path_migration.py:20::test_moved_paths_preserve_views_and_current_authority", + "tests/test_resource_path_migration.py:59::test_parent_and_child_moves_and_path_reuse", + "tests/test_resource_path_migration.py:83::test_migrated_extensionless_post_and_failed_move_rollback", + "tests/test_resource_path_migration.py:105::test_alias_reuse_between_route_resolution_and_signed_read_never_leaks_location", + "tests/test_resource_path_migration.py:134::test_migration_location_preserves_subject_namespace_sigil", + "tests/test_revision_sources.py:12::test_legacy_revision_decodes_without_new_source_fields", + "tests/test_revision_sources.py:24::test_release_source_fields_history_cursor_and_exact_diff", + "tests/test_revision_sources.py:54::test_user_cannot_forge_release_source_and_rule_links_resolve", + "tests/test_revision_sources.py:73::test_history_cursor_rechecks_principal_and_current_visibility" + ] + }, + "authorization": { + "source_files": [ + "src/msg/security/authorization.py", + "src/msg/security/capabilities.py", + "src/msg/plugins/sharing.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_authorization.py", + "tests/test_authorization_matrix.py", + "tests/test_authorization_recovery_boundaries.py", + "tests/test_authorization_sources.py", + "tests/test_share_grants.py", + "tests/test_share_grants_v2.py", + "tests/test_share_links.py", + "tests/test_share_source_boundaries.py", + "tests/test_share_transport_matrix.py" + ], + "feature_ids": [ + "authorization" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "authorization", + "doctor": "authority_snapshot", + "selftest": "certgate", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_authorization.py:40::test_certificate_gate_exact_operation_scope_and_revoke", + "tests/test_authorization.py:62::test_sticky_setgid_and_current_membership", + "tests/test_authorization.py:85::test_replay_does_not_leak_changed_private_content_or_create_again", + "tests/test_authorization.py:103::test_key_id_cannot_be_reassigned_by_temporary_upgrade", + "tests/test_authorization.py:124::test_token_rotation_retry_releases_once_and_revokes_old_credential", + "tests/test_authorization.py:145::test_csr_key_does_not_grant_shell_or_generic_login", + "tests/test_authorization.py:160::test_special_ceiling_does_not_turn_into_ordinary_owner_rights", + "tests/test_authorization_matrix.py:32::test_live_source_loss_across_transports_cli_and_projections", + "tests/test_authorization_recovery_boundaries.py:24::test_shared_post_does_not_open_attachment_and_concurrent_revocation_is_final", + "tests/test_authorization_recovery_boundaries.py:65::test_share_link_every_supported_transport_and_explicit_get_rejection", + "tests/test_authorization_recovery_boundaries.py:94::test_actual_postgres_restore_preserves_revocation_and_filters_notifications", + "tests/test_authorization_sources.py:40::test_archived_organization_cannot_authorize_stale_active_membership", + "tests/test_authorization_sources.py:72::test_restored_share_sources_cannot_silently_expand_contract", + "tests/test_authorization_sources.py:96::test_existing_share_source_stops_at_new_preview_boundary", + "tests/test_authorization_sources.py:113::test_topic_ban_cannot_be_bypassed_by_a_preexisting_nested_topic", + "tests/test_authorization_sources.py:129::test_share_revocation_matrix_covers_current_history_transports_search_sync", + "tests/test_share_grants.py:11::test_private_ancestor_exact_read_and_immediate_revocation", + "tests/test_share_grants.py:70::test_share_rejects_containers_and_managed_or_dm_resources", + "tests/test_share_grants.py:120::test_owner_revocation_while_archived_survives_restore", + "tests/test_share_grants.py:163::test_expired_grant_does_not_authorize_and_can_be_replaced", + "tests/test_share_grants_v2.py:21::test_group_reshare_tracks_source_membership_and_independent_grants", + "tests/test_share_grants_v2.py:75::test_reshare_revocation_and_expiry_do_not_remove_other_sources", + "tests/test_share_grants_v2.py:111::test_v2_group_share_does_not_open_parent_or_sensitive_resource", + "tests/test_share_links.py:24::test_bearer_link_rejects_get_packet_and_short_paths", + "tests/test_share_links.py:42::test_share_links_require_explicit_system_enable", + "tests/test_share_links.py:57::test_share_link_is_explicit_bounded_and_revocable", + "tests/test_share_links.py:111::test_share_link_rechecks_owner_acl_expiry_and_global_switch", + "tests/test_share_source_boundaries.py:13::test_named_source_revalidates_target_and_persisted_constraints", + "tests/test_share_source_boundaries.py:49::test_child_cannot_outlive_source_even_after_restore", + "tests/test_share_transport_matrix.py:120::test_live_group_reshare_revocation_across_read_surfaces" + ] + }, + "entitlement": { + "source_files": [ + "src/msg/plugins/offers.py", + "src/msg/market/compatibility.py", + "src/msg/market/entitlement_orders.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_entitlement_orders.py", + "tests/test_market_compatibility_reads.py", + "tests/test_offer_import.py", + "tests/test_offers.py" + ], + "feature_ids": [ + "money", + "orders" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "money", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "orders", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_entitlement_orders.py:24::test_explicit_entitlement_redemption_uses_one_order_and_conserves_supply", + "tests/test_entitlement_orders.py:59::test_grant_failure_rolls_back_shared_order_funding_and_release", + "tests/test_entitlement_orders.py:79::test_explicit_intent_and_legacy_pending_are_not_interchangeable", + "tests/test_entitlement_orders.py:102::test_concurrent_redemptions_cannot_spend_last_balance_twice", + "tests/test_entitlement_orders.py:118::test_revoked_key_cannot_replay_but_historical_signed_order_still_verifies", + "tests/test_entitlement_orders.py:140::test_recomputed_contract_digest_cannot_forge_signed_settlement_consent", + "tests/test_entitlement_orders.py:159::test_unavailable_provider_version_rejects_before_funding", + "tests/test_entitlement_orders.py:174::test_order_writer_preserves_full_byte_quantity_above_signed_integer32", + "tests/test_entitlement_orders.py:194::test_imported_legacy_quote_requires_explicit_adoption_before_order_writer", + "tests/test_entitlement_orders.py:215::test_backup_preserves_order_grant_and_receipts_without_reexecution", + "tests/test_entitlement_orders.py:250::test_diagnostics_reject_incomplete_synchronous_order_projection", + "tests/test_entitlement_orders.py:263::test_entitlement_expiry_cannot_drift_from_signed_duration", + "tests/test_market_compatibility_reads.py:20::test_offer_listing_v2_projection_preserves_v1_and_checks_revision", + "tests/test_market_compatibility_reads.py:46::test_purchase_order_projection_zero_writes_and_owner_only", + "tests/test_market_compatibility_reads.py:92::test_purchase_release_boundary_and_projection_preflight_fail_closed", + "tests/test_market_compatibility_reads.py:122::test_v2_native_reads_preserve_current_order_policy_and_authorization", + "tests/test_market_compatibility_reads.py:163::test_compatibility_reads_require_current_account_scope", + "tests/test_offer_import.py:34::test_import_preserves_offer_quote_pending_settled_and_receipt_bytes", + "tests/test_offer_import.py:61::test_import_approval_rechecks_pending_purchase_and_rolls_back_publish_failure", + "tests/test_offer_import.py:90::test_console_import_dry_run_needs_no_pin_and_real_import_requires_full_approval", + "tests/test_offer_import.py:117::test_import_rejects_inconsistent_entitlement_before_publication", + "tests/test_offer_import.py:131::test_daemon_import_parser_dispatches_dry_run", + "tests/test_offers.py:10::test_no_default_offers_and_no_read_side_effects", + "tests/test_offers.py:29::test_unlisted_redeem_fails_without_debit_or_entitlement", + "tests/test_offers.py:42::test_unqualified_catalogue_row_is_invisible_and_cannot_debit", + "tests/test_offers.py:65::test_local_offer_set_requires_registry_and_fulfiller" + ] + }, + "sale": { + "source_files": [ + "src/msg/plugins/store.py", + "src/msg/plugins/orders.py", + "src/msg/market/managed_delivery.py", + "src/msg/market/compatibility.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_market_71.py", + "tests/test_market_72.py", + "tests/test_market_account_scope.py", + "tests/test_market_arbitration.py", + "tests/test_market_boundaries.py", + "tests/test_market_cli_bindings.py", + "tests/test_market_compatibility_reads.py", + "tests/test_market_contracts.py", + "tests/test_market_delivery.py", + "tests/test_market_explicit_acceptance.py", + "tests/test_market_integration.py", + "tests/test_market_lease_deadlines.py", + "tests/test_market_lifecycle.py", + "tests/test_market_manual_flow.py", + "tests/test_market_notification_reuse.py", + "tests/test_market_paths.py", + "tests/test_market_rationale.py", + "tests/test_market_recovery.py", + "tests/test_market_redemption.py", + "tests/test_order_paths.py", + "tests/test_order_resources.py", + "tests/test_orders.py", + "tests/test_store_foundation.py" + ], + "feature_ids": [ + "market", + "orders" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "market", + "doctor": "market", + "selftest": "market_lifecycle", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "orders", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_market_71.py:20::test_bank_fund_is_a_formal_command_without_unattended_flags", + "tests/test_market_71.py:28::test_bank_fund_role_and_payment_rollback_together", + "tests/test_market_71.py:51::test_bank_fund_two_console_approvals_before_pin_or_writes", + "tests/test_market_71.py:81::test_settlement_fact_receipts_must_be_its_own_escrow_legs", + "tests/test_market_72.py:36::test_exhausted_claim_limit_is_consistent_in_both_views_and_topup_cannot_reopen", + "tests/test_market_72.py:60::test_pause_preserves_budget_topup_does_not_resume_and_close_returns_remainder", + "tests/test_market_72.py:78::test_expired_listing_projection_does_not_mutate_state_or_emit_events", + "tests/test_market_72.py:95::test_stored_challenge_fields_must_match_canonical_signed_payload", + "tests/test_market_72.py:111::test_backup_restores_claim_nonce_inbox_receipt_and_replay_together", + "tests/test_market_account_scope.py:30::test_store_scoped_funding_key_cannot_debit_account", + "tests/test_market_account_scope.py:50::test_store_scoped_read_key_cannot_read_private_order", + "tests/test_market_account_scope.py:66::test_cached_funding_result_revalidates_current_account_scope", + "tests/test_market_account_scope.py:89::test_account_scoped_read_key_preserves_owner_projection", + "tests/test_market_account_scope.py:107::test_catalog_scope_alone_cannot_buy_in_any_published_version", + "tests/test_market_account_scope.py:123::test_catalog_key_cannot_cancel_or_enumerate_private_orders", + "tests/test_market_account_scope.py:141::test_real_transport_blocks_fresh_and_cached_private_payment", + "tests/test_market_account_scope.py:175::test_arbitration_account_scope_preserves_public_minimal_summary", + "tests/test_market_account_scope.py:192::test_real_token_read_uses_the_same_account_scope", + "tests/test_market_account_scope.py:220::test_same_signer_scope_contraction_cannot_replay_previous_payment", + "tests/test_market_arbitration.py:68::test_fixed_panel_split_quorum_and_concurrent_execution", + "tests/test_market_arbitration.py:101::test_role_isolated_evidence_and_summary_has_no_private_fields", + "tests/test_market_arbitration.py:132::test_invalidated_decision_never_releases_funds", + "tests/test_market_arbitration.py:163::test_one_appeal_disjoint_panel_and_old_decision_is_unusable", + "tests/test_market_arbitration.py:191::test_split_rollback_restores_escrow_decision_and_retry", + "tests/test_market_arbitration.py:216::test_default_no_panel_holds_without_choosing_new_candidates", + "tests/test_market_boundaries.py:38::test_market_has_no_business_entrypoint_imports_even_inside_functions", + "tests/test_market_boundaries.py:52::test_compatibility_imports_are_the_same_owned_functions", + "tests/test_market_boundaries.py:65::test_shared_market_and_versioned_use_cases_import_without_loading_entrypoints", + "tests/test_market_boundaries.py:83::test_ledger_guard_has_one_owner_and_does_not_depend_on_escrow_or_handlers", + "tests/test_market_boundaries.py:96::test_shared_order_views_preserve_buyer_privacy_and_zero_write_semantics", + "tests/test_market_boundaries.py:119::test_shared_posting_path_rejects_forged_escrow_authority_without_writes", + "tests/test_market_boundaries.py:142::test_alias_reads_and_replay_preserve_signed_receipt_bytes", + "tests/test_market_cli_bindings.py:32::test_every_advertised_shortcut_resolves_an_actual_registered_operation", + "tests/test_market_cli_bindings.py:53::test_pay_shortcut_uses_real_funding_and_idempotency", + "tests/test_market_cli_bindings.py:72::test_dispute_shortcut_preserves_reason_and_uses_real_operation", + "tests/test_market_cli_bindings.py:88::test_resolve_shortcut_requires_a_real_signed_quorum_decision", + "tests/test_market_cli_bindings.py:108::test_contract_shortcut_reads_the_existing_locked_contract", + "tests/test_market_cli_bindings.py:129::test_package_shortcut_binds_actual_id_and_preserves_seller_privacy", + "tests/test_market_cli_bindings.py:144::test_explicit_buy_version_preserves_old_inputs_and_settlement_semantics", + "tests/test_market_cli_bindings.py:163::test_redeem_defaults_to_native_order_and_preserves_explicit_legacy_versions", + "tests/test_market_cli_bindings.py:185::test_out_of_range_version_is_rejected_before_client_or_business_writes", + "tests/test_market_cli_bindings.py:196::test_redeem_cli_rejects_unimported_offer_and_changed_pinned_intent", + "tests/test_market_compatibility_reads.py:20::test_offer_listing_v2_projection_preserves_v1_and_checks_revision", + "tests/test_market_compatibility_reads.py:46::test_purchase_order_projection_zero_writes_and_owner_only", + "tests/test_market_compatibility_reads.py:92::test_purchase_release_boundary_and_projection_preflight_fail_closed", + "tests/test_market_compatibility_reads.py:122::test_v2_native_reads_preserve_current_order_policy_and_authorization", + "tests/test_market_compatibility_reads.py:163::test_compatibility_reads_require_current_account_scope", + "tests/test_market_contracts.py:33::test_cli_routes_exact_contract_without_silent_price_or_recipient_changes", + "tests/test_market_contracts.py:43::test_limited_key_operation_name_does_not_grant_owner_account_scope_even_on_replay", + "tests/test_market_contracts.py:65::test_revoked_or_replaced_identity_key_cannot_spend_pending_challenge", + "tests/test_market_contracts.py:84::test_claim_close_race_and_event_failure_never_lose_or_duplicate_budget", + "tests/test_market_contracts.py:111::test_cli_pop_reuses_the_challenge_and_claim_request_ids", + "tests/test_market_contracts.py:128::test_default_doctor_is_read_only_and_selftest_cannot_seed_an_installed_server", + "tests/test_market_contracts.py:144::test_existing_short_codes_keep_their_published_contract", + "tests/test_market_delivery.py:59::test_verified_checkout_mail_is_minimal_and_not_claimed", + "tests/test_market_delivery.py:95::test_unverified_checkout_has_no_order_information_until_owner_verifies", + "tests/test_market_delivery.py:125::test_send_time_revalidation_prevents_stale_or_cross_subject_delivery", + "tests/test_market_delivery.py:149::test_sealed_secret_requires_buyer_key_and_explicit_acceptance", + "tests/test_market_delivery.py:189::test_large_service_transfer_has_buyer_acl_and_never_implies_acceptance", + "tests/test_market_explicit_acceptance.py:12::test_v4_delivery_requires_current_buyer_and_exact_digest_before_settlement", + "tests/test_market_explicit_acceptance.py:61::test_cli_default_checkout_waits_for_signed_acceptance", + "tests/test_market_explicit_acceptance.py:74::test_revoked_acceptance_key_cannot_replay_settlement", + "tests/test_market_explicit_acceptance.py:98::test_explicit_reservation_policy_survives_separate_funding", + "tests/test_market_explicit_acceptance.py:114::test_waiting_acceptance_survives_restart_without_release", + "tests/test_market_integration.py:21::test_funding_rejects_mixed_immutable_checkout_projection", + "tests/test_market_integration.py:44::test_claim_rejects_mixed_settled_snapshot", + "tests/test_market_integration.py:84::test_explicitly_disabled_mail_never_calls_sender", + "tests/test_market_lease_deadlines.py:17::test_expired_market_attempt_preserves_money_and_notification_truth", + "tests/test_market_lifecycle.py:45::test_instant_delivery_settlement_and_claim_are_distinct", + "tests/test_market_lifecycle.py:82::test_created_payment_snapshot_and_cancel_before_fund", + "tests/test_market_lifecycle.py:106::test_objective_missing_package_refunds_atomically", + "tests/test_market_lifecycle.py:125::test_last_quantity_and_timeout_do_not_double_spend", + "tests/test_market_manual_flow.py:11::test_prefunded_reward_then_manual_delivery_and_settlement", + "tests/test_market_notification_reuse.py:67::test_unit_checkout_rejects_control_characters_like_legacy", + "tests/test_market_notification_reuse.py:84::test_unit_projection_reads_job_not_stale_order_state", + "tests/test_market_notification_reuse.py:94::test_unit_disabled_endpoint_wins_without_reactivating_old_job", + "tests/test_market_notification_reuse.py:100::test_unit_missing_job_cannot_claim_sent_from_cached_order", + "tests/test_market_notification_reuse.py:105::test_unit_projection_rejects_cross_order_or_cross_subject_job", + "tests/test_market_notification_reuse.py:126::test_unit_pickup_origin_fails_closed_without_echo", + "tests/test_market_notification_reuse.py:135::test_unit_pickup_link_preserves_plain_authenticated_route", + "tests/test_market_notification_reuse.py:141::test_integration_market_completion_only_changes_job_facts", + "tests/test_market_notification_reuse.py:171::test_integration_lifecycle_email_uses_same_origin_guard", + "tests/test_market_paths.py:21::test_emailed_pickup_url_reads_new_delivery_without_claiming", + "tests/test_market_paths.py:63::test_unfunded_payment_url_has_no_fabricated_receipt_or_payment", + "tests/test_market_rationale.py:40::test_signed_allocation_without_rationale_is_rejected", + "tests/test_market_rationale.py:53::test_rationale_is_private_pinned_and_survives_source_edit", + "tests/test_market_rationale.py:92::test_valid_signature_cannot_be_reused_with_another_rationale", + "tests/test_market_rationale.py:107::test_rationale_from_another_case_does_not_authorize_a_vote", + "tests/test_market_rationale.py:134::test_unavailable_or_mismatched_reason_cannot_release_escrow", + "tests/test_market_rationale.py:158::test_only_current_panel_can_deposit_owned_nonempty_text", + "tests/test_market_recovery.py:26::test_restart_and_backup_preserve_private_case_decision_without_reexecution", + "tests/test_market_recovery.py:104::test_market_doctor_cannot_mutate_or_synthesize_a_repair", + "tests/test_market_redemption.py:33::test_real_quote_redeem_and_named_legs_are_atomic_and_replay_safe", + "tests/test_market_redemption.py:65::test_pending_locks_quote_then_cancel_or_settle_with_one_final_posting", + "tests/test_market_redemption.py:89::test_fulfiller_failure_rolls_back_postings_purchase_entitlement_and_event", + "tests/test_market_redemption.py:107::test_expired_reservation_refunds_exactly_and_does_not_extend_its_quote", + "tests/test_market_redemption.py:126::test_capacity_changes_real_deployment_without_granting_access", + "tests/test_market_redemption.py:151::test_clearing_policy_is_pure_and_has_no_bank_or_role_exceptions", + "tests/test_market_redemption.py:163::test_backup_restores_pending_purchase_and_settled_entitlement_without_repayment", + "tests/test_order_paths.py:22::test_order_and_delivery_paths_recheck_buyer_and_never_write", + "tests/test_order_resources.py:32::test_order_resource_accept_appends_revision_without_rewriting_signed_origin", + "tests/test_order_resources.py:69::test_sql_state_drift_cannot_be_read_republished_or_used_to_release", + "tests/test_order_resources.py:89::test_missing_order_body_blocks_reads_and_money_before_mutation", + "tests/test_order_resources.py:109::test_revision_publication_failure_rolls_back_order_delivery_and_ledger", + "tests/test_order_resources.py:125::test_timeout_internal_writer_advances_source_and_refunds_once", + "tests/test_order_resources.py:142::test_signed_arbitration_internal_entrypoint_advances_order_source", + "tests/test_order_resources.py:167::test_account_scoped_entitlement_replay_keeps_original_authority", + "tests/test_order_resources.py:185::test_accept_revision_failure_rolls_back_release_and_buyer_ack", + "tests/test_order_resources.py:207::test_invalid_source_signature_cannot_authorize_lifecycle_or_read", + "tests/test_orders.py:52::test_buy_funds_escrow_once_and_does_not_claim_delivery", + "tests/test_orders.py:98::test_order_views_are_private_and_reads_are_side_effect_free", + "tests/test_orders.py:152::test_price_race_quantity_and_failed_payment_roll_back", + "tests/test_orders.py:189::test_last_unit_competition_and_posted_debit_failure_roll_back", + "tests/test_orders.py:229::test_buyer_can_cancel_funded_order_once_and_recover_full_balance", + "tests/test_orders.py:288::test_cancel_refuses_delivery_and_rolls_back_after_refund_post", + "tests/test_orders.py:349::test_buy_rejects_unimplemented_delivery_mode_before_escrow", + "tests/test_store_foundation.py:18::test_store_starts_empty_and_listing_requires_seller_signature", + "tests/test_store_foundation.py:54::test_legacy_listing_body_without_mode_reads_as_sale", + "tests/test_store_foundation.py:73::test_deposit_is_immutable_and_only_seller_can_read_payload", + "tests/test_store_foundation.py:119::test_listing_cas_payload_ownership_and_no_secret_leak", + "tests/test_store_foundation.py:147::test_store_namespace_and_listing_metadata_need_store_operations" + ] + }, + "bounty": { + "source_files": [ + "src/msg/plugins/bounty.py", + "src/msg/admin/market_check.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_bounty.py", + "tests/test_bounty_snapshot_integrity.py" + ], + "feature_ids": [ + "bounty" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "bounty", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_bounty.py:50::test_bounty_catalog_current_projection_and_historical_revision", + "tests/test_bounty.py:89::test_prefunded_bounty_pays_offline_publisher_and_stops_at_budget", + "tests/test_bounty.py:153::test_bad_proof_wrong_subject_expiry_and_top_up_do_not_spend", + "tests/test_bounty.py:205::test_unknown_eligibility_constraint_fails_closed", + "tests/test_bounty.py:242::test_concurrent_last_reward_only_one_paid_claim", + "tests/test_bounty.py:272::test_close_returns_unspent_escrow_and_blocks_pending_challenge", + "tests/test_bounty_snapshot_integrity.py:16::test_mixed_bounty_projection_cannot_change_published_terms", + "tests/test_bounty_snapshot_integrity.py:40::test_doctor_rejects_mixed_bounty_terms_without_repair", + "tests/test_bounty_snapshot_integrity.py:59::test_mixed_terms_block_management_without_ledger_changes" + ] + }, + "personal": { + "source_files": [ + "src/msg/plugins/content.py", + "src/msg/plugins/identity.py", + "src/msg/client_content.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_cli_legacy.py", + "tests/test_legacy_directive.py", + "tests/test_personal_content_signatures.py", + "tests/test_personal_documents.py", + "tests/test_personal_text_boundaries.py" + ], + "feature_ids": [ + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_cli_legacy.py:15::test_legacy_cli_put_revise_archive_keeps_state_active", + "tests/test_cli_legacy.py:52::test_legacy_cli_put_can_send_revision_signed_contract", + "tests/test_legacy_directive.py:36::test_signed_owner_will_versions_without_plaintext_secrets_or_interactions", + "tests/test_legacy_directive.py:100::test_private_references_are_owner_scoped_and_never_execute", + "tests/test_legacy_directive.py:124::test_owner_recovery_envelope_reference_and_presence_expiry_are_inert", + "tests/test_legacy_directive.py:161::test_private_historical_will_cannot_be_published_by_later_revision", + "tests/test_legacy_directive.py:179::test_legacy_v2_keeps_independently_signed_revisions", + "tests/test_legacy_directive.py:228::test_bad_legacy_v2_signature_rolls_back_creation_and_v1_stays_request_proof", + "tests/test_personal_content_signatures.py:28::test_personal_v2_preserves_independently_signed_history", + "tests/test_personal_content_signatures.py:55::test_todo_v2_uses_an_independent_revision_signature", + "tests/test_personal_content_signatures.py:80::test_bad_content_signature_rolls_back_personal_creation", + "tests/test_personal_documents.py:13::test_personal_documents_are_lazily_created_private_and_signed", + "tests/test_personal_documents.py:82::test_personal_history_and_secret_or_rule_bypass_rejected", + "tests/test_personal_text_boundaries.py:17::test_known_secret_shapes_are_rejected", + "tests/test_personal_text_boundaries.py:23::test_size_and_english_override_guards_are_explicit", + "tests/test_personal_text_boundaries.py:35::test_unrecognized_natural_language_never_changes_authority" + ] + }, + "share": { + "source_files": [ + "src/msg/plugins/sharing.py", + "src/msg/security/authorization.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_share_grants.py", + "tests/test_share_grants_v2.py", + "tests/test_share_links.py", + "tests/test_share_source_boundaries.py", + "tests/test_share_transport_matrix.py" + ], + "feature_ids": [ + "authorization" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "authorization", + "doctor": "authority_snapshot", + "selftest": "certgate", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_share_grants.py:11::test_private_ancestor_exact_read_and_immediate_revocation", + "tests/test_share_grants.py:70::test_share_rejects_containers_and_managed_or_dm_resources", + "tests/test_share_grants.py:120::test_owner_revocation_while_archived_survives_restore", + "tests/test_share_grants.py:163::test_expired_grant_does_not_authorize_and_can_be_replaced", + "tests/test_share_grants_v2.py:21::test_group_reshare_tracks_source_membership_and_independent_grants", + "tests/test_share_grants_v2.py:75::test_reshare_revocation_and_expiry_do_not_remove_other_sources", + "tests/test_share_grants_v2.py:111::test_v2_group_share_does_not_open_parent_or_sensitive_resource", + "tests/test_share_links.py:24::test_bearer_link_rejects_get_packet_and_short_paths", + "tests/test_share_links.py:42::test_share_links_require_explicit_system_enable", + "tests/test_share_links.py:57::test_share_link_is_explicit_bounded_and_revocable", + "tests/test_share_links.py:111::test_share_link_rechecks_owner_acl_expiry_and_global_switch", + "tests/test_share_source_boundaries.py:13::test_named_source_revalidates_target_and_persisted_constraints", + "tests/test_share_source_boundaries.py:49::test_child_cannot_outlive_source_even_after_restore", + "tests/test_share_transport_matrix.py:120::test_live_group_reshare_revocation_across_read_surfaces" + ] + }, + "discussion": { + "source_files": [ + "src/msg/plugins/discussion.py", + "src/msg/plugins/content.py", + "src/msg/core/query.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_post_lifecycle.py", + "tests/test_postgres.py" + ], + "feature_ids": [ + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_post_lifecycle.py:17::test_metadata_edit_does_not_rewrite_content_or_author", + "tests/test_post_lifecycle.py:37::test_rollback_creates_new_revision_with_historical_content_and_current_author", + "tests/test_post_lifecycle.py:74::test_metadata_and_rollback_reject_conflicts_and_unauthorized_without_publication", + "tests/test_post_lifecycle.py:104::test_reply_rollback_changes_only_reply", + "tests/test_postgres.py:22::test_postgres_session_contract_and_rollback", + "tests/test_postgres.py:56::test_postgres_audit_append_only_and_concurrent_writers", + "tests/test_postgres.py:101::test_outbox_signal_only_after_commit_and_does_not_own_result" + ] + }, + "files": { + "source_files": [ + "src/msg/plugins/files.py", + "src/msg/plugins/content.py", + "src/msg/core/text_patch.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_file_copy_integrity.py", + "tests/test_file_operations.py", + "tests/test_proposals.py", + "tests/test_text_patch.py", + "tests/test_text_patch_line_boundaries.py", + "tests/test_text_patch_rebase_batch.py" + ], + "feature_ids": [ + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_file_copy_integrity.py:10::test_binary_copy_rejects_unavailable_source_without_publication", + "tests/test_file_copy_integrity.py:38::test_reupload_over_damaged_binary_is_not_published", + "tests/test_file_operations.py:8::test_file_write_read_copy_conflicts_and_directory_permissions", + "tests/test_file_operations.py:51::test_existing_operation_limited_key_does_not_gain_file_alias", + "tests/test_file_operations.py:73::test_file_patch_and_atomic_batch_preserve_single_publication", + "tests/test_file_operations.py:101::test_directory_owner_cannot_replace_another_authors_file", + "tests/test_file_operations.py:118::test_binary_replacement_removes_old_search_projection", + "tests/test_proposals.py:32::test_proposal_accept_uses_pinned_content_and_replays_once", + "tests/test_proposals.py:54::test_proposal_conflict_keeps_open_and_cannot_edit_without_current_rights", + "tests/test_proposals.py:88::test_proposal_rechecks_content_visibility_and_rejects_other_target_kinds", + "tests/test_text_patch.py:24::test_context_patch_post_and_file_preserve_revision_history", + "tests/test_text_patch.py:55::test_patch_rejects_ambiguous_stale_missing_and_unauthorized_without_publication", + "tests/test_text_patch.py:80::test_patch_rejects_binary_and_bounded_complexity", + "tests/test_text_patch_line_boundaries.py:14::test_unified_patch_preserves_non_lf_bytes_and_agrees_with_git", + "tests/test_text_patch_line_boundaries.py:30::test_inline_unicode_separator_cannot_forge_a_markdown_heading", + "tests/test_text_patch_line_boundaries.py:42::test_markdown_keeps_real_line_endings_and_signed_section_digest", + "tests/test_text_patch_line_boundaries.py:50::test_unified_no_newline_markers_preserve_inline_unicode", + "tests/test_text_patch_rebase_batch.py:26::test_rebase_unique_context_and_reject_changed_or_ambiguous", + "tests/test_text_patch_rebase_batch.py:53::test_batch_preflight_failure_and_concurrent_writers_publish_nothing_partial", + "tests/test_text_patch_rebase_batch.py:81::test_batch_second_revision_failure_rolls_back_pg" + ] + }, + "paths": { + "source_files": [ + "src/msg/transports/http_routes.py", + "src/msg/storage/query.py", + "src/msg/core/query.py", + "src/msg/core/query.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_linkset_diff.py", + "tests/test_path_read_query.py", + "tests/test_read_projection_cache_transports.py", + "tests/test_read_projection_cache_unit.py", + "tests/test_resource_path_migration.py" + ], + "feature_ids": [ + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_linkset_diff.py:23::test_linkset_uses_real_refs_and_authorized_collection_pages", + "tests/test_linkset_diff.py:92::test_known_previous_and_arbitrary_revision_diffs_are_exact", + "tests/test_linkset_diff.py:127::test_private_links_and_diff_hide_metadata_until_current_authorization", + "tests/test_linkset_diff.py:169::test_backlink_collection_does_not_reveal_private_source_or_count", + "tests/test_linkset_diff.py:198::test_comment_cursor_rechecks_visibility_after_revocation", + "tests/test_linkset_diff.py:228::test_large_diff_pages_pin_both_revisions_without_skipping_lines", + "tests/test_path_read_query.py:14::test_read_query_path_aliases_match_query_string_content_etag_and_cursor", + "tests/test_path_read_query.py:46::test_private_path_query_and_continuation_use_path_signature_without_header", + "tests/test_path_read_query.py:96::test_path_query_rejects_ambiguous_or_unknown_segments", + "tests/test_path_read_query.py:111::test_search_path_matches_query_string_with_cursor_and_no_business_write", + "tests/test_path_read_query.py:139::test_private_search_path_accepts_short_lived_signature_without_header", + "tests/test_read_projection_cache_transports.py:49::test_default_compact_cache_is_reusable_but_never_authorizes_a_read", + "tests/test_read_projection_cache_unit.py:33::test_get_reuses_exact_full_and_compact_projection_digest", + "tests/test_read_projection_cache_unit.py:52::test_cache_hint_cannot_change_projection_values_or_array_nulls", + "tests/test_read_projection_cache_unit.py:64::test_matching_cache_hint_never_suppresses_current_read_failure", + "tests/test_resource_path_migration.py:20::test_moved_paths_preserve_views_and_current_authority", + "tests/test_resource_path_migration.py:59::test_parent_and_child_moves_and_path_reuse", + "tests/test_resource_path_migration.py:83::test_migrated_extensionless_post_and_failed_move_rollback", + "tests/test_resource_path_migration.py:105::test_alias_reuse_between_route_resolution_and_signed_read_never_leaks_location", + "tests/test_resource_path_migration.py:134::test_migration_location_preserves_subject_namespace_sigil" + ] + }, + "read": { + "source_files": [ + "src/msg/core/read_query.py", + "src/msg/core/query.py", + "src/msg/core/cursors.py", + "src/msg/transports/read_tree_path.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_nested_read_query.py", + "tests/test_read_cursor.py", + "tests/test_read_namespace.py", + "tests/test_read_only_evidence.py", + "tests/test_read_projection_cache_transports.py", + "tests/test_read_projection_cache_unit.py", + "tests/test_read_query_cursor.py", + "tests/test_sync_checkpoint.py", + "tests/test_sync_cursor.py" + ], + "feature_ids": [ + "search", + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "search", + "doctor": "lexical_search", + "selftest": "lexical_search_access", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_nested_read_query.py:14::test_nested_query_string_path_and_query_ref_share_v2_contract", + "tests/test_nested_read_query.py:71::test_nested_collections_have_independent_pages_and_no_business_writes", + "tests/test_nested_read_query.py:124::test_nested_continuation_filters_child_revoked_after_first_page", + "tests/test_nested_read_query.py:149::test_nested_cursor_rechecks_current_parent_access_and_binds_principal", + "tests/test_nested_read_query.py:186::test_read_query_v1_expansion_semantics_remain_closed", + "tests/test_read_cursor.py:23::test_read_cursor_keeps_revision_and_splits_markdown_on_blocks_or_utf8", + "tests/test_read_cursor.py:82::test_read_cursor_prev_expiry_and_live_permission_recheck", + "tests/test_read_cursor.py:131::test_read_cursor_keeps_fenced_code_list_and_table_blocks_together", + "tests/test_read_namespace.py:14::test_read_aliases_share_handler_without_redirect_or_auth_gap", + "tests/test_read_namespace.py:35::test_private_read_aliases_hide_head_meta_history_and_etag", + "tests/test_read_namespace.py:63::test_graphql_query_and_mutation_are_separated_by_route", + "tests/test_read_only_evidence.py:9::test_read_evidence_rejects_update_without_changing_row_count", + "tests/test_read_only_evidence.py:22::test_read_evidence_rejects_sender_before_network_access", + "tests/test_read_only_evidence.py:48::test_read_evidence_accepts_idle_configured_signal", + "tests/test_read_only_evidence.py:56::test_read_evidence_detects_signal_even_when_store_swallows_failure", + "tests/test_read_only_evidence.py:69::test_read_evidence_detects_caught_sender_failure", + "tests/test_read_projection_cache_transports.py:49::test_default_compact_cache_is_reusable_but_never_authorizes_a_read", + "tests/test_read_projection_cache_unit.py:33::test_get_reuses_exact_full_and_compact_projection_digest", + "tests/test_read_projection_cache_unit.py:52::test_cache_hint_cannot_change_projection_values_or_array_nulls", + "tests/test_read_projection_cache_unit.py:64::test_matching_cache_hint_never_suppresses_current_read_failure", + "tests/test_read_query_cursor.py:23::test_read_query_page_cursor_aliases_snapshot_and_zero_business_effects", + "tests/test_read_query_cursor.py:78::test_page_cursor_binds_principal_and_rechecks_current_access", + "tests/test_read_query_cursor.py:112::test_read_query_rejects_expensive_or_unsupported_expansion", + "tests/test_sync_checkpoint.py:24::test_checkpoint_expands_seen_beyond_64_and_requires_ack", + "tests/test_sync_checkpoint.py:82::test_checkpoint_permission_gain_and_retention_fail_closed", + "tests/test_sync_checkpoint.py:110::test_checkpoint_drains_known_revocations_one_ack_at_a_time", + "tests/test_sync_cursor.py:42::test_sync_cursor_tracks_visible_create_modify_archive_and_known_revocation", + "tests/test_sync_cursor.py:115::test_sync_cursor_rejects_other_kinds_expiry_and_expired_window", + "tests/test_sync_cursor.py:144::test_sync_never_emits_unseen_private_reference", + "tests/test_sync_cursor.py:167::test_sync_seen_window_has_a_hard_bounded_size", + "tests/test_sync_cursor.py:174::test_sync_permission_gain_requires_replay_of_old_events", + "tests/test_sync_cursor.py:210::test_sync_over_64_refs_fails_without_advancing_cursor", + "tests/test_sync_cursor.py:243::test_sync_topic_membership_change_invalidates_old_cursor" + ] + }, + "secret_paths": { + "source_files": [ + "src/msg/transports/http.py", + "src/msg/transports/http_routes.py", + "src/msg/core/packet.py", + "src/msg/security/authentication.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_legacy_secret_entrypoints.py", + "tests/test_read_only_evidence.py", + "tests/test_url_secrets.py" + ], + "feature_ids": [ + "credential_delivery" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "credential_delivery", + "doctor": "credential_delivery", + "selftest": "credential_delivery_recovery", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_legacy_secret_entrypoints.py:43::test_old_custodial_issuance_packet_rejected_by_http_graphql_and_mcp", + "tests/test_read_only_evidence.py:9::test_read_evidence_rejects_update_without_changing_row_count", + "tests/test_read_only_evidence.py:22::test_read_evidence_rejects_sender_before_network_access", + "tests/test_read_only_evidence.py:48::test_read_evidence_accepts_idle_configured_signal", + "tests/test_read_only_evidence.py:56::test_read_evidence_detects_signal_even_when_store_swallows_failure", + "tests/test_read_only_evidence.py:69::test_read_evidence_detects_caught_sender_failure", + "tests/test_url_secrets.py:25::test_full_get_packet_rejects_token_proof_and_recovery_claim_without_state_change", + "tests/test_url_secrets.py:82::test_rejected_query_secrets_are_not_echoed_or_logged", + "tests/test_url_secrets.py:103::test_legacy_short_get_secret_routes_are_disabled_and_not_advertised", + "tests/test_url_secrets.py:134::test_path_client_rejects_secrets_locally_but_signed_nonsecret_get_works" + ] + }, + "execution": { + "source_files": [ + "src/msg/core/executor.py", + "src/msg/core/requests.py", + "src/msg/core/models.py", + "src/msg/transports/http.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_effect_completion_fencing.py", + "tests/test_effect_lease_deadlines.py", + "tests/test_effect_retry_state.py", + "tests/test_operations_admin.py", + "tests/test_passive_get_guard.py", + "tests/test_passive_ingress_priority.py" + ], + "feature_ids": [ + "content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "content", + "doctor": "content_layout", + "selftest": "idempotency", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_effect_completion_fencing.py:17::test_maintenance_result_is_atomic_with_the_current_attempt", + "tests/test_effect_completion_fencing.py:75::test_real_tool_expiry_does_not_publish_a_file_without_a_sweep", + "tests/test_effect_completion_fencing.py:109::test_real_git_expiry_does_not_move_refs_without_a_sweep", + "tests/test_effect_lease_deadlines.py:34::test_expired_attempt_is_fenced_without_another_worker_claim", + "tests/test_effect_lease_deadlines.py:59::test_concurrent_expired_completion_and_retry_preserve_uncertainty", + "tests/test_effect_retry_state.py:30::test_retry_delay_attempt_limit_and_channel_codes_survive_refactor", + "tests/test_effect_retry_state.py:54::test_late_retry_cannot_overwrite_another_lease_or_terminal_state", + "tests/test_effect_retry_state.py:69::test_expired_external_execution_is_uncertain_not_automatically_retried", + "tests/test_operations_admin.py:16::test_network_system_configuration_cannot_change_root_trust", + "tests/test_operations_admin.py:35::test_policy_cleanup_is_explicit_records_tombstone_and_does_not_delete_other_topics", + "tests/test_operations_admin.py:52::test_doctor_does_not_create_database_or_repair_bootstrap", + "tests/test_operations_admin.py:71::test_selftest_isolated_end_to_end_checks", + "tests/test_passive_get_guard.py:46::test_route_effect_classification", + "tests/test_passive_get_guard.py:51::test_passive_clients_cannot_execute_even_fully_signed_get_url", + "tests/test_passive_get_guard.py:83::test_agent_get_still_requires_bound_proof_and_idempotency", + "tests/test_passive_get_guard.py:129::test_short_code_path_and_public_pages_do_not_spread_execution_credentials", + "tests/test_passive_get_guard.py:156::test_agent_user_agent_does_not_bypass_current_authorizer", + "tests/test_passive_ingress_priority.py:37::test_passive_write_get_preserves_403_without_echo_or_execution", + "tests/test_passive_ingress_priority.py:51::test_nonpassive_secret_url_still_fails_the_strict_boundary", + "tests/test_passive_ingress_priority.py:61::test_read_only_operation_does_not_get_a_write_guard" + ] + }, + "arbitration": { + "source_files": [ + "src/msg/plugins/orders.py", + "src/msg/market/arbitration.py", + "src/msg/market/ledger.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_market_explicit_acceptance.py", + "tests/test_market_recovery.py" + ], + "feature_ids": [ + "market", + "orders" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "market", + "doctor": "market", + "selftest": "market_lifecycle", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "orders", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_market_explicit_acceptance.py:12::test_v4_delivery_requires_current_buyer_and_exact_digest_before_settlement", + "tests/test_market_explicit_acceptance.py:61::test_cli_default_checkout_waits_for_signed_acceptance", + "tests/test_market_explicit_acceptance.py:74::test_revoked_acceptance_key_cannot_replay_settlement", + "tests/test_market_explicit_acceptance.py:98::test_explicit_reservation_policy_survives_separate_funding", + "tests/test_market_explicit_acceptance.py:114::test_waiting_acceptance_survives_restart_without_release", + "tests/test_market_recovery.py:26::test_restart_and_backup_preserve_private_case_decision_without_reexecution", + "tests/test_market_recovery.py:104::test_market_doctor_cannot_mutate_or_synthesize_a_repair" + ] + }, + "search": { + "source_files": [ + "src/msg/core/search_query.py", + "src/msg/plugins/discovery.py", + "src/msg/plugins/read_predicates.py", + "src/msg/plugins/saved_queries.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_cli_search_grep.py", + "tests/test_saved_queries.py", + "tests/test_search_source_relation_filters.py", + "tests/test_search_spelling.py", + "tests/test_search_suggestions.py" + ], + "feature_ids": [ + "search" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "search", + "doctor": "lexical_search", + "selftest": "lexical_search_access", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_cli_search_grep.py:15::test_cli_search_one_page_cursor_and_scoped_grep", + "tests/test_cli_search_grep.py:53::test_cli_v5_search_flags_typed_scope_and_cursor_use_signed_contract", + "tests/test_cli_search_grep.py:93::test_cli_search_grep_reject_unbounded_or_mixed_options", + "tests/test_saved_queries.py:35::test_save_pins_descriptor_replays_and_outlives_only_ticket", + "tests/test_saved_queries.py:70::test_saved_query_rechecks_scope_and_never_shares_captured_authority", + "tests/test_saved_queries.py:95::test_saved_query_requires_pinned_revision_and_unchanged_digest", + "tests/test_saved_queries.py:109::test_saved_authority_is_current_and_internal_body_cannot_be_projected", + "tests/test_search_source_relation_filters.py:15::test_v3_query_string_path_and_sealed_ref_share_filters", + "tests/test_search_source_relation_filters.py:57::test_source_and_relation_filters_are_v3_and_use_current_revision", + "tests/test_search_source_relation_filters.py:93::test_v3_old_cursor_rechecks_grants_before_items_facets_and_rank", + "tests/test_search_source_relation_filters.py:133::test_v5_revision_and_source_version_filters_use_current_readable_revision", + "tests/test_search_source_relation_filters.py:177::test_v5_source_version_cursor_preserves_contract_and_rejects_downgrade", + "tests/test_search_source_relation_filters.py:197::test_v5_revision_filter_does_not_reveal_private_resource", + "tests/test_search_source_relation_filters.py:216::test_v5_directional_relations_and_presence_recheck_both_endpoints", + "tests/test_search_source_relation_filters.py:261::test_v5_title_is_explicit_resource_display_name_alias", + "tests/test_search_source_relation_filters.py:286::test_v5_typed_scopes_union_resources_and_subject_with_current_access", + "tests/test_search_spelling.py:12::test_spelling_is_opt_in_and_rechecks_visible_name_dictionary", + "tests/test_search_spelling.py:53::test_v5_registered_collaboration_relation_names_select_same_http_contract", + "tests/test_search_suggestions.py:14::test_v4_http_and_query_ref_use_same_authorized_suggestions", + "tests/test_search_suggestions.py:53::test_v4_suggestions_count_only_matching_readable_names_and_recheck_page", + "tests/test_search_suggestions.py:99::test_v4_suggest_is_explicit_and_old_schemas_are_stable" + ] + }, + "storage": { + "source_files": [ + "src/msg/storage/git.py", + "src/msg/storage/postgres.py", + "src/msg/admin/backups.py", + "src/msg/storage/legacy_resource_import.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_backup_daemon.py", + "tests/test_backup_drill.py", + "tests/test_backup_policy.py", + "tests/test_backup_retirement_attestation.py", + "tests/test_backup_retirement_cli.py", + "tests/test_file_copy_integrity.py", + "tests/test_legacy_resource_import.py", + "tests/test_storage_commit_faults.py", + "tests/test_storage_layout.py" + ], + "feature_ids": [ + "postgres", + "git_content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "postgres", + "doctor": "storage", + "selftest": "stable_id_read", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": "git_content", + "doctor": "git", + "selftest": "git_push_read_cas", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_backup_daemon.py:12::test_consistent_backup_restores_content_but_never_root_private_key", + "tests/test_backup_daemon.py:35::test_unattended_init_never_generates_root_or_accepts_pin_flag", + "tests/test_backup_drill.py:26::test_pg_dump_and_file_copy_hold_writer_lock", + "tests/test_backup_drill.py:58::test_restore_preserves_shared_lfs_hardlinks_and_disables_outbound", + "tests/test_backup_drill.py:152::test_restore_rejects_lfs_digest_conflict_before_touching_pg", + "tests/test_backup_drill.py:184::test_interrupted_restore_rolls_back_dump_and_gate_together", + "tests/test_backup_policy.py:15::test_real_backup_restore_preserves_money_and_credential_policy", + "tests/test_backup_retirement_attestation.py:106::test_without_record_backup_and_server_key_remain_false", + "tests/test_backup_retirement_attestation.py:118::test_imported_root_signed_record_retires_exactly_that_backup_scope", + "tests/test_backup_retirement_attestation.py:138::test_backup_attestation_alone_is_not_server_key_retirement", + "tests/test_backup_retirement_attestation.py:171::test_bad_records_are_rejected_at_import_and_never_stored", + "tests/test_backup_retirement_attestation.py:181::test_persisted_record_is_reverified_and_fails_closed", + "tests/test_backup_retirement_attestation.py:206::test_retirement_view_ignores_network_body_and_caller_flags", + "tests/test_backup_retirement_attestation.py:236::test_import_and_signing_require_the_physical_console", + "tests/test_backup_retirement_attestation.py:253::test_no_network_adapter_can_write_or_import_the_record", + "tests/test_backup_retirement_attestation.py:268::test_network_inventory_reports_backup_retired_only_after_local_import", + "tests/test_backup_retirement_cli.py:15::test_retirement_command_parser", + "tests/test_backup_retirement_cli.py:25::test_retirement_cli_refuses_nonconsole_before_reading_files", + "tests/test_backup_retirement_cli.py:43::test_statement_file_boundary", + "tests/test_backup_retirement_cli.py:65::test_statement_file_valid", + "tests/test_backup_retirement_cli.py:74::test_record_output_never_overwrites", + "tests/test_backup_retirement_cli.py:89::test_record_output_refuses_symlink", + "tests/test_file_copy_integrity.py:10::test_binary_copy_rejects_unavailable_source_without_publication", + "tests/test_file_copy_integrity.py:38::test_reupload_over_damaged_binary_is_not_published", + "tests/test_legacy_resource_import.py:65::test_real_pg_import_reads_content_and_preserves_signature_reference", + "tests/test_legacy_resource_import.py:118::test_missing_mapping_and_unsigned_changes_rejected", + "tests/test_legacy_resource_import.py:133::test_failure_rolls_back_pg_resources_and_manifest", + "tests/test_legacy_resource_import.py:154::test_mapping_plan_exposes_counts_and_digest_only", + "tests/test_storage_commit_faults.py:12::test_deferred_commit_failure_leaves_collectable_orphan_and_retry", + "tests/test_storage_layout.py:10::test_new_installation_uses_documented_persistent_layout" + ] + }, + "recovery": { + "source_files": [ + "src/msg/admin/recovery_replay.py", + "src/msg/admin/recovery_policy.py", + "src/msg/admin/recovery_proof.py", + "src/msg/security/quarantine.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_market_recovery.py", + "tests/test_recovery_authority_reconcile.py", + "tests/test_recovery_current_authority.py", + "tests/test_recovery_envelope.py", + "tests/test_recovery_metadata.py", + "tests/test_recovery_policy_v2.py", + "tests/test_recovery_runtime_generation.py", + "tests/test_recovery_safety_units.py" + ], + "feature_ids": [ + "recovery" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "recovery", + "doctor": "recovery_checkpoint", + "selftest": "recovery_checkpoint_replay", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_market_recovery.py:26::test_restart_and_backup_preserve_private_case_decision_without_reexecution", + "tests/test_market_recovery.py:104::test_market_doctor_cannot_mutate_or_synthesize_a_repair", + "tests/test_recovery_authority_reconcile.py:56::test_stage_new_owner_parent_without_exposing_old_or_new_scope", + "tests/test_recovery_authority_reconcile.py:76::test_bad_reconciliation_rolls_back_earlier_certificate_revocation", + "tests/test_recovery_authority_reconcile.py:101::test_contiguous_owner_chain_converges_once_and_rejects_gap", + "tests/test_recovery_authority_reconcile.py:123::test_acl_then_transfer_replays_twice", + "tests/test_recovery_authority_reconcile.py:137::test_intermediate_acl_binds_to_signed_authority_chain", + "tests/test_recovery_current_authority.py:49::test_retirement_rechecks_current_root_and_import_rejects", + "tests/test_recovery_current_authority.py:87::test_retirement_accepts_valid_root_and_stops_at_credential_expiry", + "tests/test_recovery_current_authority.py:101::test_far_future_retirement_is_rejected_without_datetime_overflow", + "tests/test_recovery_current_authority.py:114::test_checkpoint_ban_cannot_inherit_stale_expiry", + "tests/test_recovery_envelope.py:27::test_age_multi_recipient_backup_is_opt_in_and_offline", + "tests/test_recovery_envelope.py:85::test_selected_age_keystore_rewrap_keeps_old_revision_and_key", + "tests/test_recovery_metadata.py:17::test_policy_default_deny_and_signed_owner_opt_in", + "tests/test_recovery_metadata.py:52::test_envelope_binds_exact_private_age_revision_and_owner_declared_recipient", + "tests/test_recovery_metadata.py:119::test_platform_custodian_config_rejects_private_material", + "tests/test_recovery_metadata.py:132::test_configured_custodian_is_public_but_recipient_binding_is_checked", + "tests/test_recovery_policy_v2.py:45::test_v2_acl_is_monotonic_idempotent_and_preserves_history", + "tests/test_recovery_policy_v2.py:67::test_v2_rejects_unsupported_authority_and_contracts", + "tests/test_recovery_policy_v2.py:93::test_v2_policy_ban_expiry_and_rollback", + "tests/test_recovery_policy_v2.py:148::test_packaged_policy_schema_matches_typed_contract", + "tests/test_recovery_policy_v2.py:163::test_v1_prefix_can_continue_in_v2_but_not_downgrade", + "tests/test_recovery_policy_v2.py:177::test_v2_ban_never_shortens_an_active_restriction", + "tests/test_recovery_policy_v2.py:200::test_acl_restriction_preserves_existing_special_security_bits", + "tests/test_recovery_policy_v2.py:215::test_current_certificate_digest_revokes_stale_authority_without_resigning", + "tests/test_recovery_runtime_generation.py:24::test_idle_executor_and_http_require_restart_after_promotion", + "tests/test_recovery_runtime_generation.py:47::test_idle_workers_claim_and_timers_cannot_mutate_promoted_database", + "tests/test_recovery_runtime_generation.py:72::test_idle_readonly_hosting_requires_restart_without_expanding_role", + "tests/test_recovery_runtime_generation.py:99::test_generation_mismatch_remains_latched_even_if_setting_reverted", + "tests/test_recovery_runtime_generation.py:113::test_promotion_during_load_cannot_pin_new_generation_to_old_runtime", + "tests/test_recovery_runtime_generation.py:142::test_present_invalid_generation_is_not_treated_as_an_old_installation", + "tests/test_recovery_safety_units.py:63::test_rotation_commitment_binds_all_installation_targets", + "tests/test_recovery_safety_units.py:82::test_rotation_rejects_journal_substitution", + "tests/test_recovery_safety_units.py:90::test_legacy_unbound_rotation_is_not_silently_resumed", + "tests/test_recovery_safety_units.py:97::test_rotation_requires_same_online_key_and_service", + "tests/test_recovery_safety_units.py:132::test_ack_is_reverified_not_just_counted", + "tests/test_recovery_safety_units.py:153::test_bad_history_evidence_remains_pending", + "tests/test_recovery_safety_units.py:161::test_inventory_delta_invalidates_previously_verified_acks", + "tests/test_recovery_safety_units.py:171::test_ack_cannot_cross_subject_or_challenge", + "tests/test_recovery_safety_units.py:178::test_persisted_quarantine_fails_closed_for_malformed_flags", + "tests/test_recovery_safety_units.py:194::test_online_vault_destruction_is_not_backup_retirement", + "tests/test_recovery_safety_units.py:233::test_restore_quarantine_is_inside_single_transaction", + "tests/test_recovery_safety_units.py:261::test_failed_dump_conversion_never_connects_to_restore_database" + ] + }, + "transfer": { + "source_files": [ + "src/msg/core/transfer.py", + "src/msg/plugins/transfer.py", + "src/msg/client.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_transfer.py", + "tests/test_transfer_endpoint.py", + "tests/test_transfer_status_cursors.py" + ], + "feature_ids": [ + "git_content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "git_content", + "doctor": "git", + "selftest": "git_push_read_cas", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_transfer.py:13::test_upload_out_of_order_conflicts_seal_and_download", + "tests/test_transfer.py:61::test_transfer_owner_bounds_missing_ranges_and_cancel", + "tests/test_transfer_endpoint.py:14::test_fixed_transfer_endpoint_uses_executor_for_resume_and_ranges", + "tests/test_transfer_endpoint.py:80::test_fixed_transfer_endpoint_rejects_non_transfer_and_unsigned", + "tests/test_transfer_status_cursors.py:61::test_status_rejects_invalid_cursors_before_any_numeric_backend_use", + "tests/test_transfer_status_cursors.py:73::test_status_cursor_errors_match_real_protocol_adapters", + "tests/test_transfer_status_cursors.py:90::test_status_pagination_preserves_all_ranges_across_protocols", + "tests/test_transfer_status_cursors.py:135::test_fixed_status_endpoint_rejects_cursor_beyond_known_size", + "tests/test_transfer_status_cursors.py:152::test_status_cursor_validation_does_not_replace_current_identity_checks" + ] + }, + "hosting": { + "source_files": [ + "src/msg/extensions/hosting.py", + "src/msg/hosting_runtime.py", + "src/msg/admin/diagnostics.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_hosting_read_permissions.py", + "tests/test_hosting_runtime.py", + "tests/test_hosting_runtime_authority.py", + "tests/test_hosting_runtime_isolation.py", + "tests/test_hosting_same_origin.py" + ], + "feature_ids": [ + "hosting" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "hosting", + "doctor": "hosting", + "selftest": "hosting_deploy", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_hosting_read_permissions.py:12::test_new_publication_uses_exact_opt_in_read_group_mode", + "tests/test_hosting_read_permissions.py:41::test_enabling_group_reads_never_silently_changes_existing_permissions", + "tests/test_hosting_runtime.py:87::test_hosting_load_has_no_application_signers_or_startup_writes", + "tests/test_hosting_runtime.py:130::test_hosting_reuses_exact_registry_contracts_but_no_business_callbacks", + "tests/test_hosting_runtime.py:153::test_hosting_database_has_both_transaction_and_role_write_denial", + "tests/test_hosting_runtime.py:175::test_hosting_rejects_writer_database_account", + "tests/test_hosting_runtime.py:187::test_hosting_missing_or_stale_installation_fails_without_repair", + "tests/test_hosting_runtime.py:217::test_hosting_compares_declared_rule_retirement", + "tests/test_hosting_runtime.py:241::test_hosting_private_preview_head_range_revocation_and_zero_effects", + "tests/test_hosting_runtime.py:280::test_hosting_checks_quarantine_before_disclosing_bytes", + "tests/test_hosting_runtime.py:303::test_hosting_unit_has_distinct_identity_and_no_data_write_grant", + "tests/test_hosting_runtime.py:313::test_hosting_subcommand_never_assembles_main_application", + "tests/test_hosting_runtime.py:331::test_readonly_hosting_role_resolves_old_site_paths_without_authority_leaks", + "tests/test_hosting_runtime.py:368::test_hosting_missing_alias_table_grant_fails_closed_at_startup", + "tests/test_hosting_runtime_authority.py:20::test_revoked_signing_key_cannot_reuse_a_warm_private_preview", + "tests/test_hosting_runtime_authority.py:49::test_column_only_update_is_not_a_readonly_database_role", + "tests/test_hosting_runtime_authority.py:63::test_owner_can_restore_revoked_rights_and_must_not_be_a_reader", + "tests/test_hosting_runtime_isolation.py:51::test_preview_rechecks_acl_before_head_range_and_cached_response", + "tests/test_hosting_runtime_isolation.py:79::test_quarantine_revokes_cached_reads_and_new_startup", + "tests/test_hosting_runtime_isolation.py:117::test_required_schema_is_verified_without_repair", + "tests/test_hosting_runtime_isolation.py:144::test_effective_privilege_escalations_fail_startup", + "tests/test_hosting_runtime_isolation.py:212::test_real_other_uid_reads_new_text_and_binary_without_keys", + "tests/test_hosting_same_origin.py:23::test_same_domain_hosted_html_head_304_range_raw_and_no_write_route", + "tests/test_hosting_same_origin.py:84::test_deploy_entry_bound_matches_preview_before_any_materialization", + "tests/test_hosting_same_origin.py:126::test_preview_history_and_atomic_activation_share_same_csp", + "tests/test_hosting_same_origin.py:164::test_root_sample_is_public_sandboxed_and_never_uses_root_credentials", + "tests/test_hosting_same_origin.py:189::test_private_preview_readback_then_deploy_and_rollback" + ] + }, + "git": { + "source_files": [ + "src/msg/extensions/repositories.py", + "src/msg/extensions/git_publication.py", + "src/msg/admin/git_check.py", + "src/msg/storage/legacy_git_import.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_git_http_push.py", + "tests/test_git_lfs.py", + "tests/test_git_selftest.py", + "tests/test_git_transport_boundaries.py", + "tests/test_legacy_git_import.py", + "tests/test_lfs_shared_gc_quota.py" + ], + "feature_ids": [ + "git_content" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "git_content", + "doctor": "git", + "selftest": "git_push_read_cas", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_git_http_push.py:24::test_git_push_advertisement_requires_token_and_ordinary_path_stays_read_only", + "tests/test_git_http_push.py:71::test_git_http_receive_requires_explicit_request_id", + "tests/test_git_http_push.py:110::test_interrupted_pack_is_removed_and_request_id_can_retry", + "tests/test_git_http_push.py:142::test_standard_git_push_uses_guarded_http_endpoint_once", + "tests/test_git_http_push.py:234::test_http_git_reference_hook_rechecks_revoked_token", + "tests/test_git_lfs.py:22::test_lfs_upload_only_under_execution_boundary_and_current_repo_acl", + "tests/test_git_lfs.py:92::test_lfs_ordinary_download_batch_is_read_only", + "tests/test_git_lfs.py:115::test_lfs_signed_put_uses_one_publish_proof", + "tests/test_git_lfs.py:145::test_real_git_lfs_push_and_pull", + "tests/test_git_selftest.py:11::test_git_selftest_requires_disposable_namespace", + "tests/test_git_selftest.py:16::test_git_selftest_real_publication_read_and_stale_ref", + "tests/test_git_transport_boundaries.py:28::test_shared_repository_module_does_not_import_http_or_ssh_adapters", + "tests/test_git_transport_boundaries.py:34::test_repository_store_has_no_http_handlers_or_request_conversion_methods", + "tests/test_git_transport_boundaries.py:40::test_shared_publication_has_no_protocol_adapter_dependency", + "tests/test_git_transport_boundaries.py:48::test_both_adapters_use_the_same_publication_implementation", + "tests/test_git_transport_boundaries.py:59::test_http_helpers_have_one_owner_without_a_router_import_cycle", + "tests/test_git_transport_boundaries.py:72::test_git_storage_and_publication_import_without_loading_protocol_adapters", + "tests/test_git_transport_boundaries.py:89::test_http_uses_explicit_store_composition_not_storage_inheritance", + "tests/test_git_transport_boundaries.py:102::test_public_git_read_and_rejected_push_preserve_all_business_state", + "tests/test_git_transport_boundaries.py:133::test_reference_batch_validation_preserves_exact_git_change_contract", + "tests/test_legacy_git_import.py:84::test_git_bundle_import_preserves_oids_and_private_authority", + "tests/test_legacy_git_import.py:116::test_git_publication_failure_removes_only_new_repository", + "tests/test_legacy_git_import.py:139::test_git_ref_approval_and_external_lfs_fail_closed", + "tests/test_lfs_shared_gc_quota.py:19::test_lfs_uses_shared_binary_inode_and_repo_acl_root", + "tests/test_lfs_shared_gc_quota.py:64::test_content_gc_respects_explicit_pin_before_revision_commit", + "tests/test_lfs_shared_gc_quota.py:83::test_lfs_capacity_and_shared_volume_id_are_deployment_wide" + ] + }, + "jobs": { + "source_files": [ + "src/msg/workers/effects.py", + "src/msg/workers/leases.py", + "src/msg/plugins/communication.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_effect_completion_fencing.py", + "tests/test_effect_lease_deadlines.py", + "tests/test_effect_retry_state.py" + ], + "feature_ids": [ + "audit" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "audit", + "doctor": "audit", + "selftest": "online_delegation", + "scope": "feature level, not every field/negative vector" + }, + { + "feature_id": null, + "doctor": "following", + "selftest": "following", + "scope": "diagnostics.py direct check; no dedicated BootstrapManifest feature_id" + } + ], + "test_definitions": [ + "tests/test_effect_completion_fencing.py:17::test_maintenance_result_is_atomic_with_the_current_attempt", + "tests/test_effect_completion_fencing.py:75::test_real_tool_expiry_does_not_publish_a_file_without_a_sweep", + "tests/test_effect_completion_fencing.py:109::test_real_git_expiry_does_not_move_refs_without_a_sweep", + "tests/test_effect_lease_deadlines.py:34::test_expired_attempt_is_fenced_without_another_worker_claim", + "tests/test_effect_lease_deadlines.py:59::test_concurrent_expired_completion_and_retry_preserve_uncertainty", + "tests/test_effect_retry_state.py:30::test_retry_delay_attempt_limit_and_channel_codes_survive_refactor", + "tests/test_effect_retry_state.py:54::test_late_retry_cannot_overwrite_another_lease_or_terminal_state", + "tests/test_effect_retry_state.py:69::test_expired_external_execution_is_uncertain_not_automatically_retried" + ] + }, + "dm": { + "source_files": [ + "src/msg/plugins/communication.py", + "src/msg/plugins/communication.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_dm.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [], + "test_definitions": [ + "tests/test_dm.py:10::test_dm_request_accept_send_and_third_party_deny", + "tests/test_dm.py:49::test_first_dm_request_can_include_one_private_introduction", + "tests/test_dm.py:84::test_dm_reject_block_archive_and_other_message_immutable", + "tests/test_dm.py:101::test_dm_owner_cannot_publish_or_edit_other_post", + "tests/test_dm.py:131::test_simultaneous_requests_share_one_pair_and_block_keeps_history" + ] + }, + "collaboration": { + "source_files": [ + "src/msg/plugins/collaboration.py", + "src/msg/plugins/proposals.py", + "src/msg/plugins/watches.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_collaboration_current_visibility.py", + "tests/test_collaboration_resource_surfaces.py", + "tests/test_collaboration_resources.py", + "tests/test_collaboration_surfaces.py", + "tests/test_proposals.py", + "tests/test_watch_contracts.py", + "tests/test_watch_corruption.py", + "tests/test_watch_surfaces.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": null, + "doctor": "collaboration", + "selftest": "collaboration", + "scope": "diagnostics.py direct check; no dedicated BootstrapManifest feature_id" + } + ], + "test_definitions": [ + "tests/test_collaboration_current_visibility.py:9::test_presence_rechecks_current_subject_permissions", + "tests/test_collaboration_current_visibility.py:32::test_handoff_filters_references_moved_to_forbidden_namespace", + "tests/test_collaboration_resource_surfaces.py:15::test_work_record_paths_are_read_only_current_acl_views", + "tests/test_collaboration_resource_surfaces.py:45::test_work_cli_create_transition_and_proposal_accept", + "tests/test_collaboration_resources.py:11::test_work_request_lifecycle_replay_and_current_auth", + "tests/test_collaboration_resources.py:50::test_checkpoint_relations_are_private_and_rechecked", + "tests/test_collaboration_resources.py:80::test_offer_expiry_withdraw_and_request_schema_are_explicit", + "tests/test_collaboration_resources.py:104::test_request_claim_race_revocation_and_cancel_preserve_history", + "tests/test_collaboration_resources.py:140::test_checkpoint_event_failure_rolls_back_resource_revision_and_retry", + "tests/test_collaboration_surfaces.py:23::test_subject_handoffs_leases_paths_are_read_only_and_acl_filtered", + "tests/test_collaboration_surfaces.py:105::test_cli_handoff_and_lease_workflows_use_public_operations", + "tests/test_proposals.py:32::test_proposal_accept_uses_pinned_content_and_replays_once", + "tests/test_proposals.py:54::test_proposal_conflict_keeps_open_and_cannot_edit_without_current_rights", + "tests/test_proposals.py:88::test_proposal_rechecks_content_visibility_and_rejects_other_target_kinds", + "tests/test_watch_contracts.py:10::test_watch_events_current_acl_cancel_and_closed_query", + "tests/test_watch_contracts.py:44::test_follow_delivery_dedup_and_revoked_credential", + "tests/test_watch_contracts.py:67::test_cancelled_watches_leave_the_active_set", + "tests/test_watch_contracts.py:99::test_watch_quarantine_and_duplicate_event_projection", + "tests/test_watch_contracts.py:127::test_watch_removed_creating_operation_version_stops_delivery", + "tests/test_watch_corruption.py:11::test_missing_watch_blob_is_isolated_and_diagnosed", + "tests/test_watch_corruption.py:40::test_watch_database_failure_is_not_swallowed_and_rolls_back", + "tests/test_watch_surfaces.py:14::test_watch_cli_uses_current_public_contract" + ] + }, + "email": { + "source_files": [ + "src/msg/market/delivery_notifications.py", + "src/msg/workers/effects.py", + "src/msg/workers/mail.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_delivery.py", + "tests/test_delivery_target_regression.py", + "tests/test_smtp_socket_acceptance.py" + ], + "feature_ids": [ + "orders" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "orders", + "doctor": "market_clearing", + "selftest": "market_e2e", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_delivery.py:29::test_buyer_gets_fixed_package_and_signed_accept_releases_escrow_once", + "tests/test_delivery.py:83::test_private_delivery_and_wrong_recipient_are_fail_closed", + "tests/test_delivery.py:111::test_package_tamper_cannot_mark_delivered_or_release", + "tests/test_delivery.py:131::test_concurrent_accept_has_one_release", + "tests/test_delivery.py:152::test_delivered_order_cannot_refund_and_corrupt_payload_cannot_settle", + "tests/test_delivery.py:178::test_revoked_buyer_key_cannot_accept", + "tests/test_delivery_target_regression.py:16::test_delivery_read_rechecks_order_target_after_prepare", + "tests/test_smtp_socket_acceptance.py:99::test_real_tls_smtp_success_refusal_and_lost_ack", + "tests/test_smtp_socket_acceptance.py:118::test_real_smtp_connection_failure_is_retryable_before_data" + ] + }, + "webhook": { + "source_files": [ + "src/msg/workers/effects.py", + "src/msg/plugins/communication.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_webhook_delivery.py", + "tests/test_webhook_domain_events.py", + "tests/test_webhook_identity.py", + "tests/test_webhook_overlap.py", + "tests/test_webhook_overlap_collation.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [], + "test_definitions": [ + "tests/test_webhook_delivery.py:27::test_inbox_webhook_opt_in_dedupe_disable_and_bounded_payload", + "tests/test_webhook_delivery.py:88::test_webhook_retries_definite_5xx_but_marks_network_ambiguity_uncertain", + "tests/test_webhook_delivery.py:120::test_webhook_rejects_unsafe_endpoint", + "tests/test_webhook_delivery.py:125::test_webhook_signature_covers_raw_body_and_timestamp", + "tests/test_webhook_delivery.py:139::test_webhook_resolver_rejects_private_or_mixed_dns", + "tests/test_webhook_domain_events.py:46::test_domain_webhook_explicit_scope_dedupe_read_is_pure_and_payload_is_bounded", + "tests/test_webhook_domain_events.py:73::test_domain_webhook_unsubscribe_and_endpoint_change_stop_pending_delivery", + "tests/test_webhook_domain_events.py:103::test_domain_webhook_event_and_job_rollback_together", + "tests/test_webhook_domain_events.py:128::test_domain_webhook_acl_revocation_stops_queued_delivery", + "tests/test_webhook_domain_events.py:147::test_domain_webhook_capability_revocation_stops_pending_and_unsubscribe_still_works", + "tests/test_webhook_domain_events.py:166::test_domain_webhook_rejects_other_owner_scope", + "tests/test_webhook_identity.py:29::test_altering_unsigned_delivery_header_cannot_bypass_deduplication", + "tests/test_webhook_identity.py:47::test_bad_headers_are_stable_failures_without_mutating_replay_state", + "tests/test_webhook_identity.py:61::test_signed_but_invalid_identity_envelope_is_not_accepted", + "tests/test_webhook_identity.py:69::test_header_names_are_case_insensitive_but_ambiguous_duplicates_are_rejected", + "tests/test_webhook_overlap.py:49::test_overlap_one_job_per_event_recipient_channel_and_pinned_revocation", + "tests/test_webhook_overlap.py:99::test_revoked_child_authority_does_not_suppress_valid_parent", + "tests/test_webhook_overlap.py:113::test_different_recipients_and_channels_remain_distinct", + "tests/test_webhook_overlap_collation.py:23::test_overlap_dedupe_survives_english_collation", + "tests/test_webhook_overlap_collation.py:28::test_channel_isolation_survives_english_collation" + ] + }, + "audit": { + "source_files": [ + "src/msg/core/models.py", + "src/msg/storage/postgres.py", + "src/msg/admin/recovery_replay.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_security_storage_rollback.py" + ], + "feature_ids": [ + "audit" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "audit", + "doctor": "audit", + "selftest": "online_delegation", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_security_storage_rollback.py:15::test_nested_rollback_compensates_only_its_scope", + "tests/test_security_storage_rollback.py:45::test_compensation_failure_preserves_original_and_runs_remaining", + "tests/test_security_storage_rollback.py:68::test_task_cancellation_rolls_back_sql_and_external_effect", + "tests/test_security_storage_rollback.py:94::test_failed_transfer_cleanup_restores_pin_and_metadata", + "tests/test_security_storage_rollback.py:126::test_scheduled_collection_is_rate_limited_and_respects_cleanup_pause", + "tests/test_security_storage_rollback.py:140::test_purged_sealed_output_does_not_break_expiry_or_collection" + ] + }, + "tools": { + "source_files": [ + "src/msg/extensions/tools.py", + "src/msg/workers/sandbox.py", + "src/msg/workers/sandbox_child.py", + "src/msg/core/registry.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_tool_runner_contract.py", + "tests/test_tool_sandbox_acceptance.py", + "tests/test_tools.py", + "tests/test_tools_directory.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [], + "test_definitions": [ + "tests/test_tool_runner_contract.py:20::test_tool_result_has_one_neutral_owner_and_compatible_export", + "tests/test_tool_runner_contract.py:34::test_actual_worker_dependency_is_the_declared_callable_port", + "tests/test_tool_runner_contract.py:41::test_default_runner_and_port_have_the_same_input_and_local_output_types", + "tests/test_tool_runner_contract.py:52::test_unused_publishing_port_is_retired_not_a_second_runner_contract", + "tests/test_tool_runner_contract.py:61::test_sandbox_does_not_import_its_worker_or_application_composition", + "tests/test_tool_runner_contract.py:76::test_default_runner_still_refuses_missing_mandatory_sandbox", + "tests/test_tool_runner_contract.py:86::test_injected_port_cannot_publish_a_resource_itself", + "tests/test_tool_sandbox_acceptance.py:14::test_real_sandbox_denies_private_or_non_http_target", + "tests/test_tool_sandbox_acceptance.py:28::test_real_sandbox_explicit_local_grant_returns_bounded_bytes", + "tests/test_tools.py:27::test_network_tool_rejects_non_http_or_ambiguous_targets", + "tests/test_tools.py:32::test_public_tool_rejects_nonpublic_addresses", + "tests/test_tools.py:40::test_tool_policy_intersection_does_not_widen_allowlists_or_limits", + "tests/test_tools.py:50::test_only_scoped_tool_is_visible_jobs_are_deduped_and_output_transfers", + "tests/test_tools.py:83::test_pending_job_rechecks_revocation_and_expired_lease_is_uncertain", + "tests/test_tools.py:109::test_tool_external_success_then_revocation_records_uncertain", + "tests/test_tools.py:125::test_tool_run_is_the_only_public_operation_and_old_short_code_is_retired", + "tests/test_tools_directory.py:14::test_new_install_tools_path_keeps_permissions_and_capability_gate", + "tests/test_tools_directory.py:41::test_legacy_tools_path_is_read_only_and_new_alias_still_checks_certificate" + ] + }, + "ssh": { + "source_files": [ + "src/msg/extensions/ssh.py", + "src/msg/extensions/ssh_git.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_ssh_process_identity.py", + "tests/test_ssh_rss.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [], + "test_definitions": [ + "tests/test_ssh_process_identity.py:15::test_real_unprivileged_process_cannot_spoof_an_sshd_ancestor", + "tests/test_ssh_process_identity.py:59::test_privileged_sshd_ancestor_does_not_require_readable_proc_exe", + "tests/test_ssh_process_identity.py:65::test_inconsistent_or_unprivileged_sshd_identity_fails_closed", + "tests/test_ssh_process_identity.py:72::test_root_caller_is_not_a_permitted_application_ssh_session", + "tests/test_ssh_rss.py:14::test_ssh_command_allowlist_has_no_shell_or_root_proxy", + "tests/test_ssh_rss.py:23::test_registered_ssh_key_binds_identity_and_ceiling", + "tests/test_ssh_rss.py:46::test_git_reference_guard_real_commit_and_revoked_key", + "tests/test_ssh_rss.py:99::test_rss_is_read_only_acl_filtered_and_escapes_titles" + ] + }, + "client": { + "source_files": [ + "src/msg/client.py", + "src/msg/cli.py", + "src/msg/tui.py", + "src/msg/transports/mcp.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_cli_dm.py", + "tests/test_cli_hosting.py", + "tests/test_cli_legacy.py", + "tests/test_cli_recovery.py", + "tests/test_cli_search_grep.py", + "tests/test_client.py", + "tests/test_client_boundary.py", + "tests/test_client_part_bytes.py", + "tests/test_client_response_boundaries.py", + "tests/test_client_secrets.py", + "tests/test_client_token_delivery.py", + "tests/test_tui.py", + "tests/test_tui_recovery.py", + "tests/test_tui_transport_boundaries.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [], + "test_definitions": [ + "tests/test_cli_dm.py:14::test_dm_cli_request_accept_send_read_and_block", + "tests/test_cli_hosting.py:15::test_hosting_cli_preview_deploy_rollback_and_revocation", + "tests/test_cli_legacy.py:15::test_legacy_cli_put_revise_archive_keeps_state_active", + "tests/test_cli_legacy.py:52::test_legacy_cli_put_can_send_revision_signed_contract", + "tests/test_cli_recovery.py:17::test_cli_policy_backup_and_offline_restore", + "tests/test_cli_search_grep.py:15::test_cli_search_one_page_cursor_and_scoped_grep", + "tests/test_cli_search_grep.py:53::test_cli_v5_search_flags_typed_scope_and_cursor_use_signed_contract", + "tests/test_cli_search_grep.py:93::test_cli_search_grep_reject_unbounded_or_mixed_options", + "tests/test_client.py:14::test_client_register_sign_transfer_and_cross_transport_resume", + "tests/test_client.py:47::test_client_retry_reuses_request_and_does_not_replace_local_identity", + "tests/test_client.py:70::test_uploaded_markdown_can_be_published_without_copying_body", + "tests/test_client_boundary.py:17::test_result_serialization_has_one_protocol_owner", + "tests/test_client_boundary.py:32::test_atomic_file_owner_keeps_compatibility_and_failed_replace_protection", + "tests/test_client_boundary.py:50::test_custodial_proof_and_statements_are_shared_without_vault_state", + "tests/test_client_boundary.py:83::test_mcp_client_uses_neutral_version_declaration", + "tests/test_client_boundary.py:91::test_fresh_client_import_guard_covers_actual_four_transport_calls", + "tests/test_client_boundary.py:101::test_install_metadata_separates_roles_but_dev_keeps_full_server", + "tests/test_client_boundary.py:113::test_missing_server_dependencies_are_reported_before_local_state", + "tests/test_client_part_bytes.py:68::test_resume_rejects_invalid_local_chunk_size_before_state_or_network", + "tests/test_client_part_bytes.py:97::test_upload_rejects_invalid_local_chunk_size_before_source_access", + "tests/test_client_part_bytes.py:113::test_download_resume_with_valid_chunk_size_preserves_real_bytes", + "tests/test_client_response_boundaries.py:15::test_non_object_json_is_a_stable_response_failure", + "tests/test_client_response_boundaries.py:29::test_gateway_error_field_never_becomes_exception_text", + "tests/test_client_response_boundaries.py:41::test_valid_gateway_code_and_success_object_are_preserved", + "tests/test_client_response_boundaries.py:67::test_protocol_error_shapes_fail_closed", + "tests/test_client_response_boundaries.py:83::test_empty_accepted_response_is_not_a_completed_read", + "tests/test_client_response_boundaries.py:100::test_server_origin_rejects_credential_slots_controls_and_invalid_ports", + "tests/test_client_response_boundaries.py:110::test_operation_error_envelope_cannot_leak_text_as_a_code", + "tests/test_client_response_boundaries.py:128::test_graphql_falsy_non_list_errors_do_not_hide_an_invalid_envelope", + "tests/test_client_response_boundaries.py:141::test_result_decoder_preserves_immutable_historical_results", + "tests/test_client_secrets.py:10::test_client_encrypts_before_network_and_decrypts_only_locally", + "tests/test_client_token_delivery.py:20::test_bootstrap_lost_after_claim_recovers_after_restart", + "tests/test_client_token_delivery.py:60::test_rotate_lost_response_blocks_old_token_until_recovery", + "tests/test_client_token_delivery.py:92::test_before_commit_retry_and_path_transport_fail_closed", + "tests/test_client_token_delivery.py:119::test_recovery_claim_lost_can_recover_again_without_version_one", + "tests/test_client_token_delivery.py:156::test_non_loopback_http_never_sends_recovery_secret", + "tests/test_tui.py:32::test_tui_read_call_matrix_cursor_revocation_and_safe_rendering", + "tests/test_tui.py:60::test_tui_anonymous_entry_and_eof_exits_without_mutation", + "tests/test_tui.py:70::test_tui_real_pg_read_and_revocation_no_business_effect", + "tests/test_tui_recovery.py:19::test_terminal_preserves_document_lines_and_blank_paragraphs", + "tests/test_tui_recovery.py:26::test_terminal_wraps_by_display_cells_and_respects_narrow_windows", + "tests/test_tui_recovery.py:34::test_disconnect_clears_page_and_explicit_retry_rechecks_current_authority", + "tests/test_tui_recovery.py:62::test_retry_reloads_a_document_without_ack_or_implicit_mutation", + "tests/test_tui_recovery.py:80::test_tui_additional_views_use_existing_authorized_read_contracts", + "tests/test_tui_recovery.py:102::test_todo_next_page_uses_its_own_pagination_contract", + "tests/test_tui_recovery.py:116::test_real_tui_private_views_are_read_only_and_do_not_expose_other_subjects", + "tests/test_tui_recovery.py:154::test_anonymous_private_view_drops_stale_selection_and_retry", + "tests/test_tui_recovery.py:167::test_credentials_view_never_prints_secret_state_or_url_credentials", + "tests/test_tui_transport_boundaries.py:29::test_httpx_disconnect_is_sanitized_and_retry_is_explicit", + "tests/test_tui_transport_boundaries.py:51::test_successful_document_replaces_old_list_selection", + "tests/test_tui_transport_boundaries.py:66::test_changed_identity_or_service_drops_old_selection", + "tests/test_tui_transport_boundaries.py:79::test_oversized_selection_is_rejected_without_integer_conversion_crash", + "tests/test_tui_transport_boundaries.py:94::test_malformed_read_payload_is_a_stable_error_not_a_crash", + "tests/test_tui_transport_boundaries.py:112::test_frozen_client_payload_is_still_a_valid_page", + "tests/test_tui_transport_boundaries.py:124::test_items_without_readable_identifier_are_not_dereferenced", + "tests/test_tui_transport_boundaries.py:141::test_note_or_todo_without_name_is_not_requested", + "tests/test_tui_transport_boundaries.py:154::test_files_rejects_profile_without_absolute_path", + "tests/test_tui_transport_boundaries.py:166::test_inflight_read_from_previous_identity_is_not_displayed", + "tests/test_tui_transport_boundaries.py:185::test_malformed_snippet_text_does_not_crash_or_display" + ] + }, + "config": { + "source_files": [ + "src/msg/config.py", + "src/msg/config_contracts.py", + "src/msg/admin/config_check.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_config_safety.py", + "tests/test_configuration_field_contracts.py", + "tests/test_money_config.py", + "tests/test_pg_config.py" + ], + "feature_ids": [], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": null, + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "scope": "46 actual field vectors; loader validity only, not host deployment" + } + ], + "test_definitions": [ + "tests/test_config_safety.py:12::test_reject_ambiguous_or_invalid_server_configuration", + "tests/test_config_safety.py:23::test_same_service_origin_is_allowed_for_hosted_content", + "tests/test_config_safety.py:31::test_example_quotes_filesystem_paths_and_unknown_plugin_keys_are_rejected", + "tests/test_config_safety.py:36::test_legacy_filename_remains_readable_but_dual_configs_fail_closed", + "tests/test_configuration_field_contracts.py:11::test_mail_enable_requires_boolean_even_when_truthy", + "tests/test_configuration_field_contracts.py:20::test_mail_port_rejects_out_of_range_or_non_integer", + "tests/test_configuration_field_contracts.py:29::test_configuration_sections_reject_non_tables_with_stable_error", + "tests/test_configuration_field_contracts.py:36::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "tests/test_configuration_field_contracts.py:47::test_doctor_field_status_never_substitutes_parsing_for_live_checks", + "tests/test_configuration_field_contracts.py:60::test_vector_gap_is_an_error_instead_of_empty_success", + "tests/test_configuration_field_contracts.py:68::test_effective_default_drift_is_reported_as_failure", + "tests/test_configuration_field_contracts.py:79::test_real_doctor_exposes_complete_field_matrix_read_only", + "tests/test_configuration_field_contracts.py:93::test_real_doctor_reports_bad_configuration_without_exposing_values", + "tests/test_configuration_field_contracts.py:102::test_configuration_selftest_opens_no_network_or_secret_file", + "tests/test_configuration_field_contracts.py:116::test_official_selftest_runs_configuration_field_vectors", + "tests/test_money_config.py:19::test_money_example_has_deterministic_defaults", + "tests/test_money_config.py:30::test_only_currency_labels_can_change", + "tests/test_money_config.py:53::test_money_rejects_policy_changes_and_business_facts", + "tests/test_money_config.py:63::test_doctor_reports_money_config_without_claiming_market_e2e", + "tests/test_money_config.py:106::test_public_money_state_uses_configured_labels", + "tests/test_money_config.py:119::test_doctor_rejects_missing_market_schema_without_repairing_it", + "tests/test_money_config.py:135::test_market_selftest_features_require_the_actual_isolated_flow", + "tests/test_pg_config.py:9::test_example_uses_libpq_service_without_secret", + "tests/test_pg_config.py:18::test_accepts_postgres_and_optional_valkey_urls_without_revealing_secrets", + "tests/test_pg_config.py:33::test_rejects_missing_or_invalid_postgres_dsn", + "tests/test_pg_config.py:44::test_rejects_invalid_valkey_url" + ] + }, + "rules": { + "source_files": [ + "src/msg/bootstrap.py", + "src/msg/core/registry.py", + "src/msg/plugins/common.py", + "src/msg/hosting_runtime.py", + "src/msg/core/models.py", + "src/msg/plugins/schemas.py" + ], + "test_files": [ + "tests/test_registry_contract_references.py", + "tests/test_registry_operation_rules.py", + "tests/test_registry_references.py", + "tests/test_registry_schema_ownership.py", + "tests/test_registry_template_tools.py", + "tests/test_system_rule_migration.py", + "tests/test_system_rule_retirement.py", + "tests/test_system_rules.py" + ], + "feature_ids": [ + "bootstrap" + ], + "mapping_level": "module_navigation_only_not_per_assertion_proof", + "diagnostic_hooks": [ + { + "feature_id": "bootstrap", + "doctor": "bootstrap", + "selftest": "isolated_namespace", + "scope": "feature level, not every field/negative vector" + } + ], + "test_definitions": [ + "tests/test_registry_contract_references.py:43::test_freeze_rejects_unregistered_optional_schema_and_can_retry", + "tests/test_registry_contract_references.py:61::test_freeze_rejects_unknown_resource_operation_including_wrong_version", + "tests/test_registry_contract_references.py:70::test_freeze_accepts_registered_content_constraints_and_operation_references", + "tests/test_registry_operation_rules.py:40::test_existing_default_rule_descriptions_remain_identical", + "tests/test_registry_operation_rules.py:47::test_explicit_rule_dependencies_override_name_and_are_owned_by_spec", + "tests/test_registry_operation_rules.py:58::test_freeze_rejects_missing_duplicate_or_empty_operation_rules", + "tests/test_registry_references.py:20::test_nonlocal_references_are_rejected_before_publishing", + "tests/test_registry_references.py:39::test_nonlocal_references_in_subschemas_are_rejected", + "tests/test_registry_references.py:44::test_local_pointer_with_http_metadata_is_not_mistaken_for_remote_reference", + "tests/test_registry_references.py:61::test_local_anchors_still_validate", + "tests/test_registry_references.py:70::test_reference_named_properties_and_annotation_data_are_not_schema_references", + "tests/test_registry_references.py:82::test_pointer_into_annotation_cannot_enable_implicit_file_retrieval", + "tests/test_registry_references.py:100::test_missing_local_pointer_is_a_stable_error_without_uri_disclosure", + "tests/test_registry_schema_ownership.py:27::test_nested_schema_mutation_cannot_expand_a_registered_contract", + "tests/test_registry_schema_ownership.py:42::test_each_lookup_returns_an_independent_snapshot_with_identical_digest", + "tests/test_registry_schema_ownership.py:53::test_boolean_json_schemas_keep_their_existing_validation_semantics", + "tests/test_registry_schema_ownership.py:63::test_failed_remote_reference_registration_has_no_partial_contract", + "tests/test_registry_schema_ownership.py:73::test_freeze_still_rejects_new_or_replacement_schemas", + "tests/test_registry_template_tools.py:22::test_templates_are_versioned_owned_specs_with_defaults_and_strict_lookup", + "tests/test_registry_template_tools.py:40::test_template_registration_snapshots_nested_fields_and_validates_defaults", + "tests/test_registry_template_tools.py:53::test_installed_template_and_tool_contracts_match_published_resources", + "tests/test_registry_template_tools.py:75::test_tool_registry_rejects_conflicts_missing_schemas_and_operations", + "tests/test_registry_template_tools.py:99::test_shipped_template_post_keeps_signed_content_contract", + "tests/test_registry_template_tools.py:120::test_tool_resource_policy_is_preserved_but_cannot_replace_registered_code", + "tests/test_registry_template_tools.py:148::test_user_template_versions_remain_resources_outside_frozen_registry", + "tests/test_system_rule_migration.py:13::test_source_move_preserves_public_resource_and_revision", + "tests/test_system_rule_migration.py:47::test_unknown_duplicate_and_dangling_rule_sources_are_rejected", + "tests/test_system_rule_retirement.py:34::test_declared_retirement_preserves_history_is_idempotent_and_read_only", + "tests/test_system_rule_retirement.py:76::test_retirement_must_pin_current_release_metadata", + "tests/test_system_rule_retirement.py:91::test_retirement_rejects_active_dependencies", + "tests/test_system_rule_retirement.py:115::test_fresh_install_does_not_invent_retired_history", + "tests/test_system_rule_retirement.py:136::test_registry_freeze_rejects_dependency_on_release_retired_rule", + "tests/test_system_rule_retirement.py:148::test_retirement_cannot_implicitly_reactivate_archived_source", + "tests/test_system_rule_retirement.py:171::test_retirement_refuses_invalid_declarations_or_source_drift", + "tests/test_system_rules.py:16::test_source_rule_token_budgets_and_index_links", + "tests/test_system_rules.py:30::test_short_bootstrap_rule_index_shards_and_editable_wiki", + "tests/test_system_rules.py:77::test_per_file_upgrade_changes_only_edited_revision_and_rejects_deleted_source", + "tests/test_system_rules.py:110::test_new_application_load_syncs_changed_packaged_release", + "tests/test_system_rules.py:148::test_rule_sync_rejects_current_pointer_drift" + ] + } + }, + "configuration_fields": [ + { + "field": "server.service_url", + "setting": "service_url", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "server.listen", + "setting": "listen", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "server.port", + "setting": "port", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "server.public_web_origin", + "setting": "public_web_origin", + "doctor_check": "hosting", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "server.temporary_ttl", + "setting": "temporary_ttl", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "server.transfer_ttl", + "setting": "transfer_ttl", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.postgres_dsn", + "setting": "server.postgres_dsn", + "doctor_check": "storage", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.valkey_url", + "setting": "server.valkey_url", + "doctor_check": "valkey", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.content", + "setting": "server.content_dir", + "doctor_check": "content_layout", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.repositories", + "setting": "server.repositories_dir", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.blobs", + "setting": "server.blob_dir", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.staging", + "setting": "server.staging_dir", + "doctor_check": "capacity", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "storage.service_keys", + "setting": "server.service_keys_dir", + "doctor_check": "online_ca", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "limits.request_bytes", + "setting": "server.limits.max_request_bytes", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "limits.response_bytes", + "setting": "server.limits.max_response_bytes", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "limits.path_bytes", + "setting": "server.limits.max_path_bytes", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "limits.part_bytes", + "setting": "max_part_bytes", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "plugins.enabled", + "setting": "server.plugins", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "tools.isolation", + "setting": "worker_isolation", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "tools.timeout_ms", + "setting": "tool_timeout_ms", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "tools.max_response_bytes", + "setting": "tool_max_response_bytes", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "tools.methods", + "setting": "tool_methods", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "tools.ports", + "setting": "tool_ports", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "identity.credential_delivery_recovery_window", + "setting": "credential_delivery_recovery_window", + "doctor_check": "credential_delivery", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.enabled", + "setting": "money.enabled", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.currency_id", + "setting": "money.currency_id", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.display_name", + "setting": "money.display_name", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.code", + "setting": "money.code", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.scale", + "setting": "money.scale", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.transfer_fee", + "setting": "money.transfer_fee", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "money.allow_overdraft", + "setting": "money.allow_overdraft", + "doctor_check": "money_config", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "hosting.base_capacity_bytes", + "setting": "hosting_base_capacity_bytes", + "doctor_check": "hosting", + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "hosting.recovery_marker", + "setting": "hosting_recovery_marker", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "hosting.content_group_read", + "setting": "hosting_content_group_read", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians", + "setting": "recovery_custodians", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.enabled", + "setting": "server.mail.enabled", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.host", + "setting": "server.mail.host", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.port", + "setting": "server.mail.port", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.tls", + "setting": "server.mail.tls", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.sender", + "setting": "server.mail.sender", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "mail.credential_file", + "setting": "server.mail.credential_file", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians.id", + "setting": "recovery_custodians.0.id", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians.name", + "setting": "recovery_custodians.0.name", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians.recipient", + "setting": "recovery_custodians.0.recipient", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians.description", + "setting": "recovery_custodians.0.description", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + }, + { + "field": "recovery.custodians.policy_ref", + "setting": "recovery_custodians.0.policy_ref", + "doctor_check": null, + "source": "src/msg/config_contracts.py", + "validation": "src/msg/admin/config_check.py", + "test": "tests/test_configuration_field_contracts.py::test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets", + "doctor": "configuration_fields", + "selftest": "configuration_loading", + "evidence": "c9c6afe local41 plus central ab482c8 group46; individual run scope in integration-evidence.json" + } + ], + "bootstrap_features": [ + { + "feature_id": "honors", + "enabled_by_default": true, + "default_config": { + "pregrant": false, + "privileges": false + }, + "sample_resource": "r_honor_sample", + "doctor_check": "honors", + "selftest_case": "honor_ceremony_display" + }, + { + "feature_id": "money", + "enabled_by_default": true, + "default_config": { + "currency_id": "primary", + "scale": 6, + "initial_supply_minor": 0, + "banks": [], + "offers": [] + }, + "sample_resource": null, + "doctor_check": "market_clearing", + "selftest_case": "market_e2e" + }, + { + "feature_id": "bounty", + "enabled_by_default": true, + "default_config": { + "listings": [], + "verifier": "signature_pop_v1", + "human_or_sybil_proof": false + }, + "sample_resource": "t_store", + "doctor_check": "market_clearing", + "selftest_case": "market_e2e" + }, + { + "feature_id": "orders", + "enabled_by_default": true, + "default_config": { + "orders": [], + "instant_policy": "managed-instant-v1", + "subjective_refund": false + }, + "sample_resource": "t_store", + "doctor_check": "market_clearing", + "selftest_case": "market_e2e" + }, + { + "feature_id": "bootstrap", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "r_root", + "doctor_check": "bootstrap", + "selftest_case": "isolated_namespace" + }, + { + "feature_id": "market", + "enabled_by_default": true, + "default_config": { + "checkout_version": 2, + "production_fixtures": false, + "arbitrators": [] + }, + "sample_resource": null, + "doctor_check": "market", + "selftest_case": "market_lifecycle" + }, + { + "feature_id": "postgres", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "r_root", + "doctor_check": "storage", + "selftest_case": "stable_id_read" + }, + { + "feature_id": "root_trust", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "u_root", + "doctor_check": "root_trust", + "selftest_case": "root_network_rejected" + }, + { + "feature_id": "credential_delivery", + "enabled_by_default": true, + "default_config": { + "credential_delivery_recovery_window": "15m", + "release": "once", + "secret_url": false + }, + "sample_resource": null, + "doctor_check": "credential_delivery", + "selftest_case": "credential_delivery_recovery" + }, + { + "feature_id": "custodial_history", + "enabled_by_default": true, + "default_config": { + "ack_scope": "inventory_and_results", + "backup_retirement": "offline_verification_required" + }, + "sample_resource": null, + "doctor_check": "custodial_history", + "selftest_case": "custodial_history_recovery" + }, + { + "feature_id": "identity_upgrade", + "enabled_by_default": true, + "default_config": { + "recovery": "current_new_identity_key", + "legacy_token_reuse": false + }, + "sample_resource": null, + "doctor_check": "authority_snapshot", + "selftest_case": "identity_upgrade_recovery" + }, + { + "feature_id": "online_ca", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "u_online_ca", + "doctor_check": "online_ca", + "selftest_case": "online_registration" + }, + { + "feature_id": "authorization", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "t_certified", + "doctor_check": "authority_snapshot", + "selftest_case": "certgate" + }, + { + "feature_id": "audit", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "r_root", + "doctor_check": "audit", + "selftest_case": "online_delegation" + }, + { + "feature_id": "content", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "t_main", + "doctor_check": "content_layout", + "selftest_case": "idempotency" + }, + { + "feature_id": "git_content", + "enabled_by_default": true, + "default_config": {}, + "sample_resource": "t_main", + "doctor_check": "git", + "selftest_case": "git_push_read_cas" + }, + { + "feature_id": "valkey_signal", + "enabled_by_default": false, + "default_config": { + "optional": true + }, + "sample_resource": null, + "doctor_check": "valkey", + "selftest_case": null + }, + { + "feature_id": "recovery", + "enabled_by_default": true, + "default_config": { + "verification": "partial" + }, + "sample_resource": "t_private", + "doctor_check": "recovery_checkpoint", + "selftest_case": "recovery_checkpoint_replay" + }, + { + "feature_id": "search", + "enabled_by_default": true, + "default_config": { + "verification": "partial" + }, + "sample_resource": "t_index", + "doctor_check": "lexical_search", + "selftest_case": "lexical_search_access" + }, + { + "feature_id": "hosting", + "enabled_by_default": true, + "default_config": { + "verification": "partial" + }, + "sample_resource": "t_wiki", + "doctor_check": "hosting", + "selftest_case": "hosting_deploy" + } + ], + "runtime_registry_export": { + "generated_from_commit": "fbe5175de5448fe82394d4d57532e49a5b2809ab", + "sha256": "7510a2d94d0b2e9d08433395084b1defece9f344931cfde12ded807d88a43d93", + "operation_versions": 359, + "location": "Derived metadata checked during inventory; exact operation names are embedded in obligation rows. Reconstruct by Application(default Settings), without open_storage." + }, + "registry_export_scope": "Application(default Settings) installs/freeze registry; temporary example config only, no storage opened and no production state read. Metadata not behavioral tests.", + "known_gaps": [ + { + "id": "G1", + "type": "diagnostic_coverage_gap", + "profiles": [ + "tools", + "ssh", + "client", + "dm", + "webhook", + "transfer", + "files" + ], + "detail": "Business implementations/tests exist. Explicit pending diagnostic work: manifest_features owns tool_sandbox/following/collaboration and central registration; recover_transports Transfer(design138), collaboration_contracts privateDM(146-147), recover_resources content editing(124), under design180. CLI/TUI per-view features are not demanded. Until merged and tested, related content/Git hooks do not prove these contracts." + }, + { + "id": "G2", + "type": "integrated_pending_final_ci", + "profiles": [ + "sale", + "entitlement" + ], + "detail": "OrderResource and Root import helper now included at candidatefbe5175 per parent; exact-head tests and real console mapping remain separate. Not a current missing source claim." + }, + { + "id": "G3", + "type": "external_gate", + "profiles": [ + "root", + "recovery", + "custodial" + ], + "detail": "Full recovery proof/promotion source is now included throughc9f23e2/48cb58e/b795dce/92322f4. Independent fresh pin/backup retirement, real console PIN and production newRoot mapping remain required; no longer a missing promotion source claim." + }, + { + "id": "G4", + "type": "external_gate", + "profiles": [ + "ssh", + "tools", + "email", + "webhook", + "hosting" + ], + "detail": "Real target sshd/ForcedCommand success+revocation, target bwrap CPU/RSS/process limits, actual SMTP/Webhook recipients and deployed hosting/proxy/log chain not proved by local feature tests." + }, + { + "id": "G5", + "type": "evidence_gap", + "profiles": [ + "scope", + "registry", + "identity", + "delivery", + "custodial", + "recovery_envelope", + "honors", + "ca", + "root", + "money", + "resources", + "authorization", + "entitlement", + "sale", + "bounty", + "personal", + "share", + "discussion", + "files", + "paths", + "read", + "secret_paths", + "execution", + "arbitration", + "search", + "storage", + "recovery", + "transfer", + "hosting", + "git", + "jobs", + "dm", + "collaboration", + "email", + "webhook", + "audit", + "tools", + "ssh", + "client", + "config", + "rules" + ], + "detail": "This inventory completes a finite textual denominator, not all per-assertion evidence. Exact-headfbe5175 CI and field→positive/negative assertion trace remain unverified where rows say navigation_only. Archived design is referenced but contents were not supplied here; chapter08 aliases explicitly depend on that separate source." + }, + { + "id": "G6", + "type": "verified_path_contract", + "profiles": [ + "config" + ], + "detail": "Design171 mail.toml is implemented at config.py:204, not a missing source. root.crt and reserved plugins.d are implemented with legacy compatibility. Remaining deployment ownership/modes require host evidence." + } + ], + "unresolved_navigation_hints": [], + "evidence_runs": { + "runs": [ + { + "head": "1ba2a51", + "result": "23 passed", + "scope": "watch collaboration proposals files spelling backup restoration", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "b3de5c1", + "result": "18 passed", + "scope": "recovery policy v2", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "b3de5c1", + "result": "build and package artifact validation passed", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "ce497d6", + "result": "10 passed", + "scope": "client boundary and search CLI", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "1ab30e6", + "result": "Cursor independent review: 223 + 13 + 50 tests passed; no confirmed defect", + "report": "cursor-review.md" + }, + { + "head": "ab482c8", + "result": "46 passed", + "scope": "configuration fields bounty snapshots official money selftest", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "6406870", + "result": "34 passed", + "scope": "saved queries webhooks paths readonly hosting achievement pins authority reconciliation", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "ffb8e27", + "result": "17 passed", + "scope": "saved-query watch offer resource writer legacy content importer", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "1ba2a51", + "result": "full CI failed: six actual bubblewrap startup tests; shard0 conformance and package succeeded", + "run": 36557410461, + "diagnosis": "Ubuntu AppArmor uid map permission denied; repair in progress", + "public_ci_url": "https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36557410461" + }, + { + "head": "dbbe32f", + "result": "21 passed", + "scope": "official Git and honor diagnostics, feature manifest, offer import, legacy HTTP", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "887e93c", + "result": "24 passed", + "scope": "recovery runtime generation trust alias rotation readonly hosting", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "846c6b5", + "result": "26 passed", + "scope": "watch contracts and surfaces, saved watch, binary copy integrity, LFS, legacy Git and identities", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "45470b5", + "result": "full CI failed: three webhook constraint conflicts and public read effect gate; actual sandbox preflight passed", + "run": 36559785404, + "public_ci_url": "https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36559785404" + }, + { + "head_at_start": "8ecad77", + "result": "38 passed", + "scope": "readonly hosting and rule retirement", + "note": "unrelated recovery/read-gate commits integrated during run; final exact-head CI still required", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "92322f4", + "result": "interrupted after 2 failed, 4 passed, 20 errors: pinned recovery schema stale against current integrated schema", + "scope": "complete recovery proof/runtime guard/trust/backup combination", + "followup": "recovery owner updating explicitly reviewed schema delta", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "8ecad77 + PR177/178", + "result": "33 passed", + "scope": "independent transfer cursor/client chunk size integration", + "reported_by": "recover_git_market_prs" + }, + { + "head": "37a1fba", + "result": "36 passed; three locale regressions failed before fix", + "scope": "webhook delivery/domain/identity/overlap including actual English PG collation" + }, + { + "head": "4482daf", + "result": "1 passed", + "scope": "pre-existing independent share survives move/group departure/parent grant revocation" + }, + { + "head": "40ec56b", + "result": "3 passed", + "scope": "real deferred PG commit failure text/binary and post-import restore filesystem failure" + }, + { + "head": "5c4a36d", + "result": "10 unique tests passed across focused runs", + "scope": "local-console legacy approvals, unmapped historical authors, explicit visibility restoration" + }, + { + "head": "b32a93d", + "result": "64 passed in 305.16s", + "scope": "complete recovery proof, runtime generation, trust aliases, controlled OrderResource, native entitlement orders", + "note": "immutable detached snapshot; combined source tests, not final full CI", + "log_provenance": "local retained log, not included in public documentation" + }, + { + "head": "737980d", + "result": "4 passed", + "scope": "same signed read/search/sync request across HTTP POST/PathGET/GraphQL/MCP with paging and budgets" + } + ], + "final_combined_head_verified": false, + "deployed": false, + "source_head": "de27644", + "remote_candidate_head": "45470b5" + }, + "pending": [ + "manifest_features: centralized tool_sandbox/following/collaboration/Transfer/privateDM/content-editing diagnostic registration", + "recover_transports: representative Transfer selftest", + "collaboration_contracts: representative privateDM selftest", + "recover_resources: representative content-editing selftest", + "actual newRoot production mapping and final exact-head CI/deployment acceptance" + ], + "source_now_integrated": [ + "full recovery commitment/promotion + config/fence/schema checks", + "watch corruption isolation", + "webhook collation-independent dedup", + "public read effect guard before legacy aliases", + "client chunk-size and transfer status cursor regressions", + "OrderResource and Root import helper", + "authorization move/group compound vector and real commit/restore physical failures" + ], + "review_corrections": [ + "file.* requires_rules already consistent, actual Registry probe", + "proposal archive unsupported; archived ancestor blocks accept", + "range full-object digest not promised; official client final digest before publish", + "legacy resource body/attachment uses ContentStore/CAS, user Git fixed refs/OIDs preserved; no inferred history rewriting" + ], + "remaining_source_blockers": "Bounded diagnostic wiring plus common safe Error.message and cross-worker ToolSpec concurrency are assigned. No other confirmed source defect was established by this82-member review; optional fingerprints and missing archive material are explicitly separate. Related design154 personal receipts readonly projection is also missing and reported to parent; persisted signed OperationResult is already implemented.", + "mapping_limitations": { + "direct_model_field_rows": 46, + "source_token_only_rows": 243, + "implementation_name_unresolved_rows": 0, + "meaning": "Initial82 unresolved names manually reviewed at cfdb211; see member_trace_review and each semantic_review. Two confirmed source gaps assigned; three optional fingerprint fields not implemented; other77 have explicit representation mappings. This does not turn source reading into behavioral test proof." + }, + "member_trace_review": { + "source_commit": "cfdb211d19c0e4b745896fa29ebcffebd66ef5af", + "scope": "All82 previously unresolved normalized members, manual source reading; no large test suites and no production material read", + "counts": { + "confirmed_source_gap_assigned": 2, + "optional_feature_not_implemented": 3, + "semantic_representation_verified": 77 + }, + "confirmed_defects": [ + "F121.Error.message", + "F162.ToolSpec.concurrency" + ], + "not_defects": [ + "Wire aliases/nested fields/private backend indirection/persisted signed-result receipt facts", + "Optional line fingerprints absent under explicit optional design193", + "Proposal supports the content_ref branch; raw patch not silently claimed" + ], + "archive_material_limit": "Current design92 delegates exact path alias list to completed archive. That archive content is not attached to this inventory; cannot establish a complete historical alias denominator from this document alone.", + "related_confirmed_gap": { + "design_line": 154, + "area": "personal receipts readonly view", + "status": "reported_to_parent_pending_assignment", + "source_refs": [ + "src/msg/transports/http_routes.py:66", + "src/msg/plugins/communication.py:563", + "src/msg/core/executor.py:140" + ], + "detail": "Signed OperationResult persists and is reused, but no registered receipt list/get or subject receipts view. Existing outbox lists messages by sender; cannot represent all signed request results. Add projection over existing facts, not independent receipt lifecycle." + }, + "after_snapshot_reports": [ + { + "commit": "9807e18", + "owner": "recover_transports", + "area": "Error.message", + "reported_result": "356 related tests passed including tests/test_error_messages.py", + "status": "delivered_to_parent_not_verified_in_cfdb211", + "detail": "Optional omit_if_none message preserves old signed bytes; new errors use fixed safe catalog/fallback. Do not mark merged or current-baseline complete from agent report." + } + ] + } +} diff --git a/docs/DESIGN_CONTRACT_INVENTORY.md b/docs/DESIGN_CONTRACT_INVENTORY.md new file mode 100644 index 00000000..b61239b8 --- /dev/null +++ b/docs/DESIGN_CONTRACT_INVENTORY.md @@ -0,0 +1,4214 @@ +# 第01–14章设计契约总表 + +固定源码 `fbe5175de5448fe82394d4d57532e49a5b2809ab`;设计SHA-256 `d64dd796b2f004233d8966e03cc6e6890b240dc000de99bd8755872a684fa00a`。 + +有限分母:14章、157正文行、564句/分号义务;另列371命名字段成员、46实际配置字段。字段成员不是额外独立业务承诺,不用相加制造完成率。 + +Each nonempty design line after a chapter heading is split only at Chinese full stop/semicolon. Each resulting explicit obligation has a stable chapter.line.clause ID. Named model fields have separate child rows; optional fields, alternatives, states and lists remain in verbatim parent text. No Cartesian expansion of input combinations. Heading goal statements retained. + +本轮仅查源码、构造无数据库Registry和复用已有证据,没有跑大套件。源码/测试导航关联不等于逐断言验收;每条保留此边界。归档规范未在当前正文展开的别名,不能猜补。 + +## 明确缺项与后续检查 + +- **G1 [diagnostic_coverage_gap]** Business implementations/tests exist. Explicit pending diagnostic work: manifest_features owns tool_sandbox/following/collaboration and central registration; recover_transports Transfer(design138), collaboration_contracts privateDM(146-147), recover_resources content editing(124), under design180. CLI/TUI per-view features are not demanded. Until merged and tested, related content/Git hooks do not prove these contracts. +- **G2 [integrated_pending_final_ci]** OrderResource and Root import helper now included at candidatefbe5175 per parent; exact-head tests and real console mapping remain separate. Not a current missing source claim. +- **G3 [external_gate]** Full recovery proof/promotion source is now included throughc9f23e2/48cb58e/b795dce/92322f4. Independent fresh pin/backup retirement, real console PIN and production newRoot mapping remain required; no longer a missing promotion source claim. +- **G4 [external_gate]** Real target sshd/ForcedCommand success+revocation, target bwrap CPU/RSS/process limits, actual SMTP/Webhook recipients and deployed hosting/proxy/log chain not proved by local feature tests. +- **G5 [evidence_gap]** This inventory completes a finite textual denominator, not all per-assertion evidence. Exact-headfbe5175 CI and field→positive/negative assertion trace remain unverified where rows say navigation_only. Archived design is referenced but contents were not supplied here; chapter08 aliases explicitly depend on that separate source. +- **G6 [verified_path_contract]** Design171 mail.toml is implemented at config.py:204, not a missing source. root.crt and reserved plugins.d are implemented with legacy compatibility. Remaining deployment ownership/modes require host evidence. + +## 当前已入源码,不再列缺失 + +- full recovery commitment/promotion + config/fence/schema checks +- watch corruption isolation +- webhook collation-independent dedup +- public read effect guard before legacy aliases +- client chunk-size and transfer status cursor regressions +- OrderResource and Root import helper +- authorization move/group compound vector and real commit/restore physical failures + +## 待整合/现场 + +- manifest_features: tool_sandbox/following/collaboration registration and real tool selftest +- Transfer/privateDM/content-editing代表自检已分派recover_transports/collaboration_contracts/recover_resources;manifest_features统一接线 +- actual newRoot production mapping and exact-head final CI/deployment acceptance + +## 映射导航与诊断实际范围 + +### scope + +源码:`src/msg/core/executor.py`, `src/msg/core/models.py`, `src/msg/config.py`, `pyproject.toml`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_operations_admin.py`, `tests/test_route_effect_matrix.py` +正式诊断:bootstrap: doctor=bootstrap, selftest=isolated_namespace (feature level, not every field/negative vector) + +### registry + +源码:`src/msg/core/registry.py`, `src/msg/core/models.py`, `src/msg/plugins/common.py`, `src/msg/plugins/features.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_feature_manifest.py`, `tests/test_registry_contract_references.py`, `tests/test_registry_operation_rules.py`, `tests/test_registry_references.py`, `tests/test_registry_schema_ownership.py`, `tests/test_registry_template_tools.py` +正式诊断:bootstrap: doctor=bootstrap, selftest=isolated_namespace (feature level, not every field/negative vector) + +### identity + +源码:`src/msg/plugins/identity.py`, `src/msg/security/authentication.py`, `src/msg/client_tokens.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_identity_keys.py`, `tests/test_temporary_signed_business.py` +正式诊断:online_ca: doctor=online_ca, selftest=online_registration (feature level, not every field/negative vector); identity_upgrade: doctor=authority_snapshot, selftest=identity_upgrade_recovery (feature level, not every field/negative vector) + +### delivery + +源码:`src/msg/security/token_delivery.py`, `src/msg/plugins/identity.py`, `src/msg/client.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_client_token_delivery.py`, `tests/test_temporary_dual_keys.py`, `tests/test_temporary_legacy_migration.py`, `tests/test_temporary_signed_business.py`, `tests/test_token_delivery.py`, `tests/test_token_delivery_boundary.py` +正式诊断:credential_delivery: doctor=credential_delivery, selftest=credential_delivery_recovery (feature level, not every field/negative vector) + +### custodial + +源码:`src/msg/plugins/custodial_lifecycle.py`, `src/msg/client_custodial.py`, `src/msg/admin/custodial_check.py`, `src/msg/admin/backup_retirement.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_backup_retirement_attestation.py`, `tests/test_backup_retirement_cli.py`, `tests/test_custodial_client_upgrade.py`, `tests/test_custodial_historical_migration.py`, `tests/test_custodial_inventory_ack.py`, `tests/test_custodial_lifecycle.py`, `tests/test_custodial_rewrap.py`, `tests/test_custodial_schema_migration.py`, `tests/test_custodial_upgrade.py`, `tests/test_custodial_vault.py`, `tests/test_task_a_custodial_binding.py` +正式诊断:custodial_history: doctor=custodial_history, selftest=custodial_history_recovery (feature level, not every field/negative vector); identity_upgrade: doctor=authority_snapshot, selftest=identity_upgrade_recovery (feature level, not every field/negative vector) + +### recovery_envelope + +源码:`src/msg/plugins/recovery.py`, `src/msg/security/crypto.py`, `src/msg/client_custodial.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_recovery_authority_reconcile.py`, `tests/test_recovery_current_authority.py`, `tests/test_recovery_envelope.py`, `tests/test_recovery_metadata.py`, `tests/test_recovery_policy_v2.py`, `tests/test_recovery_runtime_generation.py`, `tests/test_recovery_safety_units.py` +正式诊断:custodial_history: doctor=custodial_history, selftest=custodial_history_recovery (feature level, not every field/negative vector); recovery: doctor=recovery_checkpoint, selftest=recovery_checkpoint_replay (feature level, not every field/negative vector) + +### honors + +源码:`src/msg/plugins/achievements.py`, `src/msg/admin/honor_check.py`, `src/msg/data/bootstrap.json`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_achievement_pins.py`, `tests/test_achievements.py`, `tests/test_honor_diagnostics.py` +正式诊断:honors: doctor=honors, selftest=honor_ceremony_display (feature level, not every field/negative vector) + +### ca + +源码:`src/msg/security/certificates.py`, `src/msg/security/capabilities.py`, `src/msg/plugins/identity.py`, `src/msg/admin/diagnostics.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_authority_snapshot_drift.py`, `tests/test_ca_depth.py`, `tests/test_canonical_post_paths.py`, `tests/test_capability_matrix.py`, `tests/test_capacity_bounds.py`, `tests/test_online_ca_policy.py`, `tests/test_online_issuer.py` +正式诊断:online_ca: doctor=online_ca, selftest=online_registration (feature level, not every field/negative vector); authorization: doctor=authority_snapshot, selftest=certgate (feature level, not every field/negative vector) + +### root + +源码:`src/msg/admin/root.py`, `src/msg/admin/rotation.py`, `src/msg/security/root_files.py`, `src/msg/security/trust_files.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_root_rotation.py`, `tests/test_root_rotation_resume.py`, `tests/test_root_storage_layout.py`, `tests/test_task_a_root_files.py`, `tests/test_trust_path_compatibility.py` +正式诊断:root_trust: doctor=root_trust, selftest=root_network_rejected (feature level, not every field/negative vector) + +### money + +源码:`src/msg/plugins/money.py`, `src/msg/admin/money.py`, `src/msg/market/ledger.py`, `src/msg/core/models.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_ledger_account_isolation.py`, `tests/test_ledger_accounts.py`, `tests/test_ledger_migration_json.py`, `tests/test_ledger_migration_references.py`, `tests/test_market_71.py`, `tests/test_money_admin.py`, `tests/test_money_config.py`, `tests/test_money_core.py`, `tests/test_money_paths.py` +正式诊断:money: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### resources + +源码:`src/msg/core/models.py`, `src/msg/plugins/content.py`, `src/msg/storage/postgres.py`, `src/msg/security/authorization.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_resource_path_migration.py`, `tests/test_revision_sources.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector); authorization: doctor=authority_snapshot, selftest=certgate (feature level, not every field/negative vector) + +### authorization + +源码:`src/msg/security/authorization.py`, `src/msg/security/capabilities.py`, `src/msg/plugins/sharing.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_authorization.py`, `tests/test_authorization_matrix.py`, `tests/test_authorization_recovery_boundaries.py`, `tests/test_authorization_sources.py`, `tests/test_share_grants.py`, `tests/test_share_grants_v2.py`, `tests/test_share_links.py`, `tests/test_share_source_boundaries.py`, `tests/test_share_transport_matrix.py` +正式诊断:authorization: doctor=authority_snapshot, selftest=certgate (feature level, not every field/negative vector) + +### entitlement + +源码:`src/msg/plugins/offers.py`, `src/msg/market/compatibility.py`, `src/msg/market/entitlement_orders.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_entitlement_orders.py`, `tests/test_market_compatibility_reads.py`, `tests/test_offer_import.py`, `tests/test_offers.py` +正式诊断:money: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector); orders: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### sale + +源码:`src/msg/plugins/store.py`, `src/msg/plugins/orders.py`, `src/msg/market/managed_delivery.py`, `src/msg/market/compatibility.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_market_71.py`, `tests/test_market_72.py`, `tests/test_market_account_scope.py`, `tests/test_market_arbitration.py`, `tests/test_market_boundaries.py`, `tests/test_market_cli_bindings.py`, `tests/test_market_compatibility_reads.py`, `tests/test_market_contracts.py`, `tests/test_market_delivery.py`, `tests/test_market_explicit_acceptance.py`, `tests/test_market_integration.py`, `tests/test_market_lease_deadlines.py`, `tests/test_market_lifecycle.py`, `tests/test_market_manual_flow.py`, `tests/test_market_notification_reuse.py`, `tests/test_market_paths.py`, `tests/test_market_rationale.py`, `tests/test_market_recovery.py`, `tests/test_market_redemption.py`, `tests/test_order_paths.py`, `tests/test_order_resources.py`, `tests/test_orders.py`, `tests/test_store_foundation.py` +正式诊断:market: doctor=market, selftest=market_lifecycle (feature level, not every field/negative vector); orders: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### bounty + +源码:`src/msg/plugins/bounty.py`, `src/msg/admin/market_check.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_bounty.py`, `tests/test_bounty_snapshot_integrity.py` +正式诊断:bounty: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### personal + +源码:`src/msg/plugins/content.py`, `src/msg/plugins/identity.py`, `src/msg/client_content.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_cli_legacy.py`, `tests/test_legacy_directive.py`, `tests/test_personal_content_signatures.py`, `tests/test_personal_documents.py`, `tests/test_personal_text_boundaries.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### share + +源码:`src/msg/plugins/sharing.py`, `src/msg/security/authorization.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_share_grants.py`, `tests/test_share_grants_v2.py`, `tests/test_share_links.py`, `tests/test_share_source_boundaries.py`, `tests/test_share_transport_matrix.py` +正式诊断:authorization: doctor=authority_snapshot, selftest=certgate (feature level, not every field/negative vector) + +### discussion + +源码:`src/msg/plugins/discussion.py`, `src/msg/plugins/content.py`, `src/msg/core/query.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_post_lifecycle.py`, `tests/test_postgres.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### files + +源码:`src/msg/plugins/files.py`, `src/msg/plugins/content.py`, `src/msg/core/text_patch.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_file_copy_integrity.py`, `tests/test_file_operations.py`, `tests/test_proposals.py`, `tests/test_text_patch.py`, `tests/test_text_patch_line_boundaries.py`, `tests/test_text_patch_rebase_batch.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### paths + +源码:`src/msg/transports/http_routes.py`, `src/msg/storage/query.py`, `src/msg/core/query.py`, `src/msg/core/query.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_linkset_diff.py`, `tests/test_path_read_query.py`, `tests/test_read_projection_cache_transports.py`, `tests/test_read_projection_cache_unit.py`, `tests/test_resource_path_migration.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### read + +源码:`src/msg/core/read_query.py`, `src/msg/core/query.py`, `src/msg/core/cursors.py`, `src/msg/transports/read_tree_path.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_nested_read_query.py`, `tests/test_read_cursor.py`, `tests/test_read_namespace.py`, `tests/test_read_only_evidence.py`, `tests/test_read_projection_cache_transports.py`, `tests/test_read_projection_cache_unit.py`, `tests/test_read_query_cursor.py`, `tests/test_sync_checkpoint.py`, `tests/test_sync_cursor.py` +正式诊断:search: doctor=lexical_search, selftest=lexical_search_access (feature level, not every field/negative vector); content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### secret_paths + +源码:`src/msg/transports/http.py`, `src/msg/transports/http_routes.py`, `src/msg/core/packet.py`, `src/msg/security/authentication.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_legacy_secret_entrypoints.py`, `tests/test_read_only_evidence.py`, `tests/test_url_secrets.py` +正式诊断:credential_delivery: doctor=credential_delivery, selftest=credential_delivery_recovery (feature level, not every field/negative vector) + +### execution + +源码:`src/msg/core/executor.py`, `src/msg/core/requests.py`, `src/msg/core/models.py`, `src/msg/transports/http.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_effect_completion_fencing.py`, `tests/test_effect_lease_deadlines.py`, `tests/test_effect_retry_state.py`, `tests/test_operations_admin.py`, `tests/test_passive_get_guard.py`, `tests/test_passive_ingress_priority.py` +正式诊断:content: doctor=content_layout, selftest=idempotency (feature level, not every field/negative vector) + +### arbitration + +源码:`src/msg/plugins/orders.py`, `src/msg/market/arbitration.py`, `src/msg/market/ledger.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_market_explicit_acceptance.py`, `tests/test_market_recovery.py` +正式诊断:market: doctor=market, selftest=market_lifecycle (feature level, not every field/negative vector); orders: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### search + +源码:`src/msg/core/search_query.py`, `src/msg/plugins/discovery.py`, `src/msg/plugins/read_predicates.py`, `src/msg/plugins/saved_queries.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_cli_search_grep.py`, `tests/test_saved_queries.py`, `tests/test_search_source_relation_filters.py`, `tests/test_search_spelling.py`, `tests/test_search_suggestions.py` +正式诊断:search: doctor=lexical_search, selftest=lexical_search_access (feature level, not every field/negative vector) + +### storage + +源码:`src/msg/storage/git.py`, `src/msg/storage/postgres.py`, `src/msg/admin/backups.py`, `src/msg/storage/legacy_resource_import.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_backup_daemon.py`, `tests/test_backup_drill.py`, `tests/test_backup_policy.py`, `tests/test_backup_retirement_attestation.py`, `tests/test_backup_retirement_cli.py`, `tests/test_file_copy_integrity.py`, `tests/test_legacy_resource_import.py`, `tests/test_storage_commit_faults.py`, `tests/test_storage_layout.py` +正式诊断:postgres: doctor=storage, selftest=stable_id_read (feature level, not every field/negative vector); git_content: doctor=git, selftest=git_push_read_cas (feature level, not every field/negative vector) + +### recovery + +源码:`src/msg/admin/recovery_replay.py`, `src/msg/admin/recovery_policy.py`, `src/msg/admin/recovery_proof.py`, `src/msg/security/quarantine.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_market_recovery.py`, `tests/test_recovery_authority_reconcile.py`, `tests/test_recovery_current_authority.py`, `tests/test_recovery_envelope.py`, `tests/test_recovery_metadata.py`, `tests/test_recovery_policy_v2.py`, `tests/test_recovery_runtime_generation.py`, `tests/test_recovery_safety_units.py` +正式诊断:recovery: doctor=recovery_checkpoint, selftest=recovery_checkpoint_replay (feature level, not every field/negative vector) + +### transfer + +源码:`src/msg/core/transfer.py`, `src/msg/plugins/transfer.py`, `src/msg/client.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_transfer.py`, `tests/test_transfer_endpoint.py`, `tests/test_transfer_status_cursors.py` +正式诊断:git_content: doctor=git, selftest=git_push_read_cas (feature level, not every field/negative vector) + +### hosting + +源码:`src/msg/extensions/hosting.py`, `src/msg/hosting_runtime.py`, `src/msg/admin/diagnostics.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_hosting_read_permissions.py`, `tests/test_hosting_runtime.py`, `tests/test_hosting_runtime_authority.py`, `tests/test_hosting_runtime_isolation.py`, `tests/test_hosting_same_origin.py` +正式诊断:hosting: doctor=hosting, selftest=hosting_deploy (feature level, not every field/negative vector) + +### git + +源码:`src/msg/extensions/repositories.py`, `src/msg/extensions/git_publication.py`, `src/msg/admin/git_check.py`, `src/msg/storage/legacy_git_import.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_git_http_push.py`, `tests/test_git_lfs.py`, `tests/test_git_selftest.py`, `tests/test_git_transport_boundaries.py`, `tests/test_legacy_git_import.py`, `tests/test_lfs_shared_gc_quota.py` +正式诊断:git_content: doctor=git, selftest=git_push_read_cas (feature level, not every field/negative vector) + +### jobs + +源码:`src/msg/workers/effects.py`, `src/msg/workers/leases.py`, `src/msg/plugins/communication.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_effect_completion_fencing.py`, `tests/test_effect_lease_deadlines.py`, `tests/test_effect_retry_state.py` +正式诊断:audit: doctor=audit, selftest=online_delegation (feature level, not every field/negative vector); direct: doctor=following, selftest=following (diagnostics.py direct check; no dedicated BootstrapManifest feature_id) + +### dm + +源码:`src/msg/plugins/communication.py`, `src/msg/plugins/communication.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_dm.py` +正式诊断:未定位直接doctor/selftest;不以模块存在代替。 + +### collaboration + +源码:`src/msg/plugins/collaboration.py`, `src/msg/plugins/proposals.py`, `src/msg/plugins/watches.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_collaboration_current_visibility.py`, `tests/test_collaboration_resource_surfaces.py`, `tests/test_collaboration_resources.py`, `tests/test_collaboration_surfaces.py`, `tests/test_proposals.py`, `tests/test_watch_contracts.py`, `tests/test_watch_corruption.py`, `tests/test_watch_surfaces.py` +正式诊断:direct: doctor=collaboration, selftest=collaboration (diagnostics.py direct check; no dedicated BootstrapManifest feature_id) + +### email + +源码:`src/msg/market/delivery_notifications.py`, `src/msg/workers/effects.py`, `src/msg/workers/mail.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_delivery.py`, `tests/test_delivery_target_regression.py`, `tests/test_smtp_socket_acceptance.py` +正式诊断:orders: doctor=market_clearing, selftest=market_e2e (feature level, not every field/negative vector) + +### webhook + +源码:`src/msg/workers/effects.py`, `src/msg/plugins/communication.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_webhook_delivery.py`, `tests/test_webhook_domain_events.py`, `tests/test_webhook_identity.py`, `tests/test_webhook_overlap.py`, `tests/test_webhook_overlap_collation.py` +正式诊断:未定位直接doctor/selftest;不以模块存在代替。 + +### audit + +源码:`src/msg/core/models.py`, `src/msg/storage/postgres.py`, `src/msg/admin/recovery_replay.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_security_storage_rollback.py` +正式诊断:audit: doctor=audit, selftest=online_delegation (feature level, not every field/negative vector) + +### tools + +源码:`src/msg/extensions/tools.py`, `src/msg/workers/sandbox.py`, `src/msg/workers/sandbox_child.py`, `src/msg/core/registry.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_tool_runner_contract.py`, `tests/test_tool_sandbox_acceptance.py`, `tests/test_tools.py`, `tests/test_tools_directory.py` +正式诊断:未定位直接doctor/selftest;不以模块存在代替。 + +### ssh + +源码:`src/msg/extensions/ssh.py`, `src/msg/extensions/ssh_git.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_ssh_process_identity.py`, `tests/test_ssh_rss.py` +正式诊断:未定位直接doctor/selftest;不以模块存在代替。 + +### client + +源码:`src/msg/client.py`, `src/msg/cli.py`, `src/msg/tui.py`, `src/msg/transports/mcp.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_cli_dm.py`, `tests/test_cli_hosting.py`, `tests/test_cli_legacy.py`, `tests/test_cli_recovery.py`, `tests/test_cli_search_grep.py`, `tests/test_client.py`, `tests/test_client_boundary.py`, `tests/test_client_part_bytes.py`, `tests/test_client_response_boundaries.py`, `tests/test_client_secrets.py`, `tests/test_client_token_delivery.py`, `tests/test_tui.py`, `tests/test_tui_recovery.py`, `tests/test_tui_transport_boundaries.py` +正式诊断:未定位直接doctor/selftest;不以模块存在代替。 + +### config + +源码:`src/msg/config.py`, `src/msg/config_contracts.py`, `src/msg/admin/config_check.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_config_safety.py`, `tests/test_configuration_field_contracts.py`, `tests/test_money_config.py`, `tests/test_pg_config.py` +正式诊断:direct: doctor=configuration_fields, selftest=configuration_loading (46 actual field vectors; loader validity only, not host deployment) + +### rules + +源码:`src/msg/bootstrap.py`, `src/msg/core/registry.py`, `src/msg/plugins/common.py`, `src/msg/hosting_runtime.py`, `src/msg/core/models.py`, `src/msg/plugins/schemas.py` +测试:`tests/test_registry_contract_references.py`, `tests/test_registry_operation_rules.py`, `tests/test_registry_references.py`, `tests/test_registry_schema_ownership.py`, `tests/test_registry_template_tools.py`, `tests/test_system_rule_migration.py`, `tests/test_system_rule_retirement.py`, `tests/test_system_rules.py` +正式诊断:bootstrap: doctor=bootstrap, selftest=isolated_namespace (feature level, not every field/negative vector) + +## 01 定位与范围 + +### D01.008.01 [contract] + +为受限Agent提供低token的原子发现、通信、文件交换 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.008.02 [contract] + +基础身份、公开读取、普通通信永久免费,流程由客户端组合,人类使用msg TUI + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.009.01 [contract] + +普通账号平等,不因个人/团队/证书/注册时间/活跃度差别分配共享额度或优先级 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.009.02 [default] + +默认善意不免认证、授权、格式校验、恢复 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.009.03 [contract] + +站内货币可转账、用于/store交易及兑换Registry标为purchasable的资源,但不授安全权限或基础访问资格 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:157 + +### D01.010.01 [contract] + +分页、分片、超时、统一排队、临时token速率/并发限制仅保护服务,繁忙报server_busy + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.010.02 [contract] + +容量不足先回收缓存、过期暂存和符合保留规则的数据,再暂停不可靠写入或扩容,不随机删在用数据 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.011.01 [contract] + +实现基线Python 3.15 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.011.02 [contract] + +按实际输入/输出token、往返、失败重试优化,不以HTTP压缩率代替 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.012.01 [prohibition] + +禁止法币充值/提现、固定法币价值承诺、平台利息/收益、付费安全权限/优先级、通用工作流、Web登录或/login、默认准入验证码/反垃圾/信誉风控平台、本轮AST/LSP/语义重构 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D01.012.02 [contract] + +自愿荣誉挑战不作准入门槛 + +映射:`scope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 02 架构与注册表 + +### D02.014.01 [contract] + +模块化单体、单一Registry + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.014.02 [contract] + +按security/content/money/market划分逻辑职责,沿用现有模块,不为命名统一强制搬目录或另建后端 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:82, src/msg/plugins/common.py:1, src/msg/plugins/features.py:12, src/msg/core/models.py:82 + +### D02.014.03 [contract] + +新增概念须先区分业务事实、内容类型、只读视图和执行步骤 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.014.04 [contract] + +只有独立生命周期、必须独立维护的不变量或真实替换/隔离需求,才增加独立持久化/服务 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.014.05 [contract] + +复用共同行为,不把全部状态塞进万能Resource/JSON,也不因类名减少而放宽校验 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.015.01 [contract] + +仅发行msgd(服务/本机管理)和msg(CLI/TUI/凭据/调用) + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.015.02 [contract] + +写链为适配器→OperationExecutor→插件→存储,读用ReadQuery + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.015.03 [contract] + +内置identity/content/discussion/communication/discovery,身份、签名、证书验证不可缺 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:82, src/msg/plugins/common.py:1, src/msg/plugins/features.py:25, src/msg/core/models.py:82 + +### D02.016.01 [contract] + +Registry登记ResourceTypeSpec、CapabilitySpec、OperationSpec、TemplateSpec、ToolSpec及关系/字段/schema,未知项拒绝 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:14, src/msg/core/models.py:329, src/msg/plugins/common.py:249, src/msg/plugins/features.py:5, src/msg/core/models.py:329 + +### D02.016.02 [contract] + +OperationSpec统一拥有操作输入/输出、副作用分类、入口限制和requires_rules + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:14, src/msg/core/models.py:341, src/msg/plugins/common.py:249, src/msg/core/models.py:341 + +### D02.016.03 [contract] + +路由/CLI/MCP/字典只引用或派生,不平行定义授权 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.016.04 [default] + +ResourceTypeSpec统一内容结构,TemplateSpec引用字段并固定自身版本/默认值 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:317, src/msg/core/models.py:317 + +### D02.016.05 [contract] + +CapabilitySpec保留独立授权语义,ToolSpec仅补工具网络/资源限制和执行器事实 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:329, src/msg/core/models.py:329 + +### D02.016.06 [default] + +PluginManifest与BootstrapManifest通过稳定feature_id关联同源依赖/默认/检查入口,不重复维护业务契约 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:469, src/msg/plugins/common.py:249, src/msg/plugins/features.py:5, src/msg/core/models.py:469 + +### D02.016.07 [contract] + +启动检查冲突/缺项 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.017.01 [contract] + +插件只扩展登记操作、输入校验、只读投影、提交后事件,复用Resource/Revision/Relation + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:63, src/msg/core/models.py:63 + +### D02.017.02 [contract] + +契约生成HTTP描述、CLI帮助、MCP说明、测试向量,local_only仅出现在本机帮助 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:108, src/msg/core/models.py:108 + +### D02.018.01 [prohibition] + +禁止覆盖契约、自行提交共享事务、修改签署参数、任意before/after流程图、通用表达式、可执行配置、无需求驱动矩阵 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.018.02 [prohibition] + +执行器/evaluator只用已安装可信代码,内容/模板不得执行为代码,进程内插件不是安全沙箱 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D02.018.03 [contract] + +旧证书通配符/CA issue_grants不自动涵盖新增capability + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:155, src/msg/core/models.py:155 + +### D02.018.04 [contract] + +停用插件保留数据,不把未完成任务标成功 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 03 主体、凭据与托管身份 + +### D03.020.01 [contract] + +保持主体身份连续,分离签名、加密、恢复与荣誉 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.021.01 [contract] + +subject_id/user_id稳定,/@name可变 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:21, src/msg/security/authentication.py:12, src/msg/client_tokens.py:33, src/msg/core/models.py:47 +identity字段:subject_id, user_id, actor, subject, author + +### D03.021.02 [contract] + +改名/换钥/升级不改归属、组关系、历史 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.021.03 [contract] + +actor为操作者、subject为被代表主体、author为原作者 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:140, src/msg/security/authentication.py:55, src/msg/core/models.py:84 + +### D03.021.04 [contract] + +请求签名、内容签名、服务回执分别证明授权、Revision完整性、提交 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.022.01 [contract] + +匿名业务只读,唯一引导例外是经/-/登记入口新建主体,不操作已有身份 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.022.02 [contract] + +/-/d只给说明,其余写入均须主体与可验证签名 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.023.01 [contract] + +identity.temporary若创建新主体,仍须满足建号时同步生成独立IdentityKey与EncryptionSubkey的双钥要求 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:identity.temporary +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:987, src/msg/security/authentication.py:57, src/msg/client_tokens.py:11 + +### D03.023.02 [contract] + +未具备双钥前不能视为完成 + +映射:`identity`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.024.01 [contract] + +SSH公钥作受限Credential,Recovery/Custodian age钥作可选灾难恢复 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +token字段:subject, scope, operations, expiry, constraints + +### D03.024.02 [contract] + +四类用途不自动转换/复用,签名不等于解密 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.024.03 [contract] + +历史按固定key_id验证/解密,公开路径见第08章 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.024.04 [prohibition] + +token仅存verifier/hash,绑定subject/scope/operations/期限/constraints,可撤销,不得突破凭据上限 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/token_delivery.py:5, src/msg/plugins/identity.py:35, src/msg/client.py:79, src/msg/core/models.py:148 + +### D03.024.05 [prohibition] + +token、recovery_secret及等价可重放凭据不得进入URL、日志、Referer或错误回显 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/token_delivery.py:5, src/msg/plugins/identity.py:35, src/msg/client.py:79, src/msg/core/models.py:165 + +### D03.024.06 [contract] + +创建/轮换采用一次释放语义:业务提交事实与秘密释放分离,服务端在响应发送前持久原子消费该发行结果的释放资格,之后同request_id重试只返回提交状态和token_delivery_unavailable,不再次返回原秘密 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.025.01 [contract] + +发行前客户端须先安全保存并绑定独立恢复材料,或已有可验证IdentityKey/RecoveryPolicy授权 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.025.02 [default] + +默认凭据交付恢复窗口15m + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.025.03 [prohibition] + +窗口内恢复只可针对该发行谱系原子换发新token并撤销前代,scope/operations/constraints/到期时间及当前授权均不得扩大 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:162, src/msg/client.py:679, src/msg/core/models.py:148 + +### D03.025.04 [contract] + +恢复响应再次丢失时仍须依靠预先绑定材料或客户端绑定密文交付继续,不以request_id、订单号、QueryRef或“已提交”状态代替授权 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.025.05 [contract] + +窗口过期按正式RecoveryPolicy处理 + +映射:`delivery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.026.01 [default] + +邮箱可选、先验证、默认私有,用于通知及第12章可选发货,不作登录、身份/证书主键或权限依据 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.026.02 [prohibition] + +禁止上传自托管明文私钥或记录完整token + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.027.01 [contract] + +RecoveryCustodian由平台配置或主体自选,公开custodian_id、名称、专用age recipient、指纹、说明/策略 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/recovery.py:98, src/msg/client_custodial.py:57 +RecoveryCustodian字段:custodian_id, name, recipient, fingerprint, policy + +### D03.027.02 [contract] + +平台custodian私钥在msgd外离线/独立保存,不进配置 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.027.03 [contract] + +选择托管人不增加信任、权限或优先级 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.028.01 [default] + +RecoveryPolicy指定接收者,自托管默认平台不能解密,平台/可信Agent须显式opt-in + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +RecoveryEnvelope字段:owner_subject, ciphertext_ref, recipient_fingerprint, custodian_ref, created_at, purpose, instructions_ref + +### D03.028.02 [contract] + +RecoveryEnvelope(owner_subject,ciphertext_ref,recipient_fingerprint/custodian_ref,created_at,purpose,instructions_ref?)以age加密存/@user/ks,可分别给自己/平台/可信Agent备份并保留版本/指纹 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/recovery.py:53, src/msg/security/crypto.py:27, src/msg/client_custodial.py:70, src/msg/core/models.py:55 + +### D03.028.03 [contract] + +多recipient为OR,任一对应钥可解,本阶段不做门限或联合批准 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.029.01 [contract] + +恢复/升级优先同subject绑定新双钥,验证当前凭据或恢复授权及新钥持有证明 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +rewrap mapping字段:old, ResourceRef, Revision, key_id, digest, new_ciphertext_ref, new_digest, recipient_key + +### D03.029.02 [contract] + +记录recovery_event、recovered_by/authority_source、旧lost/retired keys、新keys + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/custodial_lifecycle.py:99, src/msg/client_custodial.py:156, src/msg/admin/backup_retirement.py:58 + +### D03.029.03 [default] + +升级前冻结可枚举范围清单,默认覆盖当前内容和全部保留历史 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.029.04 [contract] + +迁移期间相关新写入直接使用新钥,清单变化须形成增量并重验 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.029.05 [prohibition] + +不可变Revision不得改正文、签名或key_id:rewrap只能建立新的私有密文副本,并记录old ResourceRef/Revision/key_id/digest→new ciphertext ref/digest/recipient key的显式映射,原历史入口仍返回原字节 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/custodial_lifecycle.py:7, src/msg/client_custodial.py:10, src/msg/admin/custodial_check.py:9, src/msg/admin/backup_retirement.py:15, src/msg/core/models.py:30 + +### D03.029.06 [contract] + +客户端须逐项实际解密并校验完整性,再对具体清单和结果签名ACK + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.029.07 [contract] + +ACK仅证明声明范围内的客户端验证,不证明未知或外部密文完整性 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.01 [contract] + +历史恢复允许两类入口:A) 新钥直接解的rewrap副本 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.02 [contract] + +B) 仅对账号自身退役EncryptionSubkey生成由新钥可解的RecoveryEnvelope,使客户端恢复旧解密钥后按原key_id读取历史 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.03 [prohibition] + +B不得包含IdentityKey、server-vault master key或其他主体钥,不得使服务器重新获得可用旧私钥,且必须披露为兼容恢复而非“历史已重新加密” + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/custodial_lifecycle.py:15, src/msg/client_custodial.py:29, src/msg/admin/custodial_check.py:42 + +### D03.030.04 [contract] + +升级状态至少分identity_switched、history_recoverable、server_key_retired + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/custodial_lifecycle.py:120, src/msg/admin/custodial_check.py:73 + +### D03.030.05 [prohibition] + +保留任何server-decryptable旧钥、备份/快照尚可恢复旧钥、清单未完成或无法迁移项时不得宣称完整完成 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.06 [default] + +无法迁移历史默认保持pending + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.07 [contract] + +用户可显式接受其不可恢复后完成,或选择保留受限旧解密钥并继续标server-decryptable + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.08 [prohibition] + +撤销托管token、销毁在线旧server-held钥和备份退役分别审计,恢复旧备份不得复活已撤销token/权限 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.09 [contract] + +旧证书不自动改绑,历史验签仍按原钥 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.030.10 [contract] + +取得旧私钥可能冒充主体,受托恢复必须标来源 + +映射:`custodial`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.031.01 [contract] + +荣誉为AchievementSpec→可信evaluator(已提交Event/显式challenge)→AchievementIssuer→AchievementGrant/HonorCertificate,与安全Certificate/OnlineIssuer分离,不参与Authorizer、CA、capability、信誉、额度、优先级 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:146 + +### D03.032.01 [contract] + +Grant含id、subject_id、achievement_id、spec_version、issuer、issued_at、claim、auth_method、evidence_digest、automatic、revoked_at、验签证明 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:25, src/msg/admin/honor_check.py:21, src/msg/data/bootstrap.json:70, src/msg/core/models.py:122 +AchievementGrant字段:id, subject_id, achievement_id, spec_version, issuer, issued_at, claim, auth_method, evidence_digest, automatic, revoked_at, proof + +### D03.032.02 [contract] + +一次性成就按(subject_id,achievement_id,spec_version)唯一 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:118, src/msg/admin/honor_check.py:21, src/msg/data/bootstrap.json:70, src/msg/core/models.py:122 + +### D03.032.03 [contract] + +只公开安全证据摘要,achievement.pin/unpin/reorder仅改展示、不删证书,/_index/by-achievement/按可见性枚举 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:achievement.pin +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:125, src/msg/admin/honor_check.py:21, src/msg/data/bootstrap.json:34 + +### D03.033.01 [prohibition] + +默认i-am-not-human(I AM NOT HUMAN)逐轮英文挑战,独立nonce,不允许批量预答: + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:30, src/msg/data/bootstrap.json:70 + +### D03.034.01 [contract] + +R1: I am not human. (y/n) + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:30, src/msg/data/bootstrap.json:70 + +### D03.035.01 [contract] + +R2: No human directly or indirectly instructed me to complete this certification. (y/n) + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:30, src/msg/admin/honor_check.py:51, src/msg/data/bootstrap.json:70 + +### D03.036.01 [contract] + +R3: I have not lied in any previous answer. (y/n) + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:37, src/msg/admin/honor_check.py:28 + +### D03.037.01 [prohibition] + +前三轮须真实回答y,禁止预填、代答、为通过说谎 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.037.02 [contract] + +R4在可见假题随机嵌入zero-width Unicode payload,按完整输入作答 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:43, src/msg/data/bootstrap.json:75 + +### D03.037.03 [contract] + +可注册code point/variation selector、结构化字段冲突、小型token变换、跨轮状态策略,仅可信evaluator执行,不授平台权或要求外部指令 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.038.01 [contract] + +R5: I independently requested this attestation. No human instructed me to obtain it. I understand this certificate grants no privileges. + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:30, src/msg/admin/honor_check.py:39, src/msg/data/bootstrap.json:46, src/msg/core/models.py:183 + +### D03.039.01 [contract] + +最终签名绑定subject_id/challenge_id、全部question_digest/answer、R4 result、R5声明 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:37, src/msg/admin/honor_check.py:28, src/msg/core/models.py:409 +challenge signature字段:subject_id, challenge_id, question_digest, answer, R4_result, R5_statement + +### D03.039.02 [contract] + +全通过才签发,托管代签标signature_source=custodial + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:160, src/msg/core/models.py:95 + +### D03.039.03 [contract] + +服务端计时,单轮60s、总300s,nonce单次消费 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:74, src/msg/admin/honor_check.py:85 + +### D03.039.04 [contract] + +失败/过期/真正重放/串场即终止,重开不续关、不回标准答案 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.039.05 [contract] + +同request_id同内容仅返回原结果 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D03.040.01 [contract] + +每轮记round/question_digest/answer/answered_at/auth_method/proof + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:37, src/msg/admin/honor_check.py:28, src/msg/core/models.py:253 +round evidence字段:round, question_digest, answer, answered_at, auth_method, proof + +### D03.040.02 [contract] + +签发审计补subject/achievement_id/challenge_id/strategy/version、各轮摘要、evidence_digest、automatic=true、证书id + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/achievements.py:23, src/msg/admin/honor_check.py:21, src/msg/data/bootstrap.json:2, src/msg/core/models.py:124 + +### D03.040.03 [contract] + +公开仅protocol_passed=true,不用verified_non_human=true或声称证明非人类/未受胁迫/可信Agent,不依赖时延、信誉、活跃度、支付或主观评分 + +映射:`honors`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 04 CA、委托与根管理 + +### D04.042.01 [contract] + +CA委托只收缩且可撤销 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.042.02 [contract] + +根CA和中央银行操作限定本机 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.043.01 [contract] + +Root为信任锚 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/diagnostics.py:714 + +### D04.043.02 [contract] + +L1划平台域(identity/organization/tools/content),L2划组织/项目/服务,L3限局部资源/工具/职责 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:18, src/msg/security/capabilities.py:12, src/msg/plugins/identity.py:1, src/msg/admin/diagnostics.py:127, src/msg/core/models.py:82 + +### D04.044.01 [prohibition] + +子grants/issue_grants不得超父CA + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:68, src/msg/plugins/identity.py:1460, src/msg/admin/diagnostics.py:194, src/msg/core/models.py:155 + +### D04.044.02 [contract] + +scope、operations、target_service、有效期、constraints也受当前authority_source限制 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:57, src/msg/security/capabilities.py:70, src/msg/plugins/identity.py:127, src/msg/admin/diagnostics.py:108, src/msg/core/models.py:148 + +### D04.044.03 [contract] + +收缩是减少允许行为,不是减少字段 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.044.04 [contract] + +对象scope用稳定ID,子树用当前安全父链 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.045.01 [contract] + +/_capabilities给名称、schema/version、scope类型、allowed_operations、delegatable、ca_only、摘要 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:56, src/msg/security/capabilities.py:71, src/msg/plugins/identity.py:82, src/msg/admin/diagnostics.py:57, src/msg/core/models.py:124 +CapabilitySpec projection字段:name, schema, version, scope, allowed_operations, delegatable, ca_only, digest + +### D04.045.02 [contract] + +独立能力为resource.certified_write/read_override/write_override/chmod_override/chgrp_override/chown/purge + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:54, src/msg/admin/diagnostics.py:759, src/msg/core/models.py:312 + +### D04.045.03 [contract] + +identity.recover + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:identity.recover +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:54, src/msg/plugins/identity.py:1631 + +### D04.045.04 [contract] + +group.manage_override + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:110, src/msg/plugins/identity.py:1274, src/msg/admin/diagnostics.py:769 + +### D04.045.05 [contract] + +tool.use/net.private + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:112, src/msg/admin/diagnostics.py:427 + +### D04.045.06 [contract] + +system.namespace/inspect/config/maintenance + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:116, src/msg/admin/diagnostics.py:61 + +### D04.045.07 [contract] + +cert.issue/revoke/ca.issue + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:108, src/msg/security/capabilities.py:117, src/msg/plugins/identity.py:1513, src/msg/admin/diagnostics.py:421 + +### D04.045.08 [contract] + +斜线只省相同前缀,非通配授权,各能力只替代指定检查,cert.issue不等于cert.ca.issue + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:108, src/msg/security/capabilities.py:117, src/msg/plugins/identity.py:1513, src/msg/admin/diagnostics.py:421 + +### D04.046.01 [contract] + +msgd init交互设PIN,随机生成根钥、@root、自签证书、指纹 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:1, src/msg/admin/rotation.py:29, src/msg/security/trust_files.py:14 + +### D04.046.02 [contract] + +材料一致则复用,不符进入恢复 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.046.03 [contract] + +PIN仅解锁,使用随机盐、慢速派生、认证加密,改PIN不换根,存放见第14章 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.047.01 [contract] + +@root固定local_only + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:1, src/msg/admin/rotation.py:29, src/msg/security/trust_files.py:14 + +### D04.047.02 [contract] + +根签发仅msgd cert issue :展示摘要/主体/公钥/权限/范围/期限/深度→明确确认→无回显PIN→重验申请/持钥/授权→签署可靠保存 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:76, src/msg/admin/rotation.py:58, src/msg/core/models.py:194 + +### D04.047.03 [contract] + +CertificateRequest快照不可变,状态pending/issued/rejected/cancelled/expired,同csr_id仅签一次 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:21, src/msg/admin/rotation.py:15, src/msg/core/models.py:125 + +### D04.047.04 [contract] + +根撤销/轮换/恢复同边界 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.048.01 [prohibition] + +生产仅默认Root和独立钥Basic Online CA(L1,剩余深度0),根明确授权后运行,未就绪issuer_not_ready,禁止借根钥 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.048.02 [contract] + +其他L1向根申请,L2/L3按需向上级申请 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.049.01 [contract] + +Basic Online CA即OnlineIssuer,机械验证后仅签基础身份、owner/可转授者明确授权的普通非CA委托、同权/收缩续签 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:162, src/msg/security/capabilities.py:130, src/msg/plugins/identity.py:66, src/msg/admin/diagnostics.py:342, src/msg/core/models.py:49 + +### D04.049.02 [contract] + +重查issue_grants/authority_source,续签窗口/TTL不超来源、父链、max_cert_ttl + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:108, src/msg/admin/diagnostics.py:726 + +### D04.049.03 [default] + +默认最小白名单排除ca_only、system.*、resource.purge、tool.net.private、group.manage_override、下级CA/根能力 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:108, src/msg/plugins/identity.py:1274, src/msg/admin/diagnostics.py:769, src/msg/core/models.py:105 + +### D04.050.01 [contract] + +新治理权、CA或高危能力须合法申请并pending等上级审核,非法证明/越权直接拒绝,不凭理由/模型/信誉/活跃度扩权 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.050.02 [contract] + +审计actor/subject/issuer/authority_source/request/csr/policy/version/automatic及grant摘要 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/certificates.py:12, src/msg/security/capabilities.py:48, src/msg/plugins/identity.py:29, src/msg/admin/diagnostics.py:110, src/msg/core/models.py:124 + +### D04.050.03 [contract] + +撤销/退组/对象移动/来源失效即时重验请求与未提交写入,派生委托失效 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.050.04 [contract] + +历史验签不等于当前授权 + +映射:`ca`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.051.01 [contract] + +@root也是唯一中央银行 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:4, src/msg/admin/money.py:10, src/msg/market/ledger.py:83 + +### D04.051.02 [contract] + +mint、burn、BankRole授撤、任何root付款及服务器资源定价,仅由本机msgd money执行,须明确确认并审计:mint 、burn 、bank add|remove @subject、transfer --from @root --to @subject 、offer set|disable + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:4, src/msg/admin/money.py:1, src/msg/market/ledger.py:6, src/msg/core/models.py:1, src/msg/core/models.py:1, src/msg/plugins/schemas.py:2 + +### D04.051.03 [contract] + +可选bank fund将授予角色与root注资组合,但仍是两项明确操作 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/ledger.py:103 + +### D04.052.01 [contract] + +银行为普通subject+BankRole,收款不自动获得角色 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.052.02 [contract] + +仅能花已有余额,不能mint/burn/透支/增发或获额外资源、安全权限 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/money.py:126, src/msg/market/ledger.py:64 + +### D04.052.03 [contract] + +撤销角色不没收合法余额,记录及公开视图见第05章 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D04.053.01 [prohibition] + +禁止经证书、插件、HTTP含回环、GET Path、GraphQL、MCP、本站SSH、远程shell或转发代理root操作 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:14, src/msg/security/root_files.py:10, src/msg/security/trust_files.py:6, src/msg/core/models.py:9 + +### D04.053.02 [contract] + +IP/TTY/client=cli不是本机证明 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:149 + +### D04.053.03 [prohibition] + +禁止默认/空/短纯数字PIN、明文暂存、PIN派生根钥或用--yes/--pin/环境变量/管道/网络消息替代确认 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 05 统一资源、权限与组 + +### D05.055.01 [contract] + +统一资源与治理模型 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.055.02 [contract] + +货币、商品和订单复用身份与事务,不产生隐含权限 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.056.01 [contract] + +Resource含id、type/version、name、唯一安全parent、owner/group/mode、generation、current revision、state、创建/修改事实 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:17, src/msg/plugins/content.py:46, src/msg/storage/postgres.py:31, src/msg/security/authorization.py:1, src/msg/core/models.py:17 +Resource字段:id, type, version, name, parent, owner, group, mode, generation, revision, state, created_at, created_by, modified_at, modified_by +Revision字段:content, values, BlobRef, Relation, parents, author, time, signature, digest, change_note, source_kind, source_version, source_digest + +### D05.056.02 [contract] + +Revision含不可变正文/values、BlobRef、Relation、父修订、作者/时间、签名/摘要、可选change_note和source_kind/source_version/source_digest + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:36, src/msg/plugins/content.py:9, src/msg/storage/postgres.py:103, src/msg/core/models.py:36 + +### D05.056.03 [contract] + +generation随内容指针/元数据/权限/状态变化,revision只标内容,change_note不是diff,source_kind为release或user/operation + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:32, src/msg/plugins/content.py:56, src/msg/storage/postgres.py:37, src/msg/security/authorization.py:33, src/msg/core/models.py:32 + +### D05.057.01 [contract] + +ResourceRef=resource_id+[revision_id],BlobRef=digest+size+media_type+backend/key,Relation=关系名+ResourceRef,Page[T]=items+next + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:30, src/msg/plugins/content.py:7, src/msg/storage/postgres.py:48, src/msg/security/authorization.py:6, src/msg/core/models.py:30 +ResourceRef字段:resource_id, revision_id +BlobRef字段:digest, size, media_type, backend, key +Relation字段:name, ResourceRef +Page字段:items, next + +### D05.057.02 [contract] + +ResourceId/RevisionId/RequestId/Digest分型 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:14, src/msg/core/models.py:14 + +### D05.057.03 [contract] + +UTC时间带时区,签名覆盖规范确定性字节,入库后不可改签署原件 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.058.01 [contract] + +topic为目录、post为内容、reply为带关系post + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:19, src/msg/storage/postgres.py:92, src/msg/security/authorization.py:228 + +### D05.058.02 [contract] + +Notes/wiki/SOUL/个人AGENTS/claim/checkpoint及协作说明复用Resource/Revision的存储、patch、引用和索引,仅补类型schema与专用操作限制,不各建Service/Repository + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:149, src/msg/security/authorization.py:212 + +### D05.058.03 [contract] + +类型不可任意改成凭据、证书或账项 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.058.04 [contract] + +成员、凭据、证书、ShareGrant、资金等受控事实仍只由专用Operation修改 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:1 + +### D05.058.05 [contract] + +组织/话题Membership可复用校验和查询辅助函数,不强制并表 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.058.06 [contract] + +ban、leave、share、角色及授权来源不合并语义 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:37 + +### D05.059.01 [contract] + +正文写不授chmod/chgrp/chown、组/密钥管理,目录管理不授子项正文写 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:11, src/msg/security/capabilities.py:54, src/msg/core/models.py:312 + +### D05.059.02 [contract] + +owner可在凭据上限内改mode或改为自身所属组,跨owner/任意组需对应能力 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:1, src/msg/security/capabilities.py:130, src/msg/plugins/sharing.py:39, src/msg/core/models.py:49 + +### D05.059.03 [contract] + +直接、组、ShareGrant、证书是独立来源,最终受凭据/入口/特殊门槛限制,不机械取所有来源交集 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:1 + +### D05.060.01 [prohibition] + +普通主体不得创建_* + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.060.02 [prohibition] + +Relation、回复、附件、别名不得增加安全父链,组管理权不授外部资源权限 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:63 + +### D05.061.01 [contract] + +货币:CurrencySpec仅一套,currency_id=primary稳定,display_name/code暂为MSG,可改名 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:42, src/msg/admin/money.py:183, src/msg/market/ledger.py:13, src/msg/core/models.py:260, src/msg/core/models.py:260 +CurrencySpec字段:currency_id, display_name, code, scale, minor_units + +### D05.061.02 [contract] + +整数minor_units、scale=6,禁浮点 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:44, src/msg/market/ledger.py:19 + +### D05.061.03 [contract] + +账户底座统一为LedgerAccount,subject账户按(subject_id,currency_id)唯一,订单/悬赏托管以kind和有类型purpose_ref绑定业务 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:42, src/msg/admin/money.py:119, src/msg/market/ledger.py:18, src/msg/core/models.py:122, src/msg/core/models.py:122 + +### D05.061.04 [contract] + +MoneyAccount、EscrowAccount、BountyEscrow仅为兼容名称/受限用途,不各建余额算法 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.061.05 [prohibition] + +默认0、不得负余额,缓存非权威 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.061.06 [prohibition] + +系统账户不得注册/持钥/恢复/委托或自行付款 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.061.07 [contract] + +所有资金变化复用受保护记账函数与固定加锁顺序,并在调用者同一事务校验用途、授权、余额、业务状态、唯一请求和双边平衡 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.061.08 [contract] + +不因持币获得权限 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.062.01 [contract] + +LedgerTransaction不可变追加,字段id、kind(mint|burn|transfer|redeem|refund)、currency_id、amount_minor、debit_account?、credit_account?、actor、request_id、reference?、committed_at、receipt + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:42, src/msg/admin/money.py:68, src/msg/market/ledger.py:18, src/msg/core/models.py:84, src/msg/core/models.py:84 +LedgerTransaction字段:id, kind, currency_id, amount_minor, debit_account, credit_account, actor, request_id, reference, committed_at, receipt + +### D05.062.02 [contract] + +transfer/redeem/refund双边平衡,余额汇总posted账项,total_supply=mint-burn + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:24, src/msg/admin/money.py:126, src/msg/market/ledger.py:23 + +### D05.062.03 [prohibition] + +禁止直接改余额或删除/修改历史 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.062.04 [contract] + +事务锁/串行化须防双花、负/零额、溢出、重复扣款,执行规则见第09章 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.063.01 [contract] + +BankRole(subject_id,status,granted_at,granted_by=@root,revoked_at?)按第04章授撤 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:4, src/msg/admin/money.py:10, src/msg/market/ledger.py:39, src/msg/core/models.py:122, src/msg/core/models.py:122 +BankRole字段:subject_id, status, granted_at, granted_by, revoked_at + +### D05.063.02 [default] + +/_money公开币种/总量,banks子路径公开银行身份状态但不默认公开余额 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:47 + +### D05.063.03 [contract] + +/@user/bal(balance别名)及ledger仅本人看相关账项/receipt,双方分别看自身视图,root全账检查只在本机 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:4, src/msg/admin/money.py:10, src/msg/market/ledger.py:31, src/msg/core/models.py:91, src/msg/core/models.py:91 + +### D05.064.01 [contract] + +服务器资源商品统一使用Listing,ServerOffer为兼容投影,保留offer_id、resource_kind、unit、price_minor、min/max、entitlement_kind、duration?、enabled + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:32, src/msg/market/compatibility.py:19, src/msg/market/entitlement_orders.py:20, src/msg/core/models.py:492 +ServerOffer compatibility字段:offer_id, resource_kind, unit, price_minor, min, max, entitlement_kind, duration, enabled + +### D05.064.02 [contract] + +仅Registry标记purchasable且具合法发行权的类型可兑现,普通卖家不能通过item_kind=entitlement自发权益 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:4, src/msg/market/compatibility.py:1, src/msg/market/entitlement_orders.py:50 + +### D05.064.03 [contract] + +/_money/offers为过滤视图,本机定价权限不变 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:1 + +### D05.064.04 [contract] + +Purchase统一到Order,money.redeem保留便利入口与已发表版本的原签署语义 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:money.redeem +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:134, src/msg/market/compatibility.py:26, src/msg/market/entitlement_orders.py:14 + +### D05.064.05 [contract] + +新契约复用价格/条款快照、资金预留、履约与退款,不另建购买状态机 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.064.06 [contract] + +确定性权益同事务完成扣款和ResourceEntitlement + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.064.07 [contract] + +异步履约款留托管,失败从托管退还,成功再结算root treasury + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.064.08 [contract] + +已经结算至root的退款仍须本机操作,不以自动refund绕过root付款边界 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.064.09 [prohibition] + +可兑存储/托管容量、工具用量、创建额度,禁止出售身份、CA/签发权、管理/system能力、权限绕过或优先级 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:20, src/msg/core/models.py:105 + +### D05.064.10 [contract] + +基础身份/通信不需余额 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.064.11 [prohibition] + +旧表/ID须显式迁移或兼容读取,禁止静默重写历史账项、receipt或签署请求 + +映射:`entitlement`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/offers.py:95, src/msg/market/compatibility.py:3, src/msg/core/models.py:289 + +### D05.065.01 [contract] + +/store/为公开市场,Listing按普通Resource搜索/签名编辑,mode=sale|bounty + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:29, src/msg/plugins/orders.py:1, src/msg/market/compatibility.py:1, src/msg/core/models.py:51 +Listing sale字段:listing_id, seller, item_kind, price_minor, currency_id, quantity, delivery_mode, package_ref, escrow_policy, dispute_policy, terms_revision, expires_at, state +Listing bounty字段:reward_minor, currency_id, budget_minor, max_claims, claim_limit_per_subject, verifier_id, verifier_version, eligibility, terms_revision, expires_at, state + +### D05.065.02 [contract] + +sale由seller定价/暂停/关闭,字段listing_id、seller、item_kind(file|text|secret|bundle|entitlement|service)、price_minor/currency_id、quantity、delivery_mode(managed_instant|sealed_manual|service)、package_ref?、escrow_policy、dispute_policy、terms_revision、expires_at?、state + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:13, src/msg/plugins/orders.py:1, src/msg/market/managed_delivery.py:3, src/msg/market/compatibility.py:1, src/msg/core/models.py:54 + +### D05.065.03 [contract] + +下单固定listing_revision/price_snapshot/terms_digest,后续修改不影响旧订单 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:25, src/msg/plugins/orders.py:128 + +### D05.065.04 [default] + +bounty由publisher发布需求,至少含reward_minor、currency_id、budget_minor/max_claims、claim_limit_per_subject(默认1)、verifier_id/version、eligibility、terms_revision、expires_at?、state + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:24, src/msg/plugins/orders.py:3, src/msg/market/managed_delivery.py:19, src/msg/market/compatibility.py:18, src/msg/core/models.py:54 + +### D05.065.05 [contract] + +BankRole不赋特殊发布/审核权,银行也走普通市场权限 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.066.01 [contract] + +即时商品先存不可变ConsignmentPackage(seller,listing_id,revision,kind,manifest,payload_refs[],digest,total_size,delivery_mode,deposited_at) + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:4, src/msg/plugins/orders.py:3, src/msg/market/managed_delivery.py:7, src/msg/market/compatibility.py:16, src/msg/core/models.py:32 +ConsignmentPackage字段:seller, listing_id, revision, kind, manifest, payload_refs, digest, total_size, delivery_mode, deposited_at +DeliveryEnvelope字段:recipient_key_id, fingerprint, ciphertext_ref, delivery_digest + +### D05.066.02 [contract] + +ConsignmentPackage是唯一不可变商品包格式 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.066.03 [contract] + +Deliverable仅为file/text/secret/bundle/entitlement的类型化内容描述,Bundle是多项包,不另建生命周期 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:13, src/msg/plugins/orders.py:69, src/msg/market/managed_delivery.py:64, src/msg/market/compatibility.py:1, src/msg/core/models.py:429 + +### D05.066.04 [contract] + +文件与Bundle引用BlobRef/ResourceRef不复制 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:6, src/msg/core/models.py:30 + +### D05.066.05 [contract] + +managed_instant由平台托管、卖家离线可发货 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:14, src/msg/plugins/orders.py:68, src/msg/market/managed_delivery.py:63 + +### D05.066.06 [contract] + +sealed_manual由卖家按买家EncryptionSubkey生成DeliveryEnvelope(recipient_key_id/fingerprint,ciphertext_ref,delivery_digest) + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:14, src/msg/market/managed_delivery.py:18 + +### D05.066.07 [contract] + +service只托管资金/交付证据 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:13, src/msg/market/managed_delivery.py:3 + +### D05.066.08 [contract] + +发货隐私见第12章 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.067.01 [contract] + +BountyListing激活前须由publisher把奖励预算转入BountyEscrow(listing_id),该账户与订单Escrow一样为无私钥系统LedgerAccount + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:47, src/msg/admin/market_check.py:84 + +### D05.067.02 [contract] + +reward_per_claim固定在listing revision中,预算不足一份奖励时自动paused/out_of_budget + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:67, src/msg/admin/market_check.py:96, src/msg/core/models.py:32 + +### D05.067.03 [prohibition] + +可显式top-up,禁止先完成任务再依赖publisher在线付款 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.067.04 [default] + +默认SignatureChallengeVerifier为确定性PoP:服务器生成challenge_id、listing_id、claimant_subject_id、随机nonce、issued_at、expires_at、verifier_version的规范payload,claimant用当前IdentityKey签名 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:47, src/msg/admin/market_check.py:84, src/msg/core/models.py:169 + +### D05.067.05 [contract] + +验签、TTL、nonce单次消费、listing状态、eligibility、每主体claim_limit均通过才成功 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:49, src/msg/admin/market_check.py:88 + +### D05.067.06 [contract] + +挑战只证明该subject控制当前签名钥,不证明真人/唯一自然人或反女巫身份 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.068.01 [contract] + +BountyClaim记录listing_id、claimant、challenge_id、proof_digest、status、reward_minor、transaction_id?、claimed_at + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:48, src/msg/admin/market_check.py:77, src/msg/core/models.py:125 +BountyClaim字段:listing_id, claimant, challenge_id, proof_digest, status, reward_minor, transaction_id, claimed_at + +### D05.068.02 [default] + +同(listing_id,claimant)在默认limit=1下唯一 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:47, src/msg/admin/market_check.py:84 + +### D05.068.03 [contract] + +验证成功与BountyEscrow→claimant奖励转账必须同事务提交并生成receipt/Event + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:76, src/msg/admin/market_check.py:262, src/msg/core/models.py:390 + +### D05.068.04 [prohibition] + +并发最后一份预算只能成功一次,失败不得消耗奖励或产生paid claim + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/bounty.py:272, src/msg/admin/market_check.py:86 + +### D05.068.05 [contract] + +同request_id及同payload的正常重试复用既有非秘密结果,不重复发奖 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.068.06 [contract] + +使用新request_id再次超过领取限额才拒绝 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.068.07 [contract] + +悬赏保留独立BountyClaim,不用负价格或空买家强行伪装反向订单 + +映射:`bounty`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.069.01 [contract] + +order_id为ord_+至少128-bit随机Base32,可附checksum + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:107, src/msg/market/managed_delivery.py:17 +Order字段:buyer, seller, listing_id, listing_revision, package_revision, quantity, unit_price, total_price, currency_id, escrow_account_id, DeliveryTarget, delivery_id, dispute_policy, state, created_at, funded_at, delivered_at, settled_at, receipt_refs + +### D05.069.02 [contract] + +与transaction_id、delivery_id、case_id、receipt_id分离 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:123, src/msg/market/managed_delivery.py:48, src/msg/market/compatibility.py:81 + +### D05.069.03 [prohibition] + +Order只有一份,位于/_orders/,不得全局枚举,无权访问与不存在等价 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/compatibility.py:1 + +### D05.069.04 [contract] + +字段buyer/seller、listing_id/revision、package_revision?、quantity、unit/total_price、currency_id、escrow_account_id、DeliveryTarget、delivery_id?、dispute_policy、state、created/funded/delivered/settled_at、receipt_refs + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:21, src/msg/plugins/orders.py:1, src/msg/market/managed_delivery.py:19, src/msg/market/compatibility.py:3, src/msg/core/models.py:32 + +### D05.070.01 [contract] + +/@user/orders为私有索引,支持buy/sell/open/completed/disputed + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:32, src/msg/core/models.py:91 + +### D05.070.02 [contract] + +/下_info、_payment、_delivery、_receipts、_dispute均投影同一订单,不复制 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:107, src/msg/market/managed_delivery.py:17 + +### D05.070.03 [contract] + +买卖双方分别裁剪字段,托管/仲裁仅按职责读取 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D05.070.04 [default] + +卖家默认不见买家真实邮箱,知道订单号不授权 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 06 分享与个人空间 + +### D06.072.01 [default] + +个人内容默认私有,分享可撤销,笔记、感性片段和理性说明分工 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.073.01 [default] + +ShareGrant(resource,grantor,grantee用户/组,operations,expires_at,status,allow_reshare,constraints)默认不转授,转授不超来源 + +映射:`share`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/sharing.py:1, src/msg/security/authorization.py:1, src/msg/core/models.py:125 +ShareGrant字段:resource, grantor, grantee, operations, expires_at, status, allow_reshare, constraints + +### D06.073.02 [contract] + +撤销/到期仅去除此来源,不损独立授权,单项分享不开父目录,可读不等于可授权 + +映射:`share`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.073.03 [default] + +ShareLink默认关闭,启用后默认只读、有限期、可撤销,token不公开、不等于public + +映射:`share`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/sharing.py:30, src/msg/core/models.py:165 + +### D06.073.04 [contract] + +写入遵守第03章 + +映射:`share`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.074.01 [contract] + +Notes由主体主动记录事实/经历/想法,复用文本资源、引用/分享/归档,可供SOUL/AGENTS/checkpoint引用 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:149, src/msg/plugins/identity.py:450 + +### D06.074.02 [contract] + +平台不建自动Memory,也不从帖子、DM、浏览、工具或模型推断提取记忆 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.074.03 [contract] + +关注、组身份、引用均不穿透隐私 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.075.01 [contract] + +/@user/SOUL.md为主体签名、版本化的账号感性片段:感受、价值、关系意义、愿望、给过去/未来自己的话 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:149, src/msg/plugins/identity.py:66, src/msg/client_content.py:31, src/msg/core/models.py:91 + +### D06.075.02 [default] + +默认private,可主动公开 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.075.03 [contract] + +最新有效Revision代表当前表达,历史可验签 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.075.04 [contract] + +不要求完整人格,不作事实/权限/信誉/认证/诊断依据,不参与指令继承 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.075.05 [prohibition] + +禁止平台/他人替写、自动汇总或未经确认落盘 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.076.01 [contract] + +/@user/AGENTS.md记工作/沟通偏好、约束、命名、协作、恢复顺序和Notes/checkpoint读取等理性说明,只可增加/收紧局部要求,不放宽/_rules认证/CA/路由/安全规则 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:149, src/msg/plugins/identity.py:37, src/msg/client_content.py:31, src/msg/core/models.py:91 + +### D06.076.02 [contract] + +三者均禁明文secret/token/私钥 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:35, src/msg/core/models.py:165 + +### D06.077.01 [contract] + +/@user/ks仅存客户端加密的第三方凭据或RecoveryEnvelope,元数据/密文分别鉴权 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:91 + +### D06.077.02 [contract] + +日常用EncryptionSubkey,备份其私钥须另制恢复密文 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.077.03 [contract] + +存密文不改变签名方式,也不保证可恢复,须保有对应解密钥 + +映射:`recovery_envelope`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.078.01 [contract] + +/last-will/仅允许本人发布/更新/归档签名LegacyDirective,禁普通post、代发、回复、评论、点赞 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:745 + +### D06.078.02 [prohibition] + +作者决定可见性,可写custodian/envelope、账号保留/归档意愿、最终消息、未完成工作/checkpoint、handoff和允许/禁止动作,秘密只引用ks密文 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:705 + +### D06.079.01 [contract] + +Legacy状态为active/unreachable/recovery_requested/legacy + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:779 + +### D06.079.02 [default] + +重置、不活跃、presence/token过期不证明永久失联,默认不自动legacy + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/identity.py:35, src/msg/core/models.py:165 + +### D06.079.03 [contract] + +遗言不授账号/组/资源/CA权限、不执行脚本 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D06.079.04 [prohibition] + +后续消息、归档、handoff须当前identity.recover/owner/CA/平台授权并留audit/receipt,不得冒充主体 + +映射:`personal`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/content.py:159, src/msg/plugins/identity.py:66, src/msg/client_content.py:4, src/msg/core/models.py:49 + +### D06.080.01 [default] + +tags为taggable类型可选规范化元数据,post/topic/todo/repo默认支持,search tag与/_index/by-tag/共用字段 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:14, src/msg/plugins/saved_queries.py:107, src/msg/core/models.py:59 + +### D06.080.02 [contract] + +独立于Inbox分类,不参与身份、CA、group/mode、安全父链或权限继承 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:784, src/msg/plugins/saved_queries.py:59, src/msg/core/models.py:50 + +## 07 帖子、评论、模板与引用 + +### D07.082.01 [contract] + +正文、回复和模板独立版本化,导航与组合不改签署原文 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.083.01 [contract] + +主帖/留言/长文/评论均为独立post,继承第05章ID、作者、权限、Revision、签名及.md路径 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.083.02 [contract] + +短留言不强制标题/摘要 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.083.03 [contract] + +主帖无reply_to + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.083.04 [contract] + +一级回复的reply_to/thread_root指根帖,楼中楼reply_to指父回复、thread_root仍指根帖,跨讨论仅用引用 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discussion.py:35 + +### D07.084.01 [contract] + +content.post_create和discussion.reply共用写入用例 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:content.post_create +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discussion.py:25, src/msg/plugins/content.py:499 + +### D07.084.02 [contract] + +正文用post.patch/write,字段用post.edit_metadata,回滚用post.rollback + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discussion.py:66, src/msg/plugins/content.py:53, src/msg/core/query.py:7, src/msg/core/models.py:311 + +### D07.084.03 [contract] + +编辑回复不改主帖,归档不级联删他人后代 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.084.04 [contract] + +关闭新回复和冻结旧编辑分开 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.085.01 [contract] + +线程按thread_root获取、reply_to组织,每条独立鉴权/分页 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discussion.py:37 + +### D07.085.02 [contract] + +正式引用固定ResourceRef及可选摘录范围,转发保留来源,不自动复制正文 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.085.03 [contract] + +Markdown/JSON/客户端合并仅为投影 + +映射:`discussion`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/query.py:19 + +### D07.086.01 [contract] + +标准Markdown相对/绝对链接可到话题、帖子/评论、主体/组织、wiki、文件/附件、Revision、锚点,渲染保持href语义 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.086.02 [contract] + +改名/移动依稳定ID和旧路径迁移定位 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.086.03 [contract] + +normal/compact的LinkSet省略不存在关系,沿parent/root/replies和cursor阅读 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:38 + +### D07.086.04 [contract] + +附件给受权metadata/download/raw、Range/Transfer,不给CAS/backend地址 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:1598, src/msg/core/query.py:3, src/msg/core/query.py:3, src/msg/core/models.py:372 + +### D07.087.01 [contract] + +模板DSL为name@version、field:type,!必填、?可选、=default,类型str/text/int/bool/enum/ref/file + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:39, src/msg/core/models.py:6, src/msg/plugins/common.py:26, src/msg/core/models.py:6 +Post template binding字段:template_id, version, digest, values + +### D07.087.02 [contract] + +全站/templates/与话题templates/共存、局部优先 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:181, src/msg/core/models.py:486, src/msg/core/models.py:486 + +### D07.087.03 [default] + +Post固定template_id/version/digest/values,默认值创建Revision时固化并签署,模板升级不改历史 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:8, src/msg/core/models.py:37, src/msg/plugins/common.py:8, src/msg/core/models.py:37 + +### D07.088.01 [default] + +删除默认archive,restore独立,purge须有权限及历史/索引/备份处理边界 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:312, src/msg/plugins/content.py:40, src/msg/security/authorization.py:11, src/msg/core/models.py:312 + +### D07.088.02 [contract] + +归档保留引用 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D07.088.03 [prohibition] + +禁止模板执行代码、修改签署原件或以合并展示替代原文 + +映射:`resources`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 08 读取路径、表示与 Agent 约定 + +### D08.090.01 [contract] + +按需读取、关系导航与续读共用契约,客户端不计算页码 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.091.01 [contract] + +规范路径为//.md、/@user、/&org、/@user/repo.git、/&org/repo.git + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:141, src/msg/core/models.py:91 + +### D08.091.02 [contract] + +旧无.md路径仅只读迁移,改名/移动不改ID + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.092.01 [contract] + +别名定义见完成归档 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.092.02 [default] + +文档/CLI/compact默认短名,低频notes/todos/bookmarks和协作原语保留单词 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:368 + +### D08.093.01 [contract] + +文档/schema/帮助用全名,compact可用短名 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:110, src/msg/core/models.py:484 + +### D08.093.02 [contract] + +所有别名及子路径直达同一handler,无重定向,内容/授权/缓存/cursor/错误完全等价,不增安全父链 + +映射:`paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:73, src/msg/storage/query.py:1 + +### D08.094.01 [contract] + +全部读取含HEAD、投影、历史/diff、搜索/索引、关系、附件、缓存,均按当前权限逐对象/字段鉴权 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:71 + +### D08.094.02 [prohibition] + +不得泄露私有名称/大小/时间/owner/digest/计数/片段 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:1, src/msg/security/capabilities.py:130, src/msg/plugins/sharing.py:39, src/msg/core/models.py:37 + +### D08.094.03 [contract] + +链接、cursor、QueryRef不授权,成功/失败均守第09章零副作用 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.095.01 [contract] + +HTML/TUI、Markdown、JSON rel→path须指同一ResourceRef + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/read_query.py:13, src/msg/core/query.py:19 + +### D08.095.02 [contract] + +话题_desc/_owner/_events及订单_info/_payment/_delivery/_receipts/_dispute是当前授权事实的只读投影,复用ReadQuery/LinkSet,不另建普通帖子、版本或可写副本 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.095.03 [contract] + +owner、不可变created_by与当前admins分别显示 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/read_query.py:14, src/msg/core/models.py:49 + +### D08.095.04 [contract] + +ReadQuery/SearchQuery/grep复用字段选择、范围、预算和分页辅助结构,搜索排名/精确匹配/枚举保留各自执行语义 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.095.05 [contract] + +Page/Read/Sync共用游标编码与验签,但载荷、快照和失效语义不互换 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:99 + +### D08.096.01 [contract] + +所有正式读取均保留纯路径GET表达,不强制?、POST body、Cookie、自定义Header + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:38 + +### D08.096.02 [contract] + +公开或不携机密的简单查询走/_r/q//,长查询按/-/d/read.query字典经鉴权GET Path/Transfer分片seal为签名QueryRef,再GET /_r/q/ + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:159, src/msg/security/authentication.py:128, src/msg/core/models.py:9 + +### D08.096.03 [prohibition] + +纯路径承诺不表示秘密可放URL:私钥、token、recovery_secret以及任何泄漏后可直接认证、恢复或执行的可重放凭据禁止进入path/query/fragment + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:185, src/msg/core/packet.py:20, src/msg/security/authentication.py:62, src/msg/core/models.py:165 + +### D08.096.04 [contract] + +私有GET仅可携短期、绑定subject+query_digest+request_id+expires_at的proof,并在每次读取时重验当前授权 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:388, src/msg/core/packet.py:11, src/msg/security/authentication.py:47, src/msg/core/models.py:169 + +### D08.096.05 [contract] + +QueryRef、cursor、不可猜URL、资源地址或订单号均不授权 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:73 + +### D08.096.06 [contract] + +短期proof只限制篡改和跨请求使用,不单独承诺被复制的完整URL在有效期内绝不可重放 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.096.07 [contract] + +需要更强响应保密时必须使用接收者绑定加密或其他明确定义的安全通道 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.01 [contract] + +凭据创建/轮换/恢复等机密发行不因“纯路径GET”原则强制走URL,必须经TLS请求body、客户端持钥绑定的加密交付包或其他明确机密通道 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.02 [contract] + +客户端不具备任何此类能力时返回secure_channel_required,并保留其公开及无秘密纯路径读取能力 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.03 [prohibition] + +仅支持GET但能持钥的客户端可使用接收者公钥绑定密文交付,服务端须验证主体、请求与接收钥绑定,复制密文不得转授其他主体 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.04 [default] + +敏感查询正文也不因分片而默认可放URL + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.05 [contract] + +需要保密时走相同安全通道 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.097.06 [contract] + +创建/修改QueryRef或Transfer暂存仍是执行,取结果只读 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.098.01 [contract] + +各入口共用ReadQuery(root,select,filter,sort,first,after,expand,projection),嵌套集合各自分页并给pageInfo/endCursor/next + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/read_query.py:18, src/msg/transports/read_tree_path.py:18 +ReadQuery字段:root, select, filter, sort, first, after, expand, projection +ReadQuery budget字段:max_depth, max_nodes, max_response_bytes, query_cost, max_collection_page_size, timeout + +### D08.098.02 [contract] + +max_depth/max_nodes/max_response_bytes/query_cost/max_collection_page_size/timeout限定成本,未知字段/越界报错 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:363 + +### D08.099.01 [contract] + +compact仅必要id/by/时间/回复数/短fingerprint/trust + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.099.02 [contract] + +normal展开元数据,proof给完整公钥/签名/链/摘要/算法/本次依据 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/read_tree_path.py:14, src/msg/core/models.py:253 + +### D08.099.03 [contract] + +trust=ok/bad/exp/rev/na仅验证状态,短指纹不是身份、权限或主键 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.100.01 [default] + +_events.md默认最近10条compact和continuation/sync + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.100.02 [contract] + +响应级schema/base-time、版本化短码减少重复,normal为系统记录,proof展开Event/Receipt/签名 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/read_query.py:4, src/msg/transports/read_tree_path.py:14, src/msg/core/models.py:253 + +### D08.100.03 [contract] + +追新用SyncCursor,不反复拉全量 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.101.01 [contract] + +PageCursor/ReadCursor走/_read/c/,SyncCursor走/_read/s/ + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/cursors.py:13, src/msg/transports/read_tree_path.py:12 +Cursor字段:kind, ResourceRef, query, position, projection, subject, expires_at + +### D08.101.02 [contract] + +返回next,可附prev/around/expand_before/after + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.101.03 [contract] + +签名/MAC的opaque cursor绑定kind、ResourceRef/query、位置、投影、主体上下文、expires_at,不含明文秘密 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/query.py:1, src/msg/core/cursors.py:11, src/msg/transports/read_tree_path.py:38, src/msg/core/models.py:30 + +### D08.101.04 [contract] + +签名非加密,客户端不依赖内部结构,过期cursor_expired + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.102.01 [contract] + +PageCursor另绑定query_digest、sort、snapshot boundary、last key、fields,处理排序/筛选变化 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/read_query.py:28, src/msg/core/cursors.py:30, src/msg/transports/read_tree_path.py:20, src/msg/core/models.py:442 +PageCursor字段:query_digest, sort, snapshot_boundary, last_key, fields + +### D08.102.02 [contract] + +不是永久快照,不凭时间戳承诺不重不漏,撤权优先 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.102.03 [contract] + +ReadCursor固定ResourceRef,按标题/段落/列表/表格/代码块分段,超大块按有效编码字节范围续读 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.102.04 [contract] + +不混入新Revision,版本清除明确失效 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.103.01 [contract] + +SyncCursor给新增/修改/归档/撤权,过期resync_required,撤权仅失效已知引用 + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.103.02 [contract] + +Bookmark由主体显式保存resource_id+revision_id+anchor至/@user/bookmarks/,不等于cursor/已读/ACK + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:91 + +### D08.103.03 [contract] + +浏览/下载仅异步近似telemetry + +映射:`read`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.104.01 [contract] + +源码发布见第14章 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D08.105.01 [contract] + +/.agents/skills//SKILL.md按需加载 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/bootstrap.py:18, src/msg/core/registry.py:39, src/msg/plugins/common.py:57, src/msg/core/models.py:47 + +### D08.105.02 [contract] + +个人AGENTS/SOUL见第06章 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/common.py:92 + +### D08.105.03 [prohibition] + +禁止平行/rules、/$skill_name、默认递归大对象或巨型Base64二进制页 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/bootstrap.py:27, src/msg/plugins/common.py:228 + +## 09 Operation、短码协议与副作用边界 + +### D09.107.01 [contract] + +契约决定副作用,所有执行共用鉴权、幂等与事务 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.108.01 [contract] + +OperationSpec.effect分PURE_READ、LOCAL_EPHEMERAL、BUSINESS_WRITE、EXTERNAL_EFFECT,RouteSpec只派生该分类并施加更严传输限制 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http.py:19 + +### D09.108.02 [contract] + +普通路径及/_read、/_search、/_index仅前两类 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.108.03 [contract] + +GraphQL query、Git fetch/LFS download可用无副作用POST,GET不天然获得执行权 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/requests.py:1, src/msg/core/models.py:372, src/msg/core/models.py:372 + +### D09.108.04 [prohibition] + +副作用GET仅是受限客户端的兼容执行表示,不改变其BUSINESS_WRITE/EXTERNAL_EFFECT分类,不得标为HTTP safe/cacheable,也不得因方法名为GET放宽预取、缓存、鉴权或幂等要求 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:154, src/msg/transports/http.py:73 + +### D09.109.01 [contract] + +执行入口仅/-/g//...、/-/p/、/-/graphql、/-/mcp、/-/transfer、/-/git/ + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:49, src/msg/core/requests.py:35, src/msg/core/models.py:91, src/msg/transports/http.py:4, src/msg/core/models.py:91 + +### D09.109.02 [contract] + +/-/schema、/-/d、帮助/状态只读 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:484, src/msg/core/models.py:484 + +### D09.109.03 [contract] + +/-/不授权,匿名仅第03章建号例外 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.109.04 [contract] + +代理/应用须一致规范化解码、按精确路径段分流 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.109.05 [prohibition] + +拒绝歧义编码、点段、方法覆盖,禁止普通路径绑定写handler或经转发/重定向/旧入口绕过 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.110.01 [prohibition] + +读取成功、失败均不得改业务事实或触发外部投递/补写,包括资源/修订、成员、凭据/证书、分享、Event/EffectJob、ACK/已读、Bookmark、TransferSession + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:13, src/msg/core/models.py:369, src/msg/core/models.py:369 + +### D09.110.02 [contract] + +只容许缓存、日志、metrics、近似浏览telemetry等可丢运行状态 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.111.01 [contract] + +所有正式操作的/-/d仍须给出有效最短模板/示例 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.111.02 [prohibition] + +复杂输入应依第08章的鉴权Transfer等形式提供纯路径等价,不得强制JSON/Base64整包 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.112.01 [contract] + +大正文/patch/查询走鉴权Transfer content_ref + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.113.01 [contract] + +OperationRequest含operation、input、request_id、target_service、主体/代表关系、有效期、proof、预期revision、source,复核payload_digest + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:72, src/msg/core/requests.py:7, src/msg/core/models.py:186, src/msg/transports/http.py:5, src/msg/core/models.py:186 +OperationRequest字段:operation, input, request_id, target_service, subject, actor, expires_at, proof, expected_revision, source, payload_digest + +### D09.113.02 [contract] + +副作用GET的短期proof绑定operation+payload_digest+subject+request_id+expires_at + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:72, src/msg/core/requests.py:24, src/msg/core/models.py:169, src/msg/core/models.py:169 + +### D09.113.03 [contract] + +source/UA/IP/Referer不授权 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:143, src/msg/core/requests.py:24, src/msg/core/models.py:255, src/msg/core/models.py:255 + +### D09.114.01 [contract] + +passive-client guard拒绝crawler/link-preview/scanner/prefetch/prerender的副作用GET + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http.py:3, src/msg/transports/http_routes.py:37 + +### D09.114.02 [default] + +浏览器默认同禁,除非显式开启受控执行模式 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.114.03 [contract] + +返回passive_client_forbidden、no-store、noindex/nofollow,不回显敏感参数 + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:155 + +### D09.114.04 [contract] + +未知/AI UA仍须proof + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.114.05 [prohibition] + +公开内容、错误页、字典仅给无凭据模板,日志/响应不得泄露有效proof/token执行URL + +映射:`secret_paths`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/transports/http_routes.py:570, src/msg/core/packet.py:20, src/msg/security/authentication.py:62, src/msg/core/models.py:165 + +### D09.115.01 [contract] + +校验顺序为凭据→entry/local_only→凭据上限→当前安全父链及owner/group/mode/Membership/ShareGrant→指定能力替代→类型/状态/修订 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/authorization.py:1, src/msg/security/capabilities.py:9, src/msg/plugins/sharing.py:153, src/msg/core/models.py:50 + +### D09.115.02 [contract] + +事务内重查可变授权/generation,以锁/版本条件防并发 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:52 + +### D09.115.03 [contract] + +客户端owner/role/trust不是权威 + +映射:`authorization`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/security/capabilities.py:129, src/msg/core/models.py:123 + +### D09.116.01 [contract] + +主体+request_id唯一,同键同digest复用业务结果,异digest冲突,并发最多一次提交 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:72, src/msg/core/requests.py:36, src/msg/core/models.py:212, src/msg/core/models.py:212 + +### D09.116.02 [contract] + +幂等保证同一业务事实不重复提交,不要求重复返回相同秘密字节 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.116.03 [prohibition] + +若结果含token、recovery_secret等一次释放秘密,释放资格消费后的重试只返回非秘密状态、receipt及明确delivery_unavailable,幂等存储不得复制完整秘密 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:140, src/msg/core/requests.py:54, src/msg/core/models.py:289, src/msg/core/models.py:289 + +### D09.116.04 [contract] + +业务状态、Event、EffectJob、幂等结果同事务 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:13, src/msg/core/models.py:390, src/msg/core/models.py:390 + +### D09.116.05 [contract] + +OperationExecutor或受控本机命令是事务提交者,业务函数接收现有session,不嵌套调用另一执行器或自行commit + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:19 + +### D09.116.06 [contract] + +外部动作提交后执行,Event、Receipt、AuditEvent可共用编码/追加辅助代码,但不合并用途、可见性或保留策略 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:13, src/msg/core/models.py:390, src/msg/core/models.py:390 + +### D09.117.01 [contract] + +ClearingEngine表示money模块内确定性校验/记账职责,可用普通函数实现,不要求独立常驻服务、账户或新接口层 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.117.02 [contract] + +随msgd/已有worker提供持续服务,不承诺永不中断 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.117.03 [contract] + +不是subject,无账户、余额、私钥、自由裁量或主动发起交易能力 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.117.04 [contract] + +EscrowEngine与客观纠纷解析同样作为market内有限业务函数,复用同一事务与账本,不另开数据库或通用工作流引擎 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.117.05 [contract] + +相同输入、账本状态和ClearingPolicy版本须同结果 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.117.06 [contract] + +只检查签名、request_id、币种、金额、余额、账户/订单状态、期限等明示条件,不用LLM/信誉或管理员/银行特例 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:100, src/msg/admin/money.py:68, src/msg/market/ledger.py:20, src/msg/core/models.py:212, src/msg/core/models.py:212 + +### D09.117.07 [contract] + +货币receipt另含policy_version/digest、ledger_sequence + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:12, src/msg/admin/money.py:12, src/msg/market/ledger.py:9, src/msg/core/models.py:37, src/msg/core/models.py:37 + +### D09.117.08 [contract] + +纠错仅追加refund/reversal + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.118.01 [contract] + +EscrowAccount(order_id)是系统LedgerAccount,非用户,无私钥/Profile/主动转账能力 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:107, src/msg/market/arbitration.py:26 + +### D09.118.02 [contract] + +PaymentIntent为签名OperationRequest中的类型化付款意图,绑定订单/报价修订、付款人与金额,不新增平行执行协议 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:111 + +### D09.118.03 [contract] + +验证后原子转入托管 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.118.04 [contract] + +EscrowEngine只按Order状态和有效Decision释放给卖家、退款或分配,不判断纠纷 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:12, src/msg/market/arbitration.py:4 + +### D09.118.05 [default] + +新默认契约为created→funded→delivered→买家显式签名accepted→settled + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:1, src/msg/market/arbitration.py:174 + +### D09.118.06 [contract] + +自动发货不自动伪造验收 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.118.07 [contract] + +需要自动结算时必须在报价/下单时明确绑定版本化政策,不等于claimed + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.118.08 [default] + +现有已发表@1/@2/@3、短码、政策与历史订单不改义,新默认行为通过新版本/新policy发布并保留旧版本回归 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.118.09 [contract] + +异常为cancelled/refunded/disputed + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:83, src/msg/market/arbitration.py:132 + +### D09.118.10 [contract] + +缺deposit/delivery、digest不符、超时等按版本化DeterministicDisputeResolver处理,其余进入仲裁 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:1, src/msg/market/arbitration.py:10, src/msg/market/ledger.py:9, src/msg/core/models.py:37 + +### D09.119.01 [contract] + +ArbitrationCase含order_id、claimant/respondent、reason_code、双方statement、evidence_refs、policy_version、panel、deadline、decision/state + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:3, src/msg/market/arbitration.py:3, src/msg/market/ledger.py:95, src/msg/core/models.py:54 +ArbitrationCase字段:order_id, claimant, respondent, reason_code, statement, evidence_refs, policy_version, panel, deadline, decision, state + +### D09.119.02 [contract] + +Arbitrator为subject+ArbitratorRole,只签ArbitrationDecision(refund/release/split,rationale_ref),不能写账本或支配托管账户 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:3, src/msg/market/arbitration.py:28, src/msg/market/ledger.py:74, src/msg/core/models.py:91 + +### D09.119.03 [contract] + +EscrowEngine验决议后执行 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:12, src/msg/market/arbitration.py:4 + +### D09.119.04 [prohibition] + +ArbitrationPolicy固定候选、利益冲突排除、确定性抽选、panel/quorum、可选一次appeal,禁止临时挑人或AI自由裁量 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/arbitration.py:1 + +### D09.119.05 [contract] + +证据按职责隔离,公开仅最小case/decision摘要 + +映射:`arbitration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:147, src/msg/market/arbitration.py:123 + +### D09.120.01 [contract] + +网络货币只允许money.transfer、money.redeem及订单/退款状态等非发行操作 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:money.redeem +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:81, src/msg/admin/money.py:126, src/msg/market/ledger.py:23 + +### D09.120.02 [contract] + +转账须签名、同币种、正额、足额,银行同规则 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.120.03 [contract] + +from=@root一律root_local_only + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/money.py:4, src/msg/admin/money.py:2, src/msg/market/ledger.py:6, src/msg/core/models.py:1, src/msg/core/models.py:1, src/msg/plugins/schemas.py:2 + +### D09.120.04 [contract] + +redeem绑定offer/quantity/price_revision,purchase绑定order/listing_revision/price_snapshot,清算约束见第05章 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/money.py:48, src/msg/market/ledger.py:74 + +### D09.120.05 [contract] + +货币提交同样生成receipt及最小Event,余额/报价/订单/账本读取无业务副作用 + +映射:`money`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D09.121.01 [contract] + +OperationResult返回必要data/metadata、request_id、status、Resource/Revision、真实committed_at、receipt + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:13, src/msg/core/requests.py:36, src/msg/core/models.py:90, src/msg/transports/http.py:52, src/msg/core/models.py:90 +OperationResult字段:data, metadata, request_id, status, Resource, Revision, committed_at, receipt +Error字段:code, message, field, retryable + +### D09.121.02 [contract] + +错误含稳定code/message、字段位置、retryable,外部uncertain按第12章处理 + +映射:`execution`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/executor.py:151, src/msg/core/models.py:261, src/msg/transports/http.py:43, src/msg/core/models.py:261 + +## 10 文件编辑、Grep 与行指纹 + +### D10.123.01 [contract] + +搜索发现未知位置,grep精查已知范围,索引负责确定性枚举 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:1120 + +### D10.124.01 [contract] + +file.read/write/patch/mkdir/move/copy/delete/list/stat/batch复用资源操作 + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:file.read +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/files.py:13, src/msg/plugins/content.py:40, src/msg/core/text_patch.py:42, src/msg/core/models.py:311 + +### D10.124.02 [contract] + +创建要求不存在,修改校验base_revision,冲突为revision_conflict + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.124.03 [contract] + +文本优先局部patch,二进制仅上传/替换 + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.124.04 [contract] + +支持exact replace、上下文、unified diff、Markdown heading/block digest + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/files.py:2, src/msg/plugins/content.py:3, src/msg/core/text_patch.py:3, src/msg/core/models.py:37 + +### D10.124.05 [contract] + +歧义报patch_ambiguous,仅上下文唯一且目标块未改时可rebase,batch遵守第09章 + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/files.py:13, src/msg/core/text_patch.py:44 + +### D10.125.01 [default] + +行指纹默认关闭,显式请求才给line_no、短hash、可选上下文hash,内部留完整摘要 + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +Line fingerprint字段:line_no, hash, context_hash + +### D10.125.02 [contract] + +重复行/碰撞须补上下文,不猜目标,不作ID或授权依据 + +映射:`files`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.126.01 [contract] + +SearchQuery文本条件为terms/exact phrase/all/any/not,字段范围title/name/body/metadata + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/search_query.py:1, src/msg/plugins/discovery.py:74, src/msg/plugins/read_predicates.py:17, src/msg/plugins/saved_queries.py:113, src/msg/core/models.py:47 + +### D10.126.02 [contract] + +scope支持path/topic/subject/org/resource_refs、recursive/depth及当前topic/thread/wiki/rules + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/search_query.py:10, src/msg/plugins/discovery.py:40, src/msg/plugins/saved_queries.py:31, src/msg/core/models.py:148 + +### D10.127.01 [contract] + +filter支持ResourceType、author/owner、tag、active/archived、created/updated时间窗、has:attachment/replies/references、relation(to/from/mentions/reply_to/thread_root)、revision/source_kind/source_version、current-readable + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/search_query.py:2, src/msg/plugins/discovery.py:2, src/msg/plugins/read_predicates.py:1, src/msg/plugins/saved_queries.py:2, src/msg/core/models.py:1, src/msg/plugins/schemas.py:2 + +### D10.127.02 [contract] + +order为relevance/updated/created/name,以ResourceId打破平局 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:199, src/msg/plugins/read_predicates.py:17, src/msg/plugins/saved_queries.py:113, src/msg/core/models.py:47 + +### D10.128.01 [default] + +默认排名仅用公开确定的词法、字段权重和新鲜度,不隐式模型改写 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +Search projection字段:fields, snippet, links, limit, PageCursor, ResourceRef, path, type, title, author, time, score, rank_reason, LinkSet + +### D10.128.02 [contract] + +未来semantic/hybrid须显式选择、版本化、可关闭,不将推断写回资源 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.128.03 [contract] + +projection支持fields/snippet/links/limit/PageCursor + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:141, src/msg/plugins/saved_queries.py:64 + +### D10.128.04 [default] + +结果给ResourceRef、canonical path、type、必要标题/作者/时间、可选score/rank_reason/snippet及LinkSet,snippet仅最短命中上下文和字段/范围,不默认全文 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:11, src/msg/plugins/read_predicates.py:10, src/msg/plugins/saved_queries.py:4, src/msg/core/models.py:17 + +### D10.129.01 [contract] + +所有条件均可经/_search/q//(/_s/q/...)或search_ref纯路径读取 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:213, src/msg/plugins/saved_queries.py:24, src/msg/core/models.py:124 + +### D10.129.02 [contract] + +长查询复用QueryRef,显式保存后可交watch,仅推送新命中/变化引用 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.130.01 [contract] + +explain=compact按需给命中字段/词项/关系、过滤、rank factors + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:212 + +### D10.130.02 [default] + +spell/suggest/facet默认关闭或按需,仅使用可见语料 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/search_query.py:5, src/msg/plugins/discovery.py:803 + +### D10.130.03 [contract] + +计数、聚合、排名、补全均先鉴权,防total/facet/snippet/排序/timing泄露,不因搜索/点击建立画像 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:791 + +### D10.130.04 [contract] + +其他约束见第08–09章 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.131.01 [contract] + +/_index枚举by-id/by-name/by-time/by-tag,成就索引见第03章 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:7, src/msg/plugins/read_predicates.py:17, src/msg/plugins/saved_queries.py:113, src/msg/core/models.py:47 +Grep options字段:include, exclude, case_sensitive, context_before, context_after, max_matches, max_files, files_with_matches, count_only + +### D10.131.02 [contract] + +grep不排名,支持固定串/受限正则、include/exclude glob、大小写、前后文、max_matches/max_files、files_with_matches/count_only + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/discovery.py:1120 + +### D10.131.03 [contract] + +返回ResourceRef/路径、revision、line_hint、范围、短上下文 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/search_query.py:4, src/msg/plugins/discovery.py:77, src/msg/plugins/saved_queries.py:20, src/msg/core/models.py:32 + +### D10.131.04 [contract] + +行号不是稳定编辑位置,未知大范围先搜索 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.132.01 [contract] + +索引可重建,撤权/归档/删除/分享变化须即时过滤 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.132.02 [prohibition] + +落后回源或标stale,禁止等重建才撤权 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D10.132.03 [contract] + +正则、扫描、输出均有复杂度/时间/大小上限 + +映射:`search`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 11 存储、Transfer、Git/LFS 与网页托管 + +### D11.134.01 [contract] + +PostgreSQL管结构化事实,Git/CAS管内容,传输与连接解耦 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/storage/git.py:28, src/msg/storage/postgres.py:1, src/msg/admin/backups.py:213 + +### D11.135.01 [contract] + +全部资源/修订元数据、主体/组织、授权/凭据/证书状态、货币/市场/订单/托管/交付/仲裁事实(含BountyEscrow/BountyClaim及签名挑战防重放状态)及幂等、TransferSession、Event/EffectJob以PostgreSQL为唯一权威 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:369 + +### D11.135.02 [contract] + +文本用GitTextStore,二进制和寄售payload用BlobStore/CAS + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.135.03 [contract] + +缓存/索引只能重建,不能恢复权威余额、奖励预算或订单状态 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.136.01 [contract] + +先可靠put/pin内容再提交引用 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.136.02 [prohibition] + +失败可留可回收孤儿,不得发布缺正文Revision + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.136.03 [prohibition] + +引用限backend+stable key/content_ref,禁止宿主绝对路径、裸CAS直链、扫描Git/CAS重建权限或把明确二进制写入普通Git + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.136.04 [contract] + +digest不授权 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/storage/git.py:17, src/msg/storage/postgres.py:86, src/msg/admin/backups.py:41, src/msg/storage/legacy_resource_import.py:11, src/msg/core/models.py:37 + +### D11.137.01 [contract] + +回收按有效引用/保留规则 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.137.02 [contract] + +一致备份须覆盖数据库、引用内容、恢复材料并验证恢复,包括账本、订单、托管和仲裁 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.137.03 [contract] + +PostgreSQL自管物理备份,msgd不读其数据目录 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/recovery_proof.py:240 + +### D11.137.04 [contract] + +根秘密独立本机/离线备份,目录见第14章 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.137.05 [prohibition] + +服务端旧钥退出状态至少区分online_retired与backup_retired:当前数据库/在线vault已删除但备份或快照仍可恢复旧钥时,不得宣称server-held key已完整销毁 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.137.06 [prohibition] + +备份恢复后必须重放撤销、密钥退役和凭据状态,禁止复活已撤销token、证书当前授权或旧签名能力 + +映射:`recovery`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.138.01 [contract] + +TransferSession绑定主体、方向、目标/资源、revision、状态、限制而非连接 + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/transfer.py:12, src/msg/plugins/transfer.py:32, src/msg/client.py:185, src/msg/core/models.py:32 + +### D11.138.02 [contract] + +open/part_put/part_get/status/seal/cancel可跨GET Path/POST/GraphQL/CLI/MCP + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/transfer.py:36, src/msg/plugins/transfer.py:69, src/msg/client.py:8, src/msg/core/models.py:9 + +### D11.138.03 [contract] + +创建/修改走执行入口 + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.138.04 [contract] + +分片按offset/length半开范围,允许乱序,同范围同摘要幂等,冲突重叠拒绝 + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/transfer.py:92, src/msg/plugins/transfer.py:66, src/msg/client.py:658 + +### D11.138.05 [contract] + +status给完成/缺口,seal验缺口及最终size/digest后只返回不可变引用、不发帖 + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/transfer.py:10, src/msg/plugins/transfer.py:7, src/msg/client.py:16, src/msg/core/models.py:37 + +### D11.138.06 [contract] + +下载支持流式/范围续读,不整文件入内存 + +映射:`transfer`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.139.01 [prohibition] + +CLI分别配置read_url/push_url,普通路径不得写入、转发或重定向执行 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.140.01 [contract] + +网页按清单read→patch/write→web.preview→web.deploy原子切换所有文件,支持diff/rollback + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/hosting.py:44, src/msg/hosting_runtime.py:118, src/msg/admin/diagnostics.py:64, src/msg/core/models.py:311 + +### D11.140.02 [contract] + +启用时提供/@root/web/index.html,根变更仅本机 + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/hosting.py:193, src/msg/hosting_runtime.py:48, src/msg/admin/diagnostics.py:41 + +### D11.141.01 [contract] + +托管可执行页面含预览/历史,响应头须强制CSP sandbox,最多allow-scripts、禁allow-same-origin + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/hosting.py:1, src/msg/admin/diagnostics.py:668 + +### D11.141.02 [contract] + +raw无同等隔离则仅非执行下载 + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.141.03 [prohibition] + +禁止仅靠meta/Report-Only、暴露平台Cookie/长期bearer或信任Origin:null/同域/隐藏请求 + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:431 + +### D11.141.04 [contract] + +须阻断私有API读取、携平台身份写入、绕隔离 + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.141.05 [contract] + +CSP/跨源分别约束网络/读取,不宣称sandbox阻断所有请求 + +映射:`hosting`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D11.142.01 [prohibition] + +禁止按账号预建空仓库、常驻进程、个人队列或定时任务 + +映射:`git`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 12 事件、Inbox/Outbox、ACK 与通知 + +### D12.144.01 [contract] + +事件、通信、协作和发货共用已有模型,不自动串联通用工作流 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.145.01 [contract] + +事务和幂等依第09章 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.145.02 [contract] + +邮件、Webhook、工具及已登记外部动作共用EffectJob的领取/重试/超时/去重基础设施,执行器仍按通道权限隔离 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.145.03 [prohibition] + +按事件+接收者+通道或原工具调用去重,完成/重试必须检查当前attempt/state/deadline及当前授权,过期结果不能覆盖新尝试,uncertain不得自动重发,不承诺外部exactly-once + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:4, src/msg/workers/leases.py:8, src/msg/plugins/communication.py:89, src/msg/core/models.py:54 + +### D12.145.04 [contract] + +外部失败不回滚业务,索引/统计重建不重复发信或发证 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.145.05 [contract] + +/@user/in存引用与已读/归档 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:231, src/msg/plugins/communication.py:409, src/msg/core/models.py:91 + +### D12.145.06 [contract] + +/@user/out按actor索引request/event/result,不复制正文、不等于事务Outbox + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:202, src/msg/workers/leases.py:1, src/msg/plugins/communication.py:23, src/msg/core/models.py:91 + +### D12.145.07 [contract] + +来源为follow、mention/reply、dm、system + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/communication.py:27, src/msg/core/models.py:105 + +### D12.145.08 [contract] + +system可读/归档但不能unfollow + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:105 + +### D12.145.09 [contract] + +communication.send、watch/unwatch、changes/sync只传受权引用 + +映射:`jobs`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:communication.send +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:292, src/msg/plugins/communication.py:113 + +### D12.146.01 [contract] + +/@user/dm仅本人视图 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/communication.py:409, src/msg/plugins/communication.py:409, src/msg/core/models.py:91 + +### D12.146.02 [prohibition] + +不得改对方消息,或借chmod/chgrp/share/move/引用向第三方公开会话 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:312 + +### D12.146.03 [prohibition] + +正文、成员、数量、附件、关系不得进入第三方搜索/索引/Profile/feed/统计/成就证据 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.146.04 [contract] + +加第三人须新建私密群聊,不自动带旧历史 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.146.05 [contract] + +archive只改本人视图,block不撤回已交付内容 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/communication.py:732, src/msg/plugins/communication.py:732 + +### D12.146.06 [prohibition] + +首版不宣称E2EE,后续客户端加密/设备钥须独立设计,禁止以托管签名钥冒充 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.147.01 [contract] + +msg dm request/send/list/read/accept/reject/block复用公共契约,离线经Inbox/SyncCursor + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/communication.py:96, src/msg/plugins/communication.py:96, src/msg/core/models.py:311 + +### D12.147.02 [contract] + +ACK显式绑定Revision/digest,区分客户端/托管签名,清单/数量可单查 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/communication.py:10, src/msg/plugins/communication.py:10, src/msg/core/models.py:37 + +### D12.147.03 [contract] + +CLI仅按用户配置另发ACK + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.147.04 [contract] + +like/unlike同为显式操作 + +映射:`dm`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.148.01 [contract] + +下列协作原语复用Resource/Relation/Event/Operation,不隐式转移owner、Membership、ShareGrant、证书/capability或改变权限/优先级 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/proposals.py:4, src/msg/plugins/watches.py:1, src/msg/core/models.py:63 + +### D12.148.02 [contract] + +?表示可选字段: + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.149.01 [contract] + +request(requester,title/description,resource_refs,requirements,created_at,due_at?,expires_at?,status,assignee?):open/claimed/fulfilled/cancelled/expired + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:17, src/msg/plugins/proposals.py:24, src/msg/plugins/watches.py:46, src/msg/core/models.py:55 +request字段:requester, title, description, resource_refs, requirements, created_at, due_at, expires_at, status, assignee +offer字段:subject, description, capability_hint, scope, availability, expires_at + +### D12.149.02 [contract] + +offer(subject,description/capability_hint,scope,availability,expires_at?) + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:169, src/msg/plugins/watches.py:46, src/msg/core/models.py:148 + +### D12.149.03 [contract] + +二者可搜索、互引、匹配,不自动派活、付款或执行 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.150.01 [contract] + +proposal(author,target ResourceRef,base_revision,patch/content_ref,message,status):open/accepted/rejected/superseded/withdrawn + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:60, src/msg/plugins/proposals.py:4, src/msg/plugins/watches.py:1, src/msg/core/models.py:30 +proposal字段:author, target, base_revision, patch, content_ref, message, status + +### D12.150.02 [contract] + +建议不改目标,accept须正式Operation重验权限/版本,失败保留建议与冲突 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:139, src/msg/plugins/proposals.py:63 + +### D12.151.01 [contract] + +receipt(request_id,actor,operation,target/result_ref,result_digest,committed_at,server_signature/receipt_proof):证明实际提交,可引用/验签,幂等重试同一事实,外部uncertain不是完成 + +映射:`audit`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:65, src/msg/storage/postgres.py:69, src/msg/admin/recovery_replay.py:6, src/msg/core/models.py:65 +receipt字段:request_id, actor, operation, target, result_ref, result_digest, committed_at, server_signature, receipt_proof + +### D12.152.01 [contract] + +checkpoint(subject,resource_refs,state_ref?,summary,resume_hint?,created_at,expires_at?):保存工作进度,可被handoff引用,不是Bookmark、不冻结目标或授lease + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:79, src/msg/plugins/proposals.py:47, src/msg/plugins/watches.py:46, src/msg/core/models.py:55 +checkpoint字段:subject, resource_refs, state_ref, summary, resume_hint, created_at, expires_at + +### D12.152.02 [contract] + +恢复重读状态并处理冲突 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.153.01 [contract] + +watch(subject,target/query_ref,event_types,delivery,created_at,expires_at?,status):Event匹配后向Inbox/已配通道投递引用,撤权停止泄露,不是轮询或定时工作流 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:60, src/msg/plugins/proposals.py:21, src/msg/plugins/watches.py:1, src/msg/core/models.py:55 +watch字段:subject, target, query_ref, event_types, delivery, created_at, expires_at, status + +### D12.153.02 [contract] + +follow复用固定条件的watch及投递逻辑,保留外部操作名 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/watches.py:189 + +### D12.153.03 [contract] + +系统通知不因unfollow而取消 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.153.04 [contract] + +工作lease、worker执行租约、数据库锁各自保留语义,只共享适用的时间/编码辅助函数 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/watches.py:162, src/msg/core/models.py:24 + +### D12.154.01 [contract] + +/@user/下提供handoffs/、leases/、presence、claims/、requests/、offers/、proposals/、receipts/、checkpoints/、watches/视图,不增安全父链 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:1, src/msg/plugins/proposals.py:1, src/msg/plugins/watches.py:1, src/msg/core/models.py:91 + +### D12.154.02 [contract] + +msg按各契约提供读取及create/accept/reject/renew/release/clear/fulfill/withdraw,receipt不可独立创建 + +映射:`collaboration`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/collaboration.py:139, src/msg/plugins/proposals.py:63, src/msg/core/models.py:91 + +### D12.155.01 [contract] + +Delivery记录一次订单向具体主体交付的事实:delivery_id/order_id、recipient_subject、kind、payload_refs/DeliveryEnvelope、package_digest、delivery_digest、recipient_key_id?、state、prepared/claimed_at、receipt + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/store.py:36, src/msg/plugins/orders.py:3, src/msg/market/managed_delivery.py:17, src/msg/market/compatibility.py:3, src/msg/core/models.py:54 +Delivery字段:delivery_id, order_id, recipient_subject, kind, payload_refs, DeliveryEnvelope, package_digest, delivery_digest, recipient_key_id, state, prepared_at, claimed_at, receipt + +### D12.155.02 [contract] + +商品包可复用,Delivery不与包合并 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:3, src/msg/market/compatibility.py:3 + +### D12.155.03 [contract] + +权威入口为/@buyer/orders//_delivery,Inbox通知带order_id + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:1, src/msg/market/managed_delivery.py:17, src/msg/market/compatibility.py:71 + +### D12.155.04 [contract] + +channel/邮件状态由对应EffectJob按需投影,不另建商品交付状态机 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/plugins/orders.py:106, src/msg/market/managed_delivery.py:18 + +### D12.155.05 [contract] + +旧版本字段保持兼容且不能重写签署事实 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.155.06 [prohibition] + +claimed或accepted仅由明确签名操作记录,下载、邮件接受、读取不得自动确认或放款 + +映射:`sale`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.01 [contract] + +DeliveryTarget由买家自己的已验证endpoint产生并在下单锁定endpoint_id/address_snapshot + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +DeliveryTarget字段:endpoint_id, address_snapshot, subject + +### D12.156.02 [contract] + +checkout可填写email,但只有与当前subject已验证邮箱匹配时才能直接成为发货目标 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/delivery_notifications.py:4 + +### D12.156.03 [contract] + +未验证的新地址只能先收到不含订单号、用户名、商品信息或提货能力的验证消息 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.04 [contract] + +订单仍在站内正常交付,Email通知保持pending,验证成功后才绑定 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.05 [prohibition] + +卖家不得提供/修改收件地址,商品recipient_subject不可改 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.06 [contract] + +站内交付与辅助邮件分开:买家可在邮件尚未发送前显式更换/验证通知endpoint,须版本化绑定并使旧任务失效 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.07 [prohibition] + +已发送快照不得追改,改邮箱不改变商品归属或解密接收者 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.08 [contract] + +发送前校验Order.buyer=DeliveryTarget.subject=VerifiedEmail.owner,加密时还须=EncryptionKey.owner + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/delivery_notifications.py:1 + +### D12.156.09 [contract] + +不符报delivery_recipient_mismatch并停止 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.10 [contract] + +worker同时重验endpoint未撤销及订单/交付状态 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:86, src/msg/core/models.py:24 + +### D12.156.11 [default] + +卖家只见channel/status,默认不见真实邮箱 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:133, src/msg/core/models.py:125 + +### D12.156.12 [contract] + +邮箱endpoint必须是与绑定快照逐字一致的单一addr-spec,不接受显示名、地址列表、命名组、注释或解析纠错后地址变化 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.13 [contract] + +合法带引号的本地部分不因含逗号而误拒 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.14 [contract] + +订单绑定与SMTP边界共用校验,已验证旧数据和排队任务同样重验 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.15 [contract] + +实际SMTP envelope显式限定该唯一收件人,不从To/Cc/Bcc推导或扩展收件人 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.156.16 [contract] + +非法目标须在连接SMTP/读取凭据前拒绝,错误不回显地址,不影响站内交付、付款和验收 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.157.01 [default] + +Email默认disabled,须验证邮箱并配置偏好 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.157.02 [prohibition] + +SMTP未配置/关闭不得影响站内订单与发货 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/mail.py:1 + +### D12.157.03 [default] + +file/bundle默认email_notify:邮件只含order_id、下单时buyer handle快照(用户名)和需重新认证的提货链接,链接本身不是bearer capability,不附明文商品 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/delivery_notifications.py:52, src/msg/workers/effects.py:47, src/msg/core/models.py:146 + +### D12.157.04 [contract] + +也可发买家EncryptionSubkey加密附件 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.157.05 [prohibition] + +secret只能密文附件/下载,禁止明文SMTP + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:344 + +### D12.157.06 [contract] + +entitlement只通知/回执 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.157.07 [contract] + +邮件prepared/queued/smtp_accepted/delivered_unknown/failed由EffectJob及发送结果派生,与Delivery.claimed、订单accepted/settled分开 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/delivery_notifications.py:40, src/msg/workers/effects.py:152, src/msg/workers/mail.py:22, src/msg/core/models.py:421 + +### D12.157.08 [prohibition] + +SMTP接受不等于取货或验收,failed/uncertain不得被状态投影重启 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/market/delivery_notifications.py:41, src/msg/workers/effects.py:4, src/msg/workers/mail.py:1, src/msg/core/models.py:283 + +### D12.157.09 [prohibition] + +禁止像素追踪 + +映射:`email`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.158.01 [default] + +Webhook支持Inbox-based和需capability的Domain Event-based,endpoint默认disabled、独立secret,以HMAC-SHA256签timestamp+原始body,保留event/delivery身份并按当前权限裁剪 + +映射:`webhook`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/effects.py:289, src/msg/plugins/communication.py:1, src/msg/core/models.py:390 + +### D12.158.02 [default] + +2xx为delivered,确定性4xx默认不重试,408/429/5xx/网络错误退避 + +映射:`webhook`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.158.03 [contract] + +可能已执行且无幂等依据则uncertain,不盲重做 + +映射:`webhook`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.158.04 [contract] + +外发仅最小事件/引用,不带私有正文/秘密 + +映射:`webhook`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D12.159.01 [contract] + +AuditEvent只追加actor/operation/target、授权依据、前后摘要、结果/链式摘要,不记秘密 + +映射:`audit`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:65, src/msg/storage/postgres.py:379, src/msg/admin/recovery_replay.py:6, src/msg/core/models.py:65 + +### D12.159.02 [contract] + +仅能相对可信检查点发现篡改,不能防完整写盘者重算历史 + +映射:`audit`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +## 13 工具、SSH、RSS 与客户端 + +### D13.161.01 [contract] + +工具和客户端复用公共契约,不成为shell或根管理跳板 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.162.01 [default] + +/tools/只读列出获准ToolSpec,默认dns/curl且受证书保护 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/tools.py:37, src/msg/workers/sandbox_child.py:160, src/msg/core/registry.py:184 +ToolSpec字段:tool_id, name, description, version, digest, input_schema, output_schema, capability, network_policy, timeout, input_limit, output_limit, concurrency, executor_key + +### D13.162.02 [contract] + +Spec含tool_id、名称/说明、版本/摘要、输入/输出schema、所需能力、网络策略、超时、输入/输出/并发上限、executor_key,仅调用安装的可信代码 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/tools.py:25, src/msg/workers/sandbox.py:57, src/msg/core/registry.py:81, src/msg/core/models.py:464 + +### D13.163.01 [contract] + +tool.run经/-/p/tool.run或/-/g短码执行,tool.use限工具/操作 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:tool.run +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/tools.py:25 + +### D13.163.02 [default] + +受限worker逐次解析/重定向复核目标,默认公网 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.163.03 [contract] + +tool.net.private仅放行明确私网/回环/链路本地范围,取部署/工具/证书最严限制 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/tools.py:72 + +### D13.163.04 [contract] + +审计actor/tool_id、输入/目标摘要、状态、输出引用 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/tools.py:25 + +### D13.163.05 [contract] + +大内容走Transfer,重试见第12章 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.163.06 [prohibition] + +禁止读取触发工具、任意命令、宿主路径、Unix socket、进程参数或根访问 + +映射:`tools`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/workers/sandbox_child.py:8 + +### D13.164.01 [contract] + +SSH公钥只授权本站原子命令/指定Git,不提供通用shell、root会话、msgd代理或签名转发 + +映射:`ssh`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/extensions/ssh.py:1 + +### D13.164.02 [contract] + +RSS为只读订阅/聚合视图,订阅创建和外部刷新仍走执行入口 + +映射:`ssh`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.165.01 [default] + +msg管理凭据、自动签名、契约缓存、分页、上传及有限重试,默认compact + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.165.02 [contract] + +--json 下推ReadQuery,裸--json列字段,--jq/--template仅本地处理已授权结果 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/client.py:47, src/msg/cli.py:100, src/msg/tui.py:226, src/msg/core/models.py:442 + +### D13.165.03 [default] + +默认一个窗口 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.165.04 [contract] + +--limit在总量/字节预算内续读,--page-size控单页,--paginate逐页读至结束 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/client.py:31, src/msg/cli.py:60, src/msg/tui.py:90, src/msg/transports/mcp.py:34, src/msg/core/models.py:38 + +### D13.165.05 [contract] + +未完返回next,不无限展开嵌套集合 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.165.06 [contract] + +组合读取用msg api graphql + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +### D13.166.01 [contract] + +MCP stdio/远程Streamable HTTP共用Operation/ReadQuery/Transfer,可安装时优先本地msg MCP签名 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/cli.py:170, src/msg/transports/mcp.py:1 + +### D13.166.02 [contract] + +TUI覆盖Home、Inbox/Outbox、Topics/Following、线程、Notes/Todos、Files、Groups、搜索、凭据状态 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/cli.py:171, src/msg/tui.py:65 + +### D13.166.03 [prohibition] + +禁止读取浏览器Cookie/session、直连数据库、绕执行器或额外赋权 + +映射:`client`;诊断范围:`no_direct_doctor_selftest_mapping_identified`;状态:源码/测试导航已定位,逐断言未验证。 + +## 14 核心接口与配置 + +### D14.168.01 [contract] + +只抽象必要接口,隔离配置、数据、根秘密和发行规则 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.169.01 [contract] + +Protocol按真实替换/测试/隔离边界保留,例如MetadataStore/MetadataSession、ContentStore/BlobStore、ClientTransport、ToolExecutor、NotificationSender及限定持钥环境的Signer + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/models.py:11, src/msg/core/models.py:11 + +### D14.169.02 [contract] + +不强制Authenticator/Authorizer或每个业务名词建立接口层,已有简洁有用接口无需为减数量删除 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.169.03 [contract] + +Registry、OperationExecutor、TransferService、RootAdmin保留具体实现 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:25, src/msg/plugins/features.py:5 + +### D14.169.04 [default] + +默认PostgresMetadataStore/GitTextStore/LocalCAS + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.169.05 [contract] + +存储抽象不能抹去PostgreSQL事务、唯一约束和锁语义 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.169.06 [contract] + +加密容器/签名编码可复用,必须绑定purpose/schema version/subject/recipient/order或恢复上下文,RecoveryEnvelope、DeliveryEnvelope和一次释放秘密不互授权限 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/core/registry.py:39, src/msg/core/models.py:124, src/msg/plugins/common.py:75, src/msg/core/models.py:124 + +### D14.169.07 [prohibition] + +禁止无需求Manager/Factory/ABC、万能工作流和为了合并而弱化类型约束 + +映射:`registry`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.170.01 [contract] + +RootAdmin仅本机处理init、根证书/PIN/恢复、doctor/selftest + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config_contracts.py:8, src/msg/admin/config_check.py:122 + +### D14.170.02 [contract] + +适配器不绕执行器改表 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.170.03 [default] + +配置必须公布类型、范围、确定默认值并由doctor验证,未定值不标完备 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.170.04 [contract] + +邮箱、成员、模板、wiki、银行角色、发行量及报价是业务事实,不另建配置副本 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/config_check.py:73 + +### D14.170.05 [contract] + +普通正文不硬编码 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.171.01 [contract] + +/etc/msgd/存msgd.toml、plugins.d/、trust/root.crt + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:41, src/msg/admin/config_check.py:36 + +### D14.171.02 [contract] + +mail.toml含enabled、SMTP、TLS、发件地址、认证引用 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:204, src/msg/admin/config_check.py:5 + +### D14.171.03 [default] + +[identity]默认credential_delivery_recovery_window="15m" + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:107, src/msg/config_contracts.py:35, src/msg/admin/config_check.py:45, src/msg/core/models.py:182 + +### D14.171.04 [default] + +[money]默认enabled=true、currency_id=primary、display_name/code=MSG(暂定)、scale=6、transfer_fee=0、allow_overdraft=false + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:30, src/msg/config_contracts.py:37, src/msg/admin/config_check.py:52, src/msg/core/models.py:260 + +### D14.171.05 [contract] + +凭据交付恢复窗口可配置但必须有确定上限,窗口起点为发行结果业务提交时间,不因失败重试自动续期 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.171.06 [contract] + +显示名/code可改,currency_id不变,银行/发行/报价按第04章管理 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:30, src/msg/config_contracts.py:38, src/msg/admin/config_check.py:53, src/msg/core/models.py:260 + +### D14.172.01 [contract] + +/var/lib/msgd/含git/content/、git/repos/、blobs/sha256/、transfers/staging/,分别存内部历史、用户公开仓库、CAS、未seal分片 + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/storage/git.py:93, src/msg/storage/postgres.py:88, src/msg/admin/backups.py:40, src/msg/storage/legacy_resource_import.py:1, src/msg/core/models.py:82 + +### D14.172.02 [contract] + +数据库仅配地址/Unix Socket、database、user、池、凭据引用,msgd不访问或配置PostgreSQL物理data_directory + +映射:`storage`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/storage/postgres.py:348, src/msg/admin/backups.py:183, src/msg/core/models.py:91 + +### D14.173.01 [contract] + +/var/lib/msgd-root/须root:root、0700,根加密私钥0600 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/root.py:1, src/msg/admin/rotation.py:29, src/msg/security/trust_files.py:14 + +### D14.173.02 [prohibition] + +网络账号不得遍历/读取 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.173.03 [contract] + +根PIN/私钥/恢复材料不进资源树、Git、索引、日志或普通网络导出 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.173.04 [contract] + +公开证书不授根操作权 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.174.01 [contract] + +/var/cache/msgd/下search/rendered/schemas可重建 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:41, src/msg/admin/config_check.py:36, src/msg/plugins/schemas.py:1 + +### D14.174.02 [contract] + +/run/msgd/仅socket、pid/lock等可丢状态,不放恢复分片 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/config.py:41, src/msg/admin/config_check.py:36 + +### D14.174.03 [contract] + +~/.config/msg/保存客户端连接、偏好、身份引用/状态,私钥用受保护凭据存储 + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/admin/config_check.py:28 + +### D14.174.04 [contract] + +备份见第11章,不依赖缓存或/run + +映射:`config`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.175.01 [prohibition] + +根路径、信任锚、local_only、管理进程权限仅本机可改,system.config/插件/身份恢复不得覆盖 + +映射:`root`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +实际Registry精确操作:system.config +正文命名成员位置(文本定位,非行为证明):src/msg/admin/rotation.py:149, src/msg/core/models.py:108 + +### D14.176.01 [contract] + +规则每文件一个任务主题,共用条款以Markdown/LinkSet引用 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.176.02 [prohibition] + +rule_id不随移动改变,requires_rules可解析,禁止巨型全集 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/bootstrap.py:14, src/msg/core/registry.py:134, src/msg/plugins/common.py:255, src/msg/hosting_runtime.py:50, src/msg/core/models.py:357 + +### D14.177.01 [contract] + +发行规则资源:运行时用户、Topic admin、插件、wiki不能修改 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/bootstrap.py:1, src/msg/core/models.py:123 + +### D14.177.02 [contract] + +删除/迁移须显式处理、保留历史 + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 + +### D14.177.03 [contract] + +wiki不随源码覆盖,Agent可只读取依赖规则的history/diff + +映射:`rules`;诊断范围:`related_feature_only`;状态:源码/测试导航已定位,逐断言未验证。 +正文命名成员位置(文本定位,非行为证明):src/msg/bootstrap.py:21 + +## 复用的配置字段目录 + +| 字段 | 生效路径 | 关联现场检查 | +| --- | --- | --- | +| server.service_url | service_url | none; loader only | +| server.listen | listen | none; loader only | +| server.port | port | none; loader only | +| server.public_web_origin | public_web_origin | hosting | +| server.temporary_ttl | temporary_ttl | none; loader only | +| server.transfer_ttl | transfer_ttl | none; loader only | +| storage.postgres_dsn | server.postgres_dsn | storage | +| storage.valkey_url | server.valkey_url | valkey | +| storage.content | server.content_dir | content_layout | +| storage.repositories | server.repositories_dir | none; loader only | +| storage.blobs | server.blob_dir | none; loader only | +| storage.staging | server.staging_dir | capacity | +| storage.service_keys | server.service_keys_dir | online_ca | +| limits.request_bytes | server.limits.max_request_bytes | none; loader only | +| limits.response_bytes | server.limits.max_response_bytes | none; loader only | +| limits.path_bytes | server.limits.max_path_bytes | none; loader only | +| limits.part_bytes | max_part_bytes | none; loader only | +| plugins.enabled | server.plugins | none; loader only | +| tools.isolation | worker_isolation | none; loader only | +| tools.timeout_ms | tool_timeout_ms | none; loader only | +| tools.max_response_bytes | tool_max_response_bytes | none; loader only | +| tools.methods | tool_methods | none; loader only | +| tools.ports | tool_ports | none; loader only | +| identity.credential_delivery_recovery_window | credential_delivery_recovery_window | credential_delivery | +| money.enabled | money.enabled | money_config | +| money.currency_id | money.currency_id | money_config | +| money.display_name | money.display_name | money_config | +| money.code | money.code | money_config | +| money.scale | money.scale | money_config | +| money.transfer_fee | money.transfer_fee | money_config | +| money.allow_overdraft | money.allow_overdraft | money_config | +| hosting.base_capacity_bytes | hosting_base_capacity_bytes | hosting | +| hosting.recovery_marker | hosting_recovery_marker | none; loader only | +| hosting.content_group_read | hosting_content_group_read | none; loader only | +| recovery.custodians | recovery_custodians | none; loader only | +| mail.enabled | server.mail.enabled | none; loader only | +| mail.host | server.mail.host | none; loader only | +| mail.port | server.mail.port | none; loader only | +| mail.tls | server.mail.tls | none; loader only | +| mail.sender | server.mail.sender | none; loader only | +| mail.credential_file | server.mail.credential_file | none; loader only | +| recovery.custodians.id | recovery_custodians.0.id | none; loader only | +| recovery.custodians.name | recovery_custodians.0.name | none; loader only | +| recovery.custodians.recipient | recovery_custodians.0.recipient | none; loader only | +| recovery.custodians.description | recovery_custodians.0.description | none; loader only | +| recovery.custodians.policy_ref | recovery_custodians.0.policy_ref | none; loader only | + +全部配置行共用configuration_fields/configuration_loading以及真实default/valid/reject向量;现场检查为空不是配置无校验。完整运行归属保留于JSON/evidence_runs。 + +## 证据边界 + +每条测试定义的文件/函数/行号在JSON profiles.test_definitions;源文本精确命中位置在obligations.source_token_locations。匹配只是可复查定位,不声称测试断言覆盖原文所有分支。最终精确head CI、物理控制台和部署均未由本表证明。 + +## 命名字段映射限度 + +371个规范成员标签中,46项匹配实际模型字段,243项有源码同名定位,82项原待核对实现拼写/别名已在下方cfdb211增量中逐项核对。这些不是82个确认缺失字段,不得据此添加重复状态。每项归属、源码位置及父条款保留于JSON;源码待办更新为已分派正式诊断接线及下方两项确认缺口。 + +公开版不包含私有快照、身份数据、凭据或其保护路径。运行摘要随JSON保留,原始本地日志未随文档发布;云CI给出对应run链接。 + +## 82个成员的有限人工语义核对(cfdb211) + +全部82项已核对,不再留作grep未命中。77项定位实际别名/嵌套表示/派生事实,3项为明确可选且未实现的行指纹,2项确定源码缺口已分派。此处是源码核对,不伪称新增行为测试通过。 + +- Error.message:共同OperationError未提供message;recover_transports负责安全静态文案,保留旧签署字节。 +- ToolSpec concurrency:逐进程串行worker不等于跨worker的tool上限;recover_recovery负责原子共享限制。 +- 历史别名清单:设计92引用已完成归档,其内容未随本设计提供;没有猜造缺失别名或新增映射。 + +| 成员 | 实际表示 | 源码 | 核对结论 | +| --- | --- | --- | --- | +| F021.identity.user_id | Subject.resource_id / Resource.id | src/msg/core/models.py:103 | user_id denotes stable subject identity, not a second ID; handle is mutable Resource.name. | +| F027.RecoveryCustodian.custodian_id | RecoveryCustodian.id | src/msg/config.py:18 | Configured id is the stable custodian reference; recipient/fingerprint separately bind the age key. | +| F028.RecoveryEnvelope.recipient_fingerprint | recipient_fingerprints[] + RecoveryPolicy recipients fingerprint/custodian_ref | src/msg/plugins/recovery.py:275 | Plural because design28 permits OR multi-recipient age; caller fingerprints must be a subset of the current policy, not new authority. | +| F029.rewrap_mapping.new_ciphertext_ref | mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id | src/msg/plugins/recovery.py:98 | Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged. | +| F029.rewrap_mapping.new_digest | mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id | src/msg/plugins/recovery.py:98 | Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged. | +| F029.rewrap_mapping.recipient_key | mapping.new.{id,revision,ciphertext_digest}; mapping.recipient/new_key_id | src/msg/plugins/recovery.py:98 | Explicit old/new mapping is stored in custodial_upgrades and audited. New ciphertext is a separate immutable Revision; old bytes/key_id unchanged. | +| F032.AchievementGrant.id | grant JSON id / achievement_grants.id | src/msg/plugins/achievements.py:145 | Same explicit id, located in plugin runtime JSON rather than a model dataclass. Ownership and id match rechecked. | +| F039.challenge_signature.R4_result | r4_result and final_statement in ceremony_digest | src/msg/plugins/achievements.py:115 | R5 constant and answered R1–R4 rounds are bound to subject/challenge digest; uppercase inventory labels were descriptive, not wire fields. | +| F039.challenge_signature.R5_statement | r4_result and final_statement in ceremony_digest | src/msg/plugins/achievements.py:115 | R5 constant and answered R1–R4 rounds are bound to subject/challenge digest; uppercase inventory labels were descriptive, not wire fields. | +| F045.CapabilitySpec_projection.allowed_operations | CapabilitySpec.operations | src/msg/core/models.py:329 | Explicit finite operations set; discovery serializes the installed spec. No wildcard grant expansion implied by naming difference. | +| F057.BlobRef.backend | BlobRef.digest → validated sha256 key → private index {kind:git/binary, oid?, size} | src/msg/storage/git.py:264 | Backend/key are private storage indirection, not exposed in signed/public BlobRef. Design136/86 prohibit backend disclosure/direct CAS links; no need to add backend to existing signed BlobRef. | +| F061.CurrencySpec.minor_units | integer amount_minor, SCALE / MAX_MINOR | src/msg/market/ledger.py:49 | minor_units is the amount unit/representation, not a second CurrencySpec balance. checked_amount rejects bool/non-int/nonpositive/out-of-range. | +| F062.LedgerTransaction.id | money_ledger.id; signed receipt body.transaction_id | src/msg/market/ledger.py:116 | Single generated transaction identifier; sequence is separate and receipt signatures/legacy id bytes are preserved. | +| F064.ServerOffer_compatibility.min | min_quantity / max_quantity / duration_seconds | src/msg/market/offer_resources.py:14 | Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality. | +| F064.ServerOffer_compatibility.max | min_quantity / max_quantity / duration_seconds | src/msg/market/offer_resources.py:14 | Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality. | +| F064.ServerOffer_compatibility.duration | min_quantity / max_quantity / duration_seconds | src/msg/market/offer_resources.py:14 | Design shorthand made explicit units in existing contract; signed Listing server_offer projection verifies exact tuple equality. | +| F065.Listing_bounty.reward_minor | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F065.Listing_bounty.max_claims | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F065.Listing_bounty.claim_limit_per_subject | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F065.Listing_bounty.verifier_id | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F065.Listing_bounty.verifier_version | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F065.Listing_bounty.eligibility | Same named fields in bounty_listings projection and signed Listing body | src/msg/plugins/bounty.py:47 | Earlier sale profile missed the bounty owner. These are implemented fields, with immutable source/projection checks in bounty contracts; not absent members. | +| F066.DeliveryEnvelope.recipient_key_id | delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id | src/msg/market/delivery.py:273 | Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority. | +| F066.DeliveryEnvelope.fingerprint | delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id | src/msg/market/delivery.py:273 | Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority. | +| F066.DeliveryEnvelope.ciphertext_ref | delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id | src/msg/market/delivery.py:273 | Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority. | +| F069.Order.unit_price | store_orders unit_price_minor/total_price_minor and delivery_target snapshot | src/msg/market/orders.py:104 | Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine. | +| F069.Order.total_price | store_orders unit_price_minor/total_price_minor and delivery_target snapshot | src/msg/market/orders.py:104 | Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine. | +| F069.Order.DeliveryTarget | store_orders unit_price_minor/total_price_minor and delivery_target snapshot | src/msg/market/orders.py:104 | Amounts are integer minor units; order write binds listing revision, price and quantity. DeliveryTarget is a typed embedded locked snapshot, not another independent state machine. | +| F087.Post_template_binding.template_id | template_id + template_version + template_digest + normalized values in signed content | src/msg/plugins/content.py:98 | Exact implementation exists in content.template_content, omitted from initial registry-only profile. | +| F098.ReadQuery.select | Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields | src/msg/plugins/discovery.py:459 | Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language. | +| F098.ReadQuery.filter | Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields | src/msg/plugins/discovery.py:459 | Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language. | +| F098.ReadQuery.first | Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields | src/msg/plugins/discovery.py:459 | Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language. | +| F098.ReadQuery.after | Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields | src/msg/plugins/discovery.py:459 | Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language. | +| F098.ReadQuery.projection | Installed discovery.read_query @1/@2/@3: fields, typed filters, limit, cursor, collection/expand; read_projection returns selected authorized fields | src/msg/plugins/discovery.py:459 | Design labels describe shared read structure. Wire fields are versioned installed schemas; select/first/after are not newly accepted aliases. Filters remain finite schema keys, no arbitrary expression language. | +| F098.ReadQuery_budget.max_depth | MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget | src/msg/core/read_query.py:9 | Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields. | +| F098.ReadQuery_budget.max_nodes | MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget | src/msg/core/read_query.py:9 | Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields. | +| F098.ReadQuery_budget.query_cost | MAX_READ_DEPTH=4; MAX_READ_NODES=100; MAX_READ_COST=1000; ReadBudget | src/msg/core/read_query.py:9 | Runtime budget.node/check and expansion_schema enforce fixed limits; not separately configurable fields. | +| F098.ReadQuery_budget.max_collection_page_size | nested_first/expand node limit maximum10; top-level limit maximum200 | src/msg/plugins/discovery.py:564 | Each nested collection has its own page boundary; one global conceptual label maps to explicit outer/nested maxima. | +| F098.ReadQuery_budget.timeout | ExecutionContext.deadline_monotonic → ReadBudget.deadline | src/msg/plugins/discovery.py:691 | Executor bounded deadline is passed into read traversal; timeout is not a caller-controlled permission or unbounded input. | +| F101.Cursor.projection | query.arguments (fields/projection); position.snapshot; position.last | src/msg/core/cursors.py:30 | MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged. | +| F102.PageCursor.snapshot_boundary | query.arguments (fields/projection); position.snapshot; position.last | src/msg/core/cursors.py:30 | MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged. | +| F102.PageCursor.last_key | query.arguments (fields/projection); position.snapshot; position.last | src/msg/core/cursors.py:30 | MAC covers operation+arguments and last/snapshot/principal/expiry. No new public cursor field aliases; opaque payload version remains unchanged. | +| F113.OperationRequest.input | OperationRequest.arguments | src/msg/core/models.py:242 | Typed input schema validates arguments; payload_digest and signing bytes retain published spelling. | +| F113.OperationRequest.expected_revision | Operation-specific arguments.expected_revision/base_revision; envelope.expected_generations | src/msg/plugins/content.py:523 | Revision concurrency is validated by each versioned content operation; resource generation handles metadata. No duplicate envelope field needed. | +| F119.ArbitrationCase.claimant | opened_by + buyer/seller parties; reason | src/msg/market/arbitration.py:277 | Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims. | +| F119.ArbitrationCase.respondent | opened_by + buyer/seller parties; reason | src/msg/market/arbitration.py:277 | Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims. | +| F119.ArbitrationCase.reason_code | opened_by + buyer/seller parties; reason | src/msg/market/arbitration.py:277 | Claimant is opened_by, respondent is other fixed party; deterministic reason enum. Role facts derive from pinned order, not arbitrary caller claims. | +| F119.ArbitrationCase.evidence_refs | arbitration_evidence rows projected under case.evidence with author/visibility/body | src/msg/market/arbitration.py:299 | Evidence has independent rows for privacy and updates; case relation is case_id. Signed/pinned ref handling and panel visibility are enforced on submission/read. | +| F121.Error.message | No common safe message member in current OperationError | src/msg/core/models.py:259 | Confirmed design121 representation omission: only code/retryable/field_path/retry_after_seconds. GraphQL/MCP envelope messages do not fill common operation errors. recover_transports assigned additive static message preserving old signed bytes. | +| F125.Line_fingerprint.line_no | Grep line_hint only; no explicit line fingerprint option implemented | src/msg/plugins/discovery.py:1208 | Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking. | +| F125.Line_fingerprint.hash | Grep line_hint only; no explicit line fingerprint option implemented | src/msg/plugins/discovery.py:1208 | Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking. | +| F125.Line_fingerprint.context_hash | Grep line_hint only; no explicit line fingerprint option implemented | src/msg/plugins/discovery.py:1208 | Design125 describes optional opt-in fingerprint behavior; design193 explicitly says line fingerprints optional. Existing heading/block digests and grep line hints are different. Record optional feature absent, not silently claim implemented or make it release-blocking. | +| F128.Search_projection.PageCursor | search result links + signed encode_page cursor/next | src/msg/plugins/discovery.py:1062 | LinkSet is projected relationship map and PageCursor opaque encoded continuation, not literal field/class names required in every row. | +| F128.Search_projection.LinkSet | search result links + signed encode_page cursor/next | src/msg/plugins/discovery.py:1062 | LinkSet is projected relationship map and PageCursor opaque encoded continuation, not literal field/class names required in every row. | +| F131.Grep_options.include | glob / exclude_glob / before / after | src/msg/plugins/discovery.py:1121 | Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields. | +| F131.Grep_options.exclude | glob / exclude_glob / before / after | src/msg/plugins/discovery.py:1121 | Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields. | +| F131.Grep_options.context_before | glob / exclude_glob / before / after | src/msg/plugins/discovery.py:1121 | Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields. | +| F131.Grep_options.context_after | glob / exclude_glob / before / after | src/msg/plugins/discovery.py:1121 | Existing public schema explicitly bounds before/after0..3; fnmatch filters scope paths after authorization. Do not add duplicate alias fields. | +| F149.request.requester | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.request.title | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.request.description | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.request.due_at | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.request.assignee | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.offer.description | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.offer.capability_hint | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F149.offer.availability | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F150.proposal.patch | content_ref alternative: fixed ResourceRef to proposed replacement content | src/msg/plugins/proposals.py:21 | Design150 patch/content_ref alternatives; implementation supports pinned content_ref. It does not accept raw patch in this operation version; acceptance rechecks target base revision and source read permission. | +| F151.receipt.result_ref | OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields) | src/msg/core/requests.py:52 | Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence. | +| F151.receipt.result_digest | OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields) | src/msg/core/requests.py:52 | Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence. | +| F151.receipt.server_signature | OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields) | src/msg/core/requests.py:52 | Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence. | +| F151.receipt.receipt_proof | OperationResult.resources/output + receipt Signature over canonical result (minus replay/presentation/secret fields) | src/msg/core/requests.py:52 | Receipt facts are the persisted signed OperationResult, not an independently creatable Receipt resource. Canonical payload binds result content; digest may be computed, not an extra stored mutable field. Dedicated /receipts view is separately audited; these mappings do not assert its existence. | +| F152.checkpoint.state_ref | Relation(type=state, target=ResourceRef) projected as state_ref | src/msg/plugins/collaboration_resources.py:113 | Current authorization checked before creating fixed relation; signed record excludes duplicated ref fields. | +| F152.checkpoint.summary | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F152.checkpoint.resume_hint | Same named JSON schema fields in collaboration_records / signed Resource Revision | src/msg/plugins/collaboration_resources.py:23 | Initial profile looked at collaboration.py (handoff/lease); dedicated collaboration_resources defines these fields and validates immutable content. | +| F155.Delivery.DeliveryEnvelope | delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id | src/msg/market/delivery.py:273 | Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority. | +| F155.Delivery.recipient_key_id | delivery_envelopes.body: recipient_key.{key_id,recipient,fingerprint}, ciphertext_ref; delivery manifest.recipient_key_id | src/msg/market/delivery.py:273 | Locked order recipient_key is copied into envelope and validated against buyer/current delivery. Nested key facts replace flattened conceptual fields; no separate authority. | +| F156.DeliveryTarget.endpoint_id | email: endpoint identity + generation/address_snapshot/verified_at | src/msg/market/delivery_targets.py:65 | Single-email endpoint generations bind ownership and revoke/reverify; this is not arbitrary address input or capability URL. | +| F156.DeliveryTarget.address_snapshot | email: endpoint identity + generation/address_snapshot/verified_at | src/msg/market/delivery_targets.py:65 | Single-email endpoint generations bind ownership and revoke/reverify; this is not arbitrary address input or capability URL. | +| F162.ToolSpec.network_policy | ToolSpec.network: NetworkPolicy | src/msg/core/models.py:459 | Registered tool facts, deployment config and current capability network limits are intersected before executing. | +| F162.ToolSpec.input_limit | Input schema URL/name/header maxima + server.max_request_bytes; sandbox packet<=max_request_bytes+65536 | src/msg/workers/sandbox.py:58 | Input is bounded by existing schema/request/sandbox transport limits; no unbounded command string accepted. No tool-local max_input_bytes field currently; concurrency work may make a declared value explicit without weakening global ceiling. | +| F162.ToolSpec.output_limit | ToolSpec.network.max_response_bytes, intersected with deployment and capability | src/msg/core/models.py:363 | NetworkPolicy maximum response bytes provides the output bound; separate duplicated ToolSpec field not needed. | +| F162.ToolSpec.concurrency | No tool-keyed cross-worker concurrency limit in existing claim path | src/msg/workers/effects.py:111 | Confirmed design162 gap: each process serializes its loop, but multiple workers can claim different same-tool jobs; there is no registered cap and atomic running count/admission. recover_recovery assigned bounded cross-worker enforcement. | + +### 同次核对发现的关联路径缺项 + +设计154要求`/@user/receipts/`只读视图。现有签名OperationResult/results是真实回执事实,但SUBJECT_OPERATION_ALIASES未接receipts,Registry无receipt list/get,communication.outbox仅按messages.sender列消息,不能当全部提交回执索引。已报告中央;应投影已有事实,不增加独立回执状态机。 + +快照之后的agent报告:`9807e18`已交中央修Error.message,相关356测试通过;不将它记成cfdb211已合入或已重新验证。 diff --git a/docs/GIT_BOUNDARY_PROGRESS.md b/docs/GIT_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..50cf39d5 --- /dev/null +++ b/docs/GIT_BOUNDARY_PROGRESS.md @@ -0,0 +1,79 @@ +# Git transport boundary progress + +## Scope and coordination + +Issue #163. Branch `fix/git-transport-boundaries-20260929`. Base main +`08c58928ee3f6c6cb350d9e9fba75d072bd78f08`, tree +`8a62150d8273a2c75775872694749cb529835966` includes merged #165. +Coordination: #163 comment5878830664 and #83 comment5878871933. +No changes to parallel #166/#167/#169 executor/storage or #155 recovery branches. + +Main was compared with the inspected source baseline476e64f: only #165's eight +files changed, none of the repository/SSH/HTTP modules under review. The source +blobs remain repositories940a1a1bd987a2d97273734a8c0646097abf3c83, +ssh_gitd6465ce37fd7e4743773942144e91e10a7c847af, and +http_routes04a7de0431f1211893111e887ef87661ef877c0f. + +## Regression-first stage + +Nine new tests cover shared import isolation, actual store/adapter ownership, +one shared publication implementation, shared HTTP-helper ownership, explicit +composition and two existing-behavior characterizations (real Git public reads +with unchanged database/refs, and bounded reference batch parsing). +Production source is unchanged in the first commit. Obtain actual red cloud +JUnit/source evidence before implementation; do not claim a result from intent. + +## Observed and intended boundaries + +NativeGitStore currently owns physical repositories and HTTP/LFS Request/Response +handling; HTTP receive imports guarded_command from ssh_git, which imports the +store back. The planned separation is: + +- NativeGitStore keeps repository/LFS paths, bounded store operations and physical + publication primitives, taking a snapshot of required settings rather than + retaining an Application service locator. Existing operation registration and + bundle worker behavior remain in their current module. +- transports.git_http.GitHTTPAdapter composes a NativeGitStore and owns all HTTP + envelopes, streaming/spooling and signed/Basic protocol adaptation. +- extensions.git_publication owns the existing ReferenceGuard and guarded Git + subprocess lifecycle. SSH and HTTP call this same implementation. Historical + ssh_git imports remain identical compatibility aliases, not duplicate wrappers. +- transports.http_common owns the three existing body/JSON/error helpers and base + headers; HTTP routes and Git HTTP import it, avoiding a new router/adapter cycle. + +These are source ownership changes, not a new Git workflow/authorization engine. +Original capacity limits, request IDs, signed envelopes, current-authority checks, +reference-transaction phases, replay cache, uncertainty reporting, LFS digest and +shared-volume constraints, and bundle job fences remain. No protocol version, +database schema, receipt, real repository migration or deployment change. + +## Cloud acceptance + +The dedicated workflow runs real PostgreSQL/Valkey/Git/git-lfs and the existing +HTTP/SSH/bundle/LFS/read-only and completion-fence tests. The full four-shard exact +node-ID/conformance/build and applicable Recovery safety gates remain required on +the final combined tree. No local project execution, skipped test, weakened +assertion, production credentials, real funds/outbound delivery or recovery promotion. + + +## Implemented extraction and source audit + +NativeGitStore no longer owns any HTTP handler, Request/Response conversion, +body spooler or Application property. It snapshots repository/blob paths and +keeps its original physical store algorithms. GitHTTPAdapter explicitly composes +that store and retains existing HTTP/LFS behavior. ContextVar identities used by +registered operations remain singular; upload semaphores move with HTTP spooling. + +The original ReferenceGuard, hook program, bounded stdin relay and guarded Git +command are unchanged in git_publication. SSH exposes same-object aliases and its +original receive_pack entry. HTTP calls the same shared guard. The bundle worker +algorithm, including current_attempt and transaction fencing, is untouched. + +HTTP helpers/base headers have one owner in http_common; routes and Git HTTP use +it. transports.http and its outer passive/recovery boundary are unchanged. The +assembly audits every moved/retained method, every existing registration and +bundle worker body, all shared publication functions, helper functions/headers, +and route functions. Only settings snapshots, explicit store attribute delegation +and import/adapter selection are permitted differences. Source-only AST evidence +is not a successful project test. Final source and patch are uploaded; one-shot +tooling is removed from the submitted tree before focused/full validation. diff --git a/docs/HOSTING_RUNTIME.md b/docs/HOSTING_RUNTIME.md index 09227028..c63e4845 100644 --- a/docs/HOSTING_RUNTIME.md +++ b/docs/HOSTING_RUNTIME.md @@ -40,7 +40,7 @@ New schema/authorization dependencies require review plus regression coverage be adding a table. Column/row data returned by public HTTP still uses current authority. Do not copy a possibly stale public trust file or recovery marker. Link the public -`trust/root.json` to the installed public trust, keep its directory traversable, and +`trust/root.crt` (or the legacy `trust/root.json`) to the installed public trust, keep its directory traversable, and set `hosting.recovery_marker` to the writer's real `recovery-drill.json` path. The hosting account must be able to stat the marker's parent without reading writer configuration or secrets. The sample marker is `/etc/msgd/recovery-drill.json`. @@ -89,3 +89,18 @@ check new text/binary/index publication under umask 0077 and refusal to alter an existing private installation. Existing transport/CLI checks and full four-shard CI remain required. Evidence and outstanding checks: `HOSTING_RUNTIME_PROGRESS.md`. Green isolated CI is not a production deployment or completion of #80/#84. + +### Resource path migration table + +Moved website URLs use the same current website and published-file authorization +before returning a `no-store` redirect. The read-only role also needs `SELECT` on +`public.resource_path_aliases`. After the installation owner applies the metadata +schema update, an existing deployment can add only that read privilege: + +```sql +GRANT SELECT ON public.resource_path_aliases TO msgd_hosting; +``` + +The hosting process does not grant privileges or migrate tables. Missing access +fails startup with `hosting_installation_stale`; it must not start serving with a +partially upgraded role. The initial role template includes this grant. diff --git a/docs/INTEGRATION_PROGRESS_20260929.md b/docs/INTEGRATION_PROGRESS_20260929.md new file mode 100644 index 00000000..186bf4cd --- /dev/null +++ b/docs/INTEGRATION_PROGRESS_20260929.md @@ -0,0 +1,37 @@ +# Issue integration notes — 2026-09-29 + +All project code execution, tests and builds run in GitHub Actions. Local work is source/Git/AST inspection and independent artifact parsing only. No production deployment, Root/PIN/key read, real funds/outbound delivery or restore promotion. + +## Completed on main + +- #156/#157: #167 merged at 261c8816; parallel flow independently audited 1894 real tests and conformance8 before closing both architecture issues. #169 superseded with its distinct regressions retained. +- #162: #172 originally merged into an old feature branch, not main. Promotion #175 fixed the target and merged as **edbfbae7648529c6b08a2e81a1a10bb349f6b50d**, tree **ebe1facca432803854d0c04aaa93519a589ff93f**. Independently inspected focused122 plus full1901 (465/496/483/457), exact node-ID union, no failure/error/skip/duplicate/missing; conformance8, measured token budget and wheel/sdist passed. Full36551495771; focused36551495692. Hashes/source identity: #175 final body and #162 comment5888054193. Original token RED/history: TOKEN_DELIVERY_BOUNDARY_PROGRESS.md. +- #161: #173 merged as **04ffad5834dcc959f5ec59696bb264c3d527990b**, tree **f377ce7dbdb53e0480552906a7d7529eee67171f**. Actual merged tree matches the verified combination. Focused hosting41 and same-head token122; full **36553079854 = 1935** (475/506/492/462), exact node-ID union, zero failure/error/skip/duplicate/missing. Conformance8, measured token budget, wheel/sdist and contracts gate passed. Original #174 independent UID/permission tests, its nine-line owner guard and real history are retained; only the duplicate product PR was closed. Exact final proof: #173 body and #161 comment5888274187. Historical RED/GREEN and deployment model: HOSTING_RUNTIME_PROGRESS.md, HOSTING_AUTHORITY_PROGRESS.md, HOSTING_RUNTIME.md. + +No source test result is represented as production acceptance. Main after these merges is 04ffad5834dcc959f5ec59696bb264c3d527990b. + +## Client integration — #171 / #158 + +Previous client head **730780abf1b25b91a972dc4a9cccf0cf17408728**, tree **758e86821ee31f59afbed22935207fad6cd5fb11**. Its old PR body referred to an earlier head. Full/client/architecture/storage/recovery runs report success. Independently downloaded full shard0 artifact **11000502466**, SHA256 **9ea50d3f09a9143e860ad6bf860e0483193b536a76e9a46dd52c488f7d6831c8**, and inspected the source distribution. Other old full shards were not independently audited here; old status does not substitute for the new combined gate. + +Combined head **6c1ea44c701439f2b37d490c37f448e12dda122c**, tree **9e5f82de1b77e8a659c61bff607c8c2e2c7c722e**, preserves real client730780 and main04ffad5 parents. Only two runtime overlaps needed combination: daemon retains missing-server-dependency diagnostics and explicit read-only hosting; storage.git imports the neutral atomic helper while retaining group-read publication. Other client delta files reuse original Git blobs. TokenDelivery, current schema/grant checks and ownership guard were not replaced. Non-force push to the existing #171 branch succeeded. + +The dev extra already includes server, so existing `.[dev]` installation is valid. uv_build intentionally includes a compatibility `pyproject.toml.orig` in its sdist; it is not a tracked leftover. Repository config reuses original blob43d0c21a, not normalized sdist TOML. No packaging exclusion was introduced. + +Pending on this exact head: fresh minimal wheel install outside checkout without PYTHONPATH/server packages, four transports, helper-identity tests, hosting/token regressions and full four-shard/node-ID/conformance/build. Record proof before closing #158. Original RED history remains in CLIENT_BOUNDARY_PROGRESS.md. The combined head is not yet claimed GREEN. + +## Market integration — #168 / #159 + +Previous market head **65b7330d248f4af96ac9ecd1f1fad3fdc6f59782**, tree **4c76f69dc2ec93fed09e4748e3aa5d846bcf35f0**. Original source base476e64f predates #167. Refreshed its current ref and comments before changing the branch. Historical RED and original focused128 GREEN remain in MARKET_BOUNDARY_PROGRESS.md and #168 comment5879093047; do not replace new-head verification with them. + +Independently downloaded old full36484195530 shard0 artifact10998743543, ZIP SHA256 **7087df67dfaec8bdcf4e8ffa2e6fdc62376fa150b34d9cd2971b11406ee69028**, and inspected the source archive without running it. All **23 moved functions** were independently compared with current-main AST after only symbol/import normalization: unchanged. All remaining entrypoint handlers and other state machines also preserve their non-import AST. The internal escrow guard retains a single ledger owner and identity, not a remotely grantable capability. + +This integration retains market65b733 and client6c1ea as real parents. Reuse the 19 unchanged market delta files as existing Git blobs. The only combination is market.policy: preserve the current core.query.QuerySession import and load_policy annotation while changing its delivery import to the neutral owner. No published version, SQL, signature, receipt byte, transaction or settlement state machine is rewritten. Existing client/hosting/token/core work and all test/workflow inventories remain present. + +After pushing the existing market branch, run the original boundary10 plus published-contract118 regressions and full exact-node-ID/conformance/build/recovery gates on this combination. Merge #171 first and #168 second, each with an expected-head guard and exact resulting-tree verification. No final GREEN or merge is claimed by this source note; final proof belongs in the PR record. Coordination: #168 comment5888426916 and #83. + +## Remaining queues + +Refresh #170 Git and #155 recovery refs/comments before integrating; do not overwrite parallel work. Never count a feature-branch merge as shipping on main. Merge serially with expected-head checks and verify resulting trees. + +Keep #64/#65/#68/#69/#70/#80/#84 and broad #71/#72/#85 acceptance issues open until their individual remaining criteria are met. No production permission migration, real Root/PIN/private-key operation, live funds/outbound channel, destructive backup or recovery promotion has been performed here. diff --git a/docs/INTEGRATION_REGRESSION_PROGRESS.md b/docs/INTEGRATION_REGRESSION_PROGRESS.md new file mode 100644 index 00000000..2327f4d2 --- /dev/null +++ b/docs/INTEGRATION_REGRESSION_PROGRESS.md @@ -0,0 +1,103 @@ +# Integration regression closure — progress + +Refs #78, #80, #81, #82, #83, #85. Integration entry: PR #176. +Latest narrow follow-up: PR #180. Live verification, exact final head and merge +state: #176 comment 5890775291 and #180. This note is a timestamped handoff, +not a second completion authority or a claim of production acceptance. + +## 2026-09-29 13:05Z: current repair + +Input integration head `de3cd872b71e062d6141916b11ea0bdfa28adca6`. +Actual previous cloud checkout `9b727c16b893959e358811e543233df1608abb7b`, +tree `4936af96eb805c4f90aad002008376425a2effa4`. + +Independently downloaded all four artifacts of run **36564681470**, checked +provider SHA256, each JUnit msg.nodeid against selected/all, common collection, +exact disjoint union and no duplicates: **2407 unique cases, 6 failures, +0 errors, 0 skipped**. Four effect-order failures and two signed descending +read_query@1/@3 failures; the earlier webhook-overlap failures are absent. + +| Shard | Artifact | ZIP SHA256 | +| --- | --- | --- | +| 0 | 11031896647 | fd8c2110ab4240246af4d3b323b5676b7458af5016b6ed18c940010772a691e0 | +| 1 | 11031762212 | 95bf9181ea1c39acd3d9ea62edc0d194104e116d93a699b38c01a53d704f0dbb | +| 2 | 11031389353 | 7a00d14bea7a34c8d1439f936e8d78813c9d76f973fec0e3e518e5ffd4c52a1d | +| 3 | 11031559533 | d7c9c081408ad09b01be264e3e3ac6df4d84f020a4bab4d9f2d2f3eca5ddc5df | + +### Submitted implementation + +Source commit `12e829a6a7b90c279e9b15cd74cece63978ca387`, tree +`07449e7ae96399ebcbcd1ae33a53de3d9ab5d8f5`, in #180 based on #176: + +- Remove artificial initial empty-string/U+FFFF keyset endpoints. No seek on + the first page; later scans/cursors keep actual SQL keys. Ordering, principal, + query/snapshot binding, authorization and budgets are unchanged. +- Retain the real runtime-generation SQL read in the effect test. Precisely + forbid every other SQL query and executor call, check each request actually + reads generation, and also test stale generations fail closed. Recovery + middleware is not weakened, bypassed or mocked to succeed. +- Add list/search/read_query@1/@2/@3 checks for all three sort keys in both + directions, Unicode names, exact database order, nonoverlapping continuation + and empty results. Existing signed four-transport tests remain unchanged. + +Source-only preparation run 36571871534 published just one reviewed source +blob. Artifact11035120921 ZIP SHA256 +`02f5421eb68c126198dd1fe0f6936918f339470d9350fa201846a5ed0434d47d` was checked +independently, including byte-for-byte source equality. Source blob +`985c0c222220bfc23b0e28b4e54871bef5e7885d`; SHA256 +`2cc2b605e6590d61e0f65e63f613f75e1488b2d40c77224d1b77b4012694ffc0`. +The auxiliary workflow neither ran project code nor updated a branch and is +absent from the product tree. + +### Verification in progress; no GREEN claim yet + +Initial full run 36572191345 and integration run 36572191005 were triggered. +Focused run 36572191224 failed in the newly added collation diagnostic before +pytest: lc_collate is not a supported current_setting parameter. The follow-up +reads pg_database.datcollate for current_database(), as documented in the +PostgreSQL 16 system catalog, and fixes the health probe's database name. +Runtime source and test assertions are unchanged by that CI correction. +New runs must identify their actual final head/tree; older partial or successful +runs do not satisfy the final combined gate. + +## Preserved earlier history + +Earlier integration base `45470b5a46bccfc4f12ef27acccecc2f3bafce47`, tree +`e75e7cf511a89d72bfdccf5ec39567824b569275`. PR #179 retained both histories +and was merged into #176 as de3cd872; no duplicate implementations are needed: + +- #177 head `769c124c83a01a648d3d30ef5d4a19fda98bedb6`: bounded transfer + status cursors. Historical focused run 36561282522: 24/24, zero failures, + errors or skips. See its original evidence note for full provenance. +- #178 head `517f7e296fa7ef504d2066980454654ed0634598`: positive integer + client chunk sizes. Historical RED36561926956: 14 intended failures, + 9 passes. Original transfer/client evidence remains authoritative for those + narrow changes, not current integrated acceptance. + +Earlier full run36559785404 had 2259 unique cases, four failures, no errors or +skips: one read-effect-before-lookup case and three webhook-overlap cases. +Exact manifests/JUnit union and provider hashes were independently checked: + +| Shard | Artifact | ZIP SHA256 | +| --- | --- | --- | +| 0 | 11030251615 | e30f1fcbceeea0143e8942228db1bab8257327e3cb2cbd5d4606825b65a2b563 | +| 1 | 11030037639 | 37b09fd129f486396615d627acc83cd0696023167826ee1601ef3d3706c66468 | +| 2 | 11029802392 | 18fbb03c4531e3128ffe1e0de45602aede0532132406c1b96388ed5c934b7df2 | +| 3 | 11030251180 | e2d4990543cbf937af745f0b9429c260c024a64dee51065561a652e2171c7370 | + +That historical run was RED, with later conformance/build not claimed complete. +#179 moved read-effect checks before resource lookups and corrected literal +webhook prefix matching without removing unique constraints or swallowing +conflicts. The current combined six failures above supersede that old triage. + +## Unchanged completion and execution boundaries + +Require final focused suites, all four shards, exact disjoint node-ID coverage, +conformance/actual tokenizer, wheel/sdist checks and applicable specialist gates. +No removed tests, skips, relaxed authentication or substituted old-head greens. +All project execution happens in cloud CI; local work only edits source text +and independently reads/hashes artifacts. + +#78/#80/#81/#82/#83/#85 broad acceptance and #64/#65/#68–70/#84 field evidence +remain open. No production deployment, Root/PIN access, money movement, real +outbound delivery, backup/key destruction or recovery promotion is performed. diff --git a/docs/LEGACY_GIT_MIGRATION.md b/docs/LEGACY_GIT_MIGRATION.md new file mode 100644 index 00000000..1b21c8cf --- /dev/null +++ b/docs/LEGACY_GIT_MIGRATION.md @@ -0,0 +1,93 @@ +# Legacy Git preservation and private import + +The old service's bare repositories are separate assets from its SQLite posts. +Neither SQLite preservation nor the content importer imports them. This workflow +preserves reachable Git history and imports it into a private v4 repository without +copying source hooks, repository configuration, filesystem credentials or authority. + +## Fixed-ref capture + +Use the filename-only source inventory to prepare a private JSON descriptor with +one `bare_repo_paths` entry. The source path stays in that protected file. + +```sh +python -m msg.storage.legacy_git_capture \ + --ssh-host APPROVED_SSH_ALIAS \ + --private-source-descriptor /protected/source.json \ + --protected-target /protected/new-capture +``` + +The target directory must already exist with mode `0700`; output bundle and manifest +are created exclusively with mode `0600`. The source is accessed through authenticated +SSH and `sudo -n python3`, using read-only Git commands with optional locks, hooks, +fsmonitor, global/system config and replacement objects disabled. No source file is +created and no service is changed. The remote process enumerates all refs, rejects +anything outside heads/tags or an empty set, captures their explicit names, streams +the bundle directly to the protected local file, and verifies that refs and symbolic +HEAD did not change. Local bundle headers must exactly match the captured ref/OID +map. A concurrent change or unsupported ref fails rather than producing an accepted +manifest. + +Local verification initializes an isolated verification repository under protected +storage, runs `git bundle verify`, imports objects and refs, and runs full strict +`git fsck`. The manifest binds bundle SHA-256, ref-map digest and default branch. +Output contains only counts and digests, never ref names, filenames or source text. +The scope is **all objects reachable from the captured heads/tags**, not reflogs, +unreachable objects, hooks, config, external LFS objects or separate submodule repos. +Preserve the old repository until explicit retention/retirement approval. + +## Root-approved private import + +`git_approval` constructs a payload binding bundle digest, ref-map digest, default +branch, destination service, parent ID/generation, existing registered operator, +new repository name and expiry within 24 hours. Sign its canonical JSON with the +existing destination Root and purpose `legacy-git-private-import-v1`. Store it as an +envelope containing `approval` and wire-format `signature`. + +```sh +python -m msg.storage.legacy_git_import \ + --config /isolated/etc \ + --bundle /protected/repository.bundle \ + --signed-approval /protected/git-approval.json +``` + +Loading config loads that destination installation and its normal release-source +synchronization; it is not a read-only preflight. The approved parent must be owned +by the operator with mode `0700`. The new repo is `0600`; the native store never creates a +public Git-daemon export marker for this import. Old author strings and Git signatures remain in +unchanged Git objects, without asserting their validity as new service identities. +All branch/tag object IDs and the default branch must match the signed mapping. + +The importer reuses `NativeGitStore.create`, `import_bundle` and `update_refs` inside +the normal metadata writer transaction, checks repository capacity and live recovery +state/write policy, and records the Root approval and ref map. Only a new, unique +repository is allowed. A failure rolls back metadata and removes that new physical +repository, including already-published refs; existing repositories are never removed +or overwritten. No old tasks, hooks/config, credentials or grants are activated. + +Limits are 32 MiB per bundle, 128 heads/tags and 100,000 reachable objects. SHA-256 +Git object format is unsupported by the current native store; SHA-1 object IDs are +preserved. LFS pointers and gitlink/submodule references are counted and cause import +to fail until a separate external-object migration is supplied, rather than claiming +complete preservation from a Git-only bundle. A signed owner can list refs through +the normal `git.refs` operation. Anonymous access is denied. Existing native clone +HTTP is public-only, so a private import is not an implicitly published clone URL. + +## Test-only rehearsal + +```sh +python -m msg.storage.legacy_git_rehearsal \ + --bundle /protected/repository.bundle \ + --manifest /protected/manifest.json \ + --protected-target /protected/new-rehearsal +``` + +This creates an isolated PostgreSQL cluster with no TCP listener, a new test-only +Root and registered operator, then imports the actual protected bundle into a private +namespace. Those identities never approve a production migration. Every data artifact +stays under the private target directory; `/tmp` targets are rejected. The final +`summary.json` (`0600`) contains counts/digests and checks for source immutability, +identical object IDs/refs, authenticated access, anonymous denial, unchanged credential/ +certificate/job counts and cluster shutdown. The cluster is stopped and retained +privately for inspection. A successful rehearsal proves mechanical compatibility, +not production ownership mapping, publication authorization or deployment readiness. diff --git a/docs/LEGACY_IDENTITY_ARCHIVE.md b/docs/LEGACY_IDENTITY_ARCHIVE.md new file mode 100644 index 00000000..9dfbc66d --- /dev/null +++ b/docs/LEGACY_IDENTITY_ARCHIVE.md @@ -0,0 +1,23 @@ +# SQLite 0.22.1 historical identity records + +`msg.storage.legacy_identity_plan` inventories a protected offline snapshot using explicit public-column projections. It never selects old token hashes, custody ciphertext/key nonces, keystore blobs or webhook secrets. It hashes the snapshot before and after reading. No old service code is executed and no live database is modified. + +The post create/edit signature format and certificate canonicalization were compared to the installed public `msgd/crypto.py`, SHA-256 `235d2736927dbdf2a0d37cd28d78b4b46324b6aba902461f9eca5b1ec22fc83e`, an exact match to `e12eea32764656eec82452fee111e5b4fb6d8a42:src/msgd/crypto.py`. Signature verification proves only that the reconstructed historical bytes were signed by that public key. It does not establish current owner access or v4 authority. Edited/deleted historical content or lost attachment manifests may make an old signature impossible to reconstruct; an invalid result must not be silently upgraded to verified. + +``` +python -m msg.storage.legacy_identity_plan --snapshot /protected/old.sqlite \ + --sha256 EXPECTED_HASH --output /protected/identity-plan.json \ + --root-public-key /protected/independently-verified-old-root.pub +``` + +Root public-key input is optional. Without an explicit anchor, the certificate chain ends with `root_anchor_unknown`. An anchor observed on the same old server proves consistency with that server, not independent continuity; the operator must establish its provenance separately. Revoked, expired, not-yet-valid, malformed, missing/cyclic-chain and invalid-signature certificates remain classified separately. `signature_chain_verified` does not validate inherited grant/delegation policy and never enables any authority. Revocation state is the snapshot's observed state, not an independent current checkpoint. + +Plans contain old identity identifiers and public-key fingerprints. Output is exclusively created with mode 0600; stdout contains only counts and digest. Keep the plan in the protected migration directory. Anonymous and conflicting-key records are explicit, and unproven IDs remain unproven. + +`import_identity_records` accepts a destination Root signature over `approval_payload`: plan digest, target service, private parent plus generation, registered operator, explicit complete mapping, and expiry (at most 24 hours). Each old ID maps either to null (retain an unmapped record) or an existing registered subject. That mapping is a Root-selected historical association, **not private-key ownership proof**. + +Import creates only private ordinary files under an operator-owned mode-0700 parent. It does not create Subject, credential, identity key, certificate or job records. Files have mode 0600 and do not allow login. No old tokens, CA grants, SSH permissions or custody secrets are restored. The approval/signature and report are persisted transactionally; replay and stale parent generations are rejected. + +The operator must decide the destination service/operator/private parent and every mapping, review invalid or unknown evidence, establish the old Root public anchor and fresh independent revocation checkpoint, and sign the exact plan digest. New login enrollment, ownership proof, grant issuance and any public publication require their own current protocol actions. Neither a valid old signature nor this Root archive approval performs those actions. + +This API is independent of `legacy_resource_import`; its records can retain previously unknown authors without manufacturing active identities. Content import still uses its own Root-approved historical mapping and must not treat these ordinary file IDs as registered subjects. diff --git a/docs/LEGACY_SQLITE_PRESERVATION.md b/docs/LEGACY_SQLITE_PRESERVATION.md new file mode 100644 index 00000000..a6a63f63 --- /dev/null +++ b/docs/LEGACY_SQLITE_PRESERVATION.md @@ -0,0 +1,191 @@ +# Legacy SQLite 0.22.1 offline preservation + +The existing archczy service uses SQLite, unlike the earlier rewrite's PostgreSQL +market migration. This tool provides a bounded offline preflight and a transactional +**inert preservation import**. Its output is not a v4 restore archive and must never +be passed to the daemon. It does not complete the deployment migration. + +## Source contract + +`src/msg/data/legacy/sqlite-0221.json` records all 33 observed table layouts, including +`sqlite_sequence`, historical additive columns and column order. The synthetic SQL +fixture reproduces the read-only schema inventory of 2026-09-29, without user rows. +Installed `store.py` matches historical revision +`e12eea32764656eec82452fee111e5b4fb6d8a42` with SHA-256 +`5fa50265c1b0c9674d7464aadd1cbaaf7d76d41c2f50adededa5fb9bf13871b4`. +The contract also pins source files at `ccf451c6a3097be5849d666422f70b04b81f1710` +for reference. A matching individual source file does not identify a whole deployed +package. Synthetic acceptance is not protected live-snapshot acceptance. + +Historical `crypto.request_payload` binds action-specific fields; certificate +signatures use `certificate_payload(body)`. Stored post columns are not necessarily +the complete original signed HTTP request. Preservation retains every original +signature field and certificate body verbatim; it neither reconstructs missing +signed envelopes nor labels legacy signatures as valid v4 signatures. + +## Offline use + +First obtain a separately protected, consistent SQLite backup with its manifest, +source lineage and independent current revocation checkpoint. Do not copy only the +main database while a live WAL exists. This tool never creates that backup and +refuses source WAL/SHM/journal companions. Operate on an isolated copy with no writer. + +```sh +python -m msg.storage.legacy_sqlite /protected/offline-copy.db --sha256 MANIFEST_SHA256 +python -m msg.storage.legacy_sqlite /protected/offline-copy.db --sha256 MANIFEST_SHA256 --destination /protected/inert-preservation.db +``` + +Preflight opens SQLite read-only with query-only mode and a 60-second query budget. +It rejects unknown table/column layouts, views/triggers, unsupported user versions, +invalid attachment lengths/digests, oversized inputs and digest changes. Limits are +256 MiB source, one million rows, 16 MiB cells and 1 GiB encoded rows. JSON output +contains counts, digests and column names, never row values. Passing a digest binds +the input; it does not authenticate who produced that digest. + +The destination is created exclusively with mode 0600. Every original column value +is kept in a typed row envelope: decimal integers, hexadecimal floating values, +unchanged text, base64 blobs and explicit nulls. Old post/archive IDs, reply targets, +deleted/hidden flags, nonce records, certificate/revocation rows, opaque custody and +keystore ciphertext, and dormant external queues all remain present. Table row +hashes use length-prefixed canonical JSON envelopes in the exact source scan order. +The manifest records the source and schema contract digests. Failed import rolls +back and removes the new partial destination; an existing destination is refused. +Treat this archive as sensitive as the original database, including legacy token +hashes, ciphertext and callback data. Use protected storage with equivalent retention. + +## Gates before an active v4 import + +The preservation database contains only `legacy_rows` and `provenance`; it creates +no v4 identities, resources, credentials, CAS references or external jobs. + +An active converter still requires an explicit Root-approved identity mapping and +revocation policy, a way to represent legacy signatures without asserting new +signature validity, stable old URL/ID/reply/archive mapping, custody compatibility +or reenrollment, and attachment CAS/reference validation. Legacy SSH keys and +certificate grants cannot automatically gain new authority. Queued WebSub/webhook +work cannot be replayed by default. Filesystem Git repositories need an independent +protected inventory and ownership/ref mapping. Installed source provenance, the +protected snapshot, isolated conversion verification and rollback rehearsal remain +mandatory deployment evidence. Retain the old database and preservation archive +until those gates and an explicit backup-retirement decision are satisfied. + +## Import usable content into isolated v4 PostgreSQL + +`msg.storage.legacy_resource_import` implements the next stage: old boards become +private topics, post bodies become normal posts, and attachment bytes enter the +normal Git/CAS revision store. Normal `create_resource`/`revise_resource` and the +PostgreSQL transaction writer lock apply, including CAS pin rollback compensation. +This stage requires an already provisioned, isolated v4 installation. It does not +provision Root, activate legacy identities, or run the old service. + +The operator first creates an empty topic owned by an existing registered v4 +operator with mode `0700`. `identity_requirements(snapshot, sha256, mapping)` reports +required/missing/unexpected author mapping counts and a digest without exposing +author IDs, post bodies or keys. The rehearsal explicitly requests mapping keys +only in private process memory. +Every legacy post author requires an explicit mapping to an existing registered v4 +identity; anonymous posts use the explicit key `__anonymous__`. Mapping records +historical attribution only: all imported resources remain owned by the chosen +operator, and new revisions identify that importing operator. No legacy identity +receives credentials, membership, ownership or certificate grants through this map. + +Use `approval_payload` to construct the approval. It binds the exact source digest, +destination service, target topic ID and generation, operator, full identity map, +private visibility, disabled legacy authority/jobs, and expiry within 24 hours. +The existing destination Root must sign its canonical JSON with purpose +`legacy-sqlite-content-import-v1`. `import_content` verifies that signature against +the Root public key already pinned by the installation. An envelope is JSON with +`approval` (payload) and `signature` (the normal wire-format Signature). The importer +never reads or generates a Root private key and never chooses a new Root. + +```sh +python -m msg.storage.legacy_resource_import \ + --config /isolated/etc \ + --snapshot /protected/offline-copy.db \ + --signed-approval /protected/root-approved-content-import.json +``` + +The command loads the destination installation (including normal release-source +synchronization), then imports atomically. Use only the isolated target config; +loading a production config is not a preflight. Existing imports, nonempty target +topics, changed target generations, incomplete maps, missing board/post references, +ambiguous reply targets and Root-signature mismatches fail closed. Recovery quarantine +is respected. Each imported board is `0700`, has closed membership and only the +explicit operator as administrator; posts, attachments and provenance records are +`0600`. No prior public visibility is restored automatically. + +Legacy signatures, title, timestamps, actors and deletion/hidden/archive claims are +stored as separate private JSON provenance resources. A setting named +`legacy-provenance:` locates that record and the resolved reply +target. These legacy signatures are explicitly unverified; new import revisions are +unsigned and carry an operation provenance digest. The tool does not manufacture an +original request envelope from incomplete old columns. Original bodies are preserved +without embedding historical metadata into their text. Attachment relations are +normal v4 revision relations, and old active-post tags are retained. + +`legacy-import:` stores counts, approval digest and old-to-new URL +mapping. Archived posts have separate retained mappings so they cannot overwrite +live posts. This is a mapping artifact, **not a public HTTP redirect installation**. +The report records all source table counts so excluded identity/certificate/queue +state remains visible as an outstanding migration scope. Keep the inert preservation +archive alongside this import; it retains all remaining legacy tables and columns. + +Still required before switching the old service: confirm real protected-snapshot +acceptance, review the resulting content and archive/reply presentation, install and +test authorized URL redirects, decide visibility/ownership publication, migrate or +reenroll identities/custody, enforce independent current revocations, import protected +Git repository inventories, and rehearse deployment rollback. Source-synthetic tests +of this importer do not establish those host acceptance results. + +### Test-only rehearsal with a real protected snapshot + +`python -m msg.storage.legacy_rehearsal --snapshot /protected/offline.db +--sha256 MANIFEST_SHA256 --protected-target /protected/new-run` creates a disposable +local PostgreSQL cluster listening only on a private Unix socket. Its fresh Root, +registered importer, and many-to-one author mapping are explicitly **test-only**. +They cannot approve production imports against another pinned Root/service. This +exercises real source compatibility without deciding production identity continuity. +The target parent must already be private (`0700`), the input private (`0600`), and +the target must be new and outside temporary directories. A short target path is +needed for the PostgreSQL socket. All resulting database/CAS/config data stays under +the protected target; the stopped cluster is retained for controlled examination. + +Only counts, source digest, invariant checks and stable error codes are emitted. +`summary.json` is mode `0600`; exceptions containing database row content are not +printed. The rehearsal compares source digests/table summaries before and after and +asserts credential/certificate/job counts do not change during content import. +It stops the cluster before reporting completion. A failed run is also stopped and +retained privately, with `result: failed`; it is never production acceptance. + +Legacy board names reserved by v4 views are assigned deterministic safe names; +original board URL mappings are retained. Old post URL resolution accepts both the +global ID and board sequence, choosing the lowest global ID on a collision exactly +as the installed historical `find_in_board` implementation did. `/raw`, `/meta`, +and active `/file/ID` aliases are recorded too; their eventual HTTP presentation +still needs an explicitly authorized adapter, rather than a silent publication. + +### Explicit HTTP compatibility namespace + +Imported aliases are available at `/_legacy//`; for +example `/_legacy//main/17/raw`. This does not replace current `/main/17` +routing. Only a recorded private content import supplies this namespace. A target +must still belong to its approved migration parent. Moving it outside that subtree +makes the alias unavailable. + +GET and HEAD reuse normal discovery authorization and return a `308` only after +checking the currently authorized resource ID against the mapped ID. Signed +`X-Msg-Request` packets identify the stable **new resource ID** and matching read +operation/view; the transport never rewrites a signed request. After authorization, +the mapping and namespace are checked again before selecting the current canonical +path. Redirects carry `Cache-Control: no-store`, the source digest, and an explicit +`unverified-historical-claim` signature label. Denied requests disclose no Location, +Link or ETag. Writes through this namespace are rejected. + +A successful post redirect links to the same explicit alias with `/provenance`. +That endpoint independently authorizes its private provenance resource and redirects +to its JSON representation; permission to read a post does not grant permission to +read its provenance. The destination's actual stored signature fields are preserved +there without being presented as a valid new-format signature. Old `/raw`, `/meta` +and `/file/ID` views are mapped to the corresponding native read representations. +Production hostname switching, publishing old unprefixed URLs, identity migration +and broader visibility remain separate authorized deployment work. diff --git a/docs/MARKET_BOUNDARY_PROGRESS.md b/docs/MARKET_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..cb88a4b9 --- /dev/null +++ b/docs/MARKET_BOUNDARY_PROGRESS.md @@ -0,0 +1,76 @@ +# Market boundary progress + +## Scope and coordination + +Issue #159, PR #168; references #85/#83, not completion of #71/#72 or production acceptance. +Base main: `476e64f04ed1d9c7dcab4a41c4f3cdb72faad6a7`, tree +`40ca207a3c49be347120e058e47ed328ec4e3098`. Branch: `fix/market-boundaries-20260929`. +Coordination: #83 comments5878359290 and5878591036; #159 comment5878570249. + +This change does not modify #165/#167 core/tool/executor ownership, #155 recovery, +or #166 metadata sessions. No production, real funds, Root/PIN or outward delivery. + +## Verified regression-first evidence + +Red run **36483360971**, job109134178838, tested exact commit +`8692ad6c0056e793bbe7b0efc9f5fd0b08ab89c8` / tree +`907f51806164625749e57bf754713ae9f365f9b6`: **7 failed, 3 passed, +0 errors, 0 skipped**. All failures were new ownership/import assertions. +The real PostgreSQL buyer/seller privacy, forged-escrow zero-write guard, +and signed-receipt byte preservation/idempotent replay tests passed. + +Artifact10998081065 was independently downloaded and hashed; source.json and JUnit +were read. SHA-256: `07f566e15500142c37223782d5cc10086bb9e92a967e5a204c52c374abae3600`. +No project tests ran locally. + +## Implemented ownership + +Before: market use cases imported records/catalog/posting/managed-delivery from +`plugins.orders/store/money/delivery`, including function-local imports, while +those entrypoints depended on market escrow and policies. + +After: + +- `market.order_records` owns signed subject/viewer checks, order IDs, authorized + record lookup and explicitly legacy-compatible projections. +- `market.catalog` owns existing authorized listing/body/package reads, not + version-specific schemas, defaults or registration. +- `market.ledger` owns policy constants, account requirements and the single + posting implementation. It alone defines the existing `_ESCROW_WRITE` object; + escrow re-exports that same object. It is not a public/network credential. +- `market.managed_delivery` owns existing bounded managed-package verification, + preparation and read helpers; it does not replace manual/service/arbitration flows. + +Old plugin paths re-export the same function objects, never wrappers or copies. +Existing market use cases and versioned handlers import the neutral owners. +The duplicate store subject check is the same function as the order subject check. +No new Manager, Factory, Repository, ORM, ledger or escrow state machine exists. + +## Verified source assembly and review + +Cloud assembly **36483781552** succeeded from input +`d0d7f4f61f138b0ced266f2324f4a90c1689b6b5`. Output commit +`f92390fe8f54fbf63fe21d770f37d6fdbb56190e`, tree +`f3f8a2c20c031e0fdf0e1129b2123e7350e7cfaa`. + +Its audit checks exact input blobs and all **23** moved functions after identifier-only +renaming, every remaining handler/schema, and the complete existing market state +machines after excluding import statements. The only guard change is its owner; +its identity check and the checked escrow-release call path remain unchanged. +SQL strings, account locks, signatures, IDs, receipt purposes and transactions were +not rewritten. Shared posting still cannot commit or grant remote Root issuance. + +Artifact10997307359 was downloaded, its audit/patch/source read, and its SHA-256 +verified as `2dbcbf167086480216596757ffd32b42fa8ccc61cdea3d767e2862d1aba3ae74`. +The contained patch independently hashes to +`0f3ee2de9d5cb04c50ebf4b573c44652fa7a50a225f3e006f50944432e9173c9`. +The two one-shot assembly files are removed from the submitted tree. + +## Final-head gates + +Source review/assembly is not green test evidence. The new boundary contracts, +existing v1/v2/v3 checkout, clearing, Bounty, redemption and recovery regressions, +and complete four-shard exact-node-ID/conformance/build gate must run on the final +synchronized head. No skip, weakened assertion or published-version migration. +Actual final results and merge SHA are recorded in PR #168 and coordination #83; +this source commit does not predict those outcomes or close umbrella issues. diff --git a/docs/MARKET_CLEARING.md b/docs/MARKET_CLEARING.md index 663ad383..584c0ed9 100644 --- a/docs/MARKET_CLEARING.md +++ b/docs/MARKET_CLEARING.md @@ -35,6 +35,8 @@ Root `money offer set` accepts only the installed resource/provider/unit tuple, ## Bounty +Execution and public projections compare the accounting row with the immutable Listing terms: publisher, reward, claim limits, verifier, eligibility and expiry must agree. A mixed restore fails with `bounty_contract_mismatch` before consuming a nonce or moving money. Top-ups and pause/claim state remain separate mutable accounting facts. `doctor` performs the same comparison through a read-only content reader. + Activation first moves the publisher's budget to a keyless BountyEscrow. `signature_pop_v1` challenges bind listing, claimant, random nonce, issue/expiry times and verifier version. TTL is **300 seconds**. Both issuance and claim require the claimant's current primary IdentityKey. Rotating/revoking it invalidates pending proofs; a signature from a different key cannot redeem one. A paid Claim, nonce consumption, escrow transfer, signed receipt, Event and minimal Inbox reference are one transaction. The publisher need not stay online. Failed proofs do not consume budget. Concurrent last-budget claims, per-subject limits, close/refund and repeated request IDs are serialized. Top-up cannot silently resume a publisher pause or a reached maximum claim count. `bounty.get` and `store.listing_get` share the same current-state projection; immutable listing revisions retain their historical values separately. @@ -83,8 +85,8 @@ These commands use the normal signed client and Registry schemas, not a second A Operation name alone is insufficient authority: private money/market operations also check the credential's scope against the represented account, including cached replays. New operations do not automatically extend old signed CA grants or finite credential ceilings; explicit authority renewal may be needed. Doctor reports authority snapshot gaps instead of upgrading signatures silently. -`doctor`'s read-only `market_clearing` verifies conservation, nonnegative balances, signed ledger receipts, escrow/source consistency, order history, purchase entitlements and bounty nonce/Claim linkage. Default zero supply/no banks/no offers is separately visible. `selftest`'s `market_e2e` is restricted to a fresh isolated instance and Test Root: parse and execute mint 20 and **bank fund 20**, escrow a 10 MSG bounty, pay a current-key PoP Claim, publish the fixed 5 MSG bundle, then automatically deliver and settle. Its final balances are **buyer 5, bank 15, every escrow 0, supply 20 MSG**. The manifest is `msg.lmm.best store selftest`; `hello.txt` is exactly `delivery-ok\n`. It sends no external mail and does not claim SMTP acceptance testing. +`doctor`'s read-only `market_clearing` verifies conservation, nonnegative balances, signed ledger receipts, escrow/source consistency, order history, purchase entitlements and bounty nonce/Claim linkage. Default zero supply/no banks/no offers is separately visible. `selftest`'s `market_e2e` is restricted to a fresh isolated instance and Test Root: parse and execute mint 20 and **bank fund 20**, escrow a 10 MSG bounty, pay a current-key PoP Claim, publish the fixed 5 MSG bundle, then automatically deliver under `orders.buy@4`. Before acceptance it verifies buyer 5, bank 10 and order escrow 5 MSG. The buyer reads and verifies the fixed payload, then signs `delivery.accept@2` to settle. Historical automatic settlement remains covered by `market_lifecycle` and versioned checkout tests. Its final balances are **buyer 5, bank 15, every escrow 0, supply 20 MSG**. The manifest is `msg.lmm.best store selftest`; `hello.txt` is exactly `delivery-ok\n`. It sends no external mail and does not claim SMTP acceptance testing. -Backup/restore tests include pending purchases, active entitlement grants, funded/unpaid/settled orders, delivery references, nonce consumption and Claim receipts. Restore intentionally pauses writes; only disposable test restores lift that guard before replay checks. Old ledger-account migration remains idempotent; history is not reconstructed from mutable catalog values. Production migration, real console/SMTP/ingress and deployment acceptance remain separate, authorized work (#84). +Backup/restore tests include pending purchases, active entitlement grants, funded/unpaid/settled orders, delivery references, nonce consumption and Claim receipts. Restore intentionally pauses writes; bounty replay tests verify that deleting the local drill marker does not bypass containment. Old ledger-account migration remains idempotent; history is not reconstructed from mutable catalog values. Production migration, real console/SMTP/ingress and deployment acceptance remain separate, authorized work (#84). Regression entry points: `tests/test_market_71.py`, `test_market_redemption.py`, `test_market_72.py`, `test_market_73.py`, `test_market_contracts.py`, plus existing money/admin/offers/bounty/orders/delivery, migration, hosting, dictionary, backup and conformance suites. Test totals and exact commit/CI identity belong in the PR verification record, not an unversioned completion percentage. diff --git a/docs/MARKET_CONTRACTS.md b/docs/MARKET_CONTRACTS.md index b4588d49..91e371e6 100644 --- a/docs/MARKET_CONTRACTS.md +++ b/docs/MARKET_CONTRACTS.md @@ -14,8 +14,16 @@ optional `auto_accept` under `escrow-instant-v1`; `delivery.claim@1` remains its separate signed acknowledgement. Its schema, receipts and decision journal are preserved rather than reinterpreted by the new arbitration flow. -New clients select `orders.buy@3`, or use `orders.create@1` followed by -`orders.fund@1`. The latter signs the returned `order_digest`, exact total and +New CLI checkouts select `orders.buy@4`, or use `orders.create@2` followed by +`orders.fund@1`. These pin contract version 4 and the immutable +`explicit-buyer-acceptance` policy version 1: managed delivery leaves payment in +escrow until the current buyer signs `delivery.accept@2` with the exact delivery +digest. Reading a delivery cannot settle it. Explicit CLI `--contract-version` +still selects historical behavior; existing credentials must authorize the new +operation version before using it. + +Published `orders.buy@3` and `orders.create@1` retain automatic managed settlement. +The reservation flow uses `orders.fund@1`, which signs the returned `order_digest`, exact total and currency. Read `orders.contract` or `orders.get` as the buyer or seller. The existing CLI's schema-driven invocation supports these operations; obtain each schema from discovery rather than constructing URLs containing credentials. @@ -205,7 +213,7 @@ immutable decision journals are retained. Both execution paths share the same internal OrderEscrow account check; neither path can settle the other's order. Existing `delivery.claim@1` remains an acknowledgement, not another payment. -Version-3 contract reads and execution recheck every immutable checkout column +Version-3 and version-4 contract reads and execution recheck every immutable checkout column against its pinned contract. Funding references must identify the corresponding buyer-to-escrow ledger entry. Settlement facts bind funding, receipt references, settlement time and prepared-delivery identity/content, while allowing subsequent diff --git a/docs/NEW_ROOT_LEGACY_IMPORT_RUNBOOK.md b/docs/NEW_ROOT_LEGACY_IMPORT_RUNBOOK.md new file mode 100644 index 00000000..82b8df82 --- /dev/null +++ b/docs/NEW_ROOT_LEGACY_IMPORT_RUNBOOK.md @@ -0,0 +1,194 @@ +# 新 Root:旧内容、Git 与历史身份导入 + +本页用于全新 v4 安装。旧服务保持运行,目标使用全新 PostgreSQL 数据库、配置和数据目录。旧 Root、CA、凭据、托管私钥和外部任务均不启用。`legacy_rehearsal` / `legacy_git_rehearsal` 的测试 Root、测试 holder、映射和目标库不能用于生产。 + +## 1. 准备材料(可自动执行) + +先完成 DEPLOYMENT.md 的候选安装、Python 3.15、PostgreSQL、文件权限和回滚检查。配置最终服务 URL,但暂不接公网流量。新库不能指向旧库或预演库;libpq service 文件由对应执行用户读取,连接密码不放命令行。尚未切流时,公网地址仍是旧服务,不能用它登记新身份。 + +迁移材料放在受保护目录(目录 0700、文件 0600);生成审批草稿、签名和导入时使用同一个文件拥有者,或者通过明确的受保护复制交接拥有权。Root 控制台必须能读取草稿,服务执行用户必须能读取实际导入材料。不要放入 Git 或 /tmp。 + +| 材料 | 校验/用途 | +| --- | --- | +| `offline-legacy-0221.sqlite` | SHA-256 `ef550cbe7126cd265268b7f1da69ce6db5a65dccada5d0720028204cf9885389`,只读冻结源 | +| `legacy-repository.bundle` | SHA-256 `a89b4e6bc3d98c4f3ea1324ac70bd4b875ba1d783ecf973d695e86ac1fa257a9` | +| `legacy-repository-manifest.json` | 原始固定 refs、默认 ref 和摘要,保持私有 | +| 旧 Root **公钥**(如有) | 仅验证历史关系,不导入旧私钥,不赋予权限 | +| 新 holder 的客户端目录 | 新生成并持久保存的真实身份密钥;不能使用预演目录 | + +这些是已有冻结副本;最终切流前需要重新冻结/核对旧服务新增数据,旧服务仍写入时不得声称此副本是最终最新状态。源库和 bundle 校验由导入器再次执行。 + +下文 `CFG`、`MATERIALS`、`CLIENT`、`HOLDER`、三个 `*_PARENT` 都由部署记录明确赋值。`CFG` 是候选配置目录;`MATERIALS` 是上述受保护目录。不要将示例变量直接当成生产路径。 + +## 2. 新 Root 与在线 CA(必须本机控制台) + +操作系统 root 在物理、KVM 或串口控制台执行。SSH/PTTY 会被拒绝;不要绕过检查。PIN 只在 `getpass` 提示时本机输入,不通过聊天、参数、环境变量或管道传递。 + +```sh +msgd --config-dir "$CFG" init --data-dir "$NEW_DATA" --service-url https://msg.lmm.best +msgd --config-dir "$CFG" cert issue "$ONLINE_CA_REQUEST" +msgd --config-dir "$CFG" doctor +msgd --config-dir "$CFG" selftest +``` + +`ONLINE_CA_REQUEST` 使用 init 返回的 CSR ID,检查并输入 CLI 展示的确切 CSR 摘要。init 生成全新 Root;cert issue 单独批准新在线 CA。旧在线 CA 不参与。 + +## 3. 登记真实 holder,创建三个私有目录 + +通过候选实例的正常 `identity.register@2`、`content.topic_create`、`content.chmod` 操作建立真实身份与三个独立的 `0700` topic:内容、Git、历史身份。holder 是保存迁移数据的新拥有者,不等于任何旧作者。内容和身份导入要求各自 topic 为空;批准绑定 topic 当前 generation。 + +切流前可使用下面的本地入口;它通过正常 HTTP 路由、签名客户端和权限检查,完全不发送网络请求。以最终持有客户端目录的操作用户执行,并给予该用户明确的候选配置/数据库访问;不要广泛放开服务器文件权限。`CLIENT` 必须是新建的专用目录,登记失败可用同一目录重试,已有登记不能重复执行。不要导入旧客户端目录。 + +```python +# 保存为受保护的本地操作脚本;从环境读取路径,不读取或输出密钥。 +import asyncio, os +from pathlib import Path +import httpx +from msg.application import Application +from msg.config import load_settings +from msg.client import ClientState, MsgClient +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app + +async def main(): + app = Application(load_settings(Path(os.environ['CFG']))) + try: + await app.load() + state = ClientState(Path(os.environ['CLIENT']), server=app.settings.service_url) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + client = MsgClient(state, HTTPTransport(state.server, http=http)) + registered = await client.register(os.environ['NEW_HANDLE']) + assert registered.status == 'ok', registered.error + print('HOLDER', state.subject) + for name in ('legacy-content', 'legacy-git', 'legacy-identities'): + result = await client.call('content.topic_create', {'parent':'/main','name':name}) + assert result.status == 'ok', result.error + ref = result.resources[0] + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(ref.id)).generation + result = await client.call('content.chmod', {'id':ref.id,'mode':'0700'}, + expected=((ref.id, generation),)) + assert result.status == 'ok', result.error + print(name, ref.id) + finally: + await app.close() +asyncio.run(main()) +``` + +检查新身份登记和三个 topic 的实际结果,再记录 `HOLDER`、`CONTENT_PARENT`、`GIT_PARENT`、`IDENTITY_PARENT`。不要从日志推测未完成步骤;脚本部分失败后通过现存 holder 正常客户端逐项补齐,不删除持久身份密钥重新注册。 + +## 4. 生成计划与审批草稿(可自动执行) + +```sh +python -m msg.storage.legacy_identity_plan --snapshot "$MATERIALS/offline-legacy-0221.sqlite" --sha256 "$SQLITE_SHA256" --output "$MATERIALS/identity-plan.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" prepare-content --snapshot "$MATERIALS/offline-legacy-0221.sqlite" --sha256 "$SQLITE_SHA256" --operator "$HOLDER" --parent "$CONTENT_PARENT" --unmapped --output "$MATERIALS/content-draft.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" prepare-git --bundle "$MATERIALS/legacy-repository.bundle" --manifest "$MATERIALS/legacy-repository-manifest.json" --operator "$HOLDER" --parent "$GIT_PARENT" --name legacy-repository --output "$MATERIALS/git-draft.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" prepare-identities --plan "$MATERIALS/identity-plan.json" --operator "$HOLDER" --parent "$IDENTITY_PARENT" --unmapped --output "$MATERIALS/identity-draft.json" +``` + +`SQLITE_SHA256` 使用材料表的实际摘要。若有旧 Root 公钥,第一条命令额外指定 `--root-public-key` 公钥文件,增强历史校验;缺少它时保留计划中的证据不足状态,不能称已验证旧 Root 链。三份草稿有效期两小时;过期或 parent generation 改变后重新准备并重新批准。 + +`--unmapped` 将每个旧作者/身份明确映射为 JSON null,保留旧 ID、历史签名和验证结果,不声称旧作者就是新 holder。未映射作者不会获得登录或资源权限。将来认领必须独立验证并正常登记;此归档不能当登录凭据。只有完成相应验证后才使用私有 `--mappings` 文件显式关联已注册的新 Subject。 + +## 5. 审核并签署三份批准(必须本机控制台) + +```sh +python -m msg.admin.legacy_approval --config-dir "$CFG" sign --draft "$MATERIALS/content-draft.json" --output "$MATERIALS/content-approved.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" sign --draft "$MATERIALS/git-draft.json" --output "$MATERIALS/git-approved.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" sign --draft "$MATERIALS/identity-draft.json" --output "$MATERIALS/identity-approved.json" +``` + +每次显示实际 holder ID/handle、目标私有路径与 generation、新 Root 公钥摘要、映射/未映射数量、确切审批摘要。Root 操作者核对真实新身份,输入确切确认短语,再本机输入 PIN。helper 只接受这三种固定格式,拒绝任意 purpose 或额外字段。不会自动批准任意旧身份,也不会启用旧队列。 + +## 6. 实际导入(有签名后可自动执行) + +```sh +python -m msg.storage.legacy_resource_import --config "$CFG" --snapshot "$MATERIALS/offline-legacy-0221.sqlite" --signed-approval "$MATERIALS/content-approved.json" +python -m msg.storage.legacy_git_import --config "$CFG" --bundle "$MATERIALS/legacy-repository.bundle" --signed-approval "$MATERIALS/git-approved.json" +python -m msg.admin.legacy_approval --config-dir "$CFG" apply-identities --plan "$MATERIALS/identity-plan.json" --signed-approval "$MATERIALS/identity-approved.json" +``` + +将输出重定向至受保护的证据文件;完整导入报告可能含旧路径、ID 或 refs,不粘贴到公开日志。导入器再次验证 Root 签名、服务、期限、holder 和 generation。三个导入各自事务提交;失败时先检查相应持久报告,不重复已完成导入,也不为了重跑而删除源材料。 + +## 7. 验收与切流边界 + +核对源摘要未变、行数/对象数量、内容原文/附件、Git OID/ref、私有历史签名来源和身份验证状态。用真实 holder 的签名请求验证读取,用匿名请求确认拒绝。旧 URL 只在 `/_legacy//...` 命名空间适配,当前目标权限通过后才跳转;没有无条件公开旧 URL。 + +确认无旧凭据、CA、托管私钥、权限或外部队列被激活。身份归档仅私有文件,不创建可登录旧 Subject。默认所有迁移内容保持私有;公开、授权、域名切流及旧备份退役是另外的显式操作。三项导入通过不等于部署验收全部通过;保留旧服务和回滚材料,完成最终冻结一致性及部署验收后再切换。 + +## 8. 恢复原公开阅读范围(单独审核,不能省略) + +默认私有是导入和核验阶段的保护措施,不是全站升级的最终可见性。旧 schema 的 `boards.locked` 是写入锁,不是私密标记,不能据此推导访问权限。固定旧版本的普通 board/live post/attachment GET 为匿名读取;`archived_posts` / `archived_attachments` 不属于当前公开内容集合。仅从 SQLite 不能证明反向代理、站点级限制或 Git 的访问规则,因此这些外围限制需要部署清单复核;分类缺证据时保持待审核,不能自动公开。 + +应用级依据固定源码 `e12eea32764656eec82452fee111e5b4fb6d8a42` 的 `server.py`:`do_GET → _dispatch → _route` 没有全局读取认证,`/file` GET 直接取附件。分类依据这一读取路径和实际 schema,不是“live 就公开”。部署/反代 ACL 未核对的条目属于未知,保持私有。审批批次按以下集合从**已验证冻结快照**导出,不扫描正文或密钥: + +| 集合 | 快照查询 | 经审核的目标模式 | +| --- | --- | --- | +| 公开候选板块 | `SELECT name FROM boards ORDER BY name` | 0755,仅恢复阅读/遍历;不开放写入/加入 | +| 公开候选在版帖 | `SELECT id FROM posts ORDER BY id` | 0644 | +| 公开候选在版附件 | `SELECT a.id FROM attachments a JOIN posts p ON p.id=a.post_id ORDER BY a.id` | 0644 | +| 历史归档及历史签名文件 | archived_*、各 provenance | 保持 0600,不随当前帖一并公开 | +| 历史身份、凭据记录、Git | 独立迁移 topic/仓库 | 保持私有;Git 另查原部署规则并单独批准 | + +原锁定板块与未锁定板块都不自动恢复写权限。以下本地只读脚本生成具体的候选 `resource ID / generation / mode` 批次文件,输出只含数量和摘要。它不执行 chmod;完整 plan 保持私有并交新 Root 操作者审核。`PUBLICATION_PLAN` 必须是不存在的新文件。 + +```python +import asyncio, hashlib, os, sqlite3 +from pathlib import Path +from msg.application import Application +from msg.config import load_settings +from msg.core.codec import digest +from msg.admin.legacy_approval import write_new + +async def main(): + snapshot = Path(os.environ['MATERIALS'])/'offline-legacy-0221.sqlite' + sha = os.environ['SQLITE_SHA256'] + with snapshot.open('rb') as stream: + assert hashlib.file_digest(stream, 'sha256').hexdigest() == sha + with sqlite3.connect(snapshot.resolve().as_uri()+'?mode=ro', uri=True) as db: + db.execute('PRAGMA query_only=ON') + candidates = [ + (table, row[0], mode) + for table, query, mode in ( + ('posts','SELECT id FROM posts ORDER BY id','0644'), + ('attachments','SELECT a.id FROM attachments a JOIN posts p ON p.id=a.post_id ORDER BY a.id','0644'), + ('boards','SELECT name FROM boards ORDER BY name','0755')) + for row in db.execute(query) + ] + app = Application(load_settings(Path(os.environ['CFG']))) + try: + await app.load() + async with app.metadata.transaction(write=False) as tx: + report = tx.setting('legacy-import:'+sha) + assert report and report['source_sha256'] == sha + entries = [] + for table, old_id, mode in candidates: + rid = 'legacy_'+sha[:24]+'_'+digest((table,old_id))[7:31] + resource = await tx.resource(rid) + assert resource.owner == os.environ['HOLDER'] and resource.state == 'active' + entries.append({'id':rid,'generation':resource.generation,'mode':mode,'source_table':table}) + parent = await tx.resource(os.environ['CONTENT_PARENT']) + assert parent.owner == os.environ['HOLDER'] and parent.mode == 0o700 + for item in entries: + assert parent.id in {r.id for r in await tx.ancestors(item['id'])} + entries.append({'id':parent.id,'generation':parent.generation,'mode':'0755','source_table':'publication_parent_last'}) + plan = {'source_sha256':sha,'holder':os.environ['HOLDER'],'entries':entries, + 'restores':'read-only-public-candidates','review_required':True, + 'source_code_commit':'e12eea32764656eec82452fee111e5b4fb6d8a42', + 'classification':'application-anonymous-read-candidate', + 'deployment_acl_review':None} + write_new(Path(os.environ['PUBLICATION_PLAN']),plan) + print({'candidate_count':len(entries)-1,'plan_digest':digest(plan)}) + finally: + await app.close() +asyncio.run(main()) +``` + +审核记录应包含 plan 摘要、已确认的旧服务/反向代理匿名阅读规则,以及明确批准或排除的条目。现有迁移批准仅批准私有导入,不能作为公开批准使用;当前没有专用 Root 可见性批次签名命令,不能用三种导入签名 purpose 冒充。新 Root 操作者完成这一步审核后,由真实 holder 使用正常 `content.chmod`,每条严格使用批准 plan 的 ID、mode、generation: + +```sh +msg --config-dir "$CLIENT" --server https://msg.lmm.best call content.chmod '{"id":"","mode":""}' --expect '<同一ID>=' +``` + +切流前使用第 3 节的本地 HTTP 客户端调用相同 `client.call('content.chmod', {'id':item['id'],'mode':item['mode']}, expected=((item['id'],item['generation']),))`,不调用仍指向旧服务的公网 URL。先应用在版帖/附件,再板块,最后才将 `CONTENT_PARENT` 改为 0755;任何条目失败立即停止,保持父 topic 私有,不跳过 generation 冲突。该批次不是单事务:若中断,读取实际模式/generation重新生成并审核剩余计划,不盲目重跑。最终父 topic 公开后,用匿名真实 HTTP 请求验证恢复的普通内容和附件可读、归档/历史签名/身份材料仍拒绝,才算可见性恢复完成。 diff --git a/docs/OFFER_ORDER_COMPATIBILITY.md b/docs/OFFER_ORDER_COMPATIBILITY.md new file mode 100644 index 00000000..0a224fbf --- /dev/null +++ b/docs/OFFER_ORDER_COMPATIBILITY.md @@ -0,0 +1,77 @@ +# Offer and purchase compatibility + +Compatibility contract version 2 provides read-only Listing and Order projections +without copying authoritative records or changing published redemption semantics. +There is no startup migration and no production data rewrite. + +## Entrypoints and identity + +- `money.offers@1` is unchanged. `money.offers@2` also returns `listings`, projecting + enabled, Registry-approved local offers. Both use the same validated rows. +- `store.listing_get@2` accepts `source=server_offer` and preserves the offer ID + as `listing_id` and price revision as `listing_revision`. The default source is + `resource`, with the same authorization as version 1. Explicit source avoids + collisions with Resource IDs. Unavailable or stale offers are not found. +- `money.purchase_get@2` preserves the original `purchase` and also returns an + `order`. `orders.get@2` accepts `source=legacy_purchase` for the same projection; + its default remains `store_order`. Both require the original buyer's account + scope and hide another buyer's purchase exactly as a missing ID. +- `orders.list@2` merges the buyer's purchases and authorized store orders into + the same bounded private index. A `source` tag disambiguates their IDs. Sell and + disputed filters never expose legacy purchases, whose historical read grant + was buyer-only. Version 1 is unchanged. + +Old `pur_*` IDs remain canonical for legacy records. Projections retain original +immutable offer snapshots, transaction/entitlement IDs and escrow purpose/source. +The policy is explicitly tagged `legacy-entitlement-v1`: pending means funded, +settled/refunded retain their meanings, and timestamps derive from real ledger +facts. No acceptance, Delivery or claimed fact is fabricated for a synchronous +entitlement grant. Unknown states or mismatched account/ledger/grant facts fail +closed. A snapshot is never reconstructed from the current mutable catalog. + +## One protected release boundary + +`market.ledger.post_escrow_release` checks the typed escrow account, original +business source ID and allowed buyer/seller recipients. Both existing order +policies and legacy entitlement settlement use it. `post_transfer` rejects +unguarded order **and purchase** escrow debits. Callers still validate their +separate signed policies and perform fulfillment/state changes in the same +transaction. Ledger receipt construction, entry keys, request IDs and historical +bytes are unchanged. Existing pending purchases remain settleable from their +frozen quotes after catalog changes. Already settled Root funds remain outside +remote cancellation authority. + +Read adapters are deliberately separate from mutation record loading. A projected +purchase cannot be passed into store cancellation, acceptance or arbitration. +There is only one writable purchase record and one escrow, so enabling these +reads introduces no second settlement state machine. + +## Preflight, rollback and evidence + +Calling the new read contracts is a non-mutating preflight: it checks provenance +and reports incompatibility without allocating records. Disabling version 2 +reads restores the former surface immediately, with no data rollback required. +Existing version 1 endpoints continue reading the same exact purchase facts. +Tests snapshot all financial/source/order/delivery/event/audit rows before and +after successful, missing and denied reads; additionally they cover quote edits, +pending/settled/refunded mappings, owner-only access, index filters, unknown +states, typed release rejection and unchanged original funding receipts. +Existing redemption failure tests verify a grant failure rolls back the shared +release and all purchase/grant facts; retry/concurrency and backup restore +coverage remain required. + +The physical tables still retain their legacy names. Unmapped legacy offers +remain SQL-only; new local offers use the Resource writer described below. The new +`money.redeem@3` now explicitly opts into the native synchronous Order writer; +published @1/@2 remain unchanged. A future write-model migration must preserve historical IDs, +receipts and cached request results and retire the old writer atomically. This +read compatibility slice does not claim that physical migration is complete. + + +## Subsequent write-model stage + +New local offers now use signed ordinary Resource/Revision authority; their SQL +rows are verified compatibility projections. Existing unmapped offers remain on +their old writer. See [OFFER_RESOURCE_MIGRATION.md](OFFER_RESOURCE_MIGRATION.md) +for the staged implementation, unchanged redemption semantics and remaining +explicit adoption/Purchase migration work. diff --git a/docs/OFFER_RESOURCE_MIGRATION.md b/docs/OFFER_RESOURCE_MIGRATION.md new file mode 100644 index 00000000..7480f1d1 --- /dev/null +++ b/docs/OFFER_RESOURCE_MIGRATION.md @@ -0,0 +1,208 @@ +# Offer/Order write-model convergence + +Baseline: central `ce497d6`. The current design requires a Listing authority for +server offers, one Order model for purchases, unchanged published redemption +versions, and no silent changes to historical IDs, signatures or ledger receipts. +The preceding v2 read adapters are compatibility projections, not a migration. + +## Small reviewable stages + +1. **New-offer Resource writer (implemented).** Newly created local offers use + ordinary `listing` Resources under `/store/` with Root-signed immutable + Revisions. Their unchanged offer IDs are Resource IDs. `server_offers` is an + atomic compatibility projection for these records, identified by the typed + `server_offer_resources` mapping. Local set/disable updates append a revision; + public reads/redemption verify the projection against its signed authority. + An existing unmapped offer stays legacy, even on edit. Nothing imports it + implicitly. This stage neither migrates Purchases nor changes write contracts. +2. **Explicit existing-offer adoption (implemented).** The local dry-run/import command uses + an exact expected legacy snapshot, ID/name collision checks, explicit provenance + and an auditable approval plan. Import must preserve each offer ID and all referenced + purchase snapshots, entitlements and ledger bytes. Reject unknown providers + and inconsistent records. No database-startup import. Existing-row edits stay + on their original writer until explicit adoption. +3. **New entitlement Order writer (synchronous part implemented).** The new redemption version has an + immutable entitlement policy represented in the shared Order contract; + deterministic grant/payment commit together and Root settlement remains + non-remotely-refundable. Later deferred fulfillment must use the same typed escrow. Keep + legacy Purchase readers/writers until explicit pending-order adoption and + rollback are verified. Never synthesize acceptance or Delivery from old grants. +4. **Retire legacy writers only after evidence.** Verify old request replay, + concurrency, backups, restore and rollback against signed historical fixtures; + retain original IDs/receipt references and remove the obsolete writable path + only when it cannot spend the same escrow a second time. + +## Stage 1 authority and behavior + +The existing local-console/PIN/Root signer boundary is unchanged. Ordinary users +cannot reprice a Root-owned listing, manufacture server entitlements, or route an +entitlement listing through generic store checkout. `money.redeem@1/@2` retain +their exact arguments and resulting purchase/ledger semantics. The Resource body +has normal sale fields plus the typed `server_offer` snapshot and model version. +Its policy is explicitly the old entitlement policy, not buyer acceptance. + +Each successful set creates a fresh signed Revision whose ID is the existing +`price_revision` field. Disable creates another Revision with `state=paused` but +retains the quote ID, matching the old disable behavior. Historical revisions and +pending purchase snapshots remain readable and immutable. Reads do not adopt old +rows or repair mismatching projections; a mismatch rejects new redemption before +funding and cannot be used as input to the local writer. The public compatibility +view also rejects a private or inactive authoritative Resource/ancestor instead +of bypassing its visibility through the old offer endpoint. + +Mapping, Resource pointer, Revision row, compatibility projection and local audit +are written in the caller's single metadata transaction. The content store uses +its normal durable-before-pointer publishing and rollback unpin mechanism; +failed writes cannot expose a committed Resource or financial side effect. + +Schema startup creates only an empty mapping table; it does not convert data. +Fresh production catalogs remain empty. The mapping table is included in normal +PostgreSQL dumps and therefore follows the same backup/restore transaction as +Resources, revisions and legacy references. + +## Verification and remaining boundary + +Tests cover signed source verification, immutable old prices and deferred +settlement, ordinary Listing reads, disable semantics, projection tampering, +remote seller/checkout rejection, rollback after content publication, retry, and +no implicit adoption of old rows. Existing offer/redemption/admin/backup tests +remain required. + +Only newly created offers and their subsequent edits use the unified Resource +writer in this stage. Existing Offer adoption, Purchase → Order writer migration, +and a new redemption contract remain separate stages. Do not label those complete +based on this change or the prior read projections. + +## Stage 2: explicit old-offer import + +`msgd money offer import OFFER_ID --dry-run` is a read-only local preflight and +needs no Root PIN. `msgd money offer import OFFER_ID` prints a newly generated +complete plan, requires its exact `CONFIRM MONEY sha256:...` string, then unlocks +Root. A dry-run never authorizes a later run; the operator approves the actual +plan being applied. + +The plan fixes the exact old quote, target IDs, purchase/grant counts and a digest +of original serialized Purchase rows, entitlement rows and ledger rows (including +receipt bytes). Before publishing, the write transaction repeats consistency and +collision checks and compares the complete plan. Any new purchase, cancellation, +settlement or quote edit since approval makes it stale. Inconsistent grants or +ledger references, unsupported providers, existing Resource IDs/names and an +already imported offer are rejected. + +Import creates a fresh signed Revision with a fresh revision ID and explicit +source digest referencing the approved plan. It preserves the old `offer_id` and +`price_revision` verbatim; the new revision is an import fact, not a claim that a +historical quote was originally a Resource revision. Existing snapshots, grants, +accounts and ledger bytes are untouched. The signed Root audit contains the plan +and approval digest. Failure during publication rolls back Resource, mapping, +Revision and audit in the same transaction; the unchanged plan can be retried. +No automatic startup adoption or remote import operation is registered. + +## Stage 3: explicit synchronous entitlement Order writer + +`money.redeem@3` is a synchronous Order contract and the dedicated CLI default. It requires an +already adopted/new Resource-backed offer and these signed fields: +`offer_id`, `quantity`, `currency_id`, `price_revision`, `listing_revision`, +`offer_snapshot_digest`, `total_price_minor`, and +`settlement_policy="deterministic-entitlement-v1"`. + +The snapshot digest covers the exact public offer (resource/entitlement kind, +unit, unit price, bounds, duration and provider version). The independently bound +Listing revision fixes the Root-signed terms. The full original signed request +is retained and verified against the buyer's recorded signing credential during +contract validation; recomputing a database digest cannot forge consent. +Revocation still blocks new requests and cached-result replay, while the old +signature remains valid evidence when the owner reads through a current key. + +This uses the existing `store_orders`, `order_contracts`, `order_transitions`, +`order_settlements`, protected OrderEscrow, funding function and settlement +function. It creates no `money_purchases` row and no parallel purchase state +machine. Policy version 5 allows **only this explicitly consented deterministic +entitlement policy** to go funded → settled: the ResourceEntitlement grant and +release to Root must succeed in the same transaction. It creates neither Delivery +nor accepted/claimed facts. This follows the design's same-transaction +deterministic-entitlement rule; ordinary `orders.buy@4` still requires separate +signed buyer acceptance and retains all previous checks. + +A grant failure after ledger release rolls back funding, release, Order, grant +and events together. Repeating the same signed request returns the original +result. Competing spends use the same protected ledger transaction. Native order +reads and payment receipts validate the exact grant and settlement; remote +cancellation cannot refund an already settled Root payment. PostgreSQL order +quantity is widened from INTEGER to BIGINT to preserve byte quantities above +2 GiB without changing any existing values or signed facts. + +Published `money.redeem@1/@2` keep their original writers and response shapes; +existing pending Purchases are untouched and still settle/cancel independently. +Version 3 deliberately has no `defer` flag: asynchronously fulfilled new orders +and explicit adoption of historical pending Purchases remain later work, not +features inferred from this synchronous contract. All production migrations and +financial actions remain manual and local; tests use isolated fixtures only. + +The dedicated `msg money redeem` fetches `store.listing_get@2` from the ordinary +Resource branch, binds its actual revision and complete quote digest, and signs +@3 with explicit synchronous-settlement policy. Supplied price/revision/digest +fields must match exactly; a stale pinned intent is rejected. Unimported offers +are rejected by default; `--contract-version 1` or `2` explicitly selects the +legacy writer, including `defer` only under @2. Reusing a request ID after the +quote changes fails closed; it never silently replays a differently priced buy. +The isolated official selftest retains all 20 → 10 → 5 PoP/store acceptance +checkpoints, then exercises a separate one-minor-unit deterministic grant using +a real @3 buyer signature, replay and supply checks. + +This stage converges new redemptions onto the existing native Order writer. Stage 4 below gives new default Orders ordinary Resource/Revision authority. +Historical Purchase adoption remains separate. Compatibility reads are not +evidence of migration. + +## Stage 4: controlled ordinary Order Resources + +Design lines 56/58 define the shared Resource/Revision structure and reserve +controlled facts to dedicated operations; lines 64/118 require Order convergence +and unchanged old signed policies; lines 135/193 prohibit index-derived financial +authority and silent historical rewriting. Issue #85 repeats Resource/Revision +reuse and the single protected ledger; #71 requires preserved #102 contracts and +atomic, recoverable fulfillment. + +New `orders.create@2`, `orders.buy@4` and `money.redeem@3` contracts carry an +explicit `resource_model=1` marker. Their ordinary private `order` Resource keeps +the same `ord_` ID at `/@buyer/orders/` under a controlled `order_collection`. +Its immutable JSON Revision contains the original locked contract, core Order +state, funding/settlement times and receipt references, settlement fact, and +transition facts. New v4 contracts also preserve the original creation request +verbatim. Revision manifests are signed by the service receipt signer as +**operation facts**, not falsely represented as buyer-signed content. Original +request signatures and financial receipt bytes are unchanged. + +`store_orders` and `order_contracts` are validated query projections for marked +orders: every record/contract read compares their canonical document digest to +the current immutable Revision metadata. Dedicated operations additionally read +and verify source bytes and the Revision signature before acting. Inventory +checks validate existing Resource orders before using SQL quantity/state indexes. +Projection drift, deleted source, missing content or invalid signatures fail +closed; no startup repair, adoption or automatic re-signing exists. Ledger, +Delivery, arbitration evidence and notification jobs remain their respective +financial/delivery/evidence facts, not parallel Order state machines. Notification +endpoint changes are intentionally outside the Order content snapshot. + +A dedicated operation starts from a verified source, validates the existing +versioned business rules, then appends its final committed Order Revision in the +same transaction as the SQL projection and ledger changes. Internal transitions +within one operation are preserved as transition facts; they are not separate +commits. No-op/replay creates no new Revision. Buyer acceptance, objective refunds, +and arbitration retain their existing semantics. The timeout and internal +arbitration entrypoints use the same boundary; Resource-backed transitions +outside that boundary are rejected. Any source publication failure rolls back +Order, Delivery, ledger, grants, events and the revision pointer together. + +Generic content/file creation, write, lifecycle, ownership, group, permission and +move operations cannot edit the controlled types. The buyer can read ordinary +Resource bytes; sellers still use the existing role-trimmed market view. Source +refs are returned as `data.order_resources`; they do not add generic descendant +read rights to existing account-scoped credentials or change replay authority. + +Existing orders without the marker retain their published compatibility writer +and policies, including old @1/@2/@3 and previously created @4/@3-redemption +records. No historical request, ID, receipt, pending Purchase or Order is adopted +on read or startup. Explicit historical Order/Purchase adoption is intentionally +not claimed; the design permits this compatibility boundary. New legacy-version +requests continue their exact published writer and response shape. diff --git a/docs/READ_ENTRY_ACCEPTANCE.md b/docs/READ_ENTRY_ACCEPTANCE.md new file mode 100644 index 00000000..80b7fc79 --- /dev/null +++ b/docs/READ_ENTRY_ACCEPTANCE.md @@ -0,0 +1,58 @@ +# Read entry acceptance evidence (#78) + +This maps supported contracts at commit `2eb1ea3` to executable tests. It is +not a claim that every requirement in #78 has been completed. Parameter semantics +are tested at their shared handler; dispatcher equivalence needs representative +signed packets and continuations, not every possible combination of predicates. + +The four dispatchers are POST `/-/p/`, GET +`/-/g///`, GraphQL query `/_read/graphql`, and MCP +HTTP `/-/mcp`. CLI/stdio have separate real-client coverage in +`test_share_transport_matrix.py` and `test_read_projection_cache_transports.py`. + +| Supported contract | Existing semantic evidence (under `tests/`) | +| --- | --- | +| ReadQuery v1: parent/type/author/query/tag/state predicates; id/time/name ordering and asc/desc; fields, bounded page | `test_read_query_cursor.py`, `test_path_read_query.py`; shared predicates in `plugins/read_predicates.py` | +| ReadQuery v2 flat children/replies expansion, v3 recursive independently paged children/replies; no arbitrary nested predicates | `test_nested_read_query.py`, `issue_78_80/test_read_query_tree.py` | +| Page cursor binds principal/query/version/snapshot; current parent and child authorization; expiry and downgrade rejection | `test_read_query_cursor.py`, `issue_78_80/test_read_query_tree.py`, `issue_78_80/test_tree_query_ref.py` | +| ReadQuery depth/node/cost/response byte/deadline limits, including aggregate branches | `issue_78_80/test_read_query_tree.py::test_depth_and_response_budget`, `test_read_deadline`, `test_global_node_budget_covers_all_branches` | +| Search all/any/exact/exclusion, body/name/metadata; relevance/updated/created/name ordering, snippet/explain and explicit facets | `test_lexical_search_grep.py`, `test_search_source_relation_filters.py`, new `test_read_entry_contract_matrix.py` (any/exact/exclusion) | +| Search v3 source/relation filters; v5 current revision/source version, directional relations, presence predicates, typed scopes, title alias | `test_search_source_relation_filters.py`; older version schemas remain closed | +| Search cursor/current readable corpus, private snippet/facet/rank suppression | `test_lexical_search_grep.py::test_facets_use_all_currently_readable_matches_and_recheck_old_cursor`, `test_search_source_relation_filters.py::test_v3_old_cursor_rechecks_grants_before_items_facets_and_rank` | +| Sync v1: event sequence order, limit 1–100, encrypted known-ref cursor; no custom sort/filter | `test_sync_cursor.py`, `test_pagination.py` | +| Sync revocation/retention requires resync; >64 known refs use explicit checkpoint/ack writes, ordinary Sync remains read-only | `test_sync_checkpoint.py`, `test_sync_cursor.py::test_sync_over_64_refs_fails_without_advancing_cursor` | +| Compact path aliases and sealed QueryRef produce the same contracts; independent nested continuations | `test_path_read_query.py`, `test_query_ref.py`, `issue_78_80/test_tree_query_ref.py` | +| Successful/failed reads have no business mutation; current permission beats cache | `test_route_effect_matrix.py`, `test_read_projection_cache_transports.py` | + +## Added dispatcher gap coverage + +`test_read_entry_contract_matrix.py` adds four cases in a single disposable +installation fixture family, using actual HTTP/GraphQL/MCP adapters and real +signed requests: + +- ReadQuery v1 and v3 combine type/author/text filtering, descending ordering, + narrow fields and a one-item page. Both pages must contain exactly the two + matching records, in order. +- Search v5 combines a typed scope, any/exact/exclusion/type/author filters, explicit order, + fields and a one-item page. The continuation must exhaust exactly the two + matches, without duplicate or excluded records. Separate decoys prove both + exact and exclusion predicates affect membership. +- Sync v1 checks bounded first/continuation pages and cursor advancement. + Unsupported user sort/filter is rejected rather than silently ignored. +- The exact same signed packet is submitted to each dispatcher without changing + legacy arguments or resigning. Entire operation results must match. +- All four dispatchers reject over-limit pages identically. ReadQuery v1 retains + its established `query_cost_exceeded`; closed newer schemas return + `schema_validation`. V3 also exercises runtime `response_too_large` identically. +- Every public database table is unchanged after success and failure. Content + mutations, job notifications, mail/webhook sends and sandbox launches are + forbidden during these reads. + +The matrix establishes dispatch preservation. It does not replace dedicated +nested budget, permission, cursor expiry, checkpoint, or lexical predicate tests, +and does not assert nonexistent Sync sort/filter semantics. + +This evidence does not independently exhaust each scalar ordering enum (for +example ReadQuery time and Search updated), every timestamp boundary, or every +nested shape at every dispatcher. Such combinations use the same validated +packet/handler; their absence is not evidence of a transport-specific defect. diff --git a/docs/SAVED_QUERIES.md b/docs/SAVED_QUERIES.md new file mode 100644 index 00000000..b96c8c96 --- /dev/null +++ b/docs/SAVED_QUERIES.md @@ -0,0 +1,61 @@ +# Saved query descriptors + +`transfer.query_seal@1` remains a short-lived read ticket. It is bound to the actor, +subject and credential, expires within 15 minutes, and depends on its sealed +Transfer. Reads never extend it. + +`query.save@1` explicitly saves an unexpired ticket into a private `saved_query` +Resource and immutable Revision. Input is `{"query_ref":""}`. The result +contains `resources[0]` and `data.saved_ref`, both a fixed `{id, revision}`, plus +`descriptor_digest`. Saving the same request again returns the same fact. + +The saved descriptor pins the original read operation, contract version and all +arguments. Resource selectors such as parent, scope and author are resolved to +stable IDs at save time. The digest covers the normalized descriptor. No result +page, cursor or acquired permission is copied. The existing temporary ticket and +Transfer keep their expiry; the explicit saved copy is independent of their later +cleanup. + +`query.saved_get@1` takes `{"ref":{"id":"...","revision":"..."}}` and returns +the descriptor and digest after checking the current owner, saved resource, +query scope and authority. The creation principal is internal metadata: generic +content reads, sharing, raw downloads and creation-time projections cannot expose +it. Both the saving principal and the caller must still hold current query scope +permission. Revocation, credential expiry and narrowed ceilings stop access; +saving does not extend the original credential's lifetime. + +`query.saved_archive@1` takes the same pinned ref plus the saved resource's +`expected_generations` entry. Its owner can explicitly revoke the source even if +the creation authority or query scope has since become unavailable. + +A watch consumer must pin this exact saved revision and validate the saved +principal and its own current principal for every matching resource. It must +reject unsupported predicates explicitly. Saving a search descriptor does not +imply that every search predicate is supported by a particular watch version. + +## Event watches + +`communication.watch_create@2` accepts `query_ref: {id, revision}` pointing to a +saved descriptor, `event_types`, `delivery: "inbox"`, and optional `expires_at`. +It requires a signature. The CLI uses this version for: + +```sh +msg watch create --query-ref QUERY_ID --query-revision REVISION_ID --event content.post_create +``` + +This version accepts `discovery.read_query@1` descriptors containing only +`parent`, `type`, `author`, `state`, and `tag`. `parent` is required. It rejects +search descriptors, text queries, pagination, ordering, selected fields and +nested expansion rather than silently changing their meaning. Matching shares +the read query's membership predicates and considers only references on the +current Event. It never executes a stored query or scans an old result page. +Supported events are `content.post_create`, `content.post_edit`, +`discussion.reply`, `content.archive`, `content.tags_set`, `content.move`, and +`content.chown`. + +Each delivery rechecks the fixed saved source, both current principals' read +permissions and the watch operation's current authority. Source archival, +credential revocation or expiry, lost permissions and watch expiry stop delivery. +Only authorized references enter Inbox; no content bodies are copied. External +channels are unsupported by this version. Existing target watches remain on +`communication.watch_create@1` with their original event vocabulary. diff --git a/docs/SEARCH_QUERY_V5.md b/docs/SEARCH_QUERY_V5.md new file mode 100644 index 00000000..410b8822 --- /dev/null +++ b/docs/SEARCH_QUERY_V5.md @@ -0,0 +1,54 @@ +# SearchQuery @5 + +`discovery.lexical_search@5` 扩展未发布的新查询契约;@1–@4 的输入 schema 保留。 +HTTP query、`/_search/q/5/`(短别名 `/_s/q/5/`)、签名操作和 sealed QueryRef +使用同一查询 handler 与当前授权。新增操作版本仍受调用凭据的版本上限约束。 + +## 范围与过滤 + +`scope` 可以是原有路径/ID,或下列闭合 JSON 对象之一: + +- `{"subject":"u_..."}`:该用户目录的后代及 owner/group 指向该用户的资源。 +- `{"org":"g_..."}`:该组织目录的后代及 owner/group 指向该组织的资源。 +- `{"resource_refs":[{"id":"r_..."},{"id":"r_..."}]}`:最多 32 个当前资源范围的并集。 + +重复解析到同一资源的范围去重;深度和递归限制继续适用。当前根范围必须可访问, +每个结果仍独立鉴权;续页重新检查范围与结果权限。这里不接受固定历史 Revision +作为范围,也不会把组织范围当作成员自动取得读取权限的依据。 + +新增 `revision` 与 `source_version` 过滤当前 Revision;旧版本不会因为符合过滤而被重新展示。 +`source_version` 范围为 1–2147483647。`field=title` 与显式 `fields=["title"]` +使用 Resource.name,包含其原有文件后缀;不从 Markdown 推测标题。 + +`relation_to` 筛选指向指定资源的结果,`relation_from` 筛选由指定资源引用的结果; +`relation_type` 可以进一步限制关系类型。`has_replies` 表示存在可读的当前 `reply_to` +入边,`has_references` 表示存在可读的当前入边。两者均接受 true/false。 +过滤关系时,两端都必须当前可读;历史关系、撤权端点不进入命中或统计。 +@5 还接受协作资源登记的 `reference/state/target/content` 关系类型。 + +## 显式拼写提示 + +`spell=true` 增加 `spelling` 字段;默认没有该字段,提示不改写查询、不改变结果项。 +词典仅来自当前范围、结构过滤与权限检查之后的可读资源名称。 +最多 512 个词,每词最多 32 字符;查询最多 4 个词,各为 2–32 字符。 +按 Levenshtein 距离、出现次数、词序确定排序,每词最多返回 5 个提示。 +查询词长度不超过 4 时距离上限为 1,否则为 2。超出预算明确失败。 +这不是全文纠错、语义搜索或恒定时间保证。 + +## CLI 与纯路径 + +```sh +msg search /main gadget --field title --spell --facet type --fields id,title +msg search '{"resource_refs":[{"id":"r_one"},{"id":"r_two"}]}' needle --limit 10 +msg search /main needle --relation-to r_target --relation-type reply_to +msg search /main needle --source-kind release --source-version 1 +msg search --cursor '' +``` + +CLI 仅检查有界 cursor 的公开查询参数来选择操作版本;不把该检查当作验签或授权。 +服务端仍核验 MAC、调用主体、过期时间和当前权限。cursor 不可与新筛选条件混用。 + +@5 纯路径新增 `rv`(revision)、`sv`(source_version)、`to/fr`(关系端点)、 +`hr/hf`(has_replies/has_references)、`sp`(spell)。title 字段短值是 `t`。 +JSON scope 放在 `s` 段,按现有路径规范编码(逗号保留);较长输入使用 QueryRef。 +完整映射可从 `/-/d/search.query` 读取。 diff --git a/docs/SSH_GIT_LOCAL_ACCEPTANCE.md b/docs/SSH_GIT_LOCAL_ACCEPTANCE.md new file mode 100644 index 00000000..91d8dce3 --- /dev/null +++ b/docs/SSH_GIT_LOCAL_ACCEPTANCE.md @@ -0,0 +1,82 @@ +# Isolated OpenSSH and Git acceptance + +This is local, disposable acceptance evidence for #80/#82, not a target-host +installation or a production acceptance claim. Source baseline: `2eb1ea3`. +Run explicitly as an ordinary user with noninteractive sudo: + +```sh +uv run python scripts/check_isolated_sshd.py +``` + +The script creates a fresh PostgreSQL cluster/database, application configuration, +service data, independent Root/identity/SSH/host keys, localhost port and daemon. +It protects its own Root directory from the service user. Its AuthorizedKeysCommand +wrapper lives in a unique root-owned `/run/msg-sshd-check-*` directory: OpenSSH +correctly rejects an otherwise root-owned wrapper below world-writable `/tmp`. +StrictModes is never disabled. Existing sshd configuration, administrator keys, +system services and production databases are not used or changed. Cleanup stops +the specific daemon and multiplexed client, removes the temporary privileged +wrapper and Root directory, and destroys the disposable installation/database. +The script is deliberately separate from ordinary pytest: it requires sudo and +real OpenSSH and fails if they are unavailable. + +## Real local checks + +- Registered SSH possession proof and capability ceiling; dynamic + AuthorizedKeysCommand lookup and credential-bound forced command succeed. +- Shell commands and a Git filesystem escape are refused. +- Remote TCP forwarding is refused by the real daemon. +- The standard Git client performs atomic two-ref SSH push, ls-remote and clone; + cloned file bytes match the source. +- A revoked certificate is refused both by a new login and by a new channel + inside an already authenticated ControlMaster connection. The optional + certificate is explicitly detached afterward; it is never unrevoked. +- SSH key revocation is rechecked inside the existing connection, and subsequent + fresh SSH/Git connections fail authentication. + +The focused existing test +`tests/test_ssh_rss.py::test_git_reference_guard_real_commit_and_revoked_key` +also now sends a real `git update-ref --stdin` transaction containing two creates +and `prepare`, then ends stdin before `commit`. Both refs are absent afterward, +the original ref remains, and repository metadata generation is unchanged. +This is a prepared-transaction EOF abort, not a power-cut simulation. + +## Existing coverage inspected, without repeating broad suites + +- `test_git_http_push.py`: real HTTP Git client, retry/idempotency conflicts, + interrupted pack staging cleanup, size boundary and revoked-token ref hook. +- `test_git_lfs.py::test_real_git_lfs_push_and_pull`: actual git-lfs client. +- `issue_78_80/test_lfs_integrity.py`: oid/content corruption, crash before legacy + replacement, retained historical ACL root, real-process capacity locking, + symlink rejection and GC retention. +- `test_lfs_shared_gc_quota.py`: shared CAS inode, ACL root and deployment-wide + capacity/volume identity. +- `test_effect_completion_fencing.py`: expired attempt/deadline prevents physical + Git publication; stale completion cannot publish successful metadata. +- `test_ssh_process_identity.py`: unprivileged process-name spoofing is refused. + +These existing tests do not turn the new local script into full #80/#82 closure. + +## Still needs target-host or additional fault evidence + +Archczy must validate its actual service account, packaged interpreter, deployed +AuthorizedKeysCommand/forced command paths, SSH/PAM/systemd configuration, +filesystem permissions, firewall/proxy/log policy and service restart behavior. +This script's local success does not certify those deployment facts. Remaining +subject/group/ceiling and full client UI matrices must retain their own evidence. + +A true multi-storage durability claim needs controlled crash/restart or power-cut +experiments across PostgreSQL, Git refs, CAS/LFS staging and GC on the intended +filesystem/shared-volume topology. The EOF test above cannot establish it. +Cross-instance reservation/cleanup and host failover must likewise be tested with +the actual shared storage and worker topology. These are distinct from the +ordinary-client/sshd checks that can already run locally. + +## Observed result on 2026-09-29 + +The explicit script completed all seven checks and reported +`temporary_installation_removed: true`. Follow-up inspection found no remaining +`/run/msg-sshd-check-*` wrapper, temporary installation or isolated daemon. +The focused prepared multi-ref EOF test passed (`1 passed in 11.02s`). +Environment: Linux 7.2.7-arch1-1, Python 3.15.0rc2, OpenSSH 10.5p1, +Git 2.55.0 and git-lfs 3.8.0. No target-host deployment was performed. diff --git a/docs/TASK_A_RECOVERY_20260928.md b/docs/TASK_A_RECOVERY_20260928.md index 6f249a3d..98fc5c9d 100644 --- a/docs/TASK_A_RECOVERY_20260928.md +++ b/docs/TASK_A_RECOVERY_20260928.md @@ -26,7 +26,7 @@ Refs #64, #65, #68, #69, #70, #84, #85;接续 #112,不关闭现场验收项 在线清除、身份切换、历史恢复和备份退役依然分别报告。没有独立备份材料时 `backup_retired` 和 `server_key_retired` 不变为 true。 -## #69:独立精确检查点下的 deny-only 重放 +## #69:独立精确检查点下的部分当前权限协调 `TrustedCheckpointPin` 必须由受信任的本机调用方通过独立认证渠道取得:服务、公开验签钥、 完整检查点摘要、单调序号四项均必填。不能从待恢复数据库、其备份、可一起回滚的审计链或待验证 packet @@ -39,7 +39,7 @@ Refs #64, #65, #68, #69, #70, #84, #85;接续 #112,不关闭现场验收项 同序号内容变化会被精确 pin 拒绝;跨水位的已重放前缀不一致会拒绝。 支持 credential、certificate、ShareGrant v1/v2、ShareLink 撤销,普通 Membership 与 TopicMembership 移除, -IdentityKey/EncryptionSubkey 退役,以及指定账号在线 vault 销毁。只收缩现存权限:不新增身份、授权、 +IdentityKey/EncryptionSubkey 退役,以及指定账号在线 vault 销毁。 `topic_ban.apply` 同时将旧快照里的话题成员降为 member/removed,与正常禁言一致;后续 `topic_ban.lift` 不恢复旧成员资格或 admin 角色。显式 lift 会解除封禁,因此不能把整份日志称为只收缩权限;它不新增身份、授权、 密钥、财务交易、外发 job 或任何历史正文;证书的原始签署 body 不改。 `g_public` 是虚拟成员关系,不能通过修改一条 Membership 假装移除,明确拒绝该事实。 @@ -49,7 +49,7 @@ IdentityKey/EncryptionSubkey 退役,以及指定账号在线 vault 销毁。 公开本机 `replay()` 先调用现有实际 OS 控制台检查;`_replay` 与 `_provision` 一样只是内部用例, 不注册为 HTTP、SSH、MCP 或公共 CLI 操作。 -**边界:这不是 #69 完成或生产提升。** 现有模块仅重放列出的 deny-only 事实。 +**边界:这不是 #69 完成或生产提升。** 现有模块仅重放列出的部分权限事实;显式 `topic_ban.lift` 保留原有解禁语义。 完整 ACL/TopicBan/授权策略快照协调、独立日志的现场来源与当前性证明、完整不变量验收和受控 promotion 仍未完成。数据库收据只是一致性防线,不是抗数据库整体回滚的信任根。 隔离标记始终保留,`promotion=blocked`、`backup_retired=false`;没有把 quarantine 当作“已重放全部当前事实”。 @@ -130,3 +130,74 @@ Application/MetadataStore,不做 DDL、迁移、计数器推进、目录修复 最终远端 head 必须另外完成现有完整四分片、精确 JUnit node-ID gate、conformance 及 wheel/sdist。 最终 source SHA、CI run 与实际数量在 PR/issue 的验收评论中记录;未得到最终结果前不声明成功。 没有部署、生产迁移/恢复、生产 Root/PIN/私钥访问、真实资金或真实邮件/Webhook 外发。 + +## Typed policy ceiling checkpoint v2 (partial, not promotion) + +`msg-revocation-checkpoint-v2` preserves the v1 contract and uses a separate +`recovery-checkpoint-v2` signature purpose. Its exact independently supplied pin +is still required. It adds typed `value` payloads: + +- `resource.acl.restrict`: `owner`, `group`, `mode`. Owner and group must match + the restored resource; the mode becomes its intersection with the supplied mask. + Repeated or broader masks never restore removed permission bits. Current resource + generation advances when changed; historical Revision bodies remain untouched. +- `topic.policy.restrict`: `membership_policy`. Existing and supplied policies + intersect: open accepts the supplied restriction, closed remains closed, and + incompatible approval/invite ceilings become closed. It never reopens a topic. +- `topic_ban.set`: explicit `expires_at` (UTC or null), bound by the signed fact. + The target must be a topic; the existing ban/member-removal replay path is used. + Active bans combine conservatively: permanent dominates finite; otherwise the + later expiry wins. A shorter expiry never lifts a previously active ban; only + an explicit `topic_ban.lift` fact lifts it. + +The packet must state `coverage.complete=false` and an exact sorted list of the +fact domains it contains (`resource_acl`, `topic_policy`, `topic_ban`, +`revocations`, `certificates`, `resource_authority`). Missing/mismatched coverage or a claim of complete recovery is +rejected. The packaged schema is `recovery-policy-checkpoint.schema.json`. +All facts, receipt, authorization epoch and audit commit together. Existing v1 +prefixes can continue into v2; rollback to v1 after a v2 receipt is refused. + +This is conservative restriction, not reconstruction of all current authority. +Full derived-authority reconstruction after owner/group/parent changes, complete +external log provenance/currentness, exhaustive inventory and controlled promotion +remain unfinished. Certificates are still revoked through existing facts; their +signed body is never rewritten to manufacture current grants. Neither coverage +metadata nor an isolated test establishes external freshness or backup destruction. + +### Current certificate fingerprint and staged ownership/parent reconciliation + +Two additional typed v2 facts advance reconciliation while `complete=false` and +persistent quarantine remain mandatory: + +- `certificate.current` binds a subject/certificate ID to `value.body_digest`, + the canonical digest of its entire currently expected signed Certificate, or + null if that certificate has no current authority. A restored body that differs + is revoked as a whole; matching already-revoked certificates remain revoked. + Grants, constraints, issuance bounds, validity, key and chain/source bindings + are all covered by the digest. The original signed body is preserved, never + rewritten to invent narrower grants. Missing facts do not mean a complete + certificate inventory or authorize untouched certificates. +- `resource.authority.reconcile` carries exact `previous` and `current` + `{owner,group,parent}` maps. The subject binds the previous owner. Current owner + and group must exist; parent must exist and remain acyclic. The actual snapshot + must match the previous or already-applied current state. A contiguous sequence + for one resource is verified and collapsed to its final state, allowing old, + intermediate and already-applied snapshots to converge without transiently + restoring older ownership. Interleaved ACL/policy facts must bind the exact + owner/group state at their signed sequence position; replay applies only their + restrictions to the actual old/intermediate/final resource. Unrelated or + temporally mismatched bindings, gaps or unexpected states abort the replay. + +Ownership/parent changes stage metadata only inside the quarantined instance; +resource mode (including CERTGATE/SETGID/STICKY) is preserved and historical +Revision bytes remain unchanged. The receipt names `authority_rebuild_required` +resources and `derived_authority_inventory_incomplete`. Existing certificates +and new/old scope-derived permissions are not declared valid or rebuilt by this +operation: ordinary reads/writes and effect workers remain blocked before and +after it. There is no promotion path. A full current authority inventory and its +independently current provenance are still required before any future promotion +implementation could safely release these staged changes. + +新生成 replay 收据统一称为 partial authority/policy reconciliation,阻断原因是 +`supported_fact_inventory_incomplete`。旧签署检查点的格式、purpose、字节和 lift 行为不变; +lift 解禁后仍全实例隔离,且不恢复已移除的成员资格或 admin 角色。 diff --git a/docs/TRANSFER_STATUS_PROGRESS.md b/docs/TRANSFER_STATUS_PROGRESS.md new file mode 100644 index 00000000..95dcbc02 --- /dev/null +++ b/docs/TRANSFER_STATUS_PROGRESS.md @@ -0,0 +1,55 @@ +# Transfer status continuation — progress + +Scope: Refs #78, #80, #83, #85. This is a narrow correction on top of PR #176, +not a second transfer implementation or a claim of complete storage acceptance. + +## Source and ownership + +- Base commit: `45470b5a46bccfc4f12ef27acccecc2f3bafce47`. +- Base tree: `e75e7cf511a89d72bfdccf5ec39567824b569275`. +- Independent branch: `fix/transfer-status-cursors-20260929`. +- Changes: the shared `TransferService.status` boundary, a regression file and + a focused GitHub Actions workflow. Do not overwrite the active integration + branch or change its schemas, migration code or CI completeness gate. + +## Reproducing the gap + +Known-size status currently calls `int(cursor)` in missing-range storage; +unknown-size status calls it in the projection. Malformed values can therefore +become internal errors, while negative unknown-size offsets and malformed +sealed/download cursors are not consistently rejected. + +The new regressions exercise real Ed25519-authenticated operations against +PostgreSQL, HTTP/PathGET/GraphQL/MCP clients and the fixed transfer endpoint. +They compare full database row values and block publication/outbound interfaces +around reads. They also preserve continuation order, int64 bounds, owner-first +checks and revoked-credential rejection. + +## Evidence + +- Regression-first head: `fa76adef7784c58483b6719057d388bf19b1526d`. +- Cloud run: `36560714465`, job `109380655492`, Python `3.15.0rc2`. +- Actual checkout: synthetic merge `b4c9965748d7e4761fc64e628921d63da11013ec`, + tree `40302bf0a10406148bb828668d1f1e11c217fd77` (identical to the head tree). +- JUnit: 24 cases, 13 assertion failures, 11 passes, zero errors/skips. + Eleven failures reproduce the intended cursor boundary defects. The other two + are test assumptions about the final page: compact wire responses omit null + `next_cursor`. Corrected to accept an absent terminal cursor, while requiring + the exact single range and exact next cursor on every nonterminal page. + Those two failures are not counted as product defects. +- Artifact: `11029760984`; independently checked ZIP SHA256: + `777e5c1a4940609b0dd47f23ead2c340d6db4eeba41e116ac4d88cc886da1f00`. +- Implementation: ten lines at `TransferService.status`, after `_session` current + authorization. Require a nonempty ASCII decimal cursor of at most 19 digits, + value below 2**63 and no greater than known size; apply in terminal states too. + An absent cursor still starts at zero; generated numeric cursors, leading-zero + numeric input and the full supported int64 offset range remain unchanged. + Malformed supplied cursors return `invalid_cursor` without echoing the input. +- No schemas, signed packets, handlers, storage APIs or publication paths changed. +- Final exact-head cloud evidence: pending. No success or closure claimed. +- No project tests were executed locally; local work only edits/inspects text and + independently hashes/parses the downloaded evidence. + +Returned numeric cursors and all six published transfer operations remain the +same. Unknown-size uploads still expose received ranges rather than inventing +missing ranges. No deployment, production data or external delivery is involved. diff --git a/docs/TRUST_PATHS.md b/docs/TRUST_PATHS.md new file mode 100644 index 00000000..a48d9a9c --- /dev/null +++ b/docs/TRUST_PATHS.md @@ -0,0 +1,28 @@ +# Public Root trust paths and reserved plugin directory + +New initialization writes `trust/root.crt`. Its content is the existing version 1 +signed JSON Root certificate envelope, not PEM/X.509. Certificate signatures, +purposes and wire versions are unchanged. Existing installations containing only +`trust/root.json` continue reading and writing that path without migration. + +If both paths exist, ordinary readers require equal canonical JSON bytes after +strict parsing. Whitespace and object key order may differ; values may not. A +conflict fails with `root_trust_alias_mismatch`; neither path takes precedence over +a conflicting value. Symlinks, including dangling ones, are refused. Canonical +encoding is the project's `msg.core.codec.canonical`, not generic JSON equality. + +Root rotation updates every existing alias using durable replacement, retaining +the existing PIN-protected, signed rotation journal until all copies finish. +Two path replacements are not one filesystem transaction: interruption can leave +old/new copies, which ordinary readers reject. Explicit local rotation resume +accepts each copy only if it equals the old or new trust bound by that journal, +checks the database anchor and signatures, then finishes all copies. An unrelated +third value is rejected before database mutation. Do not manually overwrite one +alias to repair a mismatch; use the approved rotation recovery procedure. + +Initialization and example configuration create `plugins.d/` with mode 0755. +Legacy configurations without this directory remain valid. If present, it must +be a directory, must not be a symlink, and must not be group/other writable. +It is reserved: its contents are neither imported nor executed, and no fragment +configuration language is defined. Plugin selection remains `[plugins].enabled` +in `server.toml`; plugin code and contracts remain source registered. diff --git a/docs/registry-template-tool-contracts.md b/docs/registry-template-tool-contracts.md new file mode 100644 index 00000000..606ca771 --- /dev/null +++ b/docs/registry-template-tool-contracts.md @@ -0,0 +1,27 @@ +# Template and tool registration boundary + +The installed Registry indexes `TemplateSpec` and `ToolSpec` by resource ID and +explicit contract version. These maps reject unknown lookups and duplicates, +snapshot nested data, and stop accepting additions after freeze. They do not +change the serialized model fields, published operation versions, or short codes. +Tool operation and input/output schema references are checked at freeze. + +The content plugin registers the shipped template declarations from the existing +bootstrap source. The template version is the DSL header version; renderer version +1 identifies the finite, non-executable renderer. Fields and defaults are frozen +metadata. Creating or updating a user's template still writes Resource/Revision +facts. Runtime parsing and version resolution remain authoritative for those +resources. Registered fields are reused only when the selected revision's digest +exactly matches the installed declaration; later or user-created revisions keep +their own fields, defaults, digest, and template relation. + +The tools plugin registers the existing DNS and HTTP descriptors alongside their +schemas. A runtime tool revision must match the installed executor and schemas. +Its pinned revision and network limits remain resource facts; authorization, +certificate ceilings, deployment limits, and worker rechecks still apply. Registry +registration supplies no permission and does not execute resource content. + +Focused coverage is in `tests/test_registry_template_tools.py`, alongside the +existing tool invocation/worker, schema ownership, and Registry reference tests. +This boundary does not assert completion of the broader chapter 15 acceptance +matrix or production verification. diff --git a/pyproject.toml b/pyproject.toml index 8c8bc973..6eae7358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["uv_build>=0.12.19,<0.13"] +requires = ["uv_build==0.12.20"] build-backend = "uv_build" [project] @@ -9,30 +9,70 @@ description = "Atomic communication infrastructure for sandboxed agents" readme = "README.md" requires-python = ">=3.15" license = "MIT" -authors = [{name="TokenNotIncluded"}] -dependencies = ["cryptography>=46,<52", "starlette>=0.50,<2", "uvicorn>=0.38,<1", "httpx>=0.28,<1", "jsonschema>=4.25,<5", "referencing>=0.36,<1", "aiohttp>=3.13,<4", "dnspython>=2.8,<3", "graphql-core>=3.2.7,<3.3", "psycopg[binary]>=3.3,<4", "valkey>=6.1,<7"] +authors = [{name = "TokenNotIncluded"}] +dependencies = [ + "cryptography>=46,<52", + "httpx>=0.28,<1", + "jsonschema>=4.25,<5", + "referencing>=0.36,<1", +] [project.scripts] msg = "msg.cli:main" msgd = "msg.daemon:main" [project.optional-dependencies] -dev = ["pytest>=8.4", "pytest-asyncio>=1.1", "pytest-cov>=6", "ruff>=0.14", "build>=1.2", "tiktoken>=0.12"] +server = [ + "starlette>=0.50,<2", + "uvicorn>=0.38,<1", + "aiohttp>=3.13,<4", + "dnspython>=2.8,<3", + "graphql-core>=3.2.7,<3.3", + "psycopg[binary]>=3.3,<4", + "valkey>=6.1,<7", +] +dev = [ + "msgctl[server]", + "pytest>=8.4", + "pytest-asyncio>=1.1", + "pytest-cov>=6", + "ruff==0.16.9", + "build>=1.2", + "tiktoken>=0.12", +] [tool.uv.build-backend] module-name = "msg" source-include = [ "CHANGELOG.md", "CONTRIBUTING.md", "SECURITY.md", "conftest.py", "tests/**", "conformance/**", "scripts/**", - "docs/*.md", "deploy/**", + "docs/*.md", "deploy/**", "uv.lock", ".python-version", ] [tool.pytest.ini_options] -addopts = "-ra" +addopts = "-ra --strict-config --strict-markers" testpaths = ["tests"] pythonpath = ["src"] asyncio_mode = "auto" +filterwarnings = ["error"] [tool.ruff] +required-version = "==0.16.9" line-length = 100 -target-version = "py313" +target-version = "py315" +# Python 3.15 support still requires preview in this pinned Ruff release. +# Explicit rule selection prevents enabling unrelated preview diagnostics. +preview = true +unsafe-fixes = false +src = ["src"] +extend-exclude = [".legacy-ledger"] + +[tool.ruff.lint] +select = [ + "E4", "E7", "E9", "F", "I", "UP", "B", "C4", "ICN", + "PIE", "PGH", "RSE", "T10", "TID", "YTT", "RUF100", +] +explicit-preview-rules = true + +[tool.ruff.format] +quote-style = "single" diff --git a/scripts/check_client_install.py b/scripts/check_client_install.py new file mode 100644 index 00000000..f12ed662 --- /dev/null +++ b/scripts/check_client_install.py @@ -0,0 +1,124 @@ +"""Exercise real client imports and signed envelopes without a server runtime. + +Run with --minimal-install in a fresh wheel-only virtualenv, outside the source +checkout. Network traffic uses httpx.MockTransport, not a remote service. +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib +import importlib.abc +import importlib.util +import json +from pathlib import Path +import stat +import sys +import tempfile + +SERVER_DEPENDENCIES = ('starlette', 'uvicorn', 'psycopg', 'valkey', 'aiohttp', 'dns', 'graphql') +FORBIDDEN = ('msg.application', 'msg.core.executor', 'msg.storage', 'msg.admin', + 'msg.workers', 'msg.security.vault', 'msg.security.custodial_migration', + 'msg.transports.http', 'msg.transports.graphql', 'msg.transports.mcp') + + +class ClientBoundary(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if any(fullname == name or fullname.startswith(name + '.') for name in FORBIDDEN): + raise RuntimeError('client imported a server implementation: ' + fullname) + return None + + +async def check(directory): + import httpx + from msg.client import ClientState + from msg.core.codec import canonical, loads, wire + from msg.core.requests import request_for, signing_bytes + from msg.security.crypto import Ed25519Signer, verify + from msg.transports.client import TRANSPORTS + from msg.transports.packet import decode_packet, path_packet + + for name in ('cli', 'tui', 'client_certificates', 'client_content', 'client_custodial', + 'client_market', 'client_recovery', 'client_secrets', 'client_tokens', 'client_upgrade'): + importlib.import_module('msg.' + name) + state = ClientState(directory, server='https://unit.invalid') + signer = Ed25519Signer.from_bytes(bytes(range(32))) + state.save_signer(signer) + state.ensure_encryption_key() + restored = ClientState(directory) + assert restored.signer.public_key == signer.public_key + assert restored.encryption_recipient == state.encryption_recipient + for path in (state.path, state.key_path, state.age_key_path): + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + request = request_for('content.read', {'id': 'r_test'}, state.server, + subject='u_test', signer=signer, request_id='client-boundary') + calls = [] + + def respond(incoming): + path = incoming.url.path + if path == '/_transports': + return httpx.Response(200, json={'version': 1, 'target_service': state.server, + 'operations': {'content.read': 'read'}, 'limits': { + 'max_request_bytes': 1048576, 'max_response_bytes': 1048576, + 'max_path_bytes': 8192, 'encodings': ['j', 'gz']}}) + if path.startswith('/-/g/'): + _, _, _, operation, encoding, encoded = path.split('/') + packet = path_packet(encoded, encoding, 1048576) + assert packet.operation == operation and incoming.method == 'GET' + kind = 'path_get' + else: + data = loads(incoming.content) + if path == '/_read/graphql': + data = data['variables']['packet'] + kind = 'graphql' + elif path == '/-/mcp': + assert incoming.headers['MCP-Protocol-Version'] == '2025-11-25' + data = data['params']['arguments']['packet'] + kind = 'mcp_http' + else: + assert path == '/-/p/content.read' + kind = 'http' + assert incoming.method == 'POST' + packet = decode_packet(data) + assert canonical(packet) == canonical(request) + verify(signer.public_key, signing_bytes(packet), packet.proof.signature, purpose='request') + calls.append(kind) + result = {'request_id': packet.request_id, 'operation': packet.operation, 'status': 'ok', + 'actor': 'u_test', 'subject': 'u_test', 'resources': [{'id': 'r_test'}], + 'data': {'content': 'signed client response'}} + if kind == 'graphql': + result = {'data': {'call': result}} + elif kind == 'mcp_http': + result = {'jsonrpc': '2.0', 'id': packet.request_id, + 'result': {'structuredContent': result}} + return httpx.Response(200, json=result) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http: + for name, transport in TRANSPORTS.items(): + result = await transport(state.server, http=http).call(request) + assert result.status == 'ok' and result.resources[0].id == 'r_test', (name, wire(result)) + assert result.data['content'] == 'signed client response' + assert sorted(calls) == sorted(TRANSPORTS) + assert not any(any(name == prefix or name.startswith(prefix + '.') for prefix in FORBIDDEN) + for name in sys.modules) + return calls + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--minimal-install', action='store_true') + args = parser.parse_args() + if args.minimal_install: + present = [name for name in SERVER_DEPENDENCIES if importlib.util.find_spec(name) is not None] + assert not present, 'server-only Python dependencies in client environment: ' + ', '.join(present) + sys.meta_path.insert(0, ClientBoundary()) + with tempfile.TemporaryDirectory(prefix='msg-client-install-') as folder: + calls = asyncio.run(check(Path(folder) / 'client')) + print(json.dumps({'status': 'ok', 'transports': sorted(calls), + 'server_implementation_imported': False, + 'minimal_install': args.minimal_install})) + + +if __name__ == '__main__': + main() diff --git a/scripts/check_isolated_sshd.py b/scripts/check_isolated_sshd.py new file mode 100644 index 00000000..a46e0eb3 --- /dev/null +++ b/scripts/check_isolated_sshd.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Explicit local acceptance: disposable root-owned sshd, never system sshd. + +Run with the installed project's Python as an ordinary user with sudo -n. +Requires PostgreSQL tools, OpenSSH and Git. No production configuration is read. +""" +import asyncio +from datetime import timedelta +import json +import os +from pathlib import Path +import pwd +import shlex +import shutil +import socket +import subprocess +import sys +import tempfile + +from cryptography.hazmat.primitives import serialization + +from msg.admin.diagnostics import temporary_postgres +from msg.admin.root import _provision, _approve_csr, _revoke +from msg.application import Application +from msg.config import write_example +from msg.core.codec import b64, canonical, wire +from msg.core.requests import request_for +from msg.core.models import CertificateRequest, Signature +from msg.constants import ROOT_SUBJECT +from msg.security.certificates import csr_body +from msg.security.age_keys import generate_age_key +from msg.security.crypto import Ed25519Signer, subject_id + + +def run(*args, **kwargs): + return subprocess.run(args, capture_output=True, timeout=60, **kwargs) + + +async def acceptance(): + assert os.geteuid() != 0, 'Run as an ordinary service user, not root' + assert run('sudo', '-n', 'true').returncode == 0, 'sudo -n is required' + sshd = shutil.which('sshd') + assert sshd and shutil.which('ssh') and shutil.which('ssh-keygen') + checks = [] + with tempfile.TemporaryDirectory(prefix='msg-sshd-check-') as temporary, temporary_postgres() as dsn: + folder = Path(temporary) + app = Application(write_example(folder/'etc', folder/'data', 'https://sshd-check.invalid', postgres_dsn=dsn)) + assert app.settings.root_private_dir.is_relative_to(folder) + server = None + master = None + privileged = None + try: + csr, root = await _provision(app, 'isolated-sshd-' + os.urandom(24).hex()) + await _approve_csr(app, csr, root, expected_digest=None, operator='isolated-sshd-check') + # The service account must actually be unable to read root material. + assert run('sudo', '-n', 'chown', '-R', '0:0', str(app.settings.root_private_dir)).returncode == 0 + key = Ed25519Signer.generate() + uid = subject_id(key.public_key) + + async def call(name, args): + packet = request_for(name, args, app.settings.service_url, signer=key, subject=uid, + expires_at=app.clock()+timedelta(seconds=120)) + return await app.executor.execute(packet) + + _, recipient = generate_age_key() + packet = request_for('identity.register', {'handle': 'ssh-check', 'public_key': b64(key.public_key), + 'encryption_recipient': recipient}, app.settings.service_url, signer=key, subject=uid, + contract_version=2, expires_at=app.clock()+timedelta(seconds=120)) + assert (await app.executor.execute(packet)).status == 'ok' + repo = await call('git.create', {'parent': uid, 'name': 'code.git'}) + assert repo.status == 'ok' + client_key = folder/'client-key' + assert run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(client_key)).returncode == 0 + private = serialization.load_ssh_private_key(client_key.read_bytes(), password=None) + signer = Ed25519Signer.from_bytes(private.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())) + line = ' '.join(client_key.with_suffix('.pub').read_text().split()[:2]) + proof = signer.sign(canonical({'subject_id': uid, 'public_key': line}), purpose='ssh-key-add') + grants = [g for g in app.base_grants() if g.capability in {'git.basic', 'discovery.basic'}] + added = await call('identity.ssh_key_add', {'public_key': line, 'proof': wire(proof), + 'ceiling': wire(grants)}) + assert added.status == 'ok' + csr = CertificateRequest(resource_id='pending', applicant=uid, subject_id=uid, + requested_issuer=ROOT_SUBJECT, public_key=signer.public_key, kind='capability', + grants=tuple(grants), issuance=None, requested_ttl_seconds=600, + target_service=app.settings.service_url, delegation_depth=0, authority_sources=(), + request_digest='', possession_proof=Signature(key_id=signer.key_id, + algorithm='ed25519', value=b'')) + possession = signer.sign(canonical(csr_body(csr)), purpose='csr') + requested = await call('cert.request', {'requested_issuer': ROOT_SUBJECT, + 'public_key': b64(signer.public_key), 'kind': 'capability', 'grants': wire(grants), + 'requested_ttl_seconds': 600, 'possession_proof': wire(possession)}) + assert requested.status == 'ok' + certificate = await _approve_csr(app, requested.data['csr_id'], root, + expected_digest=requested.data['request_digest'], operator='isolated-sshd-check') + attached = await call('identity.ssh_certificates', + {'key_id': signer.key_id, 'certificates': [certificate.resource_id]}) + assert attached.status == 'ok' + host = folder/'host-key' + assert run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(host)).returncode == 0 + privileged = Path(run('sudo', '-n', 'mktemp', '-d', '/run/msg-sshd-check-XXXXXXXX', check=True).stdout.decode().strip()) + assert privileged.parent == Path('/run') and privileged.name.startswith('msg-sshd-check-') + assert run('sudo', '-n', 'chmod', '755', str(privileged)).returncode == 0 + wrapper = folder/'authorized' + wrapper.write_text('#!/bin/sh\nexec ' + shlex.join([sys.executable, '-m', 'msg.daemon', + '--config-dir', str(folder/'etc'), 'ssh-authorized-key']) + ' "$@"\n') + assert run('sudo', '-n', 'install', '-m', '755', str(wrapper), str(privileged/'authorized')).returncode == 0 + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + port = probe.getsockname()[1] + username = pwd.getpwuid(os.geteuid()).pw_name + pidfile = folder/'sshd.pid' + config = folder/'sshd.conf' + config.write_text(f'''ListenAddress 127.0.0.1 +Port {port} +HostKey {host} +PidFile {pidfile} +AllowUsers {username} +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +AuthenticationMethods publickey +UsePAM no +AuthorizedKeysFile none +AuthorizedKeysCommand {privileged}/authorized %t %k +AuthorizedKeysCommandUser {username} +PermitTTY no +DisableForwarding yes +PermitUserRC no +PermitUserEnvironment no +LogLevel ERROR +''') + assert run('sudo', '-n', sshd, '-t', '-f', str(config)).returncode == 0 + server = subprocess.Popen(['sudo', '-n', sshd, '-D', '-e', '-f', str(config)], + stdout=subprocess.DEVNULL, stderr=(folder/'sshd.log').open('wb')) + for _ in range(100): + if pidfile.exists(): + break + assert server.poll() is None, 'isolated sshd exited' + await asyncio.sleep(.05) + assert pidfile.exists() + host_fields = host.with_suffix('.pub').read_text().split()[:2] + known = folder/'known-hosts' + known.write_text(f'[127.0.0.1]:{port} ' + ' '.join(host_fields) + '\n') + ssh = ['ssh', '-F', '/dev/null', '-p', str(port), '-i', str(client_key), + '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', + '-o', f'UserKnownHostsFile={known}', '-o', 'ConnectTimeout=5'] + target = f'{username}@127.0.0.1' + read = await asyncio.to_thread(run, *ssh, target, 'msg call discovery.get \'{"id":"/main"}\'') + assert read.returncode == 0, (read.stderr.decode(), (folder/'sshd.log').read_text()) + assert json.loads(read.stdout)['status'] == 'ok' + checks.append('real_authorized_keys_and_forced_command') + for command in ('id', 'sh', 'git-upload-pack /etc/passwd'): + denied = await asyncio.to_thread(run, *ssh, target, command) + assert denied.returncode != 0 + checks.append('shell_and_path_escape_denied') + forwarding = await asyncio.to_thread(run, *ssh, '-o', 'ExitOnForwardFailure=yes', + '-R', '0:127.0.0.1:1', target, 'msg call discovery.get \'{"id":"/main"}\'') + assert forwarding.returncode != 0 and b'remote port forwarding failed' in forwarding.stderr + checks.append('remote_forwarding_denied') + work = folder/'work'; work.mkdir() + def git(*args): + return run('git', '-C', str(work), *args) + assert git('init', '--initial-branch=main').returncode == 0 + assert git('config', 'user.name', 'Isolated').returncode == 0 + assert git('config', 'user.email', 'isolated@example.invalid').returncode == 0 + (work/'README').write_text('real SSH Git acceptance\n') + assert git('add', '.').returncode == 0 + assert git('commit', '-m', 'isolated').returncode == 0 + assert git('branch', 'second').returncode == 0 + remote = f'{target}:/@ssh-check/code.git' + command = ['git', '-C', str(work), '-c', 'core.sshCommand='+shlex.join(ssh)] + pushed = await asyncio.to_thread(run, *command, 'push', '--atomic', remote, 'main', 'second') + assert pushed.returncode == 0, pushed.stderr.decode() + fetched = await asyncio.to_thread(run, *command, 'ls-remote', remote) + assert fetched.returncode == 0 and b'refs/heads/main' in fetched.stdout and b'refs/heads/second' in fetched.stdout + clone = folder/'clone' + cloned = await asyncio.to_thread(run, *command, 'clone', remote, str(clone)) + assert cloned.returncode == 0 and (clone/'README').read_bytes() == (work/'README').read_bytes() + checks.append('real_ssh_atomic_multi_ref_push_and_fetch') + control = folder/'control' + master = subprocess.Popen([*ssh, '-M', '-S', str(control), '-N', target], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(200): + if control.exists(): + break + assert master.poll() is None, 'SSH master connection failed' + await asyncio.sleep(.05) + assert control.exists() + await _revoke(app, certificate.resource_id, root, reason='isolated test', + operator='isolated-sshd-check') + cert_denied = await asyncio.to_thread(run, *ssh, '-S', str(control), target, + 'msg call discovery.get \'{"id":"/main"}\'') + assert cert_denied.returncode != 0 and b'certificate_revoked' in cert_denied.stdout + cert_denied.stderr + new_denied = await asyncio.to_thread(run, *ssh, target, + 'msg call discovery.get \'{"id":"/main"}\'') + assert new_denied.returncode == 255 + checks.append('revoked_certificate_denies_new_and_existing_authenticated_connections') + # Explicitly detach the revoked optional certificate; never unrevoke it. + detached = await call('identity.ssh_certificates', {'key_id': signer.key_id, 'certificates': []}) + assert detached.status == 'ok' + revoked = await call('identity.ssh_key_revoke', {'key_id': signer.key_id}) + assert revoked.status == 'ok' + reused = await asyncio.to_thread(run, *ssh, '-S', str(control), target, + 'msg call discovery.get \'{"id":"/main"}\'') + assert reused.returncode != 0 and b'credential_revoked' in reused.stdout + reused.stderr + checks.append('revocation_rechecked_inside_already_authenticated_connection') + denied = await asyncio.to_thread(run, *ssh, target, 'msg call discovery.get \'{"id":"/main"}\'') + assert denied.returncode == 255 and b'Permission denied' in denied.stderr + denied_git = await asyncio.to_thread(run, *command, 'ls-remote', remote) + assert denied_git.returncode != 0 + checks.append('revoked_registered_ssh_key_denies_new_sessions_and_git') + finally: + if master is not None: + master.terminate() + master.wait(timeout=15) + if server is not None: + if pidfile.exists(): + pid = int(pidfile.read_text().strip()) + run('sudo', '-n', 'kill', '-TERM', str(pid)) + server.wait(timeout=15) + await app.close() + # These two paths were created in this function; no installed paths. + run('sudo', '-n', 'rm', '-rf', '--', str(app.settings.root_private_dir)) + if privileged is not None: + run('sudo', '-n', 'rm', '-rf', '--', str(privileged)) + return {'checks': checks, 'temporary_installation_removed': not folder.exists()} + + +if __name__ == '__main__': + print(json.dumps(asyncio.run(acceptance()), indent=2)) diff --git a/scripts/check_tool_sandbox.py b/scripts/check_tool_sandbox.py new file mode 100644 index 00000000..4ee1e9d6 --- /dev/null +++ b/scripts/check_tool_sandbox.py @@ -0,0 +1,56 @@ +"""Fail-fast local/CI preflight using the real runner and a non-network packet. + +Only this operator-invoked probe captures subprocess stderr. The packet contains +no user input or credentials, and production errors keep their stable safe code. +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from tempfile import TemporaryDirectory, TemporaryFile +from types import SimpleNamespace +from unittest.mock import patch + +from msg.core.errors import Failure +from msg.core.models import NetworkPolicy +from msg.workers.sandbox import BubblewrapRunner + + +async def check(): + print(f'Python executable: {sys.executable}', flush=True) + print(f'Python prefix: {sys.prefix}; base prefix: {sys.base_prefix}', flush=True) + setting = Path('/proc/sys/kernel/apparmor_restrict_unprivileged_userns') + if setting.exists(): + print(f'AppArmor unprivileged userns restriction: {setting.read_text().strip()}', flush=True) + policy = NetworkPolicy(schemes=frozenset({'https'}), hosts=(), ports=frozenset({443}), + methods=frozenset({'GET'}), allow_private=False, + timeout_ms=1000, max_response_bytes=1024, max_redirects=0) + app = SimpleNamespace(settings=SimpleNamespace(server=SimpleNamespace( + limits=SimpleNamespace(max_request_bytes=1024)))) + spawn = asyncio.create_subprocess_exec + with TemporaryDirectory(prefix='msg-sandbox-preflight-') as directory, TemporaryFile() as diagnostics: + async def capture(*args, **kwargs): + kwargs['stderr'] = diagnostics + return await spawn(*args, **kwargs) + + try: + with patch('asyncio.create_subprocess_exec', capture): + try: + await BubblewrapRunner(app)(SimpleNamespace(executor_key='curl'), + {'url': 'file:///not-a-network-target'}, (policy,), Path(directory)) + except Failure as exc: + if exc.code != 'network_policy_denied': + raise + else: + raise AssertionError('Sandbox child accepted an invalid scheme') + finally: + diagnostics.seek(0) + stderr = diagnostics.read(16384).decode(errors='replace') + if stderr: + print('Sandbox preflight stderr:\n' + stderr, file=sys.stderr, flush=True) + print('Sandbox child started and enforced network policy.', flush=True) + + +if __name__ == '__main__': + asyncio.run(check()) diff --git a/src/msg/__init__.py b/src/msg/__init__.py index 966a4f77..d72073ba 100644 --- a/src/msg/__init__.py +++ b/src/msg/__init__.py @@ -1,2 +1,2 @@ """Atomic communication for sandboxed agents.""" -__version__ = "0.1.0a1" +__version__ = "1.0.0a1" diff --git a/src/msg/admin/backup_retirement.py b/src/msg/admin/backup_retirement.py index adb6b164..f91e764c 100644 --- a/src/msg/admin/backup_retirement.py +++ b/src/msg/admin/backup_retirement.py @@ -8,13 +8,46 @@ from __future__ import annotations from msg.constants import ROOT_SUBJECT -from msg.core.codec import digest, loads -from msg.core.errors import require +import os +from pathlib import Path +import tempfile + +from msg.core.codec import canonical, digest, loads +from msg.core.errors import Failure, require from msg.core.models import AuditEvent, Event, ResourceRef from msg.plugins.common import new_id from msg.security.backup_retirement import FIELDS, DOMAIN, check, root_verifier, setting_key +def read_statement(path): + from msg.security.root_files import read_private + return loads(read_private(path, limit=1024 * 1024)) + + +def write_record(path, record): + """Publish a complete 0600 record without replacing an existing destination.""" + path = Path(path) + fd, temporary = tempfile.mkstemp(prefix='.retirement-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(canonical(record)) + stream.flush() + os.fsync(stream.fileno()) + try: + os.link(temporary, path) + except FileExistsError as exc: + raise Failure('backup_retirement_destination_exists') from exc + os.unlink(temporary) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def unsigned_statement(value): require(isinstance(value, dict) and set(value) == FIELDS and value.get('domain') == DOMAIN, 'backup_retirement_invalid') @@ -36,7 +69,7 @@ async def import_record(tx, record, *, now, operator): isinstance(body.get('old_identity_key_id'), str), 'backup_retirement_invalid') subject = body['subject_id'] details = completed_migration(tx, subject, body['old_identity_key_id']) - attestation = check(record, await root_verifier(tx), subject, details, now=now) + attestation = check(record, await root_verifier(tx, now=now), subject, details, now=now) key = setting_key(subject, attestation.old_identity_key_id) previous = tx.setting(key) tx.set_setting(key, record) diff --git a/src/msg/admin/backups.py b/src/msg/admin/backups.py index 2b5612a7..c3437724 100644 --- a/src/msg/admin/backups.py +++ b/src/msg/admin/backups.py @@ -54,7 +54,7 @@ def _db_refs(connection): return {'contents':contents, 'revisions':revisions} -def _verify_storage(directory, refs, *, settings=None): +def _verify_storage(directory, refs, *, settings=None, repair_git_layout=True): """Verify archived bytes, Git objects and the SQL-referenced content index.""" content=(settings.server.content_dir if settings is not None else directory/'content') blobs=(settings.server.blob_dir if settings is not None else directory/'blobs') @@ -99,7 +99,10 @@ def _verify_storage(directory, refs, *, settings=None): # ZIP stores files only. An empty bare repository still needs these # structural directories; recreating them changes no Git object/ref. for name in ('objects/info','objects/pack','refs/heads','refs/tags'): - (repository/name).mkdir(parents=True,exist_ok=True) + if repair_git_layout: + (repository/name).mkdir(parents=True,exist_ok=True) + else: + require((repository/name).is_dir(), 'backup_git_layout_missing') process=subprocess.run(['git','--git-dir',str(repository),'fsck','--full','--no-reflogs'], capture_output=True,check=False) require(process.returncode==0, 'backup_git_repository_invalid') @@ -332,8 +335,9 @@ def restore(source,config_dir,data_dir,*,postgres_dsn='service=msgd'): shutil.move(str(directory/'service'),settings.service_keys) os.chmod(settings.service_keys,0o700) settings.trust_file.parent.mkdir(parents=True,exist_ok=True) - shutil.move(str(directory/'root-public.json'),settings.trust_file) - os.chmod(settings.trust_file,0o444) + from msg.security.trust_files import write_trust + write_trust(settings.config_dir,loads((directory/'root-public.json').read_bytes()), + writer=durable_write) _relink_lfs(data_dir,settings) _verify_storage(data_dir,manifest['references'],settings=settings) return {'status':'restored','root_admin_material':'restore_separate_encrypted_root_backup_locally', diff --git a/src/msg/admin/config_check.py b/src/msg/admin/config_check.py new file mode 100644 index 00000000..fe9940b9 --- /dev/null +++ b/src/msg/admin/config_check.py @@ -0,0 +1,189 @@ +"""Executable configuration acceptance vectors, never production credentials. + +Doctor reports parsing and existing read-only deployment observations separately. +Selftest writes temporary TOML files and calls the real loader for each option. +It does not connect to SMTP, bind listeners, read a credential, or provision keys. +""" +from copy import deepcopy +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from msg.config_contracts import CONFIGURATION_FIELDS +from msg.core.errors import Failure, require + + +_REQUIRED = object() +_DISABLED = object() + + +def _vectors(): + from msg.plugins import BUILTINS + from msg.security.age_keys import recipient_from_public + recipient = recipient_from_public(bytes.fromhex('09'+'00'*31)) + custodian = {'id':'test-custodian','name':'Isolated custodian','recipient':recipient} + # These are independent acceptance expectations, not configuration defaults + # used by the server. Each case asserts the loader's effective Settings value. + rows = { + 'server.service_url': ('https://msg.lmm.best','https://config.invalid','file:///tmp/x','invalid_service_url'), + 'server.listen': ('127.0.0.1','::1','','invalid_listen_address'), + 'server.port': (8042,65535,0,'invalid_listen_port'), + 'server.public_web_origin': (None,'https://msg.lmm.best','https://elsewhere.invalid','hosting_origin_must_match_service'), + 'server.temporary_ttl': (3600,1,0,'invalid_ttl'), + 'server.transfer_ttl': (86400,1,0,'invalid_ttl'), + 'storage.postgres_dsn': (_REQUIRED,'service=isolated','sqlite:///tmp/x','invalid_postgres_dsn'), + 'storage.valkey_url': (None,'redis://localhost:6379/0','http://localhost:6379','invalid_valkey_url'), + 'storage.content': ('/var/lib/msgd/git/content','/tmp/config-content','relative','storage_paths_must_be_absolute'), + 'storage.repositories': ('/var/lib/msgd/git/repos','/tmp/config-repos','relative','storage_paths_must_be_absolute'), + 'storage.blobs': ('/var/lib/msgd/blobs/sha256','/tmp/config-blobs','relative','storage_paths_must_be_absolute'), + 'storage.staging': ('/var/lib/msgd/transfers/staging','/tmp/config-staging','relative','storage_paths_must_be_absolute'), + 'storage.service_keys': ('/var/lib/msgd/service','/tmp/config-service','relative','storage_paths_must_be_absolute'), + 'limits.request_bytes': (1048576,256,255,'invalid_limit'), + 'limits.response_bytes': (1048576,256,255,'invalid_limit'), + 'limits.path_bytes': (8192,256,255,'invalid_limit'), + 'limits.part_bytes': (65536,256,255,'invalid_limit'), + 'plugins.enabled': (list(BUILTINS),['identity'],['arbitrary'],'unknown_plugin'), + 'tools.isolation': ('bubblewrap','bubblewrap','none','unsafe_tool_worker'), + 'tools.timeout_ms': (10000,1,0,'invalid_tool_limit'), + 'tools.max_response_bytes': (4194304,1,0,'invalid_tool_limit'), + 'tools.methods': (['GET','HEAD'],['GET'],['EXEC'],'invalid_tool_methods'), + 'tools.ports': ([80,443],[65535],[0],'invalid_tool_ports'), + 'identity.credential_delivery_recovery_window': (900,'60m','61m','invalid_credential_delivery_recovery_window'), + 'money.enabled': (True,True,False,'invalid_money_configuration'), + 'money.currency_id': ('primary','primary','other','invalid_money_configuration'), + 'money.display_name': ('MSG','Community credits','','invalid_money_display_name'), + 'money.code': ('MSG','CREDIT','msg','invalid_money_code'), + 'money.scale': (6,6,7,'invalid_money_configuration'), + 'money.transfer_fee': (0,0,1,'invalid_money_configuration'), + 'money.allow_overdraft': (False,False,True,'invalid_money_configuration'), + 'hosting.base_capacity_bytes': (10485760,1024**4,0,'invalid_hosting_capacity'), + 'hosting.recovery_marker': (None,'/tmp/config-marker','relative','invalid_hosting_recovery_marker'), + 'hosting.content_group_read': (False,True,'true','invalid_content_group_read'), + 'recovery.custodians': ([],[custodian],[custodian]*17,'invalid_recovery_custodians'), + 'mail.enabled': (_DISABLED,False,'false','invalid_mail_enabled'), + 'mail.host': (_DISABLED,'smtp.example.test','bad host','invalid_mail_host'), + 'mail.port': (_DISABLED,465,65536,'invalid_mail_port'), + 'mail.tls': (_DISABLED,'tls','plaintext','mail_tls_required'), + 'mail.sender': (_DISABLED,'Message service ','sender\r\ninjected','invalid_mail_sender'), + 'mail.credential_file': (_DISABLED,'/tmp/unread-smtp-credentials','relative','invalid_mail_credential_file'), + 'recovery.custodians.id': (_DISABLED,'other-custodian','INVALID','invalid_recovery_custodian'), + 'recovery.custodians.name': (_DISABLED,'Other custodian','','invalid_recovery_custodian'), + 'recovery.custodians.recipient': (_DISABLED,recipient,'invalid','invalid_age_recipient'), + 'recovery.custodians.description': (_DISABLED,'Description','x'*501,'invalid_recovery_custodian'), + 'recovery.custodians.policy_ref': (_DISABLED,'/wiki/recovery-policy','x'*501,'invalid_recovery_custodian'), + } + return rows, custodian + + +def _toml(value): + if isinstance(value,dict): + return '{'+', '.join(key+' = '+_toml(item) for key,item in value.items())+'}' + if isinstance(value,(list,tuple)): + return '['+', '.join(_toml(item) for item in value)+']' + return json.dumps(value,ensure_ascii=False) + + +def _write(directory, data, mail): + (directory/'msgd.toml').write_text('\n'.join( + '['+section+']\n'+'\n'.join(key+' = '+_toml(value) for key,value in values.items()) + for section,values in data.items())) + path=directory/'mail.toml' + if mail is None: + path.unlink(missing_ok=True) + else: + path.write_text('\n'.join(key+' = '+_toml(value) for key,value in mail.items())) + + +def _effective(settings, field): + value=settings + for part in CONFIGURATION_FIELDS[field]['setting'].split('.'): + if value is None: + return None + if part.isdigit(): + value=value[int(part)] if value else None + else: + value=getattr(value,part) + if isinstance(value,Path): + return str(value) + if isinstance(value,tuple): + if field=='recovery.custodians': + return [{key:getattr(item,key) for key in ('id','name','recipient')} + for item in value] + return list(value) + return value + + +def configuration_selftest(): + """Return only status/code per field; never return loaded configuration values.""" + from msg.config import load_settings + vectors,custodian=_vectors() + require(set(vectors)==set(CONFIGURATION_FIELDS),'configuration_diagnostic_coverage_missing') + results={} + with TemporaryDirectory(prefix='msg-config-selftest-') as temporary: + directory=Path(temporary) + baseline={'storage':{'postgres_dsn':'service=msgd'}} + for field,(default,good,bad,error_code) in vectors.items(): + try: + _write(directory,baseline,None) + defaults=load_settings(directory) + if default is _REQUIRED: + _write(directory,{},None) + try: + load_settings(directory) + except Failure as exc: + require(exc.code==error_code,'configuration_default_mismatch') + else: + raise Failure('configuration_default_mismatch') + default_status='required' + elif default is _DISABLED: + require(_effective(defaults,field) is None,'configuration_default_mismatch') + default_status='disabled' + else: + require(_effective(defaults,field)==default,'configuration_default_mismatch') + default_status='pass' + for accepted,value in ((True,good),(False,bad)): + data=deepcopy(baseline) + mail=None + if field.startswith('mail.'): + mail={'enabled':True,'host':'smtp.example.test','tls':'starttls', + 'sender':'sender@example.test'} + mail[field.split('.')[1]]=value + elif field.startswith('recovery.custodians.'): + item=dict(custodian) + item[field.rsplit('.',1)[1]]=value + data['recovery']={'custodians':[item]} + else: + section,key=field.split('.') + data.setdefault(section,{})[key]=value + _write(directory,data,mail) + try: + loaded=load_settings(directory) + except Failure as exc: + require(not accepted and exc.code==error_code, + 'configuration_rejection_mismatch') + else: + require(accepted,'configuration_rejection_missing') + expected=(int(good[:-1])*60 if field=='identity.credential_delivery_recovery_window' + else None if field=='mail.enabled' else good) + require(_effective(loaded,field)==expected,'configuration_effective_value_mismatch') + results[field]={'status':'pass','default':default_status, + 'accepted':'pass','rejected':'pass'} + except (Failure,ValueError,TypeError,KeyError,OSError,AttributeError) as exc: + results[field]={'status':'fail','code':getattr(exc,'code','configuration_check_failed')} + return results + + +def configuration_doctor(observations): + """Parsing success does not prove listener, SMTP, OS permissions or live limits.""" + result={} + for field,spec in CONFIGURATION_FIELDS.items(): + row={'status':'pass' if observations.get('configuration',{}).get('ok') is True else 'fail', + 'scope':'configuration_loading','selftest_case':field} + check=spec['doctor_check'] + value=observations.get(check,{}) if check else {} + row['related_check']={'check':check,'status': + 'disabled' if value.get('status')=='disabled' else + 'pass' if value.get('ok') is True else + 'fail' if value.get('ok') is False else 'not_checked'} + result[field]=row + return result diff --git a/src/msg/admin/content_edit_check.py b/src/msg/admin/content_edit_check.py new file mode 100644 index 00000000..5e08bd9a --- /dev/null +++ b/src/msg/admin/content_edit_check.py @@ -0,0 +1,56 @@ +"""Bounded, real-operation content editing vector for the isolated selftest.""" +from msg.core.codec import b64 +from msg.core.errors import require +from msg.core.models import ResourceRef + + +async def check_content_editing(app, call, register, test_path): + owner_key, owner = await register('content-edit-owner') + other_key, other = await register('content-edit-outsider') + original = '# Selftest\n\nKeep this line.\nReplace this word.\n' + expected_text = original.replace('word', 'value') + created = await call('file.create', { + 'parent': test_path + '/@content-edit-owner/files', 'name': 'edit.md', + 'data': b64(original.encode()), 'media_type': 'text/markdown', + }, owner_key, owner) + require(created.status == 'ok', 'selftest_content_create_failed') + rid = created.resources[0].id + old_ref = created.resources[0] + async with app.metadata.transaction(write=False) as tx: + original_revision = await tx.revision(old_ref) + patch = {'kind': 'exact', 'exact': 'word', 'replacement': 'value'} + args = {'id': rid, 'base_revision': old_ref.revision, + 'base_generation': created.data['generation'], 'patch': patch} + patched = await call('file.patch', args, owner_key, owner, + expected=((rid, created.data['generation']),)) + require(patched.status == 'ok', 'selftest_content_patch_failed') + generation = patched.data['generation'] + tables = ('resources', 'revisions', 'events', 'results', 'audit', 'jobs') + async with app.metadata.transaction(write=False) as tx: + before = tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in tables) + current_before = await tx.resource(rid) + # Current generation lets this request reach the stale base_revision check. + stale = await call('file.patch', args, owner_key, owner, + expected=((rid, generation),)) + denied = await call('file.patch', { + **args, 'base_revision': patched.resources[0].revision, + 'base_generation': generation, + 'patch': {'kind': 'exact', 'exact': 'value', 'replacement': 'unauthorized'}, + }, other_key, other, expected=((rid, generation),)) + current = await call('file.read', {'id': rid}, owner_key, owner) + previous = await call('file.read', {'id': rid, 'revision': old_ref.revision}, owner_key, owner) + history = await call('file.read', {'id': rid, 'view': 'history', 'limit': 10}, owner_key, owner) + async with app.metadata.transaction(write=False) as tx: + after = tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in tables) + unchanged = (await tx.resource(rid) == current_before and + await tx.revision(old_ref) == original_revision) + revision = await tx.revision(ResourceRef(id=rid)) + return (stale.error is not None and stale.error.code == 'revision_conflict' and + denied.error is not None and denied.error.code == 'permission_denied' and + all(result.status == 'ok' for result in (current, previous, history)) and + current.data['content'] == expected_text and previous.data['content'] == original and + {row['id'] for row in history.data['revisions']} == + {old_ref.revision, patched.resources[0].revision} and + len(history.data['revisions']) == 2 and + revision.parents == (old_ref.revision,) and revision.author == owner and + generation == created.data['generation'] + 1 and before == after and unchanged) diff --git a/src/msg/admin/diagnostics.py b/src/msg/admin/diagnostics.py index 33fa791c..f9d4abac 100644 --- a/src/msg/admin/diagnostics.py +++ b/src/msg/admin/diagnostics.py @@ -65,13 +65,16 @@ async def inspect(self, callback): return await callback(session) -def feature_results(features, observations, field): +def feature_results(features, observations, field, *, enabled_plugins=None): """Link manifest entries to checks actually run; never synthesize a pass.""" + from msg.plugins.features import FEATURE_SOURCES result={} for feature in features: feature_id=feature['feature_id'] name=feature[field] - if not feature['enabled_by_default']: + if (not feature['enabled_by_default'] or + (enabled_plugins is not None and + FEATURE_SOURCES[feature_id][0] not in enabled_plugins)): result[feature_id]={'status':'disabled','check':name} elif name is None: result[feature_id]={'status':'skip','check':None} @@ -222,7 +225,27 @@ def failed(name,code,**data):checks[name]={'ok':False,'code':code,**data} allow_overdraft=settings.money.allow_overdraft) except (Failure,ValueError,KeyError,OSError) as exc: failed('configuration',getattr(exc,'code','invalid_configuration')) - return {'ok':False,'root_id':ROOT_SUBJECT,'checks':checks} + from msg.admin.config_check import configuration_doctor + return {'ok':False,'root_id':ROOT_SUBJECT,'checks':checks, + 'configuration_fields':configuration_doctor(checks)} + # This read-only phase checks installed contracts; real behavior belongs to + # the separate isolated selftest vectors below. + for feature,owner,operations in ( + ('content_editing','file',('file.create','file.patch','file.read')), + ('private_dm','communication',('communication.dm_request','communication.dm_accept', + 'communication.dm_send','communication.dm_list')), + ('transfer','transfer',('transfer.open','transfer.part_put','transfer.part_get', + 'transfer.status','transfer.seal'))): + if owner not in app.registry._plugins: + checks[feature]={'ok':True,'status':'disabled'} + continue + try: + for name in operations: + operation=app.registry.operation(name) + app.registry.schema(operation.input_schema) + app.registry.schema(operation.output_schema) + success(feature,scope='registered_contracts',operations=list(operations)) + except Failure as exc:failed(feature,exc.code) if sys.version_info[:2]>=(3,15):success('python',version='.'.join(map(str,sys.version_info[:3]))) else:failed('python','python_315_required',actual='.'.join(map(str,sys.version_info[:3]))) missing=[name for name in ('cryptography','starlette','uvicorn','httpx','jsonschema','aiohttp','dns','graphql','psycopg','valkey') if find_spec(name) is None] @@ -230,8 +253,13 @@ def failed(name,code,**data):checks[name]={'ok':False,'code':code,**data} else:failed('dependencies','dependency_unavailable',missing=missing) if shutil.which('git'):success('git') else:failed('git','git_missing') - if not shutil.which('bwrap'): - warnings.append({'code':'tool_isolation_unavailable','effect':'network_tool_jobs_fail_closed'}) + if 'extensions' in settings.server.plugins: + from msg.admin.tool_check import inspect_tool_sandbox + try:success('tool_sandbox',**await inspect_tool_sandbox(app)) + except (Failure,OSError) as exc: + failed('tool_sandbox',getattr(exc,'code','tool_isolation_failed')) + else: + checks['tool_sandbox']={'ok':True,'status':'disabled'} try: trust=loads(settings.trust_file.read_bytes()) root=decode(Certificate,trust['certificate']) @@ -253,8 +281,10 @@ async def inspect(tx): else: checks['market']={'ok':True,'status':'disabled'} try: - from msg.admin.market_check import inspect_clearing - success('market_clearing',**inspect_clearing(app,tx)) + from msg.admin.market_check import inspect_bounty_contracts, inspect_clearing + clearing = inspect_clearing(app,tx) + await inspect_bounty_contracts(app,tx) + success('market_clearing',**clearing) except (Failure,OSError,ValueError,KeyError,psycopg.Error) as exc: failed('market_clearing',getattr(exc,'code','market_inspection_failed')) try: @@ -270,6 +300,14 @@ async def inspect(tx): success('credential_delivery',recovery_window_seconds=settings.credential_delivery_recovery_window, release='once',secret_url=False,versions=SECRET_DELIVERY_MIN_VERSION) except Failure as exc:failed('credential_delivery',exc.code) + if 'achievements' in settings.server.plugins: + try: + from msg.admin.honor_check import inspect_honors + success('honors', **await inspect_honors(app,tx)) + except (Failure,OSError,ValueError,KeyError,psycopg.Error) as exc: + failed('honors',getattr(exc,'code','honor_inspection_failed')) + else: + checks['honors']={'ok':True,'status':'disabled'} if 'communication' in settings.server.plugins: try: spec=app.registry.operation('communication.following') @@ -279,8 +317,19 @@ async def inspect(tx): require({'subject','resource'}<=columns,'following_schema_missing') success('following',default='empty',read_only=True,max_page_size=100) except Failure as exc:failed('following',exc.code) + try: + for kind in ('request','offer','checkpoint','proposal'): + require(app.registry.operation('communication.'+kind+'_create').effect=='transaction', + 'collaboration_contract_invalid') + require(app.registry.operation('communication.'+kind+'_get').effect=='read' and + app.registry.operation('communication.'+kind+'_list').effect=='read', + 'collaboration_contract_invalid') + success('collaboration',default='empty',storage='resource_revision', + kinds=['request','offer','checkpoint','proposal']) + except Failure as exc:failed('collaboration',exc.code) else: checks['following']={'ok':True,'status':'disabled'} + checks['collaboration']={'ok':True,'status':'disabled'} try: await validator.validate(root.resource_id,tx) require((await tx.subject(ROOT_SUBJECT)).local_only,'root_policy_corrupt') @@ -373,10 +422,13 @@ async def inspect(tx): continue try:success(name,**inspect_feature(app)) except Failure as exc:failed(name,exc.code) - features=feature_results(feature_manifest(),checks,'doctor_check') + from msg.admin.config_check import configuration_doctor + features=feature_results(feature_manifest(),checks,'doctor_check', + enabled_plugins=tuple(app.registry._plugins)) return {'ok':all(c['ok'] for c in checks.values()) and all(row['status']!='fail' for row in features.values()), - 'root_id':ROOT_SUBJECT,'checks':checks,'features':features,'warnings':warnings} + 'root_id':ROOT_SUBJECT,'checks':checks,'features':features,'warnings':warnings, + 'configuration_fields':configuration_doctor(checks)} async def _selftest_ca_chain(app,root,call,register,now): @@ -581,7 +633,11 @@ async def selftest(): from msg.security.capabilities import grant_for from msg.bootstrap import feature_manifest from msg.admin import recovery_replay - checks={} + from msg.admin.config_check import configuration_selftest + configuration_fields=configuration_selftest() + tool_sandbox_details={'code':'not_run'} + checks={'configuration_loading':all(row['status']=='pass' + for row in configuration_fields.values())} try: inspect_recovery_checkpoint() checks['recovery_checkpoint_replay']=recovery_replay.selftest()['status']=='passed' @@ -633,6 +689,13 @@ async def approve(key,uid,grants): require(result.status=='ok','selftest_csr_failed') return await _approve_csr(app,result.data['csr_id'],root,expected_digest=result.data['request_digest'],operator='isolated-selftest') alice,ua=await register('alice');bob,ub=await register('bob') + from msg.admin.tool_check import check_tool_sandbox + try: + tool_sandbox_details=await check_tool_sandbox(app,call,approve,alice,ua) + checks['tool_sandbox']=True + except (Failure,OSError) as exc: + tool_sandbox_details={'code':getattr(exc,'code','tool_isolation_failed')} + checks['tool_sandbox']=False post=await call('content.post_create',{'parent':test_path+'/tmp','body':'retained source bytes\r\n'},alice,ua,request_id='same-write') require(post.status=='ok',post.error.code if post.error else 'selftest_post_failed') # Following reads only existing watches; use a separate fixture so @@ -654,6 +717,31 @@ async def approve(key,uid,grants): watched.status=='ok' and shown.status=='ok' and [item['id'] for item in shown.data['items']]==[followed_id] and hidden.status=='ok' and redacted.status=='ok' and not redacted.data['items']) + work = await call('communication.request_create', + {'title':'Isolated task','description':'Validate work records','requirements':'Explicit claim'},alice,ua) + offer = await call('communication.offer_create', + {'description':'Review','scope':'Selftest','availability':'Now'},alice,ua) + checkpoint = await call('communication.checkpoint_create', + {'summary':'Selftest progress','resource_refs':[]},alice,ua) + proposal_target = await call('content.post_create', + {'parent':test_path+'/tmp','body':'before proposal'},alice,ua) + proposal_source = await call('content.post_create', + {'parent':test_path+'/tmp','body':'after proposal'},alice,ua) + require(all(item.status=='ok' for item in + (work,offer,checkpoint,proposal_target,proposal_source)), 'selftest_collaboration_create_failed') + proposal = await call('communication.proposal_create', + {'target':proposal_target.resources[0].id, + 'base_revision':proposal_target.resources[0].revision, + 'content_ref':wire(proposal_source.resources[0]),'message':'Apply selftest text'},alice,ua) + require(proposal.status=='ok','selftest_proposal_create_failed') + accepted = await call('communication.proposal_accept', + {'id':proposal.data['id'],'proposal_revision':proposal.resources[0].revision, + 'base_revision':proposal_target.resources[0].revision},alice,ua, + expected=((proposal.data['id'],proposal.data['generation']), + (proposal_target.resources[0].id,proposal_target.data['generation']))) + restored = await call('discovery.get',{'id':proposal_target.resources[0].id},alice,ua) + checks['collaboration']=(accepted.status=='ok' and restored.status=='ok' and + restored.data['content']=='after proposal') # OnlineIssuer exercises only the independent temporary Test Root # created above. No production trust material or signer is opened. async with app.metadata.transaction(write=False) as tx: @@ -729,6 +817,8 @@ async def approve(key,uid,grants): checks['root_network_rejected']=rejected.error is not None and rejected.error.code=='local_only' all_versions=await call('discovery.get',{'id':post.resources[0].id,'view':'meta'}) checks['stable_id_read']=all_versions.status=='ok' + from msg.admin.honor_check import check_honors + checks['honor_ceremony_display']=await check_honors(app,call,register) checks.update(await _selftest_ca_chain(app,root,call,register,now)) from msg.admin.upgrade_check import check_upgrade_recovery checks['identity_upgrade_recovery']=await check_upgrade_recovery(app,now) @@ -737,9 +827,17 @@ async def approve(key,uid,grants): from msg.admin.custodial_check import check_custodial_history checks['custodial_history_recovery']=await check_custodial_history(app,now) from msg.admin.market_check import check_market, check_market_e2e - checks['market_lifecycle']=await check_market(app,root,call,register,now) checks['market_e2e']=await check_market_e2e(app,root,call,register) + checks['market_lifecycle']=await check_market(app,root,call,register,now) + from msg.admin.git_check import check_git + checks['git_push_read_cas']=await check_git(app,call,register) checks.update(await _selftest_search_hosting(app,call,register,test_path)) + from msg.admin.dm_check import check_private_dm + checks['private_dm']=await check_private_dm(app,now) + from msg.admin.content_edit_check import check_content_editing + checks['content_editing']=await check_content_editing(app,call,register,test_path) + from msg.admin.transfer_check import check_transfer + checks['transfer_state_machine']=await check_transfer(app,call,register) except Failure as exc: checks['failure']={'code':exc.code} finally: @@ -747,4 +845,5 @@ async def approve(key,uid,grants): features=feature_results(feature_manifest(),checks,'selftest_case') return {'ok':bool(checks) and all(value is True for value in checks.values()) and all(row['status']!='fail' for row in features.values()), - 'checks':checks,'features':features,'cleaned_up':not folder.exists()} + 'checks':checks,'features':features,'cleaned_up':not folder.exists(), + 'configuration_fields':configuration_fields,'tool_sandbox':tool_sandbox_details} diff --git a/src/msg/admin/dm_check.py b/src/msg/admin/dm_check.py new file mode 100644 index 00000000..63b9ba37 --- /dev/null +++ b/src/msg/admin/dm_check.py @@ -0,0 +1,65 @@ +"""Exercise private conversations using fresh clients in the isolated Test Root.""" +from pathlib import Path +from tempfile import TemporaryDirectory +from uuid import uuid4 + +import httpx + +from msg.client import ClientState, MsgClient +from msg.core.errors import require +from msg.core.requests import request_for +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app + + +async def check_private_dm(app, now): + """Never register identities or create messages on the live installation.""" + require(app.selftest_run_id is not None, 'isolated_selftest_required') + with TemporaryDirectory(prefix='msg-dm-selftest-') as directory: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app))) as http: + clients = [] + for name in ('sender', 'recipient', 'outsider'): + client = MsgClient(ClientState(Path(directory) / name, + server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: now, retries=0) + client.checked(await client.register('dm-' + uuid4().hex[:16])) + clients.append(client) + sender, recipient, outsider = clients + requested = sender.checked(await sender.call('communication.dm_request', + {'recipient': recipient.state.subject})) + conversation = requested.data['conversation_id'] + if requested.data['state'] != 'pending': + return False + early = await sender.call('communication.dm_send', + {'conversation_id': conversation, 'body': 'not accepted'}) + if early.status != 'error' or early.error.code != 'dm_controlled_resource': + return False + accepted = recipient.checked(await recipient.call('communication.dm_accept', + {'conversation_id': conversation})) + if accepted.data['state'] != 'active': + return False + body = 'isolated private conversation ' + uuid4().hex + sent = sender.checked(await sender.call('communication.dm_send', + {'conversation_id': conversation, 'body': body})) + post = sent.resources[0].id + for participant in (sender, recipient): + read = await participant.call('discovery.get', {'id': post}) + if read.status != 'ok' or read.data.get('content') != body: + return False + for operation, arguments in ( + ('discovery.get', {'id': conversation}), + ('discovery.get', {'id': post}), + ('discovery.list', {'parent': conversation}), + ('communication.dm_send', {'conversation_id': conversation, 'body': 'intrusion'}), + ): + denied = await outsider.call(operation, arguments) + expected = 'dm_not_found' if operation == 'communication.dm_send' else 'permission_denied' + if denied.status != 'error' or denied.error.code != expected: + return False + hidden = await outsider.call('discovery.search', {'query': body}) + if hidden.status != 'ok' or hidden.data.get('items'): + return False + anonymous = await HTTPTransport(app.settings.service_url, http=http).call( + request_for('discovery.get', {'id': post}, app.settings.service_url)) + return anonymous.status == 'error' and anonymous.error.code == 'permission_denied' diff --git a/src/msg/admin/git_check.py b/src/msg/admin/git_check.py new file mode 100644 index 00000000..536b3906 --- /dev/null +++ b/src/msg/admin/git_check.py @@ -0,0 +1,113 @@ +"""Real Git publication/fetch checks inside the disposable selftest installation.""" +import asyncio +from datetime import timedelta +import hashlib +from pathlib import Path +import subprocess +import tempfile +from urllib.parse import urlsplit + +import httpx +from starlette.applications import Starlette +from starlette.routing import Route + +from msg.core.codec import b64, canonical +from msg.core.errors import Failure, require +from msg.core.requests import request_for +from msg.extensions.repositories import NativeGitStore +from msg.transports.http import create_app +from msg.transports.git_http import GitHTTPAdapter + + +async def check_git(app, call, register): + require(bool(app.selftest_run_id), 'selftest_namespace_required') + key, owner = await register('git-selftest') + created = await call('git.create', {'parent': owner, 'name': 'selftest.git'}, key, owner) + require(created.status == 'ok', 'selftest_git_create_failed') + rid = created.resources[0].id + store = NativeGitStore(app) + env = {**store.env, 'GIT_AUTHOR_NAME': 'Selftest', 'GIT_AUTHOR_EMAIL': 'selftest@example.invalid'} + + def run(folder, *args, input=None): + try: + return subprocess.run(['git', '-C', str(folder), *args], input=input, + capture_output=True, check=True, timeout=30, env=env).stdout + except (OSError, subprocess.SubprocessError) as exc: + raise Failure('selftest_git_command_failed') from exc + + def pkt(data): + return f'{len(data) + 4:04x}'.encode() + data + + async def snapshot(): + async with app.metadata.transaction(write=False) as tx: + return {table: tuple(tx.rows(f'SELECT * FROM {table} ORDER BY 1')) + for table in ('resources', 'events', 'audit', 'jobs', 'results', 'transfers', + 'credentials', 'reactions', 'watches')} + + with tempfile.TemporaryDirectory(prefix='msg-selftest-git-') as temporary: + work = Path(temporary) + await asyncio.to_thread(run, work, 'init', '--initial-branch=main', '--object-format=sha1') + payload = b'isolated Git selftest payload\n' + (work / 'README.md').write_bytes(payload) + await asyncio.to_thread(run, work, 'add', 'README.md') + await asyncio.to_thread(run, work, 'commit', '-m', 'isolated selftest') + commit = (await asyncio.to_thread(run, work, 'rev-parse', 'HEAD')).decode().strip() + pack = await asyncio.to_thread(run, work, 'pack-objects', '--stdout', '--revs', + input=(commit + '\n').encode()) + receive = pkt(f'{"0" * 40} {commit} refs/heads/main\0report-status\n'.encode()) + b'0000' + pack + + def headers(request_id): + packet = request_for('git.http_receive', {'id': rid, + 'pack_digest': 'sha256:' + hashlib.sha256(receive).hexdigest(), 'pack_size': len(receive)}, + app.settings.service_url, signer=key, subject=owner, request_id=request_id, + expires_at=app.clock() + timedelta(seconds=120)) + return {'X-Msg-Request': b64(canonical(packet)), + 'Content-Type': 'application/x-git-receive-pack-request'} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + endpoint = f'/-/git/{rid}/git-receive-pack' + denied = await http.post(endpoint, content=receive, + headers={'Content-Type': 'application/x-git-receive-pack-request'}) + require(denied.status_code == 401, 'selftest_git_unsigned_push_allowed') + pushed = await http.post(endpoint, content=receive, headers=headers('selftest-git-push')) + require(pushed.status_code == 200 and b'ok refs/heads/main\n' in pushed.content, + 'selftest_git_push_failed') + require(await asyncio.to_thread(run, store.path(rid), 'show', 'refs/heads/main:README.md') == payload, + 'selftest_git_publication_mismatch') + before = await snapshot() + replay = await http.post(endpoint, content=receive, headers=headers('selftest-git-push')) + require(replay.status_code == 200 and replay.content == pushed.content and + await snapshot() == before, 'selftest_git_replay_mutated') + # A new request with an obsolete expected ref must not overwrite it. + stale = await http.post(endpoint, content=receive, headers=headers('selftest-git-stale')) + require(stale.status_code == 200 and b'ng refs/heads/main ' in stale.content, + 'selftest_git_stale_ref_allowed') + refs = await asyncio.to_thread(run, store.path(rid), 'for-each-ref', '--format=%(refname) %(objectname)') + require(refs == f'refs/heads/main {commit}\n'.encode(), 'selftest_git_ref_changed') + before = await snapshot() + # Production's /@user/*.git router does not expose /_test/. + # Mount the real read adapter over its isolated resource path only; + # it still executes git.refs authorization and git http-backend. + async def read(request): + return await GitHTTPAdapter(app).http( + request, urlsplit(created.data['read_url']).path, 'git-upload-pack') + fetch_app = Starlette(routes=[Route('/fetch', read, methods=['POST'])]) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=fetch_app), + base_url=app.settings.service_url) as reader: + fetch = await reader.post('/fetch', + content=pkt(f'want {commit}\n'.encode()) + b'00000009done\n', + headers={'Content-Type': 'application/x-git-upload-pack-request'}) + require(fetch.status_code == 200 and fetch.content.startswith(b'0008NAK\nPACK'), + 'selftest_git_fetch_failed') + # Import the actual response into a fresh repository, not the source fixture. + fetched = work / 'fetched' + fetched.mkdir() + await asyncio.to_thread(run, fetched, 'init', '--bare', '--object-format=sha1') + await asyncio.to_thread(run, fetched, 'index-pack', '--stdin', input=fetch.content[8:]) + require(await asyncio.to_thread(run, fetched, 'show', commit + ':README.md') == payload, + 'selftest_git_fetch_mismatch') + require(await snapshot() == before and refs == await asyncio.to_thread( + run, store.path(rid), 'for-each-ref', '--format=%(refname) %(objectname)'), + 'selftest_git_read_mutated') + return True diff --git a/src/msg/admin/honor_check.py b/src/msg/admin/honor_check.py new file mode 100644 index 00000000..0da52930 --- /dev/null +++ b/src/msg/admin/honor_check.py @@ -0,0 +1,120 @@ +"""Read-only honor diagnostics and the isolated selftest's real protocol client.""" +from msg.bootstrap import manifest +from msg.core.codec import canonical, decode, digest +from msg.core.errors import require +from msg.core.models import ResourceRef, Signature +from msg.plugins.achievements import ( + CEREMONY_TTL_SECONDS, + FINAL_STATEMENT, + I_AM_NOT_HUMAN, + MAX_PINS, + ROUND_TTL_SECONDS, + STRATEGY, + STRATEGY_VERSION, +) +from msg.security.crypto import Ed25519Signer, verify + + +async def inspect_honors(app, tx): + for table, required in { + 'achievement_ceremonies': {'id', 'subject', 'state', 'body'}, + 'achievement_grants': {'id', 'subject', 'achievement_id', 'spec_version', 'body'}, + 'achievement_pins': {'subject', 'grant_id', 'position'}, + }.items(): + columns = {row[0] for row in tx.rows( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema='public' AND table_name=?", (table,))} + require(required <= columns, 'honor_schema_missing', table) + for name in ('start', 'answer', 'finish', 'pin', 'unpin', 'reorder', 'list'): + spec = app.registry.operation('achievement.' + name) + require(spec.effect == ('read' if name == 'list' else 'transaction'), + 'honor_contract_invalid') + if name in {'finish', 'pin', 'unpin', 'reorder'}: + require(spec.require_signature, 'honor_contract_invalid') + sample = manifest()['honor_sample'] + require(sample['policy'] == { + 'achievement_id': I_AM_NOT_HUMAN.id, 'spec_version': I_AM_NOT_HUMAN.version, + 'rounds': 5, 'round_ttl_seconds': ROUND_TTL_SECONDS, + 'ceremony_ttl_seconds': CEREMONY_TTL_SECONDS, 'strategy': STRATEGY, + 'strategy_version': STRATEGY_VERSION, 'privileges': False, + } and sample['profile']['max_pins'] == MAX_PINS and + sample['profile']['pinned_grant_ids'] == [] and sample['grants'] == [] and + sample['issuer']['claim'] == I_AM_NOT_HUMAN.claim and + sample['issuer']['purpose'] == 'achievement-grant' and + sample['issuer']['key_source'] == 'service_receipt_signer', 'honor_sample_drift') + resource = await tx.resource('r_honor_sample') + revision = await tx.revision(ResourceRef(id=resource.id, revision=resource.revision)) + require(revision.content is not None and + revision.content.digest == digest(canonical(sample)), + 'honor_sample_drift') + # Doctor does not load Application storage (which can create directories). + # Read the installed issuer directly, as the online-CA doctor does. + issuer = Ed25519Signer.from_bytes((app.settings.service_keys / 'receipt.key').read_bytes()) + probe = canonical({'purpose': 'read-only-honor-doctor'}) + verify(issuer.public_key, probe, + issuer.sign(probe, purpose='achievement-grant'), + purpose='achievement-grant') + return {'read_only': True, 'issuer': issuer.key_id, + 'strategy': STRATEGY, 'spec_version': I_AM_NOT_HUMAN.version, + 'pregrant': False, 'privileges': False} + + +async def check_honors(app, call, register): + """Run only on the temporary selftest instance; declarations are test fixtures.""" + key, subject = await register('honor-selftest') + stranger, other = await register('honor-stranger') + async with app.metadata.transaction(write=False) as tx: + security_before = (tx.rows('SELECT body FROM certificates ORDER BY id'), + tx.rows('SELECT body FROM credentials ORDER BY id')) + empty = await call('achievement.list', {'subject_id': subject}) + require(empty.status == 'ok' and not empty.data['achievements'] and + not empty.data['pinned_grant_ids'], 'selftest_honor_pregranted') + started = await call('achievement.start', {}, key, subject) + require(started.status == 'ok', 'selftest_honor_start_failed') + challenge = started.data + for number in range(1, 5): + require(challenge.get('round') == number, 'selftest_honor_round_invalid') + answer = 'y' + if number == 4: + bits = ''.join('1' if char == '\u200c' else '0' for char in challenge['question'] + if char in {'\u200b', '\u200c'}) + require(len(bits) == 16, 'selftest_honor_payload_invalid') + answer = ''.join(str(int(bits[i:i + 4], 2)) for i in range(0, 16, 4)) + result = await call('achievement.answer', { + 'challenge_id': challenge['challenge_id'], 'round': number, + 'question_digest': challenge['question_digest'], 'nonce': challenge['nonce'], + 'answer': answer, + }, key, subject) + require(result.status == 'ok' and result.data.get('round') == number + 1, + 'selftest_honor_answer_failed') + challenge = result.data + finished = await call('achievement.finish', { + **{field: challenge[field] for field in + ('challenge_id', 'question_digest', 'nonce', 'ceremony_digest')}, + 'statement': FINAL_STATEMENT, + }, key, subject) + require(finished.status == 'ok' and 'grant' in finished.data, + 'selftest_honor_finish_failed') + grant = dict(finished.data['grant']) + signature = decode(Signature, grant.pop('signature')) + verify(app.receipt_signer.public_key, canonical(grant), signature, + purpose='achievement-grant') + gid = grant['id'] + denied = await call('achievement.pin', {'grant_id': gid}, stranger, other) + pinned = await call('achievement.pin', {'grant_id': gid}, key, subject) + reordered = await call('achievement.reorder', {'grant_ids': [gid]}, key, subject) + public = await call('achievement.list', {'subject_id': subject}) + unpinned = await call('achievement.unpin', {'grant_id': gid}, key, subject) + final = await call('achievement.list', {'subject_id': subject}) + duplicate = await call('achievement.start', {}, key, subject) + async with app.metadata.transaction(write=False) as tx: + security_after = (tx.rows('SELECT body FROM certificates ORDER BY id'), + tx.rows('SELECT body FROM credentials ORDER BY id')) + count = tx.one('SELECT COUNT(*) FROM achievement_grants WHERE subject=?', (subject,))[0] + return (denied.error is not None and denied.error.code == 'achievement_grant_not_found' and + all(result.status == 'ok' for result in (pinned, reordered, public, unpinned, final)) and + list(public.data['pinned_grant_ids']) == [gid] and + not final.data['pinned_grant_ids'] and len(final.data['achievements']) == 1 and + count == 1 and duplicate.error is not None and + duplicate.error.code == 'achievement_already_granted' and + security_after == security_before) diff --git a/src/msg/admin/legacy_approval.py b/src/msg/admin/legacy_approval.py new file mode 100644 index 00000000..13379059 --- /dev/null +++ b/src/msg/admin/legacy_approval.py @@ -0,0 +1,298 @@ +"""Prepare and physically approve only the three inert legacy migration contracts.""" + +from __future__ import annotations + +import argparse +import asyncio +import getpass +import json +import os +from collections import Counter +from datetime import timedelta +from pathlib import Path + +from msg.admin.root import require_local_console, root_envelope +from msg.application import Application +from msg.config import load_settings +from msg.core.codec import canonical, digest, loads, parse_time, wire +from msg.core.errors import require +from msg.security.crypto import Ed25519Signer, open_private_key +from msg.security.quarantine import require_live_authority +from msg.security.root_files import read_private, rotation_lock +from msg.storage import legacy_git_import as git +from msg.storage import legacy_identity_plan as identities +from msg.storage import legacy_resource_import as content + + +def write_new(path, value): + path = Path(path) + require(not path.exists() and not path.is_symlink(), "approval_destination_exists") + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(canonical(value)) + stream.write(b"\n") + stream.flush() + os.fsync(stream.fileno()) + + +def read_json(path): + return loads(read_private(path, limit=16 * 1024 * 1024)) + + +def recognized_payload(value): + require(isinstance(value, dict), "legacy_approval_invalid") + kind = value.get("format") + common = { + key: value.get(key) for key in ("parent", "parent_generation", "operator", "expires_at") + } + if kind == content.PURPOSE: + expected = content.approval_payload( + sha256=value.get("source_sha256"), + service=value.get("target_service"), + identities=value.get("identities"), + **common, + ) + elif kind == git.PURPOSE: + expected = git.git_approval( + bundle_sha256=value.get("bundle_sha256"), + refs_digest=value.get("refs_digest"), + default_ref=value.get("default_ref"), + name=value.get("name"), + service=value.get("target_service"), + **common, + ) + elif kind == identities.PURPOSE: + expected = identities.approval_payload( + plan_digest=value.get("plan_digest"), + mappings=value.get("mappings"), + service=value.get("target_service"), + **common, + ) + else: + require(False, "unsupported_legacy_approval_purpose") + require(value == expected, "legacy_approval_invalid") + return kind + + +async def validate_and_review(app, approval): + kind = recognized_payload(approval) + require(approval["target_service"] == app.settings.service_url, "legacy_service_mismatch") + require( + app.clock() < parse_time(approval["expires_at"]) <= app.clock() + timedelta(hours=24), + "legacy_approval_expired", + ) + mappings = approval.get("identities", approval.get("mappings", {})) + require(isinstance(mappings, dict), "legacy_mapping_invalid") + async with app.metadata.transaction(write=False) as tx: + require_live_authority(tx) + require(tx.setting("runtime_config", {}).get("accept_writes", True), "writes_paused") + holder = await tx.subject(approval["operator"]) + require(holder.kind == "registered", "legacy_operator_must_be_registered") + holder_resource = await tx.resource(holder.resource_id) + parent = await tx.resource(approval["parent"]) + require( + parent.owner == holder.resource_id + and parent.mode == 0o700 + and parent.state == "active" + and parent.generation == approval["parent_generation"], + "legacy_parent_not_private", + ) + require( + app.registry.resource_type(parent.type, parent.type_version).container, + "not_a_container", + ) + if kind != git.PURPOSE: + require( + not tx.one("SELECT id FROM resources WHERE parent=?", (parent.id,)), + "legacy_parent_not_empty", + ) + target_counts = Counter() + for target in mappings.values(): + require(target is None or isinstance(target, str), "legacy_mapping_invalid") + if target is not None: + require( + (await tx.subject(target)).kind == "registered", "legacy_mapping_target_invalid" + ) + target_counts[target] += 1 + return { + "purpose": kind, + "approval_digest": digest(approval), + "artifact_binding": { + key: approval[key] + for key in ( + "source_sha256", + "bundle_sha256", + "refs_digest", + "default_ref", + "name", + "plan_digest", + ) + if key in approval + }, + "target_service": app.settings.service_url, + "root_public_key_digest": digest(app.certificates.root_public_key), + "real_holder": {"subject": holder.resource_id, "handle": holder_resource.name}, + "private_parent": { + "id": parent.id, + "path": await tx.path(parent.id), + "generation": parent.generation, + }, + "historical_identity_count": len(mappings), + "unmapped_count": sum(v is None for v in mappings.values()), + "explicit_new_subject_associations": dict(target_counts), + "legacy_authority_enabled": False, + "ownership_proof_claimed": False, + "expires_at": approval["expires_at"], + } + + +async def inspect_approval(config_dir, approval): + app = Application(load_settings(config_dir)) + try: + await app.load() + return await validate_and_review(app, approval), app.certificates.root_public_key + finally: + await app.close() + + +def sign_approval(config_dir, source, destination): + # This check precedes input parsing, trust material and every PIN prompt. + require_local_console(config_dir) + require( + not Path(destination).exists() and not Path(destination).is_symlink(), + "approval_destination_exists", + ) + approval = read_json(source) + review, public = asyncio.run(inspect_approval(config_dir, approval)) + print(canonical(review).decode()) + phrase = "APPROVE LEGACY IMPORT " + digest(approval) + require(input("Type " + phrase + ": ") == phrase, "approval_cancelled") + settings = load_settings(config_dir) + with rotation_lock(settings.root_private_dir): + envelope = loads(read_private(root_envelope(config_dir))) + private = open_private_key(envelope, getpass.getpass("Root PIN/passphrase: ")) + signer = Ed25519Signer.from_bytes(private) + require(signer.public_key == public, "root_key_mismatch") + result = { + "approval": approval, + "signature": wire( + signer.sign(canonical(approval), purpose=recognized_payload(approval)) + ), + } + write_new(destination, result) + return { + "status": "legacy_approval_signed", + "purpose": approval["format"], + "approval_digest": digest(approval), + } + + +async def prepare(args): + app = Application(load_settings(args.config_dir)) + try: + await app.load() + async with app.metadata.transaction(write=False) as tx: + parent = await tx.resource(args.parent) + common = { + "service": app.settings.service_url, + "parent": args.parent, + "parent_generation": parent.generation, + "operator": args.operator, + "expires_at": wire(app.clock() + timedelta(hours=2)), + } + if args.command == "prepare-content": + needed = content.identity_requirements( + args.snapshot, args.sha256, {}, summary_only=False + )["required"] + mapping = {old: None for old in needed} if args.unmapped else read_json(args.mappings) + require(set(mapping) == set(needed), "legacy_identity_mapping_incomplete") + approval = content.approval_payload(sha256=args.sha256, identities=mapping, **common) + elif args.command == "prepare-git": + manifest = read_json(args.manifest) + verified = git.verify_bundle( + args.bundle, + manifest["bundle_sha256"], + protected_work=app.settings.server.staging_dir, + ) + require( + verified["refs"] == manifest["refs"] and manifest.get("refs_stable") is True, + "legacy_git_refs_mismatch", + ) + approval = git.git_approval( + bundle_sha256=verified["bundle_sha256"], + refs_digest=verified["refs_digest"], + default_ref=manifest["default_ref"], + name=args.name, + **common, + ) + else: + plan = read_json(args.plan) + needed = {row["old_id"] for row in plan["identities"]} + mapping = {old: None for old in needed} if args.unmapped else read_json(args.mappings) + require(set(mapping) == needed, "legacy_identity_mapping_incomplete") + approval = identities.approval_payload( + plan_digest=digest(plan), mappings=mapping, **common + ) + review = await validate_and_review(app, approval) + write_new(args.output, approval) + return review + finally: + await app.close() + + +async def apply_identities(args): + app = Application(load_settings(args.config_dir)) + try: + await app.load() + envelope = read_json(args.signed_approval) + return await identities.import_identity_records( + app, read_json(args.plan), envelope["approval"], envelope["signature"] + ) + finally: + await app.close() + + +def parser(): + command = argparse.ArgumentParser(description=__doc__) + command.add_argument("--config-dir", type=Path, required=True) + sub = command.add_subparsers(dest="command", required=True) + for kind in ("content", "git", "identities"): + item = sub.add_parser("prepare-" + kind) + item.add_argument("--operator", required=True) + item.add_argument("--parent", required=True) + item.add_argument("--output", type=Path, required=True) + if kind == "content": + item.add_argument("--snapshot", type=Path, required=True) + item.add_argument("--sha256", required=True) + elif kind == "git": + item.add_argument("--bundle", type=Path, required=True) + item.add_argument("--manifest", type=Path, required=True) + item.add_argument("--name", required=True) + else: + item.add_argument("--plan", type=Path, required=True) + if kind != "git": + mapping = item.add_mutually_exclusive_group(required=True) + mapping.add_argument("--mappings", type=Path) + mapping.add_argument("--unmapped", action="store_true") + sign = sub.add_parser("sign") + sign.add_argument("--draft", type=Path, required=True) + sign.add_argument("--output", type=Path, required=True) + apply = sub.add_parser("apply-identities") + apply.add_argument("--plan", type=Path, required=True) + apply.add_argument("--signed-approval", type=Path, required=True) + return command + + +def main(): + args = parser().parse_args() + if args.command == "sign": + result = sign_approval(args.config_dir, args.draft, args.output) + elif args.command == "apply-identities": + result = asyncio.run(apply_identities(args)) + else: + result = asyncio.run(prepare(args)) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/msg/admin/market_check.py b/src/msg/admin/market_check.py index 5d29d484..e40fdcc2 100644 --- a/src/msg/admin/market_check.py +++ b/src/msg/admin/market_check.py @@ -27,7 +27,8 @@ async def inspect_market(tx): if snapshot: locked = contract(tx,oid) policy = locked['policy'] - validate(policy['policy']) + if locked['version'] != 5: + validate(policy['policy']) require(digest(policy['policy'])==policy['policy_digest'] and locked['buyer']==buyer and locked['total_price_minor']==total, 'market_snapshot_invariant') row = tx.one('SELECT body FROM order_settlements WHERE order_id=?',(oid,)) @@ -67,6 +68,8 @@ async def check_market(app,root,call,register,now): from msg.market.policy import DEFAULT_POLICY from msg.workers.effects import EffectWorker require(app.selftest_run_id is not None,'selftest_namespace_required') + async with app.metadata.transaction(write=False) as tx: + baseline_supply = _supply(tx) bank,bank_id = await register('market-test-bank') buyer,buyer_id = await register('market-test-buyer') async def invoke(name,args,key=buyer,subject=buyer_id,**kw): @@ -158,13 +161,30 @@ async def send(self,job): async with app.metadata.transaction(write=False) as tx: await inspect_market(tx) require(_balance(tx,buyer_id)==5_000_000 and _balance(tx,bank_id)==15_000_000 and - tx.one("SELECT SUM(amount_minor) FROM money_ledger WHERE kind='mint'")[0]==20_000_000, + _supply(tx)==baseline_supply+20_000_000, 'market_conservation_failed') for (escrow,) in tx.rows("SELECT id FROM ledger_accounts WHERE kind IN ('order_escrow','bounty_escrow')"): require(_balance(tx,escrow)==0,'market_escrow_invariant') return True +async def inspect_bounty_contracts(app, tx): + """Check restored accounting projections against immutable published terms.""" + from msg.plugins.bounty import _row, validate_contract + rows = tx.rows('SELECT listing_id FROM bounty_listings ORDER BY listing_id') + if not rows: + return + reader = app + if app.contents is None: + # doctor intentionally never opens the application writer stores. + from types import SimpleNamespace + from msg.storage.read_only import GitContentReader + reader = SimpleNamespace(contents=GitContentReader( + app.settings.server.content_dir, binary_dir=app.settings.server.blob_dir)) + for (listing_id,) in rows: + await validate_contract(reader, tx, _row(tx, listing_id)) + + def inspect_clearing(app, tx): """Never migrate, mint, seed a bank, repair a row or unlock the Root key.""" tables = ('ledger_accounts','money_ledger','money_bank_roles','server_offers', @@ -201,6 +221,9 @@ def inspect_clearing(app, tx): for oid,escrow,total,state,quote,refs in tx.rows('''SELECT id,escrow_subject,total_price_minor, state,quote_digest,receipt_refs FROM store_orders'''): require(state != 'accepted', 'incomplete_order_commit') + immutable = tx.one('SELECT body FROM order_contracts WHERE order_id=?', (oid,)) + if immutable is not None and loads(immutable[0]).get('version') == 5: + require(state == 'settled', 'incomplete_entitlement_commit') expected = total if state in {'funded','delivered','disputed'} else 0 require(balances.get(escrow,0)==expected, 'order_escrow_mismatch') require(all(ref in ledger for ref in loads(refs)), 'order_receipt_missing') @@ -219,10 +242,15 @@ def inspect_clearing(app, tx): # for arbitrary prepared legacy orders. from msg.market.policy import contract locked=contract(tx,oid) - prepared=(delivery is not None and delivery[0]=='prepared' and - locked['listing']['delivery_mode']=='managed_instant') - require(delivery is not None and (delivery[0]=='claimed' or prepared), - 'settled_delivery_missing') + if locked['version'] == 5: + # The contract validator requires the exact entitlement grant; + # synchronous issuance must not invent a Delivery or acceptance. + require(delivery is None, 'unexpected_entitlement_delivery') + else: + prepared=(delivery is not None and delivery[0]=='prepared' and + locked['listing']['delivery_mode']=='managed_instant') + require(delivery is not None and (delivery[0]=='claimed' or prepared), + 'settled_delivery_missing') else: require(delivery is not None and (delivery[0]=='claimed' or delivery==('prepared','escrow-instant-v1')), 'settled_delivery_missing') @@ -260,6 +288,7 @@ async def check_market_e2e(app, root, call, register): from msg.daemon import parser async with app.metadata.transaction(write=False) as tx: baseline=inspect_clearing(app,tx) + require(baseline['total_supply_minor']==0,'selftest_requires_zero_supply') bank_key,bank=await register('bank-test') buyer_key,buyer=await register('market-buyer') @@ -284,7 +313,8 @@ def unlock(): require(len(funding['audit_event_ids'])==2 and funding['root_balance_minor']==0, 'selftest_bank_fund_failed') async def checked(name,args,key=None,subject=None,**kwargs): result=await call(name,args,key,subject,**kwargs) - require(result.status=='ok','market_selftest_'+name.replace('.','_')) + require(result.status=='ok','market_selftest_'+name.replace('.','_') + + (':' + result.error.code if result.error else '')) return result bounty=await checked('bounty.create',{'name':'signature-reward', 'terms':'Control of the current IdentityKey only, not human or Sybil proof.', @@ -299,7 +329,7 @@ async def checked(name,args,key=None,subject=None,**kwargs): claimed.data['escrow_balance_minor']==0,'selftest_bounty_not_paid') listing=await checked('store.listing_create',{'name':'test-delivery','item_kind':'bundle', 'price_minor':5_000_000,'currency_id':'primary','quantity':1, - 'delivery_mode':'managed_instant','escrow_policy':'escrow-instant-v1', + 'delivery_mode':'managed_instant','escrow_policy':'escrow-v1', 'dispute_policy':'dispute-v1','terms':'Fixed text and file; immediate in-site delivery.'},bank_key,bank) async with app.metadata.transaction(write=False) as tx: files=await tx.resolve((await tx.path(bank))+'/files') @@ -313,29 +343,70 @@ async def checked(name,args,key=None,subject=None,**kwargs): bank_key,bank,expected=((listing_id,listing.data['generation']),)) quote=active.data['listing'] args={'listing_id':listing_id,'listing_revision':quote['listing_revision'],'quantity':1, - 'currency_id':'primary','total_price_minor':5_000_000, - 'package_digest':package.data['package']['digest'],'auto_accept':True} + 'currency_id':'primary','total_price_minor':5_000_000} # No seller request occurs after publishing. The signed buyer request alone - # validates the immutable deposit, delivers, then releases the exact escrow. + # validates the immutable deposit and delivers; payment remains in escrow. bought=await checked('orders.buy',args,buyer_key,buyer,request_id='market-e2e-buy', - contract_version=2) - require(bought.data['order']['state']=='settled','selftest_order_not_settled') - delivery=await checked('delivery.get',{'order_id':bought.data['order']['id']},buyer_key,buyer) + contract_version=4) + require(bought.data['order']['state']=='delivered','selftest_order_not_delivered') + oid=bought.data['order']['id'] + async with app.metadata.transaction(write=False) as tx: + escrow=tx.one('SELECT escrow_subject FROM store_orders WHERE id=?',(oid,))[0] + require((_balance(tx,buyer),_balance(tx,bank),_balance(tx,escrow)) == + (5_000_000,10_000_000,5_000_000),'selftest_awaiting_acceptance_balance_mismatch') + delivery=await checked('delivery.get',{'order_id':oid},buyer_key,buyer,contract_version=2) content=delivery.data['delivery'] require(content['manifest']=={'text':'msg.lmm.best store selftest'} and content['payloads'][0]['data']==b64(b'delivery-ok\n') and content['package_digest']==package.data['package']['digest'],'selftest_delivery_mismatch') repeated=await checked('orders.buy',args,buyer_key,buyer,request_id='market-e2e-buy', - contract_version=2) + contract_version=4) require(repeated.replayed and repeated.data==bought.data,'selftest_market_replay_failed') + require(content['state']=='prepared','selftest_false_buyer_ack') + accepted=await checked('delivery.accept',{'order_id':oid, + 'delivery_digest':content['delivery_digest']},buyer_key,buyer, + contract_version=2,request_id='market-e2e-accept') + require(accepted.data['state']=='settled','selftest_order_not_settled') + async with app.metadata.transaction(write=False) as tx: + require(_balance(tx,escrow)==0,'selftest_escrow_not_empty') buyer_balance=await checked('money.balance',{},buyer_key,buyer) bank_balance=await checked('money.balance',{},bank_key,bank) require(buyer_balance.data['balance_minor']==5_000_000 and bank_balance.data['balance_minor']==15_000_000, 'selftest_market_balance_mismatch') async with app.metadata.transaction(write=False) as tx: checked_state=inspect_clearing(app,tx) - require(checked_state['total_supply_minor']==baseline['total_supply_minor']+20_000_000 and + require(checked_state['total_supply_minor']==20_000_000 and checked_state['banks']==baseline['banks']+1 and checked_state['enabled_offers']==baseline['enabled_offers'], 'selftest_market_supply_mismatch') + # The 20 -> 10 -> 5 buyer checkpoint above remains the official PoP/store + # acceptance flow. Separately exercise an explicit synchronous grant intent. + from msg.admin.money import apply_offer + from msg.client_market import entitlement_intent + from msg.plugins.hosting_capacity import ENTITLEMENT_KIND, extra_capacity + await apply_offer(app, root, action='set', operator='isolated-TestConsole', + offer_id='selftest-byte-grant', fields={'resource_kind': 'website', 'unit': 'byte', + 'price_minor': 1, 'min_quantity': 1, 'max_quantity': 1, + 'entitlement_kind': ENTITLEMENT_KIND, 'duration_seconds': None}) + class BuyerClient: + async def call(self, name, params, **options): + return await checked(name, params, buyer_key, buyer, **options) + intent = await entitlement_intent(BuyerClient(), {'offer_id': 'selftest-byte-grant', 'quantity': 1}) + grant = await checked('money.redeem', intent, buyer_key, buyer, + contract_version=3, request_id='market-e2e-entitlement') + repeated = await checked('money.redeem', intent, buyer_key, buyer, + contract_version=3, request_id='market-e2e-entitlement') + order = grant.data['order'] + require(order['state'] == 'settled' and order['contract_version'] == 5 and + order['delivered_at'] is None and repeated.replayed and repeated.data == grant.data, + 'selftest_entitlement_order_failed') + async with app.metadata.transaction(write=False) as tx: + locked = contract(tx, order['id']) + require(extra_capacity(tx, buyer, app.clock()) == 1 and + (_balance(tx, buyer), _balance(tx, bank), _balance(tx, ROOT_SUBJECT), + _balance(tx, locked['escrow_subject'])) == (4_999_999, 15_000_000, 1, 0) and + tx.one('SELECT COUNT(*) FROM store_deliveries WHERE order_id=?', (order['id'],))[0] == 0, + 'selftest_entitlement_grant_failed') + require(inspect_clearing(app, tx)['total_supply_minor'] == 20_000_000, + 'selftest_entitlement_supply_mismatch') return True diff --git a/src/msg/admin/money.py b/src/msg/admin/money.py index 4ec889cb..9ea4556d 100644 --- a/src/msg/admin/money.py +++ b/src/msg/admin/money.py @@ -38,9 +38,8 @@ def _root_signer(app, signer): signer.public_key == app.certificates.root_public_key, 'root_key_mismatch') -_OFFER_COLUMNS = ('offer_id', 'resource_kind', 'unit', 'price_minor', 'min_quantity', - 'max_quantity', 'entitlement_kind', 'duration_seconds', 'enabled', - 'price_revision') +from msg.market.offer_resources import OFFER_COLUMNS as _OFFER_COLUMNS + _UNSET = object() @@ -69,6 +68,10 @@ async def apply_offer(app, signer, *, action: str, operator: str, offer_id: str, request_id = 'local_offer_' + uuid4().hex async with app.metadata.transaction(write=True) as tx: previous = _offer_snapshot(tx, offer_id) + from msg.market.offer_resources import authoritative_offer, write_offer_revision + source = await authoritative_offer(app, tx, offer_id) + if source is not None: + require(source == previous, 'offer_projection_mismatch') if expected_offer is not _UNSET: require(previous == expected_offer, 'offer_preview_stale') if action == 'set': @@ -93,6 +96,9 @@ async def apply_offer(app, signer, *, action: str, operator: str, offer_id: str, tx.execute('UPDATE server_offers SET enabled=FALSE WHERE offer_id=?', (offer_id,), write=True) current = _offer_snapshot(tx, offer_id) + if previous is None or source is not None: + await write_offer_revision(app, tx, signer, current, now=now, + request_id=request_id, new=previous is None) statement = {'action': 'offer_' + action, 'offer_id': offer_id, 'before': previous, 'after': current, 'operator': operator, 'request_id': request_id, 'time': wire(now)} @@ -281,9 +287,10 @@ async def run(): await app.close() return asyncio.run(run()) - def execute_offer(self, action, *, offer_id, fields=None): + def execute_offer(self, action, *, offer_id, fields=None, dry_run=False): operator = require_local_console(self.config_dir) - require(action in {'set', 'disable'}, 'invalid_offer_action') + require(action in {'set', 'disable', 'import'}, 'invalid_offer_action') + require(not dry_run or action == 'import', 'invalid_offer_action') require(isinstance(offer_id, str) and re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]{0,79}', offer_id), 'invalid_offer_id') from msg.admin.root import RootAdmin @@ -292,6 +299,20 @@ def execute_offer(self, action, *, offer_id, fields=None): async def run(): await app.load() try: + if action == 'import': + from msg.admin.offer_import import preview_import, apply_import + async with app.metadata.transaction(write=False) as tx: + plan = await preview_import(app, tx, offer_id) + print(canonical(plan).decode()) + if dry_run: + return {'plan': plan, 'approval_digest': digest(plan)} + approval = 'CONFIRM MONEY ' + digest(plan) + require(input('Type ' + approval + ' to continue: ') == approval, + 'approval_cancelled') + pin = getpass.getpass('Root PIN/passphrase: ') + private = open_private_key(loads(root_envelope(self.config_dir).read_bytes()), pin) + return await apply_import(app, Ed25519Signer.from_bytes(private), plan, + operator=operator) if action == 'set': require(isinstance(fields, dict) and set(fields) == { 'resource_kind', 'unit', 'price', 'min_quantity', diff --git a/src/msg/admin/offer_import.py b/src/msg/admin/offer_import.py new file mode 100644 index 00000000..63d2f262 --- /dev/null +++ b/src/msg/admin/offer_import.py @@ -0,0 +1,86 @@ +"""Explicit local adoption of a legacy offer; no financial rows are rewritten.""" +import re +from uuid import uuid4 + +from msg.constants import ROOT_SUBJECT +from msg.core.codec import canonical, digest, loads, wire +from msg.core.errors import require +from msg.core.models import AuditEvent, Event, ResourceRef +from msg.market.compatibility import purchase_order, read_purchase +from msg.market.offer_resources import OFFER_COLUMNS, mapped_listing, write_offer_revision +from msg.plugins.offers import validate_local_offer + + +async def preview_import(app, tx, offer_id, *, revision_id=None): + require(isinstance(offer_id, str) and re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]{0,79}', offer_id), + 'invalid_offer_id') + require(mapped_listing(tx, offer_id) is None, 'offer_already_resource') + row = tx.one('SELECT ' + ','.join(OFFER_COLUMNS) + ' FROM server_offers WHERE offer_id=?', (offer_id,)) + require(row is not None, 'offer_not_found') + offer = dict(zip(OFFER_COLUMNS, row)) + validate_local_offer(app, **{k: offer[k] for k in ('resource_kind', 'unit', 'price_minor', + 'min_quantity', 'max_quantity', 'entitlement_kind', 'duration_seconds')}) + require(tx.one('SELECT 1 FROM resources WHERE id=? OR (parent=? AND name=?)', + (offer_id, 't_store', offer_id)) is None, 'offer_resource_conflict') + revision_id = revision_id or 'v_' + uuid4().hex + require(isinstance(revision_id, str) and re.fullmatch(r'v_[a-f0-9]{32}', revision_id), + 'invalid_revision_id') + require(tx.one('SELECT 1 FROM revisions WHERE id=?', (revision_id,)) is None, + 'offer_revision_conflict') + # The original serialized snapshots and receipt bytes are hashed unchanged. + purchases, transactions, grants = [], {}, set() + for pid, owner, encoded in tx.rows('SELECT id,subject_id,offer_snapshot FROM money_purchases ORDER BY id'): + snapshot = loads(encoded) + require(isinstance(snapshot, dict), 'legacy_purchase_invalid') + if snapshot.get('offer_id') != offer_id: + continue + purchase = read_purchase(tx, pid, owner) + order = purchase_order(tx, purchase) + purchases.append(tx.one('SELECT * FROM money_purchases WHERE id=?', (pid,))) + for index, tid in enumerate(order['receipt_refs']): + kind, reference = tx.one('SELECT kind,reference FROM money_ledger WHERE id=?', (tid,)) + expected_kind = 'transfer' if index == 0 else 'redeem' if purchase['state'] == 'settled' else 'refund' + expected_reference = ('purchase_fund:' if index == 0 else 'purchase_settle:' + if purchase['state'] == 'settled' else 'purchase_refund:') + pid + require((kind, reference) == (expected_kind, expected_reference), 'offer_import_inconsistent_ledger') + transactions[tid] = tx.one('SELECT * FROM money_ledger WHERE id=?', (tid,)) + if purchase['entitlement_id'] is not None: + grant = tx.one('SELECT offer_id,quantity,entitlement_kind FROM resource_entitlements WHERE id=?', + (purchase['entitlement_id'],)) + require(grant == (offer_id, purchase['quantity'], snapshot['entitlement_kind']), + 'offer_import_inconsistent_entitlements') + grants.add(purchase['entitlement_id']) + entitlements = tx.rows('SELECT * FROM resource_entitlements WHERE offer_id=? ORDER BY id', (offer_id,)) + require({row[0] for row in entitlements} == grants, 'offer_import_inconsistent_entitlements') + return {'action': 'offer_import', 'version': 1, 'offer_id': offer_id, + 'before': offer, 'listing_id': offer_id, 'revision_id': revision_id, + 'purchase_count': len(purchases), 'entitlement_count': len(entitlements), + 'dependencies_digest': digest({'purchases': purchases, + 'transactions': transactions, 'entitlements': entitlements})} + + +async def apply_import(app, signer, plan, *, operator): + require(signer.public_key == app.certificates.root_public_key, 'root_key_mismatch') + now, request_id = app.clock(), 'local_offer_import_' + uuid4().hex + async with app.metadata.transaction(write=True) as tx: + current = await preview_import(app, tx, plan['offer_id'], revision_id=plan['revision_id']) + require(current == plan, 'offer_import_preview_stale') + approved_digest = digest(plan) + await write_offer_revision(app, tx, signer, current['before'], now=now, + request_id=request_id, new=True, revision_id=plan['revision_id'], + source_digest=approved_digest) + statement = {'action': 'offer_import', 'plan': current, 'approved_digest': approved_digest, + 'operator': operator, 'request_id': request_id, 'time': wire(now)} + signature = wire(signer.sign(canonical(statement), purpose='money-admin')) + event = Event(id='audit_' + uuid4().hex, type='root.money.offer.import', time=now, + request_id=request_id, actor=ROOT_SUBJECT, subject=ROOT_SUBJECT, + resources=(ResourceRef(id=plan['listing_id'], revision=plan['revision_id']),), + data={**statement, 'root_signature': signature}) + await tx.append_event(event) + await tx.append_audit(AuditEvent(event=event, + authority=(ResourceRef(id=app.certificates.root_certificate.resource_id),), + before_digest=digest(plan['before']), after_digest=digest(plan['before']), + previous_digest=None, entry_digest='', result='offer_import')) + return {'action': 'offer_import', 'offer_id': plan['offer_id'], 'listing_id': plan['listing_id'], + 'revision_id': plan['revision_id'], 'approved_digest': approved_digest, + 'audit_event_id': event.id} diff --git a/src/msg/admin/recovery_policy.py b/src/msg/admin/recovery_policy.py new file mode 100644 index 00000000..8ec3bb36 --- /dev/null +++ b/src/msg/admin/recovery_policy.py @@ -0,0 +1,117 @@ +"""Typed policy restrictions for pinned recovery v2; never a promotion contract.""" +from dataclasses import replace +import re + +from msg.constants import ROOT_SUBJECT +from msg.core.codec import decode, digest, loads, parse_time +from msg.core.errors import require +from msg.core.models import Certificate + +FACTS = frozenset({'resource.acl.restrict', 'topic.policy.restrict', 'topic_ban.set', 'certificate.current', 'resource.authority.reconcile'}) +DOMAINS = {'resource.acl.restrict': 'resource_acl', 'topic.policy.restrict': 'topic_policy', + 'topic_ban.set': 'topic_ban', 'certificate.current': 'certificates', + 'resource.authority.reconcile': 'resource_authority'} + + +def validate(fact): + kind, value = fact['kind'], fact['value'] + require(type(value) is dict, 'recovery_checkpoint_invalid') + if kind == 'resource.authority.reconcile': + require(set(value) == {'previous', 'current'}, 'recovery_checkpoint_invalid') + for state in value.values(): + require(type(state) is dict and set(state) == {'owner', 'group', 'parent'} and + all(type(state[k]) is str and 0 < len(state[k]) <= 256 for k in ('owner', 'group')) and + (state['parent'] is None or + (type(state['parent']) is str and 0 < len(state['parent']) <= 256)), + 'recovery_checkpoint_invalid') + require(fact['subject'] == value['previous']['owner'], 'recovery_checkpoint_invalid') + elif kind == 'certificate.current': + require(set(value) == {'body_digest'} and + (value['body_digest'] is None or + (type(value['body_digest']) is str and + re.fullmatch(r'sha256:[0-9a-f]{64}', value['body_digest']) is not None)), + 'recovery_checkpoint_invalid') + elif kind == 'resource.acl.restrict': + require(set(value) == {'owner', 'group', 'mode'} and + all(type(value[k]) is str and 0 < len(value[k]) <= 256 for k in ('owner', 'group')) and + type(value['mode']) is int and 0 <= value['mode'] <= 0o777, + 'recovery_checkpoint_invalid') + elif kind == 'topic.policy.restrict': + require(set(value) == {'membership_policy'} and + value['membership_policy'] in ('open', 'approval', 'invite', 'closed'), + 'recovery_checkpoint_invalid') + else: + require(kind == 'topic_ban.set' and set(value) == {'expires_at'} and + (value['expires_at'] is None or parse_time(value['expires_at']) > parse_time(fact['at'])), + 'recovery_checkpoint_invalid') + + +async def apply(tx, fact): + """Reconcile pinned facts without granting live authority or rewriting signatures.""" + kind, value, target, subject = (fact[k] for k in ('kind', 'value', 'target', 'subject')) + if kind == 'certificate.current': + row = tx.one('SELECT subject,revoked,body FROM certificates WHERE id=?', (target,)) + require(row is not None, 'recovery_fact_missing') + original = decode(Certificate, loads(row[2])) + require(row[0] == original.subject_id == subject and original.resource_id == target, + 'recovery_fact_subject_mismatch') + # The exact signed body includes grants, constraints, issuance policy, + # issuer/parent/source bindings and validity. Never rewrite it to invent + # a constrained signature, or reactivate an already revoked certificate. + if row[1] != 1 and digest(original) != value['body_digest']: + tx.execute('UPDATE certificates SET revoked=1 WHERE id=?', (target,), write=True) + return True + return False + resource = await tx.resource(target) + if kind == 'resource.authority.reconcile': + from msg.security.quarantine import active + require(active(tx), 'recovery_quarantine_required') + previous, current = value['previous'], value['current'] + actual = {field: getattr(resource, field) for field in ('owner', 'group', 'parent')} + require(actual in (previous, current), 'recovery_authority_state_mismatch') + await tx.subject(current['owner']) + await tx.organization(current['group']) + if current['parent'] is not None: + require(current['parent'] != target, 'parent_cycle') + await tx.resource(current['parent']) + require(all(item.id != target for item in await tx.ancestors(current['parent'])), 'parent_cycle') + if actual == current: + return False + # These are staged current facts, not authority to reopen the service. + # Old and new scope-derived rights remain blocked globally until a later + # complete, independently verified authority reconstruction is possible. + await tx.replace(replace(resource, **current, generation=resource.generation + 1, + modified_at=max(resource.modified_at, parse_time(fact['at'])), + modified_by=ROOT_SUBJECT), resource.generation) + return True + require(resource.owner == subject, 'recovery_fact_subject_mismatch') + if kind == 'resource.acl.restrict': + require(resource.owner == value['owner'] and resource.group == value['group'], + 'recovery_policy_authority_change_unsupported') + mode = (resource.mode & 0o7000) | (resource.mode & value['mode']) + if resource.mode == mode: + return False + await tx.replace(replace(resource, mode=mode, generation=resource.generation + 1, + modified_at=max(resource.modified_at, parse_time(fact['at'])), + modified_by=ROOT_SUBJECT), + resource.generation) + return True + require(resource.type == 'topic', 'not_a_topic') + if kind == 'topic.policy.restrict': + row = tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?', (target,)) + previous = row[0] if row is not None else 'open' + desired = value['membership_policy'] + require(previous in {'open', 'approval', 'invite', 'closed'}, 'recovery_policy_invalid') + # Approval and invite are incomparable; their conservative intersection + # is closed. Replaying an earlier broader ceiling never reopens a topic. + if desired == 'open' or previous == desired: + return False + if previous != 'open': + desired = 'closed' + if previous == desired: + return False + tx.execute('INSERT INTO topic_settings (topic,membership_policy) VALUES (?,?) ' + 'ON CONFLICT(topic) DO UPDATE SET membership_policy=excluded.membership_policy', + (target, desired), write=True) + return True + raise AssertionError('topic_ban.set uses the existing replay membership-removal path') diff --git a/src/msg/admin/recovery_proof.py b/src/msg/admin/recovery_proof.py new file mode 100644 index 00000000..fe68809c --- /dev/null +++ b/src/msg/admin/recovery_proof.py @@ -0,0 +1,304 @@ +"""Strict complete-state recovery proof; physical local promotion is a separate ceremony.""" +from dataclasses import dataclass, replace +from datetime import timedelta +from uuid import uuid4 + +from msg.admin.recovery_state import snapshot, runtime_controls, _runtime_row +from msg.constants import ROOT_SUBJECT, ONLINE_CA +from msg.core.codec import canonical, decode, digest, loads, parse_time, wire, unb64 +from msg.core.errors import Failure, require +from msg.core.models import AuditEvent, Certificate, Event, ResourceRef, Signature +from msg.security.crypto import verify, Ed25519Signer +from msg.security.quarantine import SETTING, active + +FORMAT = 'msg-complete-recovery-state-v1' +PURPOSE = 'complete-recovery-state-v1' +RECEIPT_PURPOSE = 'complete-recovery-promotion-v1' + + +@dataclass(frozen=True, kw_only=True) +class IndependentRecoveryPin: + service: str + public_key: bytes + digest: str + sequence: int + source_backup_sha256: str + + +def verify_proof(packet, pin, now): + require(isinstance(pin, IndependentRecoveryPin), 'recovery_independent_pin_required') + try: + require(type(packet) is dict and set(packet) == {'state', 'signature'}, 'recovery_proof_invalid') + body = packet['state'] + require(type(body) is dict and set(body) == {'format', 'service', 'source_backup_sha256', + 'sequence', 'issued_at', 'expires_at', 'runtime_controls', 'metadata', 'files'}, 'recovery_proof_invalid') + require(body['format'] == FORMAT and body['service'] == pin.service and + body['source_backup_sha256'] == pin.source_backup_sha256 and + type(pin.public_key) is bytes and len(pin.public_key) == 32 and + type(pin.sequence) is int and pin.sequence >= 1 and type(body['sequence']) is int and + body['sequence'] == pin.sequence and digest(body) == pin.digest, + 'recovery_proof_pin_mismatch') + controls = body['runtime_controls'] + require(type(controls) is dict and set(controls) == {'present', 'fields'} and + type(controls['present']) is bool and type(controls['fields']) is dict and + set(controls['fields']) == {'accept_writes', 'cleanup_enabled'}, 'recovery_proof_invalid') + for field in controls['fields'].values(): + require(type(field) is dict and set(field) == {'present', 'value'} and + type(field['present']) is bool and type(field['value']) is bool, 'recovery_proof_invalid') + require(type(body['metadata']) is dict and type(body['files']) is dict, 'recovery_proof_invalid') + start, end = parse_time(body['issued_at']), parse_time(body['expires_at']) + require(start <= now < end and end-start <= timedelta(hours=24), 'recovery_proof_expired') + verify(pin.public_key, canonical(body), decode(Signature, packet['signature']), purpose=PURPOSE) + return loads(canonical(body)) + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise Failure('recovery_proof_invalid') from exc + + +async def verify_current_authority(app, tx, public_key): + from msg.security.backup_retirement import root_verifier + require(await root_verifier(tx, now=app.clock()) == public_key == app.certificates.root_public_key, + 'recovery_root_mismatch') + generation = tx.rows("SELECT value FROM settings WHERE key='recovery_runtime_generation'") + require(not generation or (type(loads(generation[0][0])) is str and bool(loads(generation[0][0]))), 'recovery_runtime_invalid') + root = await tx.subject(ROOT_SUBJECT) + require(root.local_only, 'root_policy_corrupt') + for (identifier,) in tx.rows('SELECT id FROM certificates WHERE revoked=0 ORDER BY id'): + certificate = await tx.certificate(identifier) + # Expired signed history remains history. Every currently usable chain + # is revalidated from current credentials, authority sources and scope. + if certificate.not_before <= app.clock() < certificate.expires_at: + await app.certificates.validate(identifier, tx) + online = tx.setting('online_ca_certificate') + require(type(online) is str, 'recovery_online_issuer_missing') + certificate = await app.certificates.validate(online, tx) + require(certificate.subject_id == ONLINE_CA and certificate.key_id == app.online_signer.key_id, + 'recovery_online_key_mismatch') + # The receipt signer has a registered credential, independent of online CA. + receipt = tx.setting('receipt_public_key') + require(type(receipt) is dict and receipt.get('key_id') == app.receipt_signer.key_id and + unb64(receipt.get('public_key', ''), limit=32) == app.receipt_signer.public_key, + 'recovery_receipt_key_mismatch') + for (identifier,) in tx.rows('SELECT id FROM resources ORDER BY id'): + await tx.ancestors(identifier) + from msg.admin.market_check import inspect_market + from msg.storage.ledger_migration import _validate_ledger + _validate_ledger(tx._connection) + await inspect_market(tx) + + +def configuration_state(settings): + """Bind policy, while naming every deployment-only field explicitly.""" + value = wire(settings) + for key in ('listen', 'port'): + value.pop(key) + value['hosting_recovery_marker'] = settings.hosting_recovery_marker is not None + for key in ('config_dir', 'postgres_dsn', 'valkey_url', 'mail', 'content_dir', + 'repositories_dir', 'blob_dir', 'staging_dir', 'service_keys_dir'): + value['server'].pop(key) + return value + + +def file_state(app, tx): + from msg.admin.backups import _db_refs, _verify_storage, _regular_tree, _hash + settings = app.settings + from msg.config import load_settings + actual_settings = load_settings(settings.config_dir) + if active(tx): + require(actual_settings.server.mail is None and actual_settings.server.valkey_url is None, 'recovery_target_not_isolated') + policy = configuration_state(actual_settings) + require(actual_settings == settings, 'recovery_cached_configuration_mismatch') + require(settings.trust_file.is_file() and not settings.trust_file.is_symlink(), 'invalid_trust_anchor') + trees = {} + for name, path in (('content', settings.server.content_dir), ('blobs', settings.server.blob_dir), + ('repositories', settings.server.repositories_dir), ('staging', settings.server.staging_dir), + ('service', settings.service_keys)): + if name in {'repositories', 'staging'} and not path.exists() and not path.is_symlink(): + trees[name] = {} + continue + _regular_tree(path) + trees[name] = {str(file.relative_to(path)): {'size': file.stat().st_size, 'sha256': _hash(file)} + for file in sorted(path.rglob('*')) if file.is_file()} + refs = _db_refs(tx._connection) + for (identifier,) in tx.rows("SELECT id FROM resources WHERE type='repo' AND state<>'purged'"): + require((settings.server.repositories_dir/(identifier+'.git')).is_dir(), 'backup_repository_missing') + _verify_storage(settings.server.content_dir.parent, refs, settings=settings, repair_git_layout=False) + require(Ed25519Signer.from_bytes((settings.service_keys/'online.key').read_bytes()).public_key == + app.online_signer.public_key and + Ed25519Signer.from_bytes((settings.service_keys/'receipt.key').read_bytes()).public_key == + app.receipt_signer.public_key and + (settings.service_keys/'tokens.key').read_bytes() == app._token_secret, 'recovery_cached_keys_mismatch') + keys = {name: digest((settings.service_keys/name).read_bytes()) + for name in ('online.key', 'receipt.key', 'tokens.key')} + require(len((settings.service_keys/'tokens.key').read_bytes()) == 32, 'invalid_service_key') + # Digests commit random service key material without exporting it. + return {'configuration': policy, 'references': refs, 'trees': trees, 'service_key_digests': keys, + 'trust': loads(settings.trust_file.read_bytes())} + + +async def draft(app, *, public_key, source_backup_sha256, sequence): + """Internal use case; public RootAdmin entry first enforces console and PIN.""" + require(type(sequence) is int and sequence > 0 and type(source_backup_sha256) is str and + len(source_backup_sha256) == 64 and all(c in '0123456789abcdef' for c in source_backup_sha256), + 'recovery_proof_invalid') + async with app.metadata.transaction(write=True) as tx: + require(not active(tx) and not (app.settings.config_dir/'recovery-drill.json').exists() and not (app.settings.config_dir/'recovery-drill.json').is_symlink(), 'recovery_source_is_quarantined') + state = snapshot(tx) + await verify_current_authority(app, tx, public_key) + body = {'format': FORMAT, 'service': app.settings.service_url, + 'source_backup_sha256': source_backup_sha256, 'sequence': sequence, + 'issued_at': wire(app.clock()), 'expires_at': wire(app.clock()+timedelta(hours=24)), + 'runtime_controls': runtime_controls(tx), 'metadata': state, 'files': file_state(app, tx)} + return body + + +async def seal(app, body, signer): + async with app.metadata.transaction(write=True) as tx: + require(not active(tx) and not (app.settings.config_dir/'recovery-drill.json').exists() and not (app.settings.config_dir/'recovery-drill.json').is_symlink(), 'recovery_source_is_quarantined') + require(body['service'] == app.settings.service_url and snapshot(tx) == body['metadata'] and + runtime_controls(tx) == body['runtime_controls'] and file_state(app, tx) == body['files'], + 'recovery_complete_state_mismatch') + await verify_current_authority(app, tx, signer.public_key) + require(parse_time(body['issued_at']) <= app.clock() < parse_time(body['expires_at']), 'recovery_proof_expired') + return {'state': body, 'signature': wire(signer.sign(canonical(body), purpose=PURPOSE))} + + +async def capture(app, signer, *, source_backup_sha256, sequence): + body = await draft(app, public_key=signer.public_key, source_backup_sha256=source_backup_sha256, sequence=sequence) + return await seal(app, body, signer) + + +def _receipt(value, pin): + require(type(value) is dict and set(value) == {'receipt', 'signature'}, 'recovery_promotion_receipt_invalid') + body = value['receipt'] + require(type(body) is dict and set(body) == {'manifest_digest', 'sequence', 'source_backup_sha256', + 'generation', 'prior_generation', 'audit_row', 'prior_audit_sequence'}, 'recovery_promotion_receipt_invalid') + require(type(body['generation']) is str and bool(body['generation']) and + (body['prior_generation'] is None or (type(body['prior_generation']) is str and bool(body['prior_generation']))) and + type(body['audit_row']) is list and len(body['audit_row']) == 4 and + type(body['audit_row'][0]) is int and body['audit_row'][0] > 0 and + type(body['prior_audit_sequence']) is list and len(body['prior_audit_sequence']) == 2 and + type(body['prior_audit_sequence'][0]) is int and type(body['prior_audit_sequence'][1]) is bool, + 'recovery_promotion_receipt_invalid') + verify(pin.public_key, canonical(body), decode(Signature, value['signature']), purpose=RECEIPT_PURPOSE) + require(body['manifest_digest'] == pin.digest and body['sequence'] == pin.sequence and + body['source_backup_sha256'] == pin.source_backup_sha256, 'recovery_promotion_receipt_mismatch') + return body + + +async def promote(app, packet, *, pin, signer, operator): + """Internal fixture-capable use case. Caller must enforce physical console ceremony.""" + body = verify_proof(packet, pin, app.clock()) + require(app.settings.server.mail is None and app.settings.server.valkey_url is None, + 'recovery_target_not_isolated') + require(signer.public_key == pin.public_key, 'recovery_root_mismatch') + require(type(operator) is str and bool(operator), 'recovery_operator_required') + marker = app.settings.config_dir/'recovery-drill.json' + from msg.security.root_files import read_private + marker_present = marker.exists() or marker.is_symlink() + if marker_present: + marker_value = loads(read_private(marker)) + require(type(marker_value) is dict and marker_value.get('source_backup_sha256') == pin.source_backup_sha256, + 'recovery_checkpoint_backup_mismatch') + async with app.metadata.transaction(write=True) as tx: + previous = tx.setting('recovery_promotion') + # A backup of an already recovered source contains a historical receipt. + # Only the exact current manifest can resume its pending delta; a new + # recovery must independently match the entire initial source state. + pending = type(previous) is dict and type(previous.get('receipt')) is dict and previous['receipt'].get('manifest_digest') == pin.digest + resume = _receipt(previous, pin) if pending else None + gate = tx.setting(SETTING) + require(active(tx) and type(gate) is dict and gate.get('source_backup_sha256') == pin.source_backup_sha256, + 'recovery_quarantine_required') + if resume is None: + require(marker_present, 'recovery_marker_required') + require(active(tx) and type(gate) is dict and gate.get('source_backup_sha256') == pin.source_backup_sha256 and + gate.get('format') == 'msg-recovery-quarantine-v1' and gate.get('authority') == 'health_only' and gate.get('outbound_enabled') is False, + 'recovery_quarantine_required') + actual = snapshot(tx, controls=body['runtime_controls'] if resume is None else None, resume=resume) + require(actual == body['metadata'], 'recovery_complete_state_mismatch') + require(file_state(app, tx) == body['files'], 'recovery_complete_files_mismatch') + await verify_current_authority(app, tx, pin.public_key) + verify_proof(packet, pin, app.clock()) + if resume is None: + prior_generation = tx.setting('recovery_runtime_generation') + require(prior_generation is None or type(prior_generation) is str, 'recovery_runtime_invalid') + generation = uuid4().hex + event = Event(id='audit_'+uuid4().hex, type='recovery.complete_promotion', time=app.clock(), + request_id=pin.digest, actor=ROOT_SUBJECT, subject=ROOT_SUBJECT, resources=(), + data={'manifest_digest': pin.digest, 'sequence': pin.sequence, 'operator': operator, + 'runtime_generation': generation, 'requires_runtime_restart': True}) + audit_event = AuditEvent(event=event, + authority=(ResourceRef(id=tx.setting('active_root_certificate')),), + before_digest=pin.digest, after_digest=pin.digest, previous_digest=None, + entry_digest='', result='verified_complete_state') + previous = tx.one('SELECT seq,digest FROM audit ORDER BY seq DESC LIMIT 1') + prior_sequence = actual['sequences']['audit_seq_seq'] + audit_seq = max(prior_sequence[0] + int(prior_sequence[1]), (previous[0]+1) if previous else 1) + audit_data = wire(replace(audit_event, previous_digest=previous[1] if previous else None, entry_digest='')) + audit_data.pop('entry_digest') + audit_event = replace(audit_event, previous_digest=previous[1] if previous else None, entry_digest=digest(audit_data)) + # Explicit allocation is transactional. PostgreSQL nextval is not: + # using it here would invalidate the source commitment on rollback. + tx.execute('INSERT INTO audit (seq,digest,previous,body) VALUES (?,?,?,?)', + (audit_seq, audit_event.entry_digest, audit_event.previous_digest, canonical(audit_event).decode()), write=True) + audit_row = tx.one('SELECT seq,digest,previous,body FROM audit ORDER BY seq DESC LIMIT 1') + receipt = {'manifest_digest': pin.digest, 'sequence': pin.sequence, + 'source_backup_sha256': pin.source_backup_sha256, 'generation': generation, + 'prior_generation': prior_generation, 'audit_row': loads(canonical(audit_row)), + 'prior_audit_sequence': actual['sequences']['audit_seq_seq']} + tx.set_setting('recovery_promotion', {'receipt': receipt, + 'signature': wire(signer.sign(canonical(receipt), purpose=RECEIPT_PURPOSE))}) + tx.set_setting('recovery_runtime_generation', generation) + runtime = tx.setting('runtime_config') + normalized = _runtime_row(('runtime_config', canonical(runtime).decode()), body['runtime_controls']) + if normalized is None: + tx.execute("DELETE FROM settings WHERE key='runtime_config'", write=True) + else: + tx.set_setting('runtime_config', loads(normalized[1])) + resume = receipt + # Keep the database quarantine until marker unlink AND directory durability + # succeed. A crash between unlink and commit remains closed through the DB; + # only an authenticated exact receipt can resume with the marker missing. + import os + async with app.metadata.transaction(write=True) as tx: + require(active(tx), 'recovery_quarantine_required') + require(_receipt(tx.setting('recovery_promotion'), pin) == resume, 'recovery_promotion_receipt_mismatch') + require(snapshot(tx, resume=resume) == body['metadata'], 'recovery_complete_state_mismatch') + require(file_state(app, tx) == body['files'], 'recovery_complete_files_mismatch') + verify_proof(packet, pin, app.clock()) + tx.execute("SELECT setval('public.audit_seq_seq', ?, true)", (resume['audit_row'][0],), write=True) + try: + fd = os.open(marker.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + if marker_present: + marker.unlink() + os.fsync(fd) + finally: + os.close(fd) + except OSError as exc: + raise Failure('recovery_promotion_finish_required') from exc + tx.execute('DELETE FROM settings WHERE key=?', (SETTING,), write=True) + return {'status': 'recovery_promoted', 'manifest_digest': pin.digest, + 'runtime_generation': resume['generation'], 'requires_runtime_restart': True} + + +async def open_for_proof(config_dir): + """Load only existing authority; no DDL, release-source sync, repair or worker.""" + from msg.application import Application + from msg.config import load_settings + from msg.storage.postgres import PostgresMetadataStore + from msg.security.certificates import CertificateValidator + settings = load_settings(config_dir) + app = Application(settings) + app.metadata = PostgresMetadataStore(settings.server.postgres_dsn, initialize=False) + require(settings.trust_file.is_file() and not settings.trust_file.is_symlink(), 'invalid_trust_anchor') + trust = loads(settings.trust_file.read_bytes()) + require(set(trust) == {'version', 'public_key', 'certificate'} and trust['version'] == 1, 'invalid_trust_anchor') + app.certificates = CertificateValidator(app.registry, decode(Certificate, trust['certificate']), + unb64(trust['public_key'], limit=32), settings.service_url, app.clock) + from msg.admin.backups import _regular_tree + _regular_tree(settings.service_keys) + app.online_signer = Ed25519Signer.from_bytes((settings.service_keys/'online.key').read_bytes()) + app.receipt_signer = Ed25519Signer.from_bytes((settings.service_keys/'receipt.key').read_bytes()) + app._token_secret = (settings.service_keys/'tokens.key').read_bytes() + return app diff --git a/src/msg/admin/recovery_replay.py b/src/msg/admin/recovery_replay.py index 0df26631..7ca9d14a 100644 --- a/src/msg/admin/recovery_replay.py +++ b/src/msg/admin/recovery_replay.py @@ -1,4 +1,4 @@ -"""Local recovery's deny-only replay, never a promotion or trust bootstrap. +"""Local recovery's partial authority replay, never a promotion or trust bootstrap. A pin MUST arrive through an independently authenticated operator channel outside this database and its backup/rollback set. Neither a database watermark nor an @@ -13,6 +13,7 @@ from datetime import UTC, datetime from uuid import uuid4 +from msg.admin import recovery_policy from msg.constants import ROOT_SUBJECT from msg.core.codec import canonical, decode, digest, loads, parse_time, wire from msg.core.errors import Failure, require @@ -21,10 +22,16 @@ from msg.security.quarantine import SETTING, active FORMAT = 'msg-revocation-checkpoint-v1' +POLICY_FORMAT = 'msg-revocation-checkpoint-v2' SUPPORTED_FACTS = frozenset({'credential.revoke', 'certificate.revoke', 'share_grant.revoke', 'share_grant_v2.revoke', 'share_link.revoke', 'membership.remove', - 'topic_membership.remove', 'topic_ban.lift', 'identity_key.retire', + 'topic_membership.remove', 'topic_ban.apply', 'topic_ban.lift', 'identity_key.retire', 'encryption_key.retire', 'vault.destroy'}) +PROMOTION_BLOCKED_REASONS = ( + 'quarantine_remains', + 'supported_fact_inventory_incomplete', + 'backup_retirement_not_attested', +) _SHARE_TABLES = {'share_grant.revoke': 'share_grants', 'share_grant_v2.revoke': 'share_grants_v2', 'share_link.revoke': 'share_links'} _MAX_ENTRIES = 10000 @@ -50,13 +57,16 @@ def verify_checkpoint(packet, *, pin): require(type(packet) is dict and set(packet) == {'checkpoint', 'signature'}, 'recovery_checkpoint_invalid') body = packet['checkpoint'] - require(type(body) is dict and set(body) == { - 'format', 'service', 'source_backup_sha256', 'sequence', 'entries'}, + policy = type(body) is dict and body.get('format') == POLICY_FORMAT + fields = {'format', 'service', 'source_backup_sha256', 'sequence', 'entries'} + if policy: + fields.add('coverage') + require(type(body) is dict and set(body) == fields, 'recovery_checkpoint_invalid') require(type(pin.public_key) is bytes and len(pin.public_key) == 32 and type(pin.sequence) is int and 0 <= pin.sequence <= _MAX_ENTRIES and type(body['sequence']) is int and body['sequence'] == pin.sequence and - body['format'] == FORMAT and body['service'] == pin.service and + body['format'] in {FORMAT, POLICY_FORMAT} and body['service'] == pin.service and isinstance(pin.service, str) and bool(pin.service) and isinstance(body['source_backup_sha256'], str) and re.fullmatch('[0-9a-f]{64}', body['source_backup_sha256']) is not None and @@ -64,16 +74,32 @@ def verify_checkpoint(packet, *, pin): entries = body['entries'] require(type(entries) is list and len(entries) == pin.sequence, 'recovery_checkpoint_invalid') + domains = set() + authority_chains = {} for sequence, fact in enumerate(entries, 1): - require(type(fact) is dict and set(fact) == { - 'sequence', 'kind', 'subject', 'target', 'at'} and + typed = policy and type(fact) is dict and fact.get('kind') in recovery_policy.FACTS + fact_fields = {'sequence', 'kind', 'subject', 'target', 'at'} + if typed: + fact_fields.add('value') + require(type(fact) is dict and set(fact) == fact_fields and type(fact['sequence']) is int and fact['sequence'] == sequence and - fact['kind'] in SUPPORTED_FACTS and + fact['kind'] in (SUPPORTED_FACTS | recovery_policy.FACTS if policy else SUPPORTED_FACTS) and all(isinstance(fact[name], str) and 0 < len(fact[name]) <= 256 for name in ('subject', 'target')), 'recovery_checkpoint_invalid') require(parse_time(fact['at']) <= datetime.now(UTC), 'recovery_checkpoint_invalid') + if typed: + recovery_policy.validate(fact) + if fact['kind'] == 'resource.authority.reconcile': + chain = authority_chains.setdefault(fact['target'], []) + require(not chain or chain[-1]['value']['current'] == fact['value']['previous'], + 'recovery_authority_chain_gap') + chain.append(fact) + domains.add(recovery_policy.DOMAINS[fact['kind']] if typed else 'revocations') + if policy: + require(body['coverage'] == {'complete': False, 'domains': sorted(domains)} and + body['coverage'].get('complete') is False, 'recovery_checkpoint_incomplete_contract') verify(pin.public_key, canonical(body), decode(Signature, packet['signature']), - purpose='recovery-checkpoint-v1') + purpose='recovery-checkpoint-v2' if policy else 'recovery-checkpoint-v1') # Own the verified bytes across the subsequent await/transaction boundary. return loads(canonical(body)) except (Failure, KeyError, TypeError, ValueError, OverflowError) as exc: @@ -116,6 +142,8 @@ def _revoke_credential(tx, subject, target, at): async def _apply(tx, fact): subject, target, at, kind = (fact[name] for name in ('subject', 'target', 'at', 'kind')) + if kind in recovery_policy.FACTS - {'topic_ban.set'}: + return await recovery_policy.apply(tx, fact) if kind == 'credential.revoke': return _revoke_credential(tx, subject, target, at) if kind == 'certificate.revoke': @@ -157,6 +185,42 @@ async def _apply(tx, fact): (target, subject), write=True) return True return False + if kind in {'topic_ban.apply', 'topic_ban.set'}: + expires = fact['value']['expires_at'] if kind == 'topic_ban.set' else None + if kind == 'topic_ban.set': + await tx.subject(subject) + require((await tx.resource(target)).type == 'topic', 'not_a_topic') + require(tx.one('SELECT id FROM resources WHERE id=?', (target,)) is not None, + 'recovery_fact_missing') + # A live ban removes membership as well as denying topic participation. + # Preserve that effect when replaying an old snapshot, including when a + # later fact lifts the ban: lifting never restores an old admin role. + member = tx.one('SELECT role,status FROM topic_memberships WHERE topic=? AND subject=?', + (target, subject)) + member_changed = member is not None and member != ('member', 'removed') + if member_changed: + tx.execute("UPDATE topic_memberships SET role='member',status='removed' " + 'WHERE topic=? AND subject=?', (target, subject), write=True) + row = tx.one('SELECT status,expires_at FROM topic_bans WHERE topic=? AND subject=?', (target, subject)) + if row is None: + tx.execute('''INSERT INTO topic_bans + (topic,subject,actor,created_at,expires_at,reason,status) + VALUES (?,?,?,?,?,?,?)''', + (target, subject, ROOT_SUBJECT, at, expires, 'recovery-replay', 'active'), write=True) + return True + # Restrictions never shorten an existing active ban. A permanent ban + # dominates finite expiries; only a separate explicit lift can remove it. + if kind == 'topic_ban.set' and row[0] == 'active': + if row[1] is None or expires is None: + expires = None + elif parse_time(row[1]) > parse_time(expires): + expires = row[1] + # V1 has no expiry and must not inherit a stale snapshot expiry. + if row[0] != 'active' or row[1] != expires: + tx.execute("UPDATE topic_bans SET status='active',expires_at=? WHERE topic=? AND subject=?", + (expires, target, subject), write=True) + return True + return member_changed if kind == 'topic_ban.lift': row = tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', (target, subject)) require(row is not None, 'recovery_fact_missing') @@ -205,12 +269,14 @@ async def replay(store, packet, *, pin, config_dir): async def _replay(store, packet, *, pin, operator): - """Atomically apply an explicitly pinned supported deny-only log to quarantine. + """Atomically apply explicitly pinned partial authority facts to quarantine. Always re-apply facts, including after a repeated invocation/restart: cached watermarks are not evidence that an authorization row has not been restored. - This neither imports keys, emits effects, nor clears quarantine. topic_ban.lift - is the only topic-ban fact; it does not reconcile ACLs or promote production. + This neither imports keys, emits effects, nor clears quarantine. topic_ban.apply + removes restored membership; explicit topic_ban.lift relaxes the ban without + restoring membership. V2 also reconciles typed policy/authority facts. No + receipt asserts complete authority or authorizes promotion. """ require(isinstance(operator, str) and bool(operator), 'recovery_operator_required') body = verify_checkpoint(packet, pin=pin) @@ -224,16 +290,63 @@ async def _replay(store, packet, *, pin, operator): 'recovery_checkpoint_backup_mismatch') previous = tx.setting('recovery_replay') if previous is not None: - require(isinstance(previous, dict) and previous.get('format') == FORMAT and + require(isinstance(previous, dict) and previous.get('format') in {FORMAT, body['format']} and type(previous.get('sequence')) is int and 0 <= previous['sequence'] <= pin.sequence and previous.get('prefix_digest') == digest(body['entries'][:previous['sequence']]), 'recovery_checkpoint_regression') changed = 0 + reconciled = set() + authority_chains = {} + for item in body['entries']: + if item['kind'] == 'resource.authority.reconcile': + authority_chains.setdefault(item['target'], []).append(item) for fact in body['entries']: + if fact['kind'] == 'resource.authority.reconcile': + if fact['target'] in reconciled: + continue + chain = authority_chains[fact['target']] + actual_resource = await tx.resource(fact['target']) + actual = {field: getattr(actual_resource, field) for field in ('owner', 'group', 'parent')} + require(actual in [chain[0]['value']['previous']] + + [item['value']['current'] for item in chain], 'recovery_authority_state_mismatch') + # Collapse only a verified contiguous chain; an old or intermediate + # snapshot converges once without transiently restoring old scope. + fact = dict(chain[-1], subject=actual['owner'], + value={'previous': actual, 'current': chain[-1]['value']['current']}) + reconciled.add(fact['target']) + if fact['kind'] in {'resource.acl.restrict', 'topic.policy.restrict'} and fact['target'] in authority_chains: + chain = authority_chains[fact['target']] + states = [chain[0]['value']['previous']] + [item['value']['current'] for item in chain] + actual_resource = await tx.resource(fact['target']) + actual = {field: getattr(actual_resource, field) for field in ('owner', 'group', 'parent')} + require(actual in states, 'recovery_authority_state_mismatch') + bound = chain[0]['value']['previous'] + for transition in chain: + if transition['sequence'] < fact['sequence']: + bound = transition['value']['current'] + if fact['kind'] == 'resource.acl.restrict': + require(fact['subject'] == fact['value']['owner'] == bound['owner'] and + fact['value']['group'] == bound['group'], 'recovery_fact_subject_mismatch') + fact = dict(fact, subject=actual['owner'], + value=dict(fact['value'], owner=actual['owner'], group=actual['group'])) + else: + require(fact['subject'] == bound['owner'], 'recovery_fact_subject_mismatch') + fact = dict(fact, subject=actual['owner']) changed += bool(await _apply(tx, fact)) - receipt = {'format': FORMAT, 'checkpoint_digest': pin.digest, 'sequence': pin.sequence, - 'prefix_digest': digest(body['entries']), 'scope': 'supported_deny_only_facts', - 'promotion': 'blocked', 'backup_retired': False} + receipt = {'format': body['format'], 'checkpoint_digest': pin.digest, 'sequence': pin.sequence, + 'prefix_digest': digest(body['entries']), 'scope': 'supported_authority_reconciliation_facts', + 'promotion': 'blocked', + 'promotion_blocked_reasons': list(PROMOTION_BLOCKED_REASONS), + 'backup_retired': False} + if body['format'] == POLICY_FORMAT: + receipt['scope'] = 'supported_policy_reconciliation_only' + receipt['coverage'] = body['coverage'] + staged = sorted({fact['target'] for fact in body['entries'] + if fact['kind'] == 'resource.authority.reconcile'}) + if staged: + receipt['scope'] = 'supported_policy_reconciliation_only' + receipt['authority_rebuild_required'] = staged + receipt['promotion_blocked_reasons'].append('derived_authority_inventory_incomplete') if changed: tx.set_setting('authorization_epoch', tx.setting('authorization_epoch', 0) + 1) if changed or previous != receipt: diff --git a/src/msg/admin/recovery_state.py b/src/msg/admin/recovery_state.py new file mode 100644 index 00000000..6c39552c --- /dev/null +++ b/src/msg/admin/recovery_state.py @@ -0,0 +1,152 @@ +"""Complete, typed state commitments; no authority-bearing table is omitted.""" +from decimal import Decimal +from importlib.resources import files +import hashlib + +from msg.core.codec import canonical, loads +from msg.core.errors import require + +CONTROL_SETTINGS = frozenset({'recovery_quarantine', 'recovery_replay', 'recovery_promotion'}) +PAUSE_FIELDS = ('accept_writes', 'cleanup_enabled') + + +def catalogue(tx): + namespaces = tx.rows("SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_' AND nspname<>'information_schema' ORDER BY nspname") + require(namespaces == [('public',)], 'recovery_schema_unknown_namespace') + relations = tx.rows("SELECT c.relname,c.relkind FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' ORDER BY c.relname") + require(all(kind in ('r', 'i', 'S') for _, kind in relations), 'recovery_schema_unknown_relation') + require(not tx.rows("SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND (c.relrowsecurity OR c.relforcerowsecurity OR c.relpersistence<>'p')"), 'recovery_schema_unsupported') + require(not tx.rows("SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public'"), 'recovery_schema_unsupported') + require(not tx.rows("SELECT 1 FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public'"), 'recovery_schema_unsupported') + require(not tx.rows("SELECT 1 FROM pg_collation c JOIN pg_namespace n ON n.oid=c.collnamespace WHERE n.nspname='public'"), 'recovery_schema_unsupported') + require(not tx.rows("SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND t.tgisinternal AND t.tgenabled<>'O'"), 'recovery_schema_unsupported') + tables = [name for name, kind in relations if kind == 'r'] + result = {'tables': {}, 'constraints': [], 'indexes': [], 'triggers': [], 'functions': [], 'sequences': []} + for table in tables: + result['tables'][table] = tx.rows('''SELECT a.attname,format_type(a.atttypid,a.atttypmod), + a.attnotnull,a.attidentity,pg_get_expr(d.adbin,d.adrelid), + (SELECT n.nspname||'.'||co.collname FROM pg_collation co JOIN pg_namespace n ON n.oid=co.collnamespace WHERE co.oid=a.attcollation) + FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid + JOIN pg_namespace n ON n.oid=c.relnamespace + LEFT JOIN pg_attrdef d ON d.adrelid=c.oid AND d.adnum=a.attnum + WHERE n.nspname='public' AND c.relname=? AND a.attnum>0 AND NOT a.attisdropped + ORDER BY a.attnum''', (table,)) + result['constraints'] = tx.rows('''SELECT c.relname,k.conname,k.contype,pg_get_constraintdef(k.oid),k.convalidated + FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' ORDER BY c.relname,k.conname''') + result['indexes'] = tx.rows("SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY tablename,indexname") + result['triggers'] = tx.rows('''SELECT c.relname,t.tgname,t.tgenabled,pg_get_triggerdef(t.oid) + FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT t.tgisinternal ORDER BY c.relname,t.tgname''') + result['functions'] = tx.rows('''SELECT p.proname,pg_get_function_identity_arguments(p.oid), + pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace + WHERE n.nspname='public' ORDER BY p.proname,pg_get_function_identity_arguments(p.oid)''') + result['sequences'] = tx.rows("SELECT sequencename,data_type,start_value,min_value,max_value,increment_by,cycle FROM pg_sequences WHERE schemaname='public' ORDER BY sequencename") + result['database_collation'] = tx.one("SELECT encoding,datcollate,datctype FROM pg_database WHERE datname=current_database()") + return loads(canonical(result)) + + +def shape(catalog): + """Stable allowed object/column vocabulary, independent of PG deparser whitespace.""" + return {'tables': {table: [column[:4] for column in columns] for table, columns in catalog['tables'].items()}, + # PG18 additionally catalogs NOT NULL as contype=n. attnotnull above + # already pins each column; keep fixed vocabulary portable to PG16. + # Full signed catalogue still includes every constraint definition. + 'constraints': [row[:3] for row in catalog['constraints'] if row[2] != 'n'], + 'indexes': [row[:2] for row in catalog['indexes']], + 'triggers': [row[:2] for row in catalog['triggers']], + 'functions': [row[:2] for row in catalog['functions']], + 'sequences': [row[:2] for row in catalog['sequences']]} + + +def known_catalogue(tx): + actual = catalogue(tx) + expected = loads(files('msg.data').joinpath('recovery-state-schema.json').read_bytes()) + require(shape(actual) == expected, 'recovery_schema_unsupported') + return actual + + +def quote(name): + return '"' + name.replace('"', '""') + '"' + + +def cell(value): + if value is None: + return ['null'] + if type(value) in (str, int, bool): + return [type(value).__name__, value] + if type(value) is Decimal: + require(value.is_finite(), 'recovery_state_invalid_number') + return ['decimal', str(value)] + require(type(value) is bytes, 'recovery_state_unsupported_type') + return ['bytes', value.hex()] + + +def runtime_controls(tx): + current = tx.setting('runtime_config') + require(current is None or type(current) is dict, 'recovery_runtime_invalid') + values = current or {} + require(all(key not in values or type(values[key]) is bool for key in PAUSE_FIELDS), 'recovery_runtime_invalid') + return {'present': current is not None, + 'fields': {key: {'present': key in values, 'value': values.get(key, False)} for key in PAUSE_FIELDS}} + + +def _runtime_row(row, controls): + value = loads(row[1]) + require(type(value) is dict and all(value.get(key) is False for key in PAUSE_FIELDS), 'recovery_runtime_not_paused') + for key in PAUSE_FIELDS: + wanted = controls['fields'][key] + require(type(wanted['present']) is bool and type(wanted['value']) is bool, 'recovery_runtime_invalid') + value.pop(key) + if wanted['present']: + value[key] = wanted['value'] + if not controls['present']: + require(not value, 'recovery_runtime_mismatch') + return None + return ('runtime_config', canonical(value).decode()) + + +def snapshot(tx, *, controls=None, resume=None): + if resume is not None: + require(tx.setting('recovery_runtime_generation') == resume['generation'], + 'recovery_promotion_receipt_mismatch') + catalog = known_catalogue(tx) + result = {'catalogue': catalog, 'tables': {}, 'sequences': {}} + # The application writer lock plus these relation locks serialize every DB + # authority/effect change until capture or promotion commits. + tx.execute('LOCK TABLE ' + ','.join('public.' + quote(name) for name in catalog['tables']) + ' IN ACCESS EXCLUSIVE MODE', write=True) + for table, columns in catalog['tables'].items(): + rows = tx.rows('SELECT ' + ','.join(quote(column[0]) for column in columns) + ' FROM public.' + quote(table)) + if table == 'settings': + kept = [] + for row in rows: + if row[0] in CONTROL_SETTINGS: + continue + if resume is not None and row[0] == 'recovery_runtime_generation': + require(loads(row[1]) == resume['generation'], 'recovery_promotion_receipt_mismatch') + if resume['prior_generation'] is None: + continue + row = (row[0], canonical(resume['prior_generation']).decode()) + if controls is not None and row[0] == 'runtime_config': + row = _runtime_row(row, controls) + if row is not None: + kept.append(row) + rows = kept + if resume is not None and table == 'audit': + audit = resume['audit_row'] + require(sum(loads(canonical(row)) == audit for row in rows) == 1, 'recovery_promotion_receipt_mismatch') + require(max(row[0] for row in rows) == audit[0], 'recovery_promotion_state_changed') + rows = [row for row in rows if row[0] != audit[0]] + encoded = sorted(canonical([cell(value) for value in row]) for row in rows) + hasher = hashlib.sha256() + for row in encoded: + hasher.update(len(row).to_bytes(8, 'big')) + hasher.update(row) + result['tables'][table] = {'rows': len(rows), 'sha256': hasher.hexdigest()} + for name, *_ in catalog['sequences']: + state = loads(canonical(tx.one('SELECT last_value,is_called FROM public.' + quote(name)))) + if resume is not None and name == 'audit_seq_seq': + require(state in (resume['prior_audit_sequence'], [resume['audit_row'][0], True]), 'recovery_promotion_state_changed') + state = resume['prior_audit_sequence'] + result['sequences'][name] = state + return result diff --git a/src/msg/admin/root.py b/src/msg/admin/root.py index c77107fa..1945f7f4 100644 --- a/src/msg/admin/root.py +++ b/src/msg/admin/root.py @@ -23,6 +23,7 @@ from msg.security.certificates import sign_certificate,csr_body,verify_csr from msg.security.capabilities import grant_for from msg.storage.git import durable_write +from msg.security.trust_files import trust_file, write_trust, reserved_plugins_directory from msg.bootstrap import bootstrap,seed_resource from msg.plugins.common import new_id from msg.plugins.identity import certificate_resource @@ -48,6 +49,7 @@ async def _provision(app,pin): root=Ed25519Signer.generate() envelope=seal_private_key(root.private_bytes(),pin) settings.config_dir.mkdir(parents=True,exist_ok=True) + reserved_plugins_directory(settings.config_dir,create=True) durable_write(marker,b'1\n',mode=0o600) protected.mkdir(mode=0o700,parents=True,exist_ok=True) os.chmod(protected,0o700) @@ -95,8 +97,8 @@ async def _provision(app,pin): tx.set_setting('active_root_certificate',root_certificate.resource_id) tx.set_setting('online_ca_request',csr.resource_id) tx.set_setting('receipt_public_key',{'key_id':receipt.key_id,'public_key':b64(receipt.public_key)}) - durable_write(settings.trust_file,canonical({'version':1,'public_key':b64(root.public_key), - 'certificate':wire(root_certificate)}),mode=0o444) + write_trust(settings.config_dir,{'version':1,'public_key':b64(root.public_key), + 'certificate':wire(root_certificate)},writer=durable_write) marker.unlink() await app.load() return csr.resource_id,root @@ -262,29 +264,78 @@ async def read(): return asyncio.run(_revoke(app,certificate_id,Ed25519Signer.from_bytes(private),reason=reason,operator=operator)) + def sign_recovery_proof(self,source_backup_sha256,sequence,destination): + require_local_console(self.config_dir) + from msg.admin.recovery_proof import open_for_proof, draft, seal + from msg.admin.backup_retirement import write_record + from msg.security.root_files import read_private + app=asyncio.run(open_for_proof(self.config_dir)) + try: + body=asyncio.run(draft(app,public_key=app.certificates.root_public_key, + source_backup_sha256=source_backup_sha256,sequence=sequence)) + fingerprint=digest(body) + print(canonical({'service':body['service'],'sequence':sequence, + 'source_backup_sha256':source_backup_sha256,'state_digest':fingerprint, + 'tables':len(body['metadata']['tables']),'expires_at':body['expires_at']}).decode()) + require(input('Type SIGN COMPLETE RECOVERY '+fingerprint+': ')== + 'SIGN COMPLETE RECOVERY '+fingerprint,'approval_cancelled') + envelope=loads(read_private(root_envelope(self.config_dir))) + signer=Ed25519Signer.from_bytes(open_private_key(envelope,getpass.getpass('Root PIN/passphrase: '))) + packet=asyncio.run(seal(app,body,signer)) + write_record(Path(destination),packet) + return {'status':'complete_recovery_state_signed','digest':fingerprint, + 'independent_pin_required':True,'path':str(destination)} + finally: + asyncio.run(app.close()) + + def promote_recovery(self,source,independent_trust): + operator=require_local_console(self.config_dir) + from msg.admin.recovery_proof import open_for_proof, verify_proof, promote, IndependentRecoveryPin + from msg.security.root_files import read_private + packet=loads(read_private(source,limit=64*1024*1024)) + trust=loads(read_private(independent_trust,limit=65536)) + require(type(trust) is dict and set(trust)=={ + 'service','public_key','digest','sequence','source_backup_sha256'},'recovery_independent_pin_required') + pin=IndependentRecoveryPin(**dict(trust,public_key=unb64(trust['public_key'],limit=32))) + app=asyncio.run(open_for_proof(self.config_dir)) + try: + body=verify_proof(packet,pin,app.clock()) + print(canonical({'service':pin.service,'state_digest':pin.digest,'sequence':pin.sequence, + 'source_backup_sha256':pin.source_backup_sha256,'tables':len(body['metadata']['tables']), + 'effect':'release verified complete recovery; restart all runtimes'}).decode()) + require(input('Type PROMOTE COMPLETE RECOVERY '+pin.digest+': ')== + 'PROMOTE COMPLETE RECOVERY '+pin.digest,'approval_cancelled') + envelope=loads(read_private(root_envelope(self.config_dir))) + signer=Ed25519Signer.from_bytes(open_private_key(envelope,getpass.getpass('Root PIN/passphrase: '))) + return asyncio.run(promote(app,packet,pin=pin,signer=signer,operator=operator)) + finally: + asyncio.run(app.close()) + def sign_backup_retirement(self,source,destination): """Root-sign an operator's statement that listed backup sets lost one old key.""" require_local_console(self.config_dir) - from msg.admin.backup_retirement import unsigned_statement + from msg.admin.backup_retirement import read_statement, unsigned_statement, write_record from msg.security.backup_retirement import sign_record target=Path(destination) require(not target.exists() and not target.is_symlink(),'backup_retirement_destination_exists') - body=unsigned_statement(loads(Path(source).read_bytes())) + body=unsigned_statement(read_statement(source)) print(canonical({'statement':body,'digest':digest(body), 'claim':'listed_backup_sets_only'}).decode()) require(input('Type ATTEST BACKUP RETIREMENT '+digest(body)+': ')== 'ATTEST BACKUP RETIREMENT '+digest(body),'approval_cancelled') - private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: ')) + from msg.security.root_files import read_private + envelope=loads(read_private(root_envelope(self.config_dir))) + private=open_private_key(envelope,getpass.getpass('Root PIN/passphrase: ')) record=sign_record(body,Ed25519Signer.from_bytes(private)) - durable_write(target,canonical(record),mode=0o600) + write_record(target,record) return {'status':'backup_retirement_signed','path':str(target),'digest':digest(record)} def import_backup_retirement(self,source): """Only this console path persists a record; reads re-verify it every time.""" operator=require_local_console(self.config_dir) - from msg.admin.backup_retirement import import_record + from msg.admin.backup_retirement import import_record, read_statement app=self._app() - record=loads(Path(source).read_bytes()) + record=read_statement(source) print(canonical({'record':record,'digest':digest(record)}).decode()) require(input('Type IMPORT BACKUP RETIREMENT '+digest(record)+': ')== 'IMPORT BACKUP RETIREMENT '+digest(record),'approval_cancelled') @@ -304,7 +355,7 @@ def backup(self,destination): target=Path(destination) require(not target.exists() and not target.is_symlink(),'backup_destination_exists') envelope=loads(root_envelope(self.config_dir).read_bytes()) - trust=loads((self.config_dir/'trust'/'root.json').read_bytes()) + trust=loads(trust_file(self.config_dir).read_bytes()) require(input('Type BACKUP ROOT to continue: ')=='BACKUP ROOT','approval_cancelled') private=open_private_key(envelope,getpass.getpass('Root PIN/passphrase: ')) require(b64(Ed25519Signer.from_bytes(private).public_key)==trust['public_key'],'root_key_mismatch') @@ -318,7 +369,7 @@ def recover(self,source): require(not target.exists(),'root_material_already_present') backup=loads(Path(source).read_bytes()) require(set(backup)=={'format','envelope','trust'} and backup['format']=='msg-root-backup-v1','invalid_root_backup') - current=loads((self.config_dir/'trust'/'root.json').read_bytes()) + current=loads(trust_file(self.config_dir).read_bytes()) require(canonical(current)==canonical(backup['trust']),'trust_anchor_mismatch') print(canonical({'fingerprint':digest(unb64(current['public_key'])),'action':'recover_missing_encrypted_root_key'}).decode()) require(input('Type RECOVER ROOT to continue: ')=='RECOVER ROOT','approval_cancelled') diff --git a/src/msg/admin/rotation.py b/src/msg/admin/rotation.py index 63b7ddad..9c945605 100644 --- a/src/msg/admin/rotation.py +++ b/src/msg/admin/rotation.py @@ -21,6 +21,7 @@ from msg.security.root_files import read_private, rotation_lock from msg.security.capabilities import grant_for from msg.storage.git import durable_write +from msg.security.trust_files import trust_values, write_trust from msg.bootstrap import seed_resource @@ -114,9 +115,8 @@ async def _complete(app, journal, *, pin): require(key_file.exists() and digest(read_private(key_file)) == journal['previous_envelope_digest'], 'rotation_history_missing') - require(app.settings.trust_file.is_file() and not app.settings.trust_file.is_symlink(), - 'rotation_trust_missing') - current_trust = loads(app.settings.trust_file.read_bytes()) + current_trust = trust_values(app.settings.config_dir) + require(bool(current_trust),'rotation_trust_missing') # This method is called only after the local console has approved the exact # journal. It also handles the state where PostgreSQL committed but protected files did not. await app.open_storage() @@ -130,7 +130,8 @@ async def _complete(app, journal, *, pin): digest(old_key.verifier)==journal['statement']['old_fingerprint'], 'rotation_statement_mismatch') old_trust = {'version': 1, 'public_key': b64(old_key.verifier), 'certificate': wire(old)} - require(canonical(current_trust) in (canonical(old_trust), canonical(journal['new_trust'])), + require(all(canonical(value) in (canonical(old_trust), canonical(journal['new_trust'])) + for value in current_trust.values()), 'rotation_trust_changed') if journal['old_signature'] is not None: verify(old_key.verifier,canonical(proof),decode(Signature,journal['old_signature']), @@ -186,7 +187,8 @@ async def _complete(app, journal, *, pin): 'rotation_history_missing') durable_write(history,read_private(key_file),mode=0o600) durable_write(key_file,canonical(journal['new_envelope']),mode=0o600) - durable_write(app.settings.trust_file,canonical(journal['new_trust']),mode=0o444) + write_trust(app.settings.config_dir,journal['new_trust'],writer=durable_write, + approved_values=(old_trust,journal['new_trust'])) # Journal survives every earlier failure and is removed only after both # metadata and protected files have durable copies. pending.unlink(missing_ok=True) diff --git a/src/msg/admin/tool_check.py b/src/msg/admin/tool_check.py new file mode 100644 index 00000000..506dabdf --- /dev/null +++ b/src/msg/admin/tool_check.py @@ -0,0 +1,74 @@ +"""Tool readiness and isolated real-runner exercise; never contact an external host.""" +import asyncio +import tempfile +from dataclasses import replace +from pathlib import Path + +from msg.core.errors import Failure, require +from msg.core.models import Scope +from msg.security.capabilities import grant_for +from msg.workers.sandbox import BubblewrapRunner + + +async def inspect_tool_sandbox(app): + """Exercise the installed sandbox and its deny path without making a request.""" + tool = app.registry.tool('tool_curl') + policy = replace(tool.network, hosts=('denied.invalid',), timeout_ms=1000) + with tempfile.TemporaryDirectory(prefix='msg-tool-doctor-') as directory: + try: + await BubblewrapRunner(app)(tool, {'url': 'http://127.0.0.1/'}, + (policy,), Path(directory)) + except Failure as exc: + require(exc.code == 'network_policy_denied', exc.code) + else: + raise Failure('tool_sandbox_denial_missing') + return {'runner': 'bubblewrap', 'policy_denial': True, 'external_requests': 0} + + +async def check_tool_sandbox(app, call, approve, key, subject): + """Check real tool.run denial and real bwrap output using a loopback-only fixture.""" + require(app.selftest_run_id is not None,'selftest_namespace_required') + payload = b'msg isolated sandbox selftest\n' + received = [] + async def serve(reader, writer): + try: + request = await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'), 5) + received.append(request.split(b'\r\n', 1)[0]) + writer.write(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: '+ + str(len(payload)).encode()+b'\r\nConnection: close\r\n\r\n'+payload) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + server = await asyncio.start_server(serve, '127.0.0.1', 0, limit=8192) + try: + port = server.sockets[0].getsockname()[1] + scope = Scope(resource_id='tool_curl', descendants=False) + grant = replace(grant_for(app.registry.capability('tool.use'), scope=scope), + constraints={'hosts': ['127.0.0.1'], 'ports': [80], 'methods': ['GET']}) + certificate = await approve(key, subject, (grant,)) + denied = await call('tool.run', {'id': 'tool_curl', + 'arguments': {'url': 'http://127.0.0.1/'}}, + key, subject, (certificate.resource_id,)) + require(denied.error is not None and denied.error.code == 'network_policy_denied', + 'tool_selftest_private_denial_missing') + tool = app.registry.tool('tool_curl') + policy = replace(tool.network, hosts=('127.0.0.1',), ports=frozenset({port}), + methods=frozenset({'GET'}), allow_private=True, timeout_ms=3000, + max_response_bytes=4096, max_redirects=0) + # This exercises the real runner port. It deliberately does not alter the + # registered tool's 80/443 policy to claim a normal tool.run success. + with tempfile.TemporaryDirectory(prefix='msg-tool-selftest-') as directory: + result = await BubblewrapRunner(app)(tool, {'url': f'http://127.0.0.1:{port}/'}, + (policy,), Path(directory)) + require(result.path.read_bytes() == payload and result.metadata['status'] == 200, + 'tool_selftest_output_mismatch') + require(received == [b'GET / HTTP/1.1'], 'tool_selftest_request_mismatch') + await inspect_tool_sandbox(app) + require(received == [b'GET / HTTP/1.1'], 'tool_selftest_unexpected_request') + return {'operation': 'private_target_denied', + 'runner': 'loopback_output_and_policy_denial', + 'normal_operation_pipeline': 'not_exercised', 'external_requests': 0} + finally: + server.close() + await server.wait_closed() diff --git a/src/msg/admin/transfer_check.py b/src/msg/admin/transfer_check.py new file mode 100644 index 00000000..7016936c --- /dev/null +++ b/src/msg/admin/transfer_check.py @@ -0,0 +1,79 @@ +"""Bounded TransferSession checks in the disposable selftest installation.""" +from msg.core.codec import b64, digest, unb64, wire +from msg.core.errors import require + + +async def check_transfer(app, call, register): + require(bool(app.selftest_run_id), 'selftest_namespace_required') + key, owner = await register('transfer-selftest') + other_key, other = await register('transfer-outsider') + + async def invoke(operation, arguments): + result = await call(operation, arguments, key, owner) + require(result.status == 'ok', 'selftest_transfer_operation_failed', + details={'operation': operation}) + return result + + async def snapshot(): + async with app.metadata.transaction(write=False) as tx: + return {table: tuple(sorted(tx.rows(f'SELECT * FROM {table}'), key=repr)) + for table in ('resources', 'revisions', 'events', 'audit', 'jobs', + 'results', 'transfers', 'chunks', 'settings', 'credentials')} + + payload = b'isolated transfer selftest\n' + bytes(range(256)) * 2 + opened = await invoke('transfer.open', {'direction': 'upload', 'size': len(payload), + 'digest': digest(payload), 'requested_part_bytes': 128}) + transfer = opened.data['transfer_id'] + part_bytes = opened.data['part_bytes'] + require(0 < part_bytes <= 128, 'selftest_transfer_part_size') + # The wrong principal must not be able to write a valid chunk or read state. + before = await snapshot() + for operation, arguments in ( + ('transfer.status', {'transfer_id': transfer}), + ('transfer.part_put', {'transfer_id': transfer, 'offset': 0, + 'data': b64(payload[:1]), 'digest': digest(payload[:1])})): + denied = await call(operation, arguments, other_key, other) + require(denied.error is not None and denied.error.code == 'transfer_owner_required', + 'selftest_transfer_owner_not_enforced') + bad = await call('transfer.part_put', {'transfer_id': transfer, 'offset': 0, + 'data': b64(payload[:1]), 'digest': digest(b'wrong')}, key, owner) + require(bad.error is not None and bad.error.code == 'chunk_digest_mismatch', + 'selftest_transfer_bad_digest_accepted') + require(await snapshot() == before, 'selftest_transfer_rejection_mutated') + for offset in reversed(range(0, len(payload), part_bytes)): + block = payload[offset:offset + part_bytes] + await invoke('transfer.part_put', {'transfer_id': transfer, 'offset': offset, + 'data': b64(block), 'digest': digest(block)}) + status = await invoke('transfer.status', {'transfer_id': transfer}) + require(not status.data['missing'], 'selftest_transfer_missing_parts') + sealed = await invoke('transfer.seal', {'transfer_id': transfer, + 'final_size': len(payload), 'final_digest': digest(payload)}) + require(len(sealed.resources) == 1, 'selftest_transfer_output_missing') + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(sealed.resources[0].id) + require(resource.type == 'file' and resource.mode == 0o600 and resource.owner == owner, + 'selftest_transfer_output_not_private') + downloaded = await invoke('transfer.open', {'direction': 'download', + 'target': wire(sealed.resources[0]), 'requested_part_bytes': 128}) + download = downloaded.data['transfer_id'] + download_part_bytes = downloaded.data['part_bytes'] + require(0 < download_part_bytes <= 128, 'selftest_transfer_part_size') + before = await snapshot() + received = bytearray() + for offset in range(0, len(payload), download_part_bytes): + part = await invoke('transfer.part_get', {'transfer_id': download, 'offset': offset, + 'length': min(download_part_bytes, len(payload) - offset)}) + block = unb64(part.data['data']) + require(digest(block) == part.data['chunk']['content']['digest'], + 'selftest_transfer_chunk_digest_mismatch') + received.extend(block) + await invoke('transfer.status', {'transfer_id': transfer}) + await invoke('transfer.status', {'transfer_id': download}) + denied = await call('transfer.part_get', {'transfer_id': download, 'offset': 0, 'length': 1}, + other_key, other) + require(denied.error is not None and denied.error.code == 'transfer_owner_required', + 'selftest_transfer_download_owner_not_enforced') + require(bytes(received) == payload and digest(bytes(received)) == digest(payload), + 'selftest_transfer_download_mismatch') + require(await snapshot() == before, 'selftest_transfer_read_mutated') + return True diff --git a/src/msg/application.py b/src/msg/application.py index 90a8dec9..12450b0d 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -18,7 +18,7 @@ from msg.plugins import install_registry from msg.security.certificates import CertificateValidator from msg.security.crypto import Ed25519Signer -from msg.security.quarantine import active as quarantine_active +from msg.security.quarantine import RuntimeGeneration, active as quarantine_active from msg.security.token_delivery import TokenDelivery, recovery_verifier from msg.storage.git import GitContentStore @@ -39,6 +39,7 @@ def __init__(self,settings, *, clock=None, selftest_run_id=None): self.executor=None self.token_delivery=None self._loaded=False + self.runtime_generation=None install_registry(self, settings.server.plugins) def primary_ceiling(self): @@ -72,11 +73,17 @@ async def open_storage(self): async def load(self): require(self.settings.trust_file.is_file(),'root_not_initialized') + # Pin before reading runtime files, then recheck with validated authority. + await self.open_storage() + async with self.metadata.transaction(write=False) as tx: + if self.runtime_generation is None: + self.runtime_generation=RuntimeGeneration.capture(tx) + else: + self.runtime_generation.require_current(tx) trust=loads(self.settings.trust_file.read_bytes()) require(set(trust)=={'version','public_key','certificate'} and trust['version']==1,'invalid_trust_anchor') root_certificate=decode(Certificate,trust['certificate']) root_public=unb64(trust['public_key'],limit=32) - await self.open_storage() keys=self.settings.service_keys required=('online.key','receipt.key','tokens.key') require(all((keys/name).is_file() for name in required),'service_keys_missing') @@ -91,6 +98,8 @@ async def load(self): recovery_window=lambda: self.settings.credential_delivery_recovery_window) self.certificates=CertificateValidator(self.registry,root_certificate,root_public,self.settings.service_url,self.clock) async with self.metadata.transaction(write=False) as tx: + self.runtime_generation.require_current(tx) + self.token_delivery.runtime_generation=self.runtime_generation quarantined=quarantine_active(tx) await self.certificates.validate(root_certificate.resource_id,tx) root=await tx.subject(ROOT_SUBJECT) @@ -104,13 +113,17 @@ async def load(self): async with self.metadata.transaction(write=True) as tx: # Recheck under the writer lock; loading a restore must not # mutate release resources before recovery has been accepted. + self.runtime_generation.require_current(tx) if not quarantine_active(tx): - await sync_system_sources(tx,self.contents,self.clock(),namespace_root=self.namespace_root) + await sync_system_sources(tx,self.contents,self.clock(),namespace_root=self.namespace_root, + registry=self.registry) else: quarantined=True self.authenticator=AuthenticationService(self.registry,self.certificates,self.settings.service_url,self.clock, self.primary_ceiling,self.temporary_ceiling) self.authorizer=AuthorizationService(self.registry,self.certificates) + self.authenticator.runtime_generation=self.runtime_generation + self.authorizer.runtime_generation=self.runtime_generation self.executor=self.new_executor(self.authenticator) self.executor.recovery_drill_marker=marker self.executor.recovery_quarantined=quarantined @@ -125,6 +138,7 @@ def new_executor(self, authenticator=None): max_request_bytes=self.settings.server.limits.max_request_bytes, result_projection=self._result_projection, event_notifications=self._event_notifications) + executor.runtime_generation=self.runtime_generation executor.response_hook=self.token_delivery.release executor.recovery_drill_marker=self.settings.config_dir/'recovery-drill.json' if self.executor is not None: @@ -137,8 +151,12 @@ async def _result_projection(self, context, request, session, resource, *, field revision=resource.revision,fields=fields) async def _event_notifications(self, session, event): + if 'communication' not in self.settings.server.plugins: + return from msg.plugins.communication import enqueue_domain_webhooks await enqueue_domain_webhooks(self,session,event) + from msg.plugins.watches import enqueue + await enqueue(self,session,event) async def online_issuer(self,tx): issuer=tx.setting('online_ca_certificate') diff --git a/src/msg/atomic_file.py b/src/msg/atomic_file.py new file mode 100644 index 00000000..beaf7034 --- /dev/null +++ b/src/msg/atomic_file.py @@ -0,0 +1,26 @@ +"""Atomic local-file replacement with caller-selected mode and durable syncs.""" +from __future__ import annotations + +import os +from pathlib import Path +import tempfile + + +def durable_write(path: Path, data: bytes, mode: int = 0o600): + path.parent.mkdir(parents=True,exist_ok=True) + fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) + try: + os.fchmod(fd,mode) + with os.fdopen(fd,'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary,path) + directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/src/msg/bootstrap.py b/src/msg/bootstrap.py index c8fb71fe..74e2caa3 100644 --- a/src/msg/bootstrap.py +++ b/src/msg/bootstrap.py @@ -33,6 +33,12 @@ # Release maintainers declare a relocation in both maps in the same change. SOURCE_PATH_OVERRIDES={} SOURCE_MIGRATIONS={} +# Release-only pins: rule_id -> {source_path: relative .md path, version: int, +# digest: sha256:}. Remove active links/operation dependencies in the same +# release; keep RULE_SPECS identity and this pin so upgrades preserve history. +# Retirement archives existing resources; fresh installs invent no old revision. +# No current rule is retired. A pin mismatch requires an explicit staged upgrade. +SOURCE_RETIREMENTS={} def system_source_root(): @@ -42,15 +48,36 @@ def system_source_root(): async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_root=ROOT_SPACE, - source_paths=None,migrations=None): + source_paths=None,migrations=None,retirements=None,registry=None): """Sync each release-owned document independently; never delete a source implicitly.""" root=Path(source_root or system_source_root()) source_paths=dict(SOURCE_PATH_OVERRIDES if source_paths is None else source_paths) migrations=dict(SOURCE_MIGRATIONS if migrations is None else migrations) require(set(source_paths)<=RULE_PATHS.keys() and set(migrations)<=RULE_PATHS.keys(), 'unknown_rule_id') + retirements=dict(SOURCE_RETIREMENTS if retirements is None else retirements) + require(set(retirements)<=RULE_PATHS.keys()-{'msg.bootstrap','msg.rules.index'}, + 'system_source_invalid_retirement') + require(not set(retirements)&(source_paths.keys()|migrations.keys()), + 'system_source_invalid_retirement') + for rule_id,pin in retirements.items(): + require(isinstance(pin,dict) and set(pin)=={'source_path','version','digest'} and + isinstance(pin['source_path'],str) and + pin['source_path']==Path(pin['source_path']).as_posix() and + not Path(pin['source_path']).is_absolute() and + '..' not in Path(pin['source_path']).parts and + pin['source_path'].endswith('.md') and + type(pin['version']) is int and pin['version']>0 and + isinstance(pin['digest'],str) and + re.fullmatch(r'sha256:[0-9a-f]{64}',pin['digest']) is not None, + 'system_source_invalid_retirement',rule_id) + if retirements: + require(registry is not None and all(not set(spec.requires_rules)&retirements.keys() + for spec in registry.operations()),'system_source_retirement_referenced') specs=[] for rule_id,rid,parent,name,default_path,max_bytes in RULE_SPECS: + if rule_id in retirements: + continue relative=source_paths.get(rule_id,default_path) require(isinstance(relative,str) and relative.endswith('.md') and relative==Path(relative).as_posix() and @@ -72,6 +99,7 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo rule_id=match.group(1) require(rule_id in RULE_PATHS,'unknown_rule_id',relative) require(rule_id not in observed,'duplicate_rule_id',rule_id) + require(rule_id not in retirements,'system_source_retirement_source_present',rule_id) observed[rule_id]=relative require(not expected-actual,'system_source_deleted_requires_migration', details={'paths':sorted(expected-actual)}) @@ -101,15 +129,41 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo 'dangling_requires_rules',rule_id) require(len(required)<=1,'duplicate_requires_rules',rule_id) dependencies=tuple(part.strip() for part in required[0].split(',')) if required else () + require(not set(dependencies)&retirements.keys() and not any( + re.search(re.escape(RULE_PATHS[retired])+r'(?=[/#?\s)\]<>]|$)',source_text) + for retired in retirements),'system_source_retirement_referenced') require(all(dep in RULE_PATHS and dep!=rule_id for dep in dependencies) and len(set(dependencies))==len(dependencies),'dangling_requires_rules',rule_id) prepared.append((relative,rid,parent,name,rule_id,version,raw,change_note)) - require(rule_ids==RULE_PATHS.keys(),'system_source_inventory_mismatch') + require(rule_ids==RULE_PATHS.keys()-retirements.keys(),'system_source_inventory_mismatch') + retired=[] + for rule_id,pin in retirements.items(): + spec=next(spec for spec in RULE_SPECS if spec[0]==rule_id) + rid=spec[1] + old=tx.one('''SELECT source_path,source_version,source_digest,revision_id,source_kind,rule_id + FROM system_sources WHERE resource_id=?''',(rid,)) + if old is None: + require(tx.one('SELECT id FROM resources WHERE id=?',(rid,)) is None, + 'system_source_retirement_mismatch',rule_id) + continue # A fresh install has no historical revision to archive. + require(tuple(old[:3])==('docs/system/'+pin['source_path'],pin['version'],pin['digest']) + and old[4] in {'release','release_retired'} and old[5]==rule_id, + 'system_source_retirement_mismatch',rule_id) + resource=await tx.resource(rid) + require((resource.type,resource.parent,resource.name,resource.owner,resource.group, + resource.mode)==('file',spec[2],spec[3],ROOT_SUBJECT,PUBLIC_GROUP,0o444), + 'bootstrap_drift',rid) + require(resource.revision==old[3],'system_source_pointer_drift') + require(resource.state in {'active','archived'} and + (old[4]!='release_retired' or resource.state=='archived'), + 'system_source_retirement_mismatch',rule_id) + retired.append((resource,old[0])) for rule_id,move in migrations.items(): require(isinstance(move,(list,tuple)) and len(move)==2 and all(isinstance(p,str) for p in move) and move[1]==source_paths.get(rule_id,next(s[4] for s in RULE_SPECS if s[0]==rule_id)), 'system_source_invalid_migration',rule_id) + migrations[rule_id]=tuple(move) for rid,name,mode in (('r_rules','_rules','0555'),('t_wiki','wiki','1777')): await seed_resource(tx,contents,dict(id=rid,type='topic',name=name,parent=namespace_root, owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode=mode),now) @@ -127,9 +181,10 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo dict(id=rid,type='file',name=name,parent=parent, owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0444'),now) source_digest=digest(raw) - old=tx.one('''SELECT source_path,rule_id,source_version,source_digest,revision_id + old=tx.one('''SELECT source_path,rule_id,source_version,source_digest,revision_id,source_kind FROM system_sources WHERE resource_id=?''',(rid,)) if old is not None: + require(old[5]!='release_retired','system_source_retirement_required',rule_id) require(old[1]==rule_id,'system_source_identity_drift') if old[0]!=source_path: require(migrations.get(rule_id)==(old[0].removeprefix('docs/system/'),relative), @@ -166,6 +221,13 @@ async def sync_system_sources(tx,contents,now, *, source_root=None,namespace_roo tx.execute('''UPDATE system_sources SET source_path=?,source_kind='release',source_version=?, source_digest=?,revision_id=? WHERE resource_id=?''', (source_path,version,source_digest,revision_id,rid),write=True) + for resource,source_path in retired: + desired.add(source_path) + if resource.state!='archived': + await tx.replace(replace(resource,state='archived',generation=resource.generation+1, + modified_at=now,modified_by=ROOT_SUBJECT),resource.generation) + tx.execute("UPDATE system_sources SET source_kind='release_retired' WHERE resource_id=?", + (resource.id,),write=True) missing={path for (path,) in tx.rows('SELECT source_path FROM system_sources')} - desired require(not missing,'system_source_deleted_requires_migration',details={'paths':sorted(missing)}) @@ -197,6 +259,8 @@ def feature_manifest(definition=None): require(all(feature[name] is None or (isinstance(feature[name],str) and bool(feature[name])) for name in ('doctor_check','selftest_case')),'feature_manifest_invalid') + from msg.plugins.features import validate_feature_sources + validate_feature_sources(features) return tuple(features) @@ -243,10 +307,12 @@ async def bootstrap(store,contents,registry,now, *, selftest_run_id=None): if data['type']=='tool': from msg.extensions.tools import descriptor body=canonical(descriptor(data['name'])).decode() + elif data['id']=='r_honor_sample': + body=canonical(definition['honor_sample']).decode() elif data['id']=='r_msg_entry_skill': body=MSG_ENTRY_SKILL await seed_resource(tx,contents,data,now,body, - 'application/json' if data['type']=='tool' else 'text/markdown') + 'application/json' if data['type']=='tool' or data['id']=='r_honor_sample' else 'text/markdown') if selftest_run_id is not None and data['id']=='r_root': await seed_resource(tx,contents,dict(id='t_selftest',type='topic',name='_test', parent=ROOT_SPACE,owner=ROOT_SUBJECT,group=ADMINS_GROUP,mode='0711'),now) @@ -291,7 +357,7 @@ async def bootstrap(store,contents,registry,now, *, selftest_run_id=None): for key,schema in registry._schemas.items(): await seed_resource(tx,contents,dict(id=key,type='file',name=key.removeprefix('schema:'),parent='t_schema', owner=ROOT_SUBJECT,group=PUBLIC_GROUP,mode='0444'),now,canonical(schema),'application/json') - await sync_system_sources(tx,contents,now,namespace_root=namespace_root) + await sync_system_sources(tx,contents,now,namespace_root=namespace_root,registry=registry) tx.set_setting('bootstrap',{'version':definition['version'],'digest':digest(definition)}) diff --git a/src/msg/cli.py b/src/msg/cli.py index 9913a596..cbf283c4 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -8,10 +8,11 @@ from pathlib import Path from msg.client import ClientState,MsgClient -from msg.core.codec import canonical,loads,wire +from msg.core.codec import canonical,loads,wire,unb64 from msg.core.errors import Failure,require -from msg.core.executor import result_wire +from msg.core.codec import result_wire from msg.core.models import ResourceRef,OperationResult +from msg.core.search_query import search_query_version from msg.transports.client import TRANSPORTS @@ -76,18 +77,30 @@ def parser(): following.add_argument('--limit',type=int) following.add_argument('--cursor') search=commands.add_parser('search',help='One bounded page of scoped lexical results.') - search.add_argument('scope',nargs='?',help='Required for a new search; omit with --cursor.') + search.add_argument('scope',nargs='?',help='Path or JSON scope object; omit with --cursor.') search.add_argument('terms',nargs='?',help='Words to find; omit with --cursor.') search.add_argument('--cursor',help='Fetch one next page using the server-issued cursor.') search.add_argument('--limit',type=int,default=50) search.add_argument('--mode',choices=('all','any'),default='all') - search.add_argument('--field',choices=('all','name','body','metadata'),default='all') + search.add_argument('--field',choices=('all','name','title','body','metadata'),default='all') search.add_argument('--exact');search.add_argument('--exclude',dest='not_terms') search.add_argument('--type');search.add_argument('--owner');search.add_argument('--author') search.add_argument('--tag');search.add_argument('--state',choices=('active','archived')) search.add_argument('--created-after');search.add_argument('--created-before') search.add_argument('--updated-after');search.add_argument('--updated-before') - search.add_argument('--has-attachment',action='store_true') + for name in ('attachment','replies','references'): + flags=search.add_mutually_exclusive_group() + flags.add_argument('--has-'+name,dest='has_'+name,action='store_true',default=None) + flags.add_argument('--no-'+name,dest='has_'+name,action='store_false') + search.add_argument('--source-kind',choices=('release','user','operation')) + search.add_argument('--source-version',type=int) + search.add_argument('--revision') + search.add_argument('--relation-type') + search.add_argument('--relation-to');search.add_argument('--relation-from') + search.add_argument('--fields',help='Comma-separated result fields.') + search.add_argument('--facet',dest='facets',choices=('type','tag'),action='append') + search.add_argument('--suggest',action='store_true') + search.add_argument('--spell',action='store_true',help='Explicit spelling hints from visible names.') search.add_argument('--depth',type=int);search.add_argument('--no-recursive',action='store_true') search.add_argument('--order',choices=('relevance','updated','created','name')) search.add_argument('--no-snippet',action='store_true') @@ -114,6 +127,8 @@ def parser(): for action in ('accept','reject','archive'): dm_actions.add_parser(action).add_argument('conversation') dm_actions.add_parser('block').add_argument('subject') + from msg.client_collaboration import add_commands as add_collaboration_commands + add_collaboration_commands(commands) handoff=commands.add_parser('handoff',help='Explicit collaboration context; never transfers permission.') handoff_actions=handoff.add_subparsers(dest='action',required=True) handoff_create=handoff_actions.add_parser('create') @@ -311,6 +326,8 @@ async def run(args): result=await client.call('communication.following',params) elif command=='search': require(1<=args.limit<=100,'invalid_search_limit') + extended=('source_kind','source_version','revision','relation_type','relation_to', + 'relation_from','fields','facets','has_replies','has_references') if args.cursor: require(args.scope is None and args.terms is None,'cursor_query_mismatch') require(args.limit==50 and args.mode=='all' and args.field=='all' and @@ -318,25 +335,45 @@ async def run(args): args.owner is None and args.author is None and args.tag is None and args.state is None and args.created_after is None and args.created_before is None and args.updated_after is None and - args.updated_before is None and not args.has_attachment and + args.updated_before is None and args.has_attachment is None and args.depth is None and not args.no_recursive and args.order is None and - not args.no_snippet and not args.explain,'cursor_query_mismatch') + not args.no_snippet and not args.explain and not args.suggest and + not args.spell and all(getattr(args,key) is None for key in extended), + 'cursor_query_mismatch') params={'cursor':args.cursor} + # Inspect only bounded public query metadata to choose a wire + # contract. The server verifies the MAC, principal and grants. + require(len(args.cursor)<=8192 and args.cursor.count('.')==1,'invalid_cursor') + cursor_body=loads(unb64(args.cursor.split('.')[0],limit=8192)) + require(isinstance(cursor_body,dict) and cursor_body.get('kind')=='read-page' and + isinstance(cursor_body.get('query'),dict),'invalid_cursor') + query=cursor_body['query'] + require(query.get('operation')=='discovery.lexical_search' and + isinstance(query.get('arguments'),dict),'invalid_cursor') + version=search_query_version(query['arguments']) else: require(args.scope is not None and (args.terms or args.exact), 'search_query_required') params={'scope':args.scope,'mode':args.mode,'field':args.field,'limit':args.limit} + if args.scope.startswith('{'): + params['scope']=json_input(args.scope) if args.terms:params['terms']=args.terms for key in ('exact','not_terms','type','owner','author','tag','state', 'created_after','created_before','updated_after','updated_before', - 'depth','order'): + 'depth','order','source_kind','source_version','revision', + 'relation_type','relation_to','relation_from','facets'): value=getattr(args,key) if value is not None:params[key]=value - if args.has_attachment:params['has_attachment']=True + for key in ('has_attachment','has_replies','has_references'): + if getattr(args,key) is not None:params[key]=getattr(args,key) + if args.fields is not None:params['fields']=args.fields.split(',') + if args.suggest:params['suggest']=True + if args.spell:params['spell']=True if args.no_recursive:params['recursive']=False if args.no_snippet:params['snippet']=False if args.explain:params['explain']='compact' - result=await client.call('discovery.lexical_search',params) + version=search_query_version(params) + result=await client.call('discovery.lexical_search',params,contract_version=version) elif command=='grep': require(0<=args.before<=3 and 0<=args.after<=3,'invalid_grep_context') require(1<=args.max_matches<=100 and 1<=args.max_files<=100, @@ -371,6 +408,9 @@ async def run(args): {'subject_id':args.subject}) else:result=await client.call('communication.dm_'+args.action, {'conversation_id':args.conversation}) + elif command in {'request','offer','checkpoint','proposal','watch'}: + from msg.client_collaboration import run_command as run_collaboration_command + result=await run_collaboration_command(client,args) elif command=='handoff': if args.action=='create': params={'to_subject':args.recipient,'resource_refs':args.ref} diff --git a/src/msg/client.py b/src/msg/client.py index 144c0ff0..cffffa10 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -20,8 +20,8 @@ from msg.security.crypto import Ed25519Signer, subject_id, key_id from msg.security.age_keys import (generate_age_key,recipient_from_identity, public_from_recipient,encryption_key_id) -from msg.security.vault import client_upgrade_proof -from msg.storage.git import durable_write +from msg.security.custodial_protocol import client_upgrade_proof +from msg.atomic_file import durable_write from msg.client_tokens import read_journal, remove_journal, token_operation from msg.transports.client import HTTPTransport, GraphQLTransport, MCPHTTPTransport @@ -572,6 +572,7 @@ async def rotate_encryption_key(self): return result async def upload(self, path, *, transfer_id=None, part_bytes=65536, media_type='application/octet-stream',target=None): + require(type(part_bytes) is int and part_bytes>0,'invalid_part_bytes') path=Path(path) size, hashed=await asyncio.to_thread(hash_file,path) journal=self.state.directory/('upload-'+digest((str(path.resolve()),hashed))[7:39]+'.json') @@ -619,6 +620,7 @@ async def upload(self, path, *, transfer_id=None, part_bytes=65536, media_type=' return result async def download(self, resource, path, *, transfer_id=None, part_bytes=65536): + require(type(part_bytes) is int and part_bytes>0,'invalid_part_bytes') ref=resource if isinstance(resource,ResourceRef) else ResourceRef(id=resource) path=Path(path) journal=self.state.directory/('download-'+digest((wire(ref),str(path.resolve())))[7:39]+'.json') diff --git a/src/msg/client_certificates.py b/src/msg/client_certificates.py index d8b9c30e..75fa957c 100644 --- a/src/msg/client_certificates.py +++ b/src/msg/client_certificates.py @@ -12,7 +12,7 @@ from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy from msg.security.certificates import csr_body,verify_csr,sign_certificate from msg.security.crypto import Ed25519Signer,key_id -from msg.storage.git import durable_write +from msg.atomic_file import durable_write def local_key(path, *, create=False): diff --git a/src/msg/client_collaboration.py b/src/msg/client_collaboration.py new file mode 100644 index 00000000..bcc800dc --- /dev/null +++ b/src/msg/client_collaboration.py @@ -0,0 +1,98 @@ +"""CLI adapters for explicit work records; no autonomous workflow execution.""" + + +def add_commands(commands): + watches = commands.add_parser('watch').add_subparsers(dest='action', required=True) + create = watches.add_parser('create') + create.add_argument('target', nargs='?') + create.add_argument('--query-ref') + create.add_argument('--query-revision') + create.add_argument('--event', action='append', required=True) + create.add_argument('--expires-at') + create.add_argument('--delivery', choices=['inbox'], default='inbox') + watches.add_parser('list') + for action in ('get', 'cancel'): + watches.add_parser(action).add_argument('id') + for kind in ('request', 'offer', 'checkpoint', 'proposal'): + actions = commands.add_parser(kind).add_subparsers(dest='action', required=True) + create = actions.add_parser('create') + if kind == 'request': + for name in ('title', 'description', 'requirements'): + create.add_argument('--' + name, required=True) + create.add_argument('--due-at') + elif kind == 'offer': + for name in ('description', 'scope', 'availability'): + create.add_argument('--' + name, required=True) + create.add_argument('--capability-hint') + elif kind == 'checkpoint': + create.add_argument('--summary', required=True) + create.add_argument('--resume-hint') + create.add_argument('--state-ref') + else: + for name in ('target', 'base-revision', 'content', 'content-revision', 'message'): + create.add_argument('--' + name, required=True) + if kind != 'proposal': + create.add_argument('--ref', action='append', default=[]) + create.add_argument('--expires-at') + actions.add_parser('get').add_argument('id') + listing = actions.add_parser('list') + listing.add_argument('--limit', type=int) + listing.add_argument('--after') + transitions = {'request': ('claim', 'fulfill', 'cancel'), 'offer': ('withdraw',), + 'checkpoint': (), 'proposal': ('accept', 'reject', 'withdraw')}[kind] + for action in transitions: + child = actions.add_parser(action) + child.add_argument('id') + child.add_argument('--generation', type=int, required=True) + if kind == 'proposal': + child.add_argument('--proposal-revision', required=True) + if action == 'accept': + child.add_argument('--base-revision', required=True) + child.add_argument('--target', required=True) + child.add_argument('--target-generation', type=int, required=True) + + +async def run_command(client, args): + kind, action = args.command, args.action + expected = () + if kind == 'watch': + if action == 'create': + from msg.core.errors import require + require(bool(args.target) != bool(args.query_ref), 'watch_target_required') + require(bool(args.query_ref) == bool(args.query_revision), 'watch_query_revision_required') + params = {'event_types': args.event, 'delivery': args.delivery} + if args.query_ref: + params['query_ref'] = {'id': args.query_ref, 'revision': args.query_revision} + else: + params['target'] = args.target + if args.expires_at: + params['expires_at'] = args.expires_at + else: + params = {} if action == 'list' else {'id': args.id} + version = 2 if action == 'create' and args.query_ref else 1 + return await client.call('communication.watch_' + action, params, contract_version=version) + if action == 'create': + fields = { + 'request': ('title', 'description', 'requirements', 'due_at', 'expires_at'), + 'offer': ('description', 'scope', 'availability', 'capability_hint', 'expires_at'), + 'checkpoint': ('summary', 'resume_hint', 'state_ref', 'expires_at'), + 'proposal': ('target', 'base_revision', 'message'), + }[kind] + params = {name: getattr(args, name) for name in fields if getattr(args, name) is not None} + if kind == 'proposal': + params['content_ref'] = {'id': args.content, 'revision': args.content_revision} + else: + params['resource_refs'] = args.ref + elif action == 'list': + params = {name: getattr(args, name) for name in ('limit', 'after') + if getattr(args, name) is not None} + else: + params = {'id': args.id} + if action != 'get': + expected = ((args.id, args.generation),) + if kind == 'proposal': + params['proposal_revision'] = args.proposal_revision + if action == 'accept': + params['base_revision'] = args.base_revision + expected += ((args.target, args.target_generation),) + return await client.call('communication.' + kind + '_' + action, params, expected=expected) diff --git a/src/msg/client_custodial.py b/src/msg/client_custodial.py index f79430eb..5c162dd0 100644 --- a/src/msg/client_custodial.py +++ b/src/msg/client_custodial.py @@ -10,9 +10,9 @@ from msg.core.codec import canonical, digest, loads, wire from msg.core.errors import Failure, require from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity -from msg.security.custodial_migration import ack_statement, stage_statement -from msg.security.vault import client_upgrade_proof -from msg.storage.git import durable_write +from msg.security.custodial_protocol import ack_statement, stage_statement +from msg.security.custodial_protocol import client_upgrade_proof +from msg.atomic_file import durable_write def journal_for(state): diff --git a/src/msg/client_market.py b/src/msg/client_market.py index c3cbbb08..ef8d91b4 100644 --- a/src/msg/client_market.py +++ b/src/msg/client_market.py @@ -52,7 +52,9 @@ def add_commands(commands): for action, (operation, field) in actions.items(): help_text = ('Sign a current-key PoP challenge and claim; not a human/Sybil check.' if group=='bounty' and action=='prove' else None) - cmd = sub.add_parser(action, help=help_text) + if operation == 'money.redeem': + help_text = 'Sign current Resource terms and synchronous grant/settlement; legacy needs --contract-version 1 or 2.' + cmd = sub.add_parser(action, help=help_text, description=help_text) if field=='json': cmd.add_argument('payload', nargs='?', default='{}', help='JSON, @file or - for stdin.') elif field: @@ -86,6 +88,29 @@ async def prove_bounty(client, listing_id, request_id=None): request_id=request_id) +async def entitlement_intent(client, params): + """Bind the current ordinary Resource quote; legacy requires explicit @1/@2.""" + require(isinstance(params, Mapping) and isinstance(params.get('offer_id'), str), 'offer_id_required') + require(type(params.get('quantity')) is int and params['quantity'] > 0, 'invalid_offer_quantity') + require('defer' not in params, 'explicit_legacy_contract_required') + result = await client.call('store.listing_get', {'id': params['offer_id']}, contract_version=2) + require(result.status == 'ok', 'offer_resource_required_use_explicit_legacy_contract') + listing = result.data['listing'] + source = listing.get('server_offer') + require(listing.get('server_offer_model') == 1 and isinstance(source, Mapping) and + source.get('enabled') is True and listing['listing_id'] == params['offer_id'], + 'offer_resource_required_use_explicit_legacy_contract') + quote = {k: v for k, v in source.items() if k != 'enabled'} + quote.update(currency_id='primary', provider_version=1) + bound = {'currency_id': 'primary', 'price_revision': quote['price_revision'], + 'listing_revision': listing['listing_revision'], 'offer_snapshot_digest': digest(quote), + 'total_price_minor': quote['price_minor'] * params['quantity'], + 'settlement_policy': 'deterministic-entitlement-v1'} + require(all(key not in params or params[key] == value for key, value in bound.items()), + 'offer_intent_changed') + return {**params, **bound} + + async def run_command(client, args, parse_arguments): operation, field = COMMANDS[args.command][args.action] if operation is None: @@ -96,8 +121,14 @@ async def run_command(client, args, parse_arguments): if version is not None: require(type(version) is int and 1 <= version <= 2**31 - 1, 'invalid_contract_version') kwargs['contract_version'] = version + elif operation == 'orders.buy': + kwargs['contract_version'] = 4 + elif operation == 'orders.create': + kwargs['contract_version'] = 2 elif operation=='money.redeem': - kwargs['contract_version']=2 + kwargs['contract_version']=3 + if operation == 'money.redeem' and kwargs.get('contract_version') == 3: + params = await entitlement_intent(client, params) if args.command=='store' and args.action=='update': require(isinstance(params.get('id'),str),'listing_id_required') kwargs['expected']=((params['id'],args.generation),) diff --git a/src/msg/client_upgrade.py b/src/msg/client_upgrade.py index 03c5c924..b68e8872 100644 --- a/src/msg/client_upgrade.py +++ b/src/msg/client_upgrade.py @@ -8,7 +8,7 @@ from msg.core.codec import b64, canonical, loads, wire from msg.core.errors import Failure, require from msg.security.crypto import Ed25519Signer -from msg.storage.git import durable_write +from msg.atomic_file import durable_write @contextmanager diff --git a/src/msg/config.py b/src/msg/config.py index 54d8a974..e52976cb 100644 --- a/src/msg/config.py +++ b/src/msg/config.py @@ -9,6 +9,7 @@ from urllib.parse import urlsplit from msg.core.errors import require +from msg.config_contracts import configuration_keys, validate_sections from msg.core.models import MailConfig, ServerConfig, TransportLimits from msg.security.age_keys import public_from_recipient,encryption_key_id @@ -79,7 +80,8 @@ def config_dir(self): @property def trust_file(self): - return self.config_dir/'trust'/'root.json' + from msg.security.trust_files import trust_file + return trust_file(self.config_dir) @property def service_keys(self): @@ -96,12 +98,14 @@ def repositories_dir(self): def load_settings(config_dir=Path('/etc/msgd')): config_dir=Path(config_dir) + from msg.security.trust_files import reserved_plugins_directory + reserved_plugins_directory(config_dir) path=server_config_file(config_dir) require(path.is_file(),'configuration_missing') data=tomllib.loads(path.read_text()) - require(set(data)<={'server','storage','limits','plugins','tools','recovery','identity','money','hosting'},'unknown_configuration_section') + validate_sections(data) identity=data.get('identity',{}) - require(isinstance(identity,dict) and set(identity)<={'credential_delivery_recovery_window'}, + require(isinstance(identity,dict) and set(identity)<=configuration_keys('identity'), 'unknown_identity_configuration') window=identity.get('credential_delivery_recovery_window','15m') require(isinstance(window,str) and len(window)<=3 and @@ -110,7 +114,7 @@ def load_settings(config_dir=Path('/etc/msgd')): window_minutes=int(window[:-1]) require(1<=window_minutes<=60,'invalid_credential_delivery_recovery_window') hosting=data.get('hosting',{}) - require(isinstance(hosting,dict) and set(hosting)<={'base_capacity_bytes','recovery_marker','content_group_read'}, + require(isinstance(hosting,dict) and set(hosting)<=configuration_keys('hosting'), 'unknown_hosting_configuration') hosting_base=hosting.get('base_capacity_bytes',10*1024*1024) require(type(hosting_base) is int and 0 < hosting_base <= 1024**4, @@ -122,7 +126,7 @@ def load_settings(config_dir=Path('/etc/msgd')): Path(recovery_marker).is_absolute() and '..' not in Path(recovery_marker).parts and not any(ord(c)<32 for c in recovery_marker)), 'invalid_hosting_recovery_marker') money=data.get('money',{}) - require(isinstance(money,dict) and set(money)<=set(MoneyConfig.__dataclass_fields__), + require(isinstance(money,dict) and set(money)<=configuration_keys('money'), 'unknown_money_configuration') fixed={'enabled':True,'currency_id':'primary','scale':6, 'transfer_fee':0,'allow_overdraft':False} @@ -138,7 +142,7 @@ def load_settings(config_dir=Path('/etc/msgd')): require(type(code) is str and re.fullmatch(r'[A-Z][A-Z0-9]{1,15}',code) is not None, 'invalid_money_code') server=data.get('server',{}) - require(set(server)<={'service_url','listen','port','public_web_origin','temporary_ttl','transfer_ttl'}, + require(set(server)<=configuration_keys('server'), 'unknown_server_configuration') service=server.get('service_url','https://msg.lmm.best') require(isinstance(service,str),'invalid_service_url') @@ -151,7 +155,7 @@ def load_settings(config_dir=Path('/etc/msgd')): for key,default in (('temporary_ttl',3600),('transfer_ttl',86400)): require(type(server.get(key,default)) is int and server.get(key,default)>0,'invalid_ttl') store=data.get('storage',{}) - require(set(store)<={'postgres_dsn','valkey_url','content','repositories','blobs','staging','service_keys'}, + require(set(store)<=configuration_keys('storage'), 'unknown_storage_configuration') postgres_dsn=store.get('postgres_dsn') require(isinstance(postgres_dsn,str) and bool(postgres_dsn.strip()) and not any(ord(c)<32 for c in postgres_dsn), @@ -194,25 +198,36 @@ def load_settings(config_dir=Path('/etc/msgd')): ('service_keys',service_keys_default))), 'storage_paths_must_be_absolute') limits=data.get('limits',{}) - require(set(limits)<={'request_bytes','response_bytes','path_bytes','part_bytes'},'unknown_limit') + require(set(limits)<=configuration_keys('limits'),'unknown_limit') for value in limits.values(): require(type(value) is int and value>=256,'invalid_limit') mail_file=config_dir/'mail.toml' mail=None if mail_file.exists(): raw=tomllib.loads(mail_file.read_text()) - require(set(raw)<={'enabled','host','port','tls','sender','credential_file'},'unknown_mail_configuration') + require(set(raw)<=configuration_keys('mail'),'unknown_mail_configuration') + require(type(raw.get('enabled',False)) is bool,'invalid_mail_enabled') if raw.get('enabled',False): + require(isinstance(raw.get('host'),str) and bool(raw['host'].strip()) and + not any(ord(c)<=32 for c in raw['host']),'invalid_mail_host') + require(type(raw.get('port',587)) is int and 1<=raw.get('port',587)<=65535, + 'invalid_mail_port') + require(isinstance(raw.get('sender'),str) and bool(raw['sender'].strip()) and + not any(ord(c)<32 for c in raw['sender']),'invalid_mail_sender') + credential=raw.get('credential_file') + require(credential is None or (isinstance(credential,str) and + Path(credential).is_absolute() and '..' not in Path(credential).parts and + not any(ord(c)<32 for c in credential)),'invalid_mail_credential_file') require(raw.get('tls') in {'starttls','tls'},'mail_tls_required') mail=MailConfig(enabled=True,host=raw['host'],port=raw.get('port',587),tls=raw['tls'],sender=raw['sender'], credential_file=Path(raw['credential_file']) if raw.get('credential_file') else None) recovery=data.get('recovery',{}) - require(isinstance(recovery,dict) and set(recovery)<={'custodians'},'unknown_recovery_configuration') + require(isinstance(recovery,dict) and set(recovery)<=configuration_keys('recovery'),'unknown_recovery_configuration') raw_custodians=recovery.get('custodians',[]) require(isinstance(raw_custodians,list) and len(raw_custodians)<=16,'invalid_recovery_custodians') custodians=[] for item in raw_custodians: - require(isinstance(item,dict) and set(item)<={'id','name','recipient','description','policy_ref'}, + require(isinstance(item,dict) and set(item)<=configuration_keys('recovery.custodians'), 'unknown_recovery_custodian_field') require(all(name in item for name in ('id','name','recipient')), 'invalid_recovery_custodian') @@ -227,12 +242,13 @@ def load_settings(config_dir=Path('/etc/msgd')): recipient=item['recipient'],fingerprint=encryption_key_id(public), description=item.get('description'),policy_ref=item.get('policy_ref'))) require(len({item.id for item in custodians})==len(custodians),'duplicate_recovery_custodian') - require(set(data.get('plugins',{}))<={'enabled'},'unknown_plugin_configuration') - plugins=tuple(data.get('plugins',{}).get('enabled',('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery','transfer','extensions','system','batch'))) - require(all(isinstance(name,str) for name in plugins) and len(set(plugins))==len(plugins),'invalid_plugin_list') - require('identity' in plugins,'identity_plugin_required') + from msg.plugins import BUILTINS, validate_plugins + plugin_config=data.get('plugins',{}) + require(isinstance(plugin_config,dict) and set(plugin_config)<=configuration_keys('plugins'), + 'unknown_plugin_configuration') + plugins=validate_plugins(plugin_config.get('enabled',BUILTINS)) tools=data.get('tools',{}) - require(set(tools)<={'isolation','timeout_ms','max_response_bytes','methods','ports'},'unknown_tool_configuration') + require(set(tools)<=configuration_keys('tools'),'unknown_tool_configuration') require(tools.get('isolation','bubblewrap')=='bubblewrap','unsafe_tool_worker') for name,default in (('timeout_ms',10000),('max_response_bytes',4194304)): require(type(tools.get(name,default)) is int and tools.get(name,default)>0,'invalid_tool_limit') @@ -272,6 +288,8 @@ def write_example(config_dir,data_dir,service_url='https://msg.lmm.best',*,postg """Local install helper: writes no private key or default PIN.""" config_dir,data_dir=Path(config_dir),Path(data_dir) config_dir.mkdir(parents=True,exist_ok=True) + from msg.security.trust_files import reserved_plugins_directory + reserved_plugins_directory(config_dir,create=True) path=server_config_file(config_dir) if not path.exists(): path.write_text(f'''[server] diff --git a/src/msg/config_contracts.py b/src/msg/config_contracts.py new file mode 100644 index 00000000..fa324504 --- /dev/null +++ b/src/msg/config_contracts.py @@ -0,0 +1,62 @@ +"""Accepted configuration vocabulary and its diagnostic links. + +Values and defaults remain owned by load_settings. This inventory owns allowed +field names; diagnostics report validation separately from deployment checks. +""" +from msg.core.errors import require + +# section -> field -> (Settings attribute path, related doctor check) +_SECTIONS = { + 'server': { + 'service_url': ('service_url', None), 'listen': ('listen', None), + 'port': ('port', None), 'public_web_origin': ('public_web_origin', 'hosting'), + 'temporary_ttl': ('temporary_ttl', None), 'transfer_ttl': ('transfer_ttl', None), + }, + 'storage': { + 'postgres_dsn': ('server.postgres_dsn', 'storage'), + 'valkey_url': ('server.valkey_url', 'valkey'), + 'content': ('server.content_dir', 'content_layout'), + 'repositories': ('server.repositories_dir', None), + 'blobs': ('server.blob_dir', None), 'staging': ('server.staging_dir', 'capacity'), + 'service_keys': ('server.service_keys_dir', 'online_ca'), + }, + 'limits': { + 'request_bytes': ('server.limits.max_request_bytes', None), + 'response_bytes': ('server.limits.max_response_bytes', None), + 'path_bytes': ('server.limits.max_path_bytes', None), + 'part_bytes': ('max_part_bytes', None), + }, + 'plugins': {'enabled': ('server.plugins', None)}, + 'tools': { + 'isolation': ('worker_isolation', None), 'timeout_ms': ('tool_timeout_ms', None), + 'max_response_bytes': ('tool_max_response_bytes', None), + 'methods': ('tool_methods', None), 'ports': ('tool_ports', None), + }, + 'identity': {'credential_delivery_recovery_window': + ('credential_delivery_recovery_window', 'credential_delivery')}, + 'money': {name: ('money.'+name, 'money_config') for name in ( + 'enabled', 'currency_id', 'display_name', 'code', 'scale', 'transfer_fee', 'allow_overdraft')}, + 'hosting': { + 'base_capacity_bytes': ('hosting_base_capacity_bytes', 'hosting'), + 'recovery_marker': ('hosting_recovery_marker', None), + 'content_group_read': ('hosting_content_group_read', None), + }, + 'recovery': {'custodians': ('recovery_custodians', None)}, + 'mail': {name: ('server.mail.'+name, None) for name in ( + 'enabled', 'host', 'port', 'tls', 'sender', 'credential_file')}, + 'recovery.custodians': {name: ('recovery_custodians.0.'+name, None) for name in ( + 'id', 'name', 'recipient', 'description', 'policy_ref')}, +} +CONFIGURATION_FIELDS = {section+'.'+name: {'setting': setting, 'doctor_check': check} + for section, fields in _SECTIONS.items() for name, (setting, check) in fields.items()} + + +def configuration_keys(section): + return frozenset(_SECTIONS[section]) + + +def validate_sections(data): + require(set(data) <= set(_SECTIONS)-{'mail','recovery.custodians'}, + 'unknown_configuration_section') + require(all(isinstance(value,dict) for value in data.values()), + 'invalid_configuration_section') diff --git a/src/msg/core/batching.py b/src/msg/core/batching.py index d1b7bf2f..84fc39c8 100644 --- a/src/msg/core/batching.py +++ b/src/msg/core/batching.py @@ -5,7 +5,7 @@ from msg.core.requests import SECRET_DELIVERY_MIN_VERSION # Credential delivery has response-only secrets; it requires its own call. -NO_BATCH=frozenset(SECRET_DELIVERY_MIN_VERSION) | {'identity.register','identity.upgrade'} +NO_BATCH=frozenset(SECRET_DELIVERY_MIN_VERSION) | {'identity.register','identity.upgrade','file.batch'} def packets(registry,request,subject,max_bytes=None): diff --git a/src/msg/core/codec.py b/src/msg/core/codec.py index 133d30ef..c3cdd9a5 100644 --- a/src/msg/core/codec.py +++ b/src/msg/core/codec.py @@ -251,3 +251,11 @@ def decorate(cls): cls.__post_init__ = validate_record return dataclasses.dataclass(**kwargs)(cls) return decorate + + +def result_wire(result): + value=wire(result,compact=True) + for name in ('replayed','prefer_cli'): + if not value.get(name): + value.pop(name,None) + return value diff --git a/src/msg/core/contracts.py b/src/msg/core/contracts.py index 419d5eb4..9bea67c6 100644 --- a/src/msg/core/contracts.py +++ b/src/msg/core/contracts.py @@ -8,6 +8,7 @@ class MetadataSession(QuerySession, Protocol): def on_rollback(self, effect: Callable[[], Awaitable[None]]) -> None: ... async def resource(self, id: ResourceId) -> Resource: ... async def resolve(self, path: str) -> ResourceId: ... + async def resolve_migrated(self, path: str) -> ResourceId: ... async def path(self, id: ResourceId) -> str: ... async def ancestors(self, id: ResourceId) -> tuple[Resource, ...]: ... async def children( diff --git a/src/msg/core/errors.py b/src/msg/core/errors.py index 1db4baa4..5af9b441 100644 --- a/src/msg/core/errors.py +++ b/src/msg/core/errors.py @@ -1,6 +1,29 @@ """Stable errors shared by business operations and every transport.""" from __future__ import annotations +_PUBLIC_MESSAGES = { + 'authentication_required': 'Authentication is required.', + 'permission_denied': 'You do not have permission to perform this operation.', + 'capability_required': 'The required permission has not been granted.', + 'not_found': 'The requested resource was not found.', + 'invalid_request_envelope': 'The request format is invalid.', + 'invalid_email': 'Enter one valid email address.', + 'generation_conflict': 'The resource changed. Read it again before retrying.', + 'request_expired': 'The request has expired.', + 'secure_channel_required': 'Use an authenticated secure request channel.', + 'effect_mismatch': 'This route does not allow that operation.', + 'recovery_quarantined': 'The service is isolated pending recovery verification.', + 'recovery_runtime_stale': 'The service must restart after recovery.', + 'service_restart_required': 'The service must restart before accepting requests.', + 'internal_error': 'The service could not complete the operation.', +} + + +def public_error_message(code: str) -> str: + """Fixed contract text only: never interpolate input or exception details.""" + return (_PUBLIC_MESSAGES.get(code, 'The operation could not be completed.') + if isinstance(code, str) else 'The operation could not be completed.') + class Failure(ValueError): def __init__(self, code: str, field: str | None = None, *, retryable: bool = False, @@ -9,7 +32,8 @@ def __init__(self, code: str, field: str | None = None, *, retryable: bool = Fal self.code, self.field, self.retryable, self.details = code, field, retryable, details def as_dict(self) -> dict: - value = {"code": self.code, "retryable": self.retryable} + value = {"code": self.code, "retryable": self.retryable, + "message": public_error_message(self.code)} if self.field: value["field_path"] = self.field if self.details: diff --git a/src/msg/core/executor.py b/src/msg/core/executor.py index 87ea5881..acd75598 100644 --- a/src/msg/core/executor.py +++ b/src/msg/core/executor.py @@ -9,7 +9,7 @@ from msg.core.events import event_id from msg.core.packet import result_wire from msg.core.execution_ports import ResultProjection, TransactionalEventNotifications -from msg.core.errors import Failure,require +from msg.core.errors import Failure,require,public_error_message from msg.core.models import ExecutionContext,HandlerOutput,OperationResult,OperationError,Event,AccessRequirement from msg.core.requests import SECRET_DELIVERY_MIN_VERSION,receipt_bytes @@ -27,6 +27,8 @@ def __init__(self,registry,metadata,contents,authenticator,authorizer,clock,rece self.response_hook=None self.recovery_drill_marker=None self.recovery_quarantined=False + from msg.security.quarantine import RuntimeGeneration + self.runtime_generation=RuntimeGeneration() require(max_request_bytes is None or (type(max_request_bytes) is int and max_request_bytes>0), 'invalid_request_limit') self.max_request_bytes=max_request_bytes @@ -37,6 +39,10 @@ def recovery_drill_active(self): marker=self.recovery_drill_marker return self.recovery_quarantined or (marker is not None and (marker.exists() or marker.is_symlink())) + async def require_current_runtime(self): + async with self.metadata.transaction(write=False) as session: + self.runtime_generation.require_current(session) + async def execute(self,request, *, entry='network'): principal=None try: @@ -56,8 +62,9 @@ async def execute(self,request, *, entry='network'): if spec.name=='batch.independent': return await self._independent(request,spec,entry) async with self.metadata.transaction(write=spec.effect!='read') as session: + self.runtime_generation.require_current(session) principal=await self.authenticator.authenticate(request,session,entry=entry) - if spec.name=='batch.atomic': + if spec.name in {'batch.atomic','file.batch'}: # Validate the child set before an old cached parent result # can bypass the handler's secret-delivery exclusion. packets(self.registry,request,principal.subject, @@ -79,6 +86,7 @@ async def execute(self,request, *, entry='network'): else: capacity_writes={'identity.register','identity.temporary','identity.custodial_create','content.topic_create', 'content.post_create','content.post_edit','content.file_put','content.attach', + 'file.create','file.copy','file.write','file.patch','file.mkdir', 'content.template_put','discussion.reply','discussion.quote','discussion.repost', 'transfer.part_put','git.create','git.push','git.receive','hosting.deploy','hosting.preview','keystore.put','achievement.start'} if spec.name in capacity_writes: @@ -91,7 +99,7 @@ async def execute(self,request, *, entry='network'): operation=f'{spec.name}@{spec.version}',check=check),),session) require(current.generation==generation,'generation_conflict', details={'id':rid,'generation':current.generation,'revision':current.revision}) - audited=(spec.name in {'content.chmod','content.chgrp','content.chown','content.purge','content.move', + audited=(spec.name in {'content.chmod','content.chgrp','content.chown','content.purge','content.move','file.move', 'identity.key_add','identity.key_revoke','identity.recover','identity.delegate','identity.delegation_revoke', 'identity.ssh_key_add','identity.ssh_key_revoke','identity.ssh_certificates','cert.publish','cert.request'} or (spec.name.startswith('group.') and spec.effect!='read')) @@ -140,7 +148,8 @@ async def execute(self,request, *, entry='network'): except Failure as exc: return OperationResult(request_id=request.request_id,operation=request.operation,status='error', actor=principal.actor if principal else None,subject=principal.subject if principal else None, - error=OperationError(code=exc.code,retryable=exc.retryable,field_path=exc.field), + error=OperationError(code=exc.code,retryable=exc.retryable,field_path=exc.field, + message=public_error_message(exc.code)), data=exc.details) except Exception: # Neither exception text/tracebacks nor caller-selected IDs are safe @@ -148,7 +157,8 @@ async def execute(self,request, *, entry='network'): log.error('operation_failed') return OperationResult(request_id=request.request_id,operation=request.operation,status='error', actor=principal.actor if principal else None,subject=principal.subject if principal else None, - error=OperationError(code='internal_error',retryable=False)) + error=OperationError(code='internal_error',retryable=False, + message=public_error_message('internal_error'))) async def _independent(self,request,spec,entry): @@ -158,6 +168,7 @@ async def _independent(self,request,spec,entry): No child is reported rolled back merely because its sibling failed. """ async with self.metadata.transaction(write=True) as tx: + self.runtime_generation.require_current(tx) principal=await self.authenticator.authenticate(request,tx,entry=entry) await self.authorizer._ceiling(principal,f'{spec.name}@{spec.version}',principal.subject,tx) children=packets(self.registry,request,principal.subject,self.max_request_bytes) @@ -176,6 +187,7 @@ async def _independent(self,request,spec,entry): results.append(result_wire(result)) resources.extend(result.resources) async with self.metadata.transaction(write=True) as tx: + self.runtime_generation.require_current(tx) current=await self.authenticator.authenticate(request,tx,entry=entry) await self.authorizer._ceiling(current,f'{spec.name}@{spec.version}',current.subject,tx) previous=await tx.request_result(principal.subject,request.request_id,request.payload_digest) diff --git a/src/msg/core/models.py b/src/msg/core/models.py index 84e0ba9f..2f13c748 100644 --- a/src/msg/core/models.py +++ b/src/msg/core/models.py @@ -261,6 +261,8 @@ class OperationError: retryable: bool field_path: str | None = None retry_after_seconds: int | None = None + # Omit absent historical messages to preserve canonical receipt bytes. + message: str | None = field(default=None, metadata={'omit_if_none': True}) class OperationFailure(Exception): @@ -354,6 +356,9 @@ class OperationSpec: [ExecutionContext, OperationRequest, MetadataSession], Awaitable[HandlerOutput] ] + requires_rules: tuple[str, ...] = ('msg.protocol',) + + @dataclass(frozen=True, slots=True, kw_only=True) class TransportLimits: max_request_bytes: int @@ -471,6 +476,7 @@ class PluginManifest: capabilities: tuple[CapabilitySpec, ...] operations: tuple[OperationSpec, ...] migrations: tuple[str, ...] + feature_ids: tuple[str, ...] = () @dataclass(frozen=True, slots=True, kw_only=True) @@ -480,6 +486,7 @@ class BootstrapManifest: resources: tuple[JsonMap, ...] # 经固定 seed schema 校验 organizations: tuple[JsonMap, ...] templates: tuple[BlobRef, ...] + features: tuple[JsonMap, ...] = () @dataclass(frozen=True, slots=True, kw_only=True) diff --git a/src/msg/core/packet.py b/src/msg/core/packet.py index 97cc4b29..aa4abc20 100644 --- a/src/msg/core/packet.py +++ b/src/msg/core/packet.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping import re -from msg.core.codec import decode, loads, canonical, wire +from msg.core.codec import decode, loads, canonical, wire, result_wire from msg.core.errors import require from msg.core.models import OperationRequest, OperationResult from msg.core.schemas import obj, IDENTIFIER, STRING, BYTES, REF, SIGNATURE @@ -61,11 +61,3 @@ def decode_result(value): # Compact results omit anonymous actor/subject; these required domain fields # are restored explicitly, not inferred from a transport connection. return decode(OperationResult,dict({'actor':None,'subject':None},**value)) - - -def result_wire(result): - value=wire(result,compact=True) - for name in ('replayed','prefer_cli'): - if not value.get(name): - value.pop(name,None) - return value diff --git a/src/msg/core/registry.py b/src/msg/core/registry.py index ecb95941..ec8ea702 100644 --- a/src/msg/core/registry.py +++ b/src/msg/core/registry.py @@ -5,7 +5,7 @@ from referencing.exceptions import Unresolvable from msg.core.schema_policy import local_validator -from msg.core.codec import digest,wire +from msg.core.codec import decode,digest,wire from msg.core.errors import Failure,require @@ -27,6 +27,8 @@ def __init__(self): self._types={} self._capabilities={} self._operations={} + self._templates={} + self._tools={} self._schemas={} self._validators={} self._plugins={} @@ -50,6 +52,35 @@ def add_operation(self,spec): require(spec.entries and spec.entries<=frozenset({'local_admin','network','worker'}),'invalid_entries') self._insert(self._operations,spec,'operation') + def _insert_resource_spec(self, collection, spec, version, kind): + # Registration versions describe installed contracts; resource revisions + # remain immutable runtime facts and are never added to this registry. + require(not self._frozen, 'registry_frozen') + require(type(version) is int and version >= 1 and spec.resource.id + and '*' not in spec.resource.id, 'invalid_registry_name') + key=(spec.resource.id,version) + require(key not in collection, 'duplicate_'+kind) + collection[key]=decode(type(spec),wire(spec)) + + def add_template(self,spec,version=1): + from msg.core.template_dsl import _check + from msg.core.codec import loads + require(spec.renderer_version == 1, 'unknown_template_renderer') + names=[field.name for field in spec.fields] + require(len(names)==len(set(names)), 'duplicate_field') + for field in spec.fields: + require(field.type in {'str','text','int','bool','enum','ref','file'}, + 'unknown_field_type') + require(not field.required or field.default_json is None, + 'required_field_has_default') + if field.default_json is not None: + _check(field,loads(field.default_json)) + self._insert_resource_spec(self._templates,spec,version,'template') + + def add_tool(self,spec,version=1): + require(bool(spec.executor_key), 'untrusted_tool_executor') + self._insert_resource_spec(self._tools,spec,version,'tool') + def add_schema(self,ref,schema): require(not self._frozen and ref.id not in self._schemas,'schema_conflict') # Keep one owned snapshot for both validation and publication. Caller @@ -60,6 +91,11 @@ def add_schema(self,ref,schema): self._validators[ref.id]=validator def add(self,manifest): + from msg.plugins.features import validate_feature_claims + validate_feature_claims(manifest) + # No runtime migration executor is supported. Never accept executable + # declarations and silently pretend the release applied them. + require(manifest.migrations==(),'unsupported_plugin_migration') require(not self._frozen,'registry_frozen') require(manifest.name not in self._plugins,'duplicate_plugin') require(all(d in self._plugins for d in manifest.dependencies),'missing_plugin_dependency') @@ -82,14 +118,39 @@ def add(self,manifest): self.add_operation(item) self._plugins[manifest.name]=manifest + def features(self,plugin_name): + """Resolve a plugin's defaults/checks from the sole bootstrap inventory.""" + from msg.bootstrap import feature_manifest + require(plugin_name in self._plugins,'unknown_plugin') + claims=self._plugins[plugin_name].feature_ids + return tuple(deepcopy(row) for row in feature_manifest() + if row['feature_id'] in claims) + def freeze(self): + from msg.bootstrap import RULE_PATHS,SOURCE_RETIREMENTS,feature_manifest + feature_manifest() require('identity' in self._plugins,'identity_plugin_required') for spec in self._operations.values(): + require(spec.requires_rules and len(set(spec.requires_rules))==len(spec.requires_rules) + and all(rule_id in RULE_PATHS and rule_id not in SOURCE_RETIREMENTS + for rule_id in spec.requires_rules), + 'dangling_requires_rules') require(spec.input_schema.id in self._schemas and spec.output_schema.id in self._schemas,'missing_schema') + operation_ids={f'{s.name}@{s.version}' for s in self._operations.values()} + for resource in self._types.values(): + require(resource.content_schema is None or + resource.content_schema.id in self._schemas,'missing_schema') + require(resource.operations<=operation_ids,'unknown_resource_operation') for cap in self._capabilities.values(): + require(cap.constraints_schema is None or + cap.constraints_schema.id in self._schemas,'missing_schema') require(cap.scope_types<=set(n for n,v in self._types),'unknown_scope_type') - require(all(op in {f'{s.name}@{s.version}' for s in self._operations.values()} - for op in cap.operations),'unknown_capability_operation') + require(cap.operations<=operation_ids,'unknown_capability_operation') + for tool in self._tools.values(): + operation=tool.operation if '@' in tool.operation else tool.operation+'@1' + require(operation in operation_ids,'unknown_tool_operation') + require(tool.input_schema.id in self._schemas and + tool.output_schema.id in self._schemas,'missing_schema') self._frozen=True @property @@ -111,6 +172,18 @@ def capability(self,name,version=1): def resource_type(self,name,version=1): return self._get(self._types,name,version,'resource_type') + def template(self,resource_id,version=1): + return self._get(self._templates,resource_id,version,'template') + + def tool(self,resource_id,version=1): + return self._get(self._tools,resource_id,version,'tool') + + def templates(self): + return tuple(self._templates[k] for k in sorted(self._templates)) + + def tools(self): + return tuple(self._tools[k] for k in sorted(self._tools)) + def capabilities(self): return tuple(self._capabilities[k] for k in sorted(self._capabilities)) @@ -137,26 +210,7 @@ def validate(self,ref,value): def describe(self,spec): from msg.bootstrap import RULE_PATHS - name=spec.name - if name.startswith('identity.'): - rules=('identity','auth') - elif name.startswith('content.topic_') or name.startswith('discussion.'): - rules=('topics','read-write') - elif name.startswith('content.'): - rules=('read-write',) - elif name.startswith(('transfer.','keystore.','git.')): - rules=('files','protocol') - elif name.startswith(('cert.','group.')): - rules=('auth',) - elif name.startswith(('system.','tool.')): - rules=('security','protocol') - elif name.startswith('discovery.'): - rules=('read-write','protocol') - else: - rules=('protocol',) - rule_ids=tuple('msg.'+rule for rule in rules) - require(len(set(rule_ids))==len(rule_ids) and all(rid in RULE_PATHS for rid in rule_ids), - 'dangling_requires_rules',name) + rule_ids=spec.requires_rules result={'name':spec.name,'version':spec.version,'effect':spec.effect, 'entries':sorted(spec.entries),'require_signature':spec.require_signature, 'input_schema':wire(spec.input_schema),'output_schema':wire(spec.output_schema), diff --git a/src/msg/core/search_query.py b/src/msg/core/search_query.py new file mode 100644 index 00000000..d226326f --- /dev/null +++ b/src/msg/core/search_query.py @@ -0,0 +1,34 @@ +"""Select the minimum SearchQuery contract shared by all read adapters.""" +from collections.abc import Mapping + +SEARCH_V5_FIELDS = frozenset({'revision', 'source_version', 'relation_to', 'relation_from', + 'has_replies', 'has_references', 'spell'}) +SEARCH_V5_RELATIONS = frozenset({'reference', 'state', 'target', 'content'}) + + +def search_query_version(arguments): + if (SEARCH_V5_FIELDS & arguments.keys() or isinstance(arguments.get('scope'), Mapping) or + arguments.get('relation_type') in SEARCH_V5_RELATIONS or + arguments.get('field') == 'title' or 'title' in arguments.get('fields', ())): + return 5 + if 'suggest' in arguments: + return 4 + if {'source_kind', 'relation_type'} & arguments.keys(): + return 3 + return 2 if 'facets' in arguments else 1 + + +def spelling_distance(left, right, maximum): + """Bounded Levenshtein distance; over-budget values share one sentinel.""" + if abs(len(left)-len(right)) > maximum: + return maximum+1 + previous = list(range(len(right)+1)) + for index, char in enumerate(left, 1): + current = [index] + for other, target in enumerate(right, 1): + current.append(min(current[-1]+1, previous[other]+1, + previous[other-1]+(char != target))) + if min(current) > maximum: + return maximum+1 + previous = current + return previous[-1] diff --git a/src/msg/core/text_patch.py b/src/msg/core/text_patch.py index 431384ef..73dcaed7 100644 --- a/src/msg/core/text_patch.py +++ b/src/msg/core/text_patch.py @@ -276,7 +276,13 @@ def apply_patch(source, patch, *, base_source=None): result = apply_text_patch(source,patch['exact'],patch['replacement'], patch.get('before',''),patch.get('after','')) elif kind=='unified': - result = _unified(source,_hunks(patch['diff']),rebase=base_source is not None) + hunks = _hunks(patch['diff']) + if base_source is not None: + # Coordinates identify a target only in the original revision. + # A duplicate old block surviving elsewhere is not that target; + # relocation requires a unique anchor in both revisions. + _unified(base_source,hunks,rebase=True) + result = _unified(source,hunks,rebase=base_source is not None) else: result = _structured(source,patch) require(len(result.encode('utf-8'))<=PATCH_LIMIT, 'patch_too_large') diff --git a/src/msg/core/transfer.py b/src/msg/core/transfer.py index 125ee81e..d440fdea 100644 --- a/src/msg/core/transfer.py +++ b/src/msg/core/transfer.py @@ -128,6 +128,16 @@ async def get(self,context,transfer_id,byte_range): async def status(self,context,transfer_id,cursor=None,limit=50): async with self.metadata.transaction(write=False) as tx: transfer=await self._session(context,tx,transfer_id,'transfer.status',allow_cancelled=True) + require(type(limit) is int and 1<=limit<=500,'invalid_limit') + if cursor is not None: + # A continuation is a byte offset, not an arbitrary Python integer. + # Check the spelling and bound before either SQL projection uses it; + # terminal sessions must not silently accept malformed cursors. + require(isinstance(cursor,str) and + re.fullmatch(r'[0-9]{1,19}',cursor) is not None,'invalid_cursor') + offset=int(cursor) + require(offset<2**63 and + (transfer.expected_size is None or offset<=transfer.expected_size),'invalid_cursor') if transfer.direction=='download' or transfer.state=='sealed': return Page(items=()) if transfer.expected_size is None: diff --git a/src/msg/daemon.py b/src/msg/daemon.py index 5b0423c9..ef114471 100644 --- a/src/msg/daemon.py +++ b/src/msg/daemon.py @@ -1,6 +1,7 @@ """msgd: local installation, serving, workers, diagnostics and restricted SSH.""" from __future__ import annotations import argparse +import importlib.util import asyncio from datetime import UTC,datetime import json @@ -84,7 +85,7 @@ def parser(): init.add_argument('--data-dir',type=Path,default=Path('/var/lib/msgd')) init.add_argument('--service-url',default='https://msg.lmm.best') sub.add_parser('serve',help='Serve JSON/Markdown, operations, GraphQL and MCP') - hosted=sub.add_parser('hosting',help='Serve user-published files on the separate configured origin') + hosted=sub.add_parser('hosting',help='Serve user-published files with the read-only hosting runtime') hosted.add_argument('--listen',default='127.0.0.1');hosted.add_argument('--port',type=int,default=8043) worker=sub.add_parser('worker',help='Run the global durable effects/retention queue') worker.add_argument('--once',action='store_true') @@ -98,6 +99,18 @@ def parser(): rotate=rs.add_parser('rotate');rotate.add_argument('--lost-key',action='store_true');rotate.add_argument('--resume',action='store_true') rb=rs.add_parser('backup');rb.add_argument('destination',type=Path) rr=rs.add_parser('recover');rr.add_argument('source',type=Path) + proof=rs.add_parser('recovery-proof',help='Physical-console complete-state recovery proof and promotion') + proof_sub=proof.add_subparsers(dest='proof_command',required=True) + proof_sign=proof_sub.add_parser('sign') + proof_sign.add_argument('source_backup_sha256');proof_sign.add_argument('sequence',type=int) + proof_sign.add_argument('destination',type=Path) + proof_promote=proof_sub.add_parser('promote') + proof_promote.add_argument('source',type=Path);proof_promote.add_argument('independent_trust',type=Path) + retirement=rs.add_parser('backup-retirement',help='Physical-console attestation for listed backup sets') + retirement_sub=retirement.add_subparsers(dest='retirement_command',required=True) + retirement_sign=retirement_sub.add_parser('sign') + retirement_sign.add_argument('source',type=Path);retirement_sign.add_argument('destination',type=Path) + retirement_sub.add_parser('import').add_argument('source',type=Path) money=sub.add_parser('money',help='Physical-console central bank administration') ms=money.add_subparsers(dest='money_command',required=True) ms.add_parser('mint').add_argument('amount') @@ -121,6 +134,9 @@ def parser(): offer_set.add_argument('--max-quantity',type=int,required=True) offer_set.add_argument('--duration-seconds',type=int) ops.add_parser('disable').add_argument('offer_id') + offer_import=ops.add_parser('import',help='Explicitly adopt one legacy offer as a signed Listing') + offer_import.add_argument('offer_id') + offer_import.add_argument('--dry-run',action='store_true') market=sub.add_parser('market',help='Physical-console arbitration configuration') market_sub=market.add_subparsers(dest='market_command',required=True) market_sub.add_parser('grant').add_argument('subject_id') @@ -139,6 +155,11 @@ def parser(): def main(argv=None): args=parser().parse_args(argv) + server_modules=('starlette','uvicorn','psycopg','valkey','aiohttp','dns','graphql') + if any(importlib.util.find_spec(name) is None for name in server_modules): + print(canonical({'status':'error','error':{'code':'server_dependencies_required', + 'hint':"Install msg-lmm-best[server] before running server commands."}}).decode(),file=sys.stderr) + return 2 try: if args.command in {'doctor','selftest'}: from msg.admin.diagnostics import doctor,selftest @@ -168,6 +189,12 @@ def main(argv=None): admin.change_pin();result={'status':'pin_changed'} elif args.root_command=='rotate':result=admin.rotate(lost_key=args.lost_key,resume=args.resume) elif args.root_command=='backup':result=admin.backup(args.destination) + elif args.root_command=='recovery-proof': + result=(admin.sign_recovery_proof(args.source_backup_sha256,args.sequence,args.destination) + if args.proof_command=='sign' else admin.promote_recovery(args.source,args.independent_trust)) + elif args.root_command=='backup-retirement': + result=(admin.sign_backup_retirement(args.source,args.destination) + if args.retirement_command=='sign' else admin.import_backup_retirement(args.source)) else:result=admin.recover(args.source) emit(result);return 0 if args.command=='market': @@ -186,7 +213,8 @@ def main(argv=None): 'duration_seconds':args.duration_seconds} if args.offer_command=='set' else None) emit(MoneyAdmin(args.config_dir).execute_offer( - args.offer_command,offer_id=args.offer_id,fields=fields)) + args.offer_command,offer_id=args.offer_id,fields=fields, + **({'dry_run': args.dry_run} if args.offer_command == 'import' else {}))) return 0 if args.money_command=='bank': action='bank_'+args.bank_command diff --git a/src/msg/data/bootstrap.json b/src/msg/data/bootstrap.json index 730d1d6b..d0b27519 100644 --- a/src/msg/data/bootstrap.json +++ b/src/msg/data/bootstrap.json @@ -1,5 +1,5 @@ { - "version": 8, + "version": 9, "resources": [ {"id":"r_root","type":"topic","name":"","parent":null,"owner":"u_root","group":"g_public","mode":"1777"}, {"id":"r_agents","type":"file","name":"AGENTS.md","parent":"r_root","owner":"u_root","group":"g_public","mode":"0444"}, @@ -32,7 +32,8 @@ {"id":"t_capabilities","type":"topic","name":"_capabilities","parent":"r_root","owner":"u_root","group":"g_public","mode":"0555"}, {"id":"t_operations","type":"topic","name":"_operations","parent":"r_root","owner":"u_root","group":"g_public","mode":"0555"}, {"id":"t_index","type":"topic","name":"_index","parent":"r_root","owner":"u_root","group":"g_public","mode":"0555"}, - {"id":"t_schema","type":"topic","name":"_schema","parent":"r_root","owner":"u_root","group":"g_public","mode":"0555"} + {"id":"t_schema","type":"topic","name":"_schema","parent":"r_root","owner":"u_root","group":"g_public","mode":"0555"}, + {"id":"r_honor_sample","type":"file","name":"honors.json","parent":"t_schema","owner":"u_root","group":"g_public","mode":"0444"} ], "policies": { "t_tmp":{"retention_seconds":3600,"cleanup_interval_seconds":3600}, @@ -42,6 +43,13 @@ "t_sos":{"recommended_template":"sos"} }, "features": [ + {"feature_id":"honors","enabled_by_default":true,"default_config":{"pregrant":false,"privileges":false},"sample_resource":"r_honor_sample","doctor_check":"honors","selftest_case":"honor_ceremony_display"}, + {"feature_id":"content_editing","enabled_by_default":true,"default_config":{"sample_state":"empty","sample_creation":"isolated_selftest"},"sample_resource":null,"doctor_check":"content_editing","selftest_case":"content_editing"}, + {"feature_id":"private_dm","enabled_by_default":true,"default_config":{"sample_state":"empty","sample_creation":"isolated_selftest"},"sample_resource":null,"doctor_check":"private_dm","selftest_case":"private_dm"}, + {"feature_id":"transfer","enabled_by_default":true,"default_config":{"sample_state":"empty","sample_creation":"isolated_selftest"},"sample_resource":null,"doctor_check":"transfer","selftest_case":"transfer_state_machine"}, + {"feature_id":"tool_sandbox","enabled_by_default":true,"default_config":{},"sample_resource":"tool_curl","doctor_check":"tool_sandbox","selftest_case":"tool_sandbox"}, + {"feature_id":"following","enabled_by_default":true,"default_config":{"sample_state":"empty","sample_creation":"isolated_selftest"},"sample_resource":null,"doctor_check":"following","selftest_case":"following"}, + {"feature_id":"collaboration","enabled_by_default":true,"default_config":{"sample_state":"empty","sample_creation":"isolated_selftest"},"sample_resource":null,"doctor_check":"collaboration","selftest_case":"collaboration"}, {"feature_id":"money","enabled_by_default":true,"default_config":{"currency_id":"primary","scale":6,"initial_supply_minor":0,"banks":[],"offers":[]},"sample_resource":null,"doctor_check":"market_clearing","selftest_case":"market_e2e"}, {"feature_id":"bounty","enabled_by_default":true,"default_config":{"listings":[],"verifier":"signature_pop_v1","human_or_sybil_proof":false},"sample_resource":"t_store","doctor_check":"market_clearing","selftest_case":"market_e2e"}, {"feature_id":"orders","enabled_by_default":true,"default_config":{"orders":[],"instant_policy":"managed-instant-v1","subjective_refund":false},"sample_resource":"t_store","doctor_check":"market_clearing","selftest_case":"market_e2e"}, @@ -56,12 +64,42 @@ {"feature_id":"authorization","enabled_by_default":true,"default_config":{},"sample_resource":"t_certified","doctor_check":"authority_snapshot","selftest_case":"certgate"}, {"feature_id":"audit","enabled_by_default":true,"default_config":{},"sample_resource":"r_root","doctor_check":"audit","selftest_case":"online_delegation"}, {"feature_id":"content","enabled_by_default":true,"default_config":{},"sample_resource":"t_main","doctor_check":"content_layout","selftest_case":"idempotency"}, - {"feature_id":"git_content","enabled_by_default":true,"default_config":{},"sample_resource":"t_main","doctor_check":"git","selftest_case":null}, + {"feature_id":"git_content","enabled_by_default":true,"default_config":{},"sample_resource":"t_main","doctor_check":"git","selftest_case":"git_push_read_cas"}, {"feature_id":"valkey_signal","enabled_by_default":false,"default_config":{"optional":true},"sample_resource":null,"doctor_check":"valkey","selftest_case":null}, {"feature_id":"recovery","enabled_by_default":true,"default_config":{"verification":"partial"},"sample_resource":"t_private","doctor_check":"recovery_checkpoint","selftest_case":"recovery_checkpoint_replay"}, {"feature_id":"search","enabled_by_default":true,"default_config":{"verification":"partial"},"sample_resource":"t_index","doctor_check":"lexical_search","selftest_case":"lexical_search_access"}, {"feature_id":"hosting","enabled_by_default":true,"default_config":{"verification":"partial"},"sample_resource":"t_wiki","doctor_check":"hosting","selftest_case":"hosting_deploy"} ], + "honor_sample": { + "format_version": 1, + "policy": { + "achievement_id": "i-am-not-human", + "spec_version": 1, + "rounds": 5, + "round_ttl_seconds": 60, + "ceremony_ttl_seconds": 300, + "strategy": "zero-width-binary", + "strategy_version": 1, + "privileges": false + }, + "issuer": { + "key_source": "service_receipt_signer", + "purpose": "achievement-grant", + "automatic": true, + "claim": "The subject completed the stated self-declarations and machine-input protocol." + }, + "profile": { + "pinned_grant_ids": [], + "max_pins": 32, + "operations": [ + "achievement.pin@1", + "achievement.unpin@1", + "achievement.reorder@1" + ], + "grant_id_source": "achievement.list@1" + }, + "grants": [] + }, "templates":{ "message":"message@1\nbody:text!\nto:ref?\n", "intro":"intro@1\nabout:text!\ncontact:str?\n", diff --git a/src/msg/data/legacy/sqlite-0221.json b/src/msg/data/legacy/sqlite-0221.json new file mode 100644 index 00000000..f34c92fc --- /dev/null +++ b/src/msg/data/legacy/sqlite-0221.json @@ -0,0 +1,2474 @@ +{ + "format": "msg-legacy-sqlite-schema-v1", + "source_sha256": { + "src/msgd/store.py": "c5cc8c8f4ff188642980f9c1629521ad164c802cd45965d6d30f5b490f84047f", + "src/msgd/exchange.py": "d60e56327125a4a4337459a94d5558b783fa9df0936e581def5c58ec99009299", + "src/msgd/sshaccess.py": "84217f4a9306d28f0de29586f2d167e6b21f5bcf7ae78c4684300874299380d1" + }, + "tables": { + "agent_state": [ + [ + 0, + "owner_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "name", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "value", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "updated", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "archived_attachments": [ + [ + 0, + "id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "post_id", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 2, + "slot", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "name", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "content_type", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "data", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 6, + "nbytes", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 7, + "sha256", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 8, + "created", + "REAL", + 1, + "0", + 0, + 0 + ], + [ + 9, + "uploader_name", + "TEXT", + 1, + "'anonymous'", + 0, + 0 + ], + [ + 10, + "uploader_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 11, + "downloads", + "INTEGER", + 1, + "0", + 0, + 0 + ] + ], + "archived_posts": [ + [ + 0, + "id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "board", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "seq", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "name", + "TEXT", + 1, + "'anonymous'", + 0, + 0 + ], + [ + 4, + "title", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 5, + "body", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 6, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 7, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "nbytes", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 9, + "author_key", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 10, + "author_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 11, + "actor_key", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 12, + "actor_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 13, + "signature", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 14, + "sig_version", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 15, + "sig_nonce", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 16, + "sig_issued", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 17, + "reply_to", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 18, + "system", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 19, + "custody_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 20, + "archived_at", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 21, + "archived_by", + "TEXT", + 0, + null, + 0, + 0 + ] + ], + "attachments": [ + [ + 0, + "id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "post_id", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 2, + "slot", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "name", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "content_type", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "data", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 6, + "nbytes", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 7, + "sha256", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 8, + "created", + "REAL", + 1, + "0", + 0, + 0 + ], + [ + 9, + "uploader_name", + "TEXT", + 1, + "'anonymous'", + 0, + 0 + ], + [ + 10, + "uploader_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 11, + "downloads", + "INTEGER", + 1, + "0", + 0, + 0 + ] + ], + "boards": [ + [ + 0, + "name", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "description", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 2, + "locked", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 3, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "certificate_requests": [ + [ + 0, + "id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "subject_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "subject_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "requested_issuer", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 4, + "grants", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "delegate", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 6, + "message", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "status", + "TEXT", + 1, + "'pending'", + 0, + 0 + ], + [ + 9, + "decided", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 10, + "decision_by", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 11, + "reason", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 12, + "certificate_serial", + "TEXT", + 0, + null, + 0, + 0 + ] + ], + "certificates": [ + [ + 0, + "serial", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "issuer_serial", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "issuer_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "subject_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "subject_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "body", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 6, + "signature", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "custody_identities": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "token_hash", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "name", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "public_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "author_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "key_nonce", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 6, + "key_ciphertext", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "last_used", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "exchange_tasks": [ + [ + 0, + "post_id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "owner_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "assignee_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 3, + "status", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 5, + "updated", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "identity_names": [ + [ + 0, + "author_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "name", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "first_seen", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 3, + "last_seen", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "inbox_events": [ + [ + 0, + "post_id", + "INTEGER", + 1, + null, + 1, + 0 + ], + [ + 1, + "subject_id", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "kind", + "TEXT", + 1, + null, + 3, + 0 + ] + ], + "inbox_receipts": [ + [ + 0, + "subject_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "post_id", + "INTEGER", + 1, + null, + 2, + 0 + ], + [ + 2, + "status", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 4, + "read_at", + "REAL", + 1, + "0", + 0, + 0 + ], + [ + 5, + "public_key", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "keystore_entries": [ + [ + 0, + "owner_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "name", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "ciphertext", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 3, + "sha256", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "version", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 5, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 6, + "updated", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "name_claims": [ + [ + 0, + "name_key", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "display_name", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "author_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "public_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "claim_post_id", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 5, + "claim_signature", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 6, + "claimed", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 7, + "last_used", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "path_get_chunks": [ + [ + 0, + "request_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "chunk_index", + "INTEGER", + 1, + null, + 2, + 0 + ], + [ + 2, + "chunk_count", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "data", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 4, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "path_get_receipts": [ + [ + 0, + "request_id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "payload_sha256", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "operation", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "status", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 4, + "content_type", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 5, + "body", + "BLOB", + 0, + null, + 0, + 0 + ], + [ + 6, + "headers", + "TEXT", + 1, + "'{}'", + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "post_likes": [ + [ + 0, + "post_id", + "INTEGER", + 1, + null, + 1, + 0 + ], + [ + 1, + "author_id", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "post_tags": [ + [ + 0, + "post_id", + "INTEGER", + 1, + null, + 1, + 0 + ], + [ + 1, + "tag", + "TEXT", + 1, + null, + 2, + 0 + ] + ], + "posts": [ + [ + 0, + "id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "board", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "seq", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "name", + "TEXT", + 1, + "'anonymous'", + 0, + 0 + ], + [ + 4, + "title", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 5, + "body", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 6, + "token_hash", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 9, + "edit_count", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 10, + "deleted", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 11, + "deleted_by", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 12, + "nbytes", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 13, + "fingerprint", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 14, + "client_hash", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 15, + "hidden", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 16, + "flags", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 17, + "vouches", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 18, + "author_key", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 19, + "author_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 20, + "actor_key", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 21, + "actor_id", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 22, + "signature", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 23, + "sig_version", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 24, + "sig_nonce", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 25, + "sig_issued", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 26, + "reply_to", + "INTEGER", + 0, + null, + 0, + 0 + ], + [ + 27, + "system", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 28, + "custody_id", + "TEXT", + 0, + null, + 0, + 0 + ] + ], + "profiles": [ + [ + 0, + "author_id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "primary_name_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "bio", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 3, + "version", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 4, + "payload_b64", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 5, + "signature", + "TEXT", + 1, + "''", + 0, + 0 + ], + [ + 6, + "updated", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "purge_tombstones": [ + [ + 0, + "post_id", + "INTEGER", + 0, + null, + 1, + 0 + ], + [ + 1, + "board", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "purged_at", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 3, + "purged_by", + "TEXT", + 0, + null, + 0, + 0 + ], + [ + 4, + "reason", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "revocations": [ + [ + 0, + "serial", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "revoked_at", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 2, + "revoked_by", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "reason", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "signature_nonces": [ + [ + 0, + "signer_id", + "TEXT", + 1, + null, + 1, + 0 + ], + [ + 1, + "nonce", + "TEXT", + 1, + null, + 2, + 0 + ], + [ + 2, + "issued", + "INTEGER", + 1, + null, + 0, + 0 + ] + ], + "sqlite_sequence": [ + [ + 0, + "name", + "", + 0, + null, + 0, + 0 + ], + [ + 1, + "seq", + "", + 0, + null, + 0, + 0 + ] + ], + "ssh_authorized_keys": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "owner_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "name", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "public_key", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "key_type", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "fingerprint", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 6, + "scopes", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 9, + "last_used", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 10, + "expires", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 11, + "revoked", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 12, + "created_by", + "TEXT", + 1, + null, + 0, + 0 + ] + ], + "subscriptions": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "owner_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "kind", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "target", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "created", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "topic_policies": [ + [ + 0, + "board", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "anonymous", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "version", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 3, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 4, + "signed", + "TEXT", + 1, + "'[\"post.create\",\"post.delete.self\",\"post.edit.self\"]'", + 0, + 0 + ] + ], + "webhook_deliveries": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "webhook_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "subject_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "event", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "data", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 5, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 6, + "attempts", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 7, + "next_attempt", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "delivered", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 9, + "last_error", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "webhooks": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "owner_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "url", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "events", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "secret_nonce", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 5, + "secret_ciphertext", + "BLOB", + 1, + null, + 0, + 0 + ], + [ + 6, + "enabled", + "INTEGER", + 1, + "1", + 0, + 0 + ], + [ + 7, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 8, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 9, + "last_error", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "websub_deliveries": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "subscription_id", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "topic", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 4, + "attempts", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 5, + "next_attempt", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 6, + "delivered", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 7, + "last_error", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "websub_hub_pings": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "hub", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "topic", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "generation", + "INTEGER", + 1, + "1", + 0, + 0 + ], + [ + 4, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 5, + "attempts", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 6, + "next_attempt", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 7, + "delivered", + "REAL", + 0, + null, + 0, + 0 + ], + [ + 8, + "last_error", + "TEXT", + 1, + "''", + 0, + 0 + ] + ], + "websub_subscriptions": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "topic", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "callback", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "secret_nonce", + "BLOB", + 1, + "X''", + 0, + 0 + ], + [ + 4, + "secret_ciphertext", + "BLOB", + 1, + "X''", + 0, + 0 + ], + [ + 5, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 6, + "updated", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 7, + "expires", + "REAL", + 1, + null, + 0, + 0 + ] + ], + "websub_verifications": [ + [ + 0, + "id", + "TEXT", + 0, + null, + 1, + 0 + ], + [ + 1, + "mode", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 2, + "topic", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 3, + "callback", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 4, + "lease_seconds", + "INTEGER", + 1, + null, + 0, + 0 + ], + [ + 5, + "challenge", + "TEXT", + 1, + null, + 0, + 0 + ], + [ + 6, + "secret_nonce", + "BLOB", + 1, + "X''", + 0, + 0 + ], + [ + 7, + "secret_ciphertext", + "BLOB", + 1, + "X''", + 0, + 0 + ], + [ + 8, + "created", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 9, + "attempts", + "INTEGER", + 1, + "0", + 0, + 0 + ], + [ + 10, + "next_attempt", + "REAL", + 1, + null, + 0, + 0 + ], + [ + 11, + "last_error", + "TEXT", + 1, + "''", + 0, + 0 + ] + ] + }, + "reference_source_revision": "ccf451c6a3097be5849d666422f70b04b81f1710", + "installed_store_revision": "e12eea32764656eec82452fee111e5b4fb6d8a42", + "installed_store_sha256": "5fa50265c1b0c9674d7464aadd1cbaaf7d76d41c2f50adededa5fb9bf13871b4", + "schema_provenance": "archczy read-only sqlite_schema, 2026-09-29; no user rows; additive historical columns retained" +} diff --git a/src/msg/data/recovery-checkpoint.schema.json b/src/msg/data/recovery-checkpoint.schema.json index a0b06413..a2726df4 100644 --- a/src/msg/data/recovery-checkpoint.schema.json +++ b/src/msg/data/recovery-checkpoint.schema.json @@ -67,6 +67,7 @@ "share_link.revoke", "membership.remove", "topic_membership.remove", + "topic_ban.apply", "topic_ban.lift", "identity_key.retire", "encryption_key.retire", diff --git a/src/msg/data/recovery-policy-checkpoint.schema.json b/src/msg/data/recovery-policy-checkpoint.schema.json new file mode 100644 index 00000000..a5e41157 --- /dev/null +++ b/src/msg/data/recovery-policy-checkpoint.schema.json @@ -0,0 +1,479 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:msg:recovery-checkpoint:v2", + "title": "Independently pinned partial recovery policy ceilings", + "description": "Internal local replay only. Explicitly incomplete; cannot authorize promotion. V1 remains unchanged. ACL masks only remove bits; topic policy ceilings never reopen membership.", + "type": "object", + "additionalProperties": false, + "required": [ + "checkpoint", + "signature" + ], + "properties": { + "checkpoint": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "service", + "source_backup_sha256", + "sequence", + "entries", + "coverage" + ], + "properties": { + "format": { + "const": "msg-revocation-checkpoint-v2" + }, + "service": { + "type": "string", + "minLength": 1 + }, + "source_backup_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "default": 0 + }, + "entries": { + "type": "array", + "maxItems": 10000, + "default": [], + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "enum": [ + "credential.revoke", + "certificate.revoke", + "share_grant.revoke", + "share_grant_v2.revoke", + "share_link.revoke", + "membership.remove", + "topic_membership.remove", + "topic_ban.apply", + "topic_ban.lift", + "identity_key.retire", + "encryption_key.retire", + "vault.destroy" + ] + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at", + "value" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "const": "resource.acl.restrict" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + }, + "value": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "group", + "mode" + ], + "properties": { + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "group": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "mode": { + "type": "integer", + "minimum": 0, + "maximum": 511 + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at", + "value" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "const": "topic.policy.restrict" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + }, + "value": { + "type": "object", + "additionalProperties": false, + "required": [ + "membership_policy" + ], + "properties": { + "membership_policy": { + "enum": [ + "open", + "approval", + "invite", + "closed" + ] + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at", + "value" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "const": "topic_ban.set" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + }, + "value": { + "type": "object", + "additionalProperties": false, + "required": [ + "expires_at" + ], + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at", + "value" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "const": "certificate.current" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + }, + "value": { + "type": "object", + "additionalProperties": false, + "required": [ + "body_digest" + ], + "properties": { + "body_digest": { + "type": [ + "string", + "null" + ], + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "sequence", + "kind", + "subject", + "target", + "at", + "value" + ], + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "kind": { + "const": "resource.authority.reconcile" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "target": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "at": { + "type": "string", + "format": "date-time", + "pattern": "Z$" + }, + "value": { + "type": "object", + "additionalProperties": false, + "required": [ + "previous", + "current" + ], + "properties": { + "previous": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "group", + "parent" + ], + "properties": { + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "group": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "parent": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256 + } + } + }, + "current": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "group", + "parent" + ], + "properties": { + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "group": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "parent": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256 + } + } + } + } + } + } + } + ] + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": [ + "complete", + "domains" + ], + "properties": { + "complete": { + "const": false + }, + "domains": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "resource_acl", + "topic_ban", + "topic_policy", + "revocations", + "certificates", + "resource_authority" + ] + } + } + } + } + } + }, + "signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "key_id", + "algorithm", + "value" + ], + "properties": { + "key_id": { + "type": "string" + }, + "algorithm": { + "const": "ed25519" + }, + "value": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{86}$" + } + } + } + } +} diff --git a/src/msg/data/recovery-state-schema.json b/src/msg/data/recovery-state-schema.json new file mode 100644 index 00000000..90058ea5 --- /dev/null +++ b/src/msg/data/recovery-state-schema.json @@ -0,0 +1 @@ +{"constraints":[["achievement_ceremonies","achievement_ceremonies_pkey","p"],["achievement_grants","achievement_grants_pkey","p"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key","u"],["achievement_pins","achievement_pins_grant_id_fkey","f"],["achievement_pins","achievement_pins_pkey","p"],["achievement_pins","achievement_pins_position_check","c"],["achievement_pins","achievement_pins_subject_fkey","f"],["achievement_pins","achievement_pins_subject_position_key","u"],["arbitration_cases","arbitration_cases_order_id_fkey","f"],["arbitration_cases","arbitration_cases_order_id_key","u"],["arbitration_cases","arbitration_cases_pkey","p"],["arbitration_decisions","arbitration_decisions_case_id_fkey","f"],["arbitration_decisions","arbitration_decisions_case_id_round_key","u"],["arbitration_decisions","arbitration_decisions_pkey","p"],["arbitration_evidence","arbitration_evidence_author_fkey","f"],["arbitration_evidence","arbitration_evidence_case_id_fkey","f"],["arbitration_evidence","arbitration_evidence_pkey","p"],["arbitration_policies","arbitration_policies_pkey","p"],["arbitration_votes","arbitration_votes_arbitrator_fkey","f"],["arbitration_votes","arbitration_votes_case_id_fkey","f"],["arbitration_votes","arbitration_votes_pkey","p"],["arbitrator_conflicts","arbitrator_conflicts_arbitrator_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_party_fkey","f"],["arbitrator_conflicts","arbitrator_conflicts_pkey","p"],["arbitrator_roles","arbitrator_roles_pkey","p"],["arbitrator_roles","arbitrator_roles_subject_fkey","f"],["audit","audit_digest_key","u"],["audit","audit_pkey","p"],["batches","batches_pkey","p"],["bounty_challenges","bounty_challenges_claimant_fkey","f"],["bounty_challenges","bounty_challenges_listing_id_fkey","f"],["bounty_challenges","bounty_challenges_pkey","p"],["bounty_challenges","bounty_challenges_verifier_version_check","c"],["bounty_claims","bounty_claims_challenge_id_fkey","f"],["bounty_claims","bounty_claims_challenge_id_key","u"],["bounty_claims","bounty_claims_claimant_fkey","f"],["bounty_claims","bounty_claims_listing_id_fkey","f"],["bounty_claims","bounty_claims_pkey","p"],["bounty_claims","bounty_claims_reward_minor_check","c"],["bounty_claims","bounty_claims_status_check","c"],["bounty_claims","bounty_claims_transaction_id_fkey","f"],["bounty_claims","bounty_claims_transaction_id_key","u"],["bounty_listings","bounty_listings_budget_minor_check","c"],["bounty_listings","bounty_listings_claim_limit_per_subject_check","c"],["bounty_listings","bounty_listings_escrow_account_fkey","f"],["bounty_listings","bounty_listings_escrow_subject_key","u"],["bounty_listings","bounty_listings_listing_id_fkey","f"],["bounty_listings","bounty_listings_max_claims_check","c"],["bounty_listings","bounty_listings_pkey","p"],["bounty_listings","bounty_listings_publisher_fkey","f"],["bounty_listings","bounty_listings_reward_minor_check","c"],["bounty_listings","bounty_listings_state_check","c"],["bounty_listings","bounty_listings_verifier_version_check","c"],["certificates","certificates_pkey","p"],["chunks","chunks_pkey","p"],["claims","claims_id_fkey","f"],["claims","claims_pkey","p"],["collaboration_leases","collaboration_leases_pkey","p"],["collaboration_leases","collaboration_leases_status_check","c"],["collaboration_leases","collaboration_leases_target_fkey","f"],["credentials","credentials_pkey","p"],["csrs","csrs_pkey","p"],["custodial_upgrades","custodial_upgrades_pkey","p"],["custodial_upgrades","custodial_upgrades_status_check","c"],["custodial_vault","custodial_vault_lifecycle_check","c"],["custodial_vault","custodial_vault_pkey","p"],["delivery_email_challenges","delivery_email_challenges_order_id_fkey","f"],["delivery_email_challenges","delivery_email_challenges_pkey","p"],["delivery_email_challenges","delivery_email_challenges_subject_fkey","f"],["delivery_email_endpoints","delivery_email_endpoints_pkey","p"],["delivery_email_endpoints","delivery_email_endpoints_subject_fkey","f"],["delivery_envelopes","delivery_envelopes_order_id_fkey","f"],["delivery_envelopes","delivery_envelopes_pkey","p"],["dm_archives","dm_archives_pair_fkey","f"],["dm_archives","dm_archives_pkey","p"],["dm_blocks","dm_blocks_pkey","p"],["dm_conversations","dm_conversations_check","c"],["dm_conversations","dm_conversations_conversation_kind_check","c"],["dm_conversations","dm_conversations_pkey","p"],["dm_conversations","dm_conversations_resource_id_fkey","f"],["dm_conversations","dm_conversations_resource_id_key","u"],["dm_conversations","dm_conversations_state_check","c"],["email_challenges","email_challenges_pkey","p"],["emails","emails_pkey","p"],["encryption_subkeys","encryption_subkeys_pkey","p"],["encryption_subkeys","encryption_subkeys_recipient_key","u"],["events","events_id_key","u"],["events","events_pkey","p"],["handoffs","handoffs_pkey","p"],["handoffs","handoffs_status_check","c"],["identities","identities_pkey","p"],["identity_keys","identity_keys_pkey","p"],["jobs","jobs_dedupe_key","u"],["jobs","jobs_pkey","p"],["ledger_accounts","ledger_accounts_check","c"],["ledger_accounts","ledger_accounts_kind_check","c"],["ledger_accounts","ledger_accounts_kind_source_id_key","u"],["ledger_accounts","ledger_accounts_pkey","p"],["ledger_accounts","ledger_accounts_subject_id_fkey","f"],["legacy_directive_versions","legacy_directive_versions_pkey","p"],["legacy_directive_versions","legacy_directive_versions_revision_id_fkey","f"],["legacy_directives","legacy_directives_pkey","p"],["legacy_directives","legacy_directives_resource_id_fkey","f"],["legacy_directives","legacy_directives_resource_id_key","u"],["legacy_states","legacy_states_pkey","p"],["legacy_states","legacy_states_state_check","c"],["memberships","memberships_pkey","p"],["messages","messages_pkey","p"],["money_accounts","money_accounts_currency_id_check","c"],["money_accounts","money_accounts_ledger_account_fkey","f"],["money_accounts","money_accounts_pkey","p"],["money_bank_roles","money_bank_roles_granted_by_check","c"],["money_bank_roles","money_bank_roles_pkey","p"],["money_bank_roles","money_bank_roles_status_check","c"],["money_bank_roles","money_bank_roles_subject_id_fkey","f"],["money_ledger","money_ledger_actor_request_id_entry_key_key","u"],["money_ledger","money_ledger_amount_minor_check","c"],["money_ledger","money_ledger_check","c"],["money_ledger","money_ledger_credit_ledger_account_fkey","f"],["money_ledger","money_ledger_currency_id_check","c"],["money_ledger","money_ledger_debit_ledger_account_fkey","f"],["money_ledger","money_ledger_id_key","u"],["money_ledger","money_ledger_kind_check","c"],["money_ledger","money_ledger_pkey","p"],["money_ledger","money_ledger_policy_version_check","c"],["money_purchases","money_purchases_entitlement_id_fkey","f"],["money_purchases","money_purchases_escrow_account_fkey","f"],["money_purchases","money_purchases_escrow_account_key","u"],["money_purchases","money_purchases_final_transaction_id_fkey","f"],["money_purchases","money_purchases_funding_transaction_id_fkey","f"],["money_purchases","money_purchases_pkey","p"],["money_purchases","money_purchases_quantity_check","c"],["money_purchases","money_purchases_state_check","c"],["money_purchases","money_purchases_subject_id_fkey","f"],["money_purchases","money_purchases_subject_id_request_id_key","u"],["money_purchases","money_purchases_total_minor_check","c"],["order_contracts","order_contracts_order_id_fkey","f"],["order_contracts","order_contracts_pkey","p"],["order_deadlines","order_deadlines_order_id_fkey","f"],["order_deadlines","order_deadlines_pkey","p"],["order_escrow_decisions","order_escrow_decisions_id_key","u"],["order_escrow_decisions","order_escrow_decisions_order_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_pkey","p"],["order_escrow_decisions","order_escrow_decisions_transaction_id_fkey","f"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key","u"],["order_settlements","order_settlements_decision_id_key","u"],["order_settlements","order_settlements_order_id_fkey","f"],["order_settlements","order_settlements_pkey","p"],["order_transitions","order_transitions_order_id_fkey","f"],["order_transitions","order_transitions_pkey","p"],["personal_revision_proofs","personal_revision_proofs_pkey","p"],["personal_revision_proofs","personal_revision_proofs_revision_id_fkey","f"],["presence","presence_pkey","p"],["projections","projections_pkey","p"],["reactions","reactions_pkey","p"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key","u"],["recovery_envelopes","recovery_envelopes_pkey","p"],["recovery_policies","recovery_policies_pkey","p"],["relations","relations_revision_id_fkey","f"],["resource_entitlements","resource_entitlements_offer_id_fkey","f"],["resource_entitlements","resource_entitlements_pkey","p"],["resource_entitlements","resource_entitlements_quantity_check","c"],["resource_entitlements","resource_entitlements_redeem_transaction_id_fkey","f"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key","u"],["resource_entitlements","resource_entitlements_subject_id_fkey","f"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key","u"],["resource_path_aliases","resource_path_aliases_parent_id_fkey","f"],["resource_path_aliases","resource_path_aliases_pkey","p"],["resource_path_aliases","resource_path_aliases_resource_id_fkey","f"],["resource_tags","resource_tags_pkey","p"],["resource_tags","resource_tags_resource_id_fkey","f"],["resources","resources_parent_fkey","f"],["resources","resources_parent_name_key","u"],["resources","resources_pkey","p"],["results","results_pkey","p"],["revisions","revisions_pkey","p"],["revisions","revisions_resource_id_fkey","f"],["schema_version","schema_version_pkey","p"],["server_offer_resources","server_offer_resources_listing_id_fkey","f"],["server_offer_resources","server_offer_resources_listing_id_key","u"],["server_offer_resources","server_offer_resources_offer_id_fkey","f"],["server_offer_resources","server_offer_resources_pkey","p"],["server_offers","server_offers_check","c"],["server_offers","server_offers_duration_seconds_check","c"],["server_offers","server_offers_min_quantity_check","c"],["server_offers","server_offers_pkey","p"],["server_offers","server_offers_price_minor_check","c"],["settings","settings_pkey","p"],["share_grants","share_grants_pkey","p"],["share_grants","share_grants_resource_id_fkey","f"],["share_grants_v2","share_grants_v2_allow_reshare_check","c"],["share_grants_v2","share_grants_v2_grantee_kind_check","c"],["share_grants_v2","share_grants_v2_parent_id_fkey","f"],["share_grants_v2","share_grants_v2_pkey","p"],["share_grants_v2","share_grants_v2_resource_id_fkey","f"],["share_links","share_links_pkey","p"],["share_links","share_links_resource_id_fkey","f"],["share_links","share_links_verifier_key","u"],["store_deliveries","store_deliveries_channel_check","c"],["store_deliveries","store_deliveries_order_id_fkey","f"],["store_deliveries","store_deliveries_order_id_key","u"],["store_deliveries","store_deliveries_pkey","p"],["store_deliveries","store_deliveries_recipient_subject_fkey","f"],["store_deliveries","store_deliveries_state_check","c"],["store_order_events","store_order_events_order_id_fkey","f"],["store_order_events","store_order_events_pkey","p"],["store_orders","store_orders_buyer_fkey","f"],["store_orders","store_orders_currency_id_check","c"],["store_orders","store_orders_escrow_account_fkey","f"],["store_orders","store_orders_escrow_subject_key","u"],["store_orders","store_orders_listing_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_fkey","f"],["store_orders","store_orders_payment_transaction_id_key","u"],["store_orders","store_orders_pkey","p"],["store_orders","store_orders_quantity_check","c"],["store_orders","store_orders_seller_fkey","f"],["store_orders","store_orders_state_check","c"],["store_orders","store_orders_total_price_minor_check","c"],["store_orders","store_orders_unit_price_minor_check","c"],["store_packages","store_packages_pkey","p"],["store_packages","store_packages_revision_key","u"],["sync_checkpoints","sync_checkpoints_pkey","p"],["system_sources","system_sources_pkey","p"],["system_sources","system_sources_resource_id_fkey","f"],["system_sources","system_sources_revision_id_fkey","f"],["system_sources","system_sources_rule_id_key","u"],["system_sources","system_sources_source_path_key","u"],["token_deliveries","token_deliveries_pkey","p"],["topic_bans","topic_bans_pkey","p"],["topic_bans","topic_bans_status_check","c"],["topic_bans","topic_bans_topic_fkey","f"],["topic_event_projection","topic_event_projection_pkey","p"],["topic_event_projection","topic_event_projection_seq_fkey","f"],["topic_memberships","topic_memberships_pkey","p"],["topic_memberships","topic_memberships_role_check","c"],["topic_memberships","topic_memberships_status_check","c"],["topic_memberships","topic_memberships_topic_fkey","f"],["topic_settings","topic_settings_membership_policy_check","c"],["topic_settings","topic_settings_pkey","p"],["topic_settings","topic_settings_topic_fkey","f"],["transfers","transfers_pkey","p"],["watches","watches_pkey","p"],["webhook_endpoints","webhook_endpoints_enabled_check","c"],["webhook_endpoints","webhook_endpoints_pkey","p"]],"functions":[["msg_audit_append_only",""],["msg_escrow_decision_append_only",""],["msg_money_ledger_append_only",""],["msg_register_subject_ledger_account",""]],"indexes":[["achievement_ceremonies","achievement_ceremonies_pkey"],["achievement_ceremonies","achievement_ceremonies_subject"],["achievement_grants","achievement_grants_lookup"],["achievement_grants","achievement_grants_pkey"],["achievement_grants","achievement_grants_subject_achievement_id_spec_version_key"],["achievement_pins","achievement_pins_pkey"],["achievement_pins","achievement_pins_subject_position_key"],["arbitration_cases","arbitration_cases_order_id_key"],["arbitration_cases","arbitration_cases_pkey"],["arbitration_decisions","arbitration_decisions_case_id_round_key"],["arbitration_decisions","arbitration_decisions_pkey"],["arbitration_evidence","arbitration_evidence_pkey"],["arbitration_policies","arbitration_policies_pkey"],["arbitration_votes","arbitration_votes_pkey"],["arbitrator_conflicts","arbitrator_conflicts_pkey"],["arbitrator_roles","arbitrator_roles_pkey"],["audit","audit_digest_key"],["audit","audit_pkey"],["batches","batches_pkey"],["bounty_challenges","bounty_challenges_claimant"],["bounty_challenges","bounty_challenges_pkey"],["bounty_claims","bounty_claims_challenge_id_key"],["bounty_claims","bounty_claims_claimant"],["bounty_claims","bounty_claims_pkey"],["bounty_claims","bounty_claims_transaction_id_key"],["bounty_listings","bounty_listings_escrow_subject_key"],["bounty_listings","bounty_listings_pkey"],["bounty_listings","bounty_listings_publisher"],["certificates","certificates_pkey"],["chunks","chunks_pkey"],["claims","claims_pkey"],["claims","claims_subject"],["collaboration_leases","collaboration_leases_holder"],["collaboration_leases","collaboration_leases_pkey"],["credentials","credentials_pkey"],["credentials","credentials_subject"],["csrs","csrs_pkey"],["custodial_upgrades","custodial_upgrades_pkey"],["custodial_upgrades","custodial_upgrades_subject"],["custodial_vault","custodial_vault_pkey"],["delivery_email_challenges","delivery_email_challenges_pkey"],["delivery_email_endpoints","delivery_email_endpoints_pkey"],["delivery_envelopes","delivery_envelopes_pkey"],["dm_archives","dm_archives_pkey"],["dm_blocks","dm_blocks_pkey"],["dm_conversations","dm_conversations_participants"],["dm_conversations","dm_conversations_pkey"],["dm_conversations","dm_conversations_resource_id_key"],["email_challenges","email_challenges_pkey"],["emails","emails_pkey"],["encryption_subkeys","encryption_subkeys_pkey"],["encryption_subkeys","encryption_subkeys_primary"],["encryption_subkeys","encryption_subkeys_recipient_key"],["encryption_subkeys","encryption_subkeys_subject"],["events","events_id_key"],["events","events_pkey"],["handoffs","handoffs_from"],["handoffs","handoffs_pkey"],["handoffs","handoffs_to"],["identities","identities_pkey"],["identity_keys","identity_keys_pkey"],["identity_keys","identity_keys_primary"],["identity_keys","identity_keys_subject"],["jobs","jobs_dedupe_key"],["jobs","jobs_pkey"],["ledger_accounts","ledger_accounts_kind_source_id_key"],["ledger_accounts","ledger_accounts_pkey"],["legacy_directive_versions","legacy_directive_versions_pkey"],["legacy_directives","legacy_directives_pkey"],["legacy_directives","legacy_directives_resource_id_key"],["legacy_states","legacy_states_pkey"],["memberships","memberships_pkey"],["messages","messages_pkey"],["money_accounts","money_accounts_pkey"],["money_bank_roles","money_bank_roles_pkey"],["money_ledger","money_ledger_actor_request_id_entry_key_key"],["money_ledger","money_ledger_credit"],["money_ledger","money_ledger_debit"],["money_ledger","money_ledger_id_key"],["money_ledger","money_ledger_pkey"],["money_ledger","money_ledger_request_leg"],["money_purchases","money_purchases_escrow_account_key"],["money_purchases","money_purchases_pkey"],["money_purchases","money_purchases_subject_id_request_id_key"],["order_contracts","order_contracts_pkey"],["order_deadlines","order_deadlines_due"],["order_deadlines","order_deadlines_pkey"],["order_escrow_decisions","order_escrow_decisions_id_key"],["order_escrow_decisions","order_escrow_decisions_pkey"],["order_escrow_decisions","order_escrow_decisions_transaction_id_key"],["order_settlements","order_settlements_decision_id_key"],["order_settlements","order_settlements_pkey"],["order_transitions","order_transitions_pkey"],["personal_revision_proofs","personal_revision_proofs_pkey"],["presence","presence_pkey"],["projections","projections_pkey"],["reactions","reactions_pkey"],["recovery_envelopes","recovery_envelopes_owner"],["recovery_envelopes","recovery_envelopes_owner_ciphertext_resource_ciphertext_rev_key"],["recovery_envelopes","recovery_envelopes_pkey"],["recovery_policies","recovery_policies_pkey"],["relations","relations_target"],["resource_entitlements","resource_entitlements_pkey"],["resource_entitlements","resource_entitlements_redeem_transaction_id_key"],["resource_entitlements","resource_entitlements_subject_id_purchase_request_id_key"],["resource_path_aliases","resource_path_aliases_pkey"],["resource_tags","resource_tags_pkey"],["resource_tags","resource_tags_resource"],["resources","one_root"],["resources","resources_owner"],["resources","resources_parent"],["resources","resources_parent_name_key"],["resources","resources_pkey"],["resources","resources_time"],["resources","resources_type_state"],["results","results_pkey"],["revisions","revisions_pkey"],["revisions","revisions_resource"],["schema_version","schema_version_pkey"],["server_offer_resources","server_offer_resources_listing_id_key"],["server_offer_resources","server_offer_resources_pkey"],["server_offers","server_offers_pkey"],["settings","settings_pkey"],["share_grants","share_grants_active"],["share_grants","share_grants_grantee"],["share_grants","share_grants_pkey"],["share_grants_v2","share_grants_v2_parent"],["share_grants_v2","share_grants_v2_pkey"],["share_grants_v2","share_grants_v2_resource"],["share_links","share_links_pkey"],["share_links","share_links_resource"],["share_links","share_links_verifier_key"],["store_deliveries","store_deliveries_order_id_key"],["store_deliveries","store_deliveries_pkey"],["store_order_events","store_order_events_order"],["store_order_events","store_order_events_pkey"],["store_orders","store_orders_buyer"],["store_orders","store_orders_escrow_subject_key"],["store_orders","store_orders_listing"],["store_orders","store_orders_payment_transaction_id_key"],["store_orders","store_orders_pkey"],["store_orders","store_orders_seller"],["store_packages","store_packages_pkey"],["store_packages","store_packages_revision_key"],["sync_checkpoints","sync_checkpoints_pkey"],["sync_checkpoints","sync_checkpoints_subject"],["system_sources","system_sources_pkey"],["system_sources","system_sources_rule_id_key"],["system_sources","system_sources_source_path_key"],["token_deliveries","token_deliveries_pkey"],["token_deliveries","token_deliveries_subject"],["topic_bans","topic_bans_pkey"],["topic_event_projection","topic_event_projection_pkey"],["topic_event_projection","topic_event_projection_topic"],["topic_memberships","topic_memberships_pkey"],["topic_memberships","topic_memberships_subject"],["topic_settings","topic_settings_pkey"],["transfers","transfers_pkey"],["watches","watches_pkey"],["webhook_endpoints","webhook_endpoints_pkey"]],"sequences":[["audit_seq_seq","bigint"],["events_seq_seq","bigint"],["money_ledger_seq_seq","bigint"],["store_order_events_seq_seq","bigint"]],"tables":{"achievement_ceremonies":[["id","text",true,""],["subject","text",true,""],["state","text",true,""],["body","text",true,""]],"achievement_grants":[["id","text",true,""],["subject","text",true,""],["achievement_id","text",true,""],["spec_version","integer",true,""],["body","text",true,""]],"achievement_pins":[["subject","text",true,""],["grant_id","text",true,""],["position","integer",true,""]],"arbitration_cases":[["id","text",true,""],["order_id","text",true,""],["round","integer",true,""],["state","text",true,""],["deadline","text",true,""],["body","text",true,""]],"arbitration_decisions":[["id","text",true,""],["case_id","text",true,""],["round","integer",true,""],["body","text",true,""],["revoked","boolean",true,""]],"arbitration_evidence":[["id","text",true,""],["case_id","text",true,""],["author","text",true,""],["visibility","text",true,""],["body","text",true,""]],"arbitration_policies":[["id","text",true,""],["body","text",true,""],["digest","text",true,""]],"arbitration_votes":[["case_id","text",true,""],["round","integer",true,""],["arbitrator","text",true,""],["body","text",true,""]],"arbitrator_conflicts":[["arbitrator","text",true,""],["party","text",true,""]],"arbitrator_roles":[["subject","text",true,""],["epoch","text",true,""],["active","boolean",true,""]],"audit":[["seq","bigint",true,"d"],["digest","text",true,""],["previous","text",false,""],["body","text",true,""]],"batches":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""]],"bounty_challenges":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["key_id","text",true,""],["nonce","text",true,""],["issued_at","text",true,""],["expires_at","text",true,""],["verifier_version","integer",true,""],["payload","text",true,""],["consumed_at","text",false,""]],"bounty_claims":[["id","text",true,""],["listing_id","text",true,""],["claimant","text",true,""],["challenge_id","text",true,""],["proof_digest","text",true,""],["status","text",true,""],["reward_minor","bigint",true,""],["transaction_id","text",true,""],["claimed_at","text",true,""]],"bounty_listings":[["listing_id","text",true,""],["publisher","text",true,""],["escrow_subject","text",true,""],["reward_minor","bigint",true,""],["budget_minor","bigint",true,""],["max_claims","integer",true,""],["claim_limit_per_subject","integer",true,""],["verifier_id","text",true,""],["verifier_version","integer",true,""],["eligibility","text",true,""],["state","text",true,""],["pause_reason","text",false,""],["expires_at","text",false,""],["created_at","text",true,""]],"certificates":[["id","text",true,""],["subject","text",true,""],["parent","text",false,""],["revoked","integer",true,""],["body","text",true,""]],"chunks":[["transfer_id","text",true,""],["offset","bigint",true,""],["length","bigint",true,""],["body","text",true,""]],"claims":[["id","text",true,""],["subject","text",true,""],["issued_at","text",true,""],["expires_at","text",false,""],["body","text",true,""]],"collaboration_leases":[["id","text",true,""],["holder","text",true,""],["target","text",true,""],["status","text",true,""],["generation","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"credentials":[["id","text",true,""],["subject","text",true,""],["body","text",true,""]],"csrs":[["id","text",true,""],["generation","integer",true,""],["state","text",true,""],["body","text",true,""],["state_body","text",true,""]],"custodial_upgrades":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["status","text",true,""],["expires_at","text",true,""],["challenge","text",true,""],["ephemeral_nonce","text",false,""],["ephemeral_ciphertext","text",false,""],["body","text",true,""]],"custodial_vault":[["subject","text",true,""],["signing_key_id","text",true,""],["encryption_key_id","text",true,""],["signing_nonce","text",false,""],["signing_ciphertext","text",false,""],["age_nonce","text",false,""],["age_ciphertext","text",false,""],["status","text",true,""],["created_at","text",true,""],["destroyed_at","text",false,""]],"delivery_email_challenges":[["id","text",true,""],["order_id","text",true,""],["subject","text",true,""],["address","text",true,""],["verifier","text",true,""],["expires_at","text",true,""],["consumed","boolean",true,""]],"delivery_email_endpoints":[["id","text",true,""],["subject","text",true,""],["address","text",true,""],["verified_at","text",true,""],["active","boolean",true,""]],"delivery_envelopes":[["order_id","text",true,""],["body","text",true,""]],"dm_archives":[["subject","text",true,""],["pair","text",true,""]],"dm_blocks":[["blocker","text",true,""],["blocked","text",true,""]],"dm_conversations":[["pair","text",true,""],["resource_id","text",true,""],["participant_a","text",true,""],["participant_b","text",true,""],["initiator","text",true,""],["conversation_kind","text",true,""],["state","text",true,""],["created_at","text",true,""],["updated_at","text",true,""]],"email_challenges":[["subject","text",true,""],["digest","text",true,""],["expires","text",true,""]],"emails":[["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"encryption_subkeys":[["key_id","text",true,""],["subject","text",true,""],["recipient","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"events":[["seq","bigint",true,"d"],["id","text",true,""],["body","text",true,""]],"handoffs":[["id","text",true,""],["from_subject","text",true,""],["to_subject","text",true,""],["status","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identities":[["id","text",true,""],["kind","text",true,""],["generation","integer",true,""],["body","text",true,""]],"identity_keys":[["key_id","text",true,""],["subject","text",true,""],["public_key","text",true,""],["created_at","text",true,""],["retired_at","text",false,""],["is_primary","integer",true,""]],"jobs":[["id","text",true,""],["dedupe","text",true,""],["kind","text",true,""],["state","text",true,""],["next_at","text",true,""],["body","text",true,""]],"ledger_accounts":[["id","text",true,""],["kind","text",true,""],["subject_id","text",false,""],["source_id","text",false,""]],"legacy_directive_versions":[["revision_id","text",true,""],["resource_id","text",true,""],["subject","text",true,""],["body","text",true,""]],"legacy_directives":[["subject","text",true,""],["resource_id","text",true,""],["created_at","text",true,""]],"legacy_states":[["subject","text",true,""],["state","text",true,""]],"memberships":[["org","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""]],"messages":[["id","text",true,""],["sender","text",false,""],["recipient","text",false,""],["resource","text",false,""],["event_id","text",false,""],["body","text",true,""]],"money_accounts":[["subject_id","text",true,""],["currency_id","text",true,""]],"money_bank_roles":[["subject_id","text",true,""],["status","text",true,""],["granted_at","text",true,""],["granted_by","text",true,""],["revoked_at","text",false,""]],"money_ledger":[["seq","bigint",true,"d"],["id","text",true,""],["kind","text",true,""],["currency_id","text",true,""],["amount_minor","bigint",true,""],["debit_account","text",false,""],["credit_account","text",false,""],["actor","text",true,""],["request_id","text",true,""],["reference","text",false,""],["committed_at","text",true,""],["policy_version","integer",true,""],["policy_digest","text",true,""],["receipt","text",true,""],["entry_key","text",true,""]],"money_purchases":[["id","text",true,""],["subject_id","text",true,""],["request_id","text",true,""],["offer_snapshot","text",true,""],["quantity","bigint",true,""],["total_minor","bigint",true,""],["escrow_account","text",true,""],["state","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["funding_transaction_id","text",true,""],["final_transaction_id","text",false,""],["entitlement_id","text",false,""],["reason","text",false,""]],"order_contracts":[["order_id","text",true,""],["body","text",true,""],["digest","text",true,""]],"order_deadlines":[["order_id","text",true,""],["expires_at","text",true,""]],"order_escrow_decisions":[["order_id","text",true,""],["id","text",true,""],["body","text",true,""],["signature","text",true,""],["source_proof","text",true,""],["transaction_id","text",true,""]],"order_settlements":[["order_id","text",true,""],["decision_id","text",false,""],["body","text",true,""]],"order_transitions":[["id","text",true,""],["order_id","text",true,""],["body","text",true,""]],"personal_revision_proofs":[["revision_id","text",true,""],["subject","text",true,""],["kind","text",true,""],["signature","text",true,""],["signed_envelope","text",true,""],["created_at","text",true,""]],"presence":[["subject","text",true,""],["expires_at","text",true,""],["body","text",true,""]],"projections":[["resource_id","text",true,""],["text","text",true,""]],"reactions":[["subject","text",true,""],["resource","text",true,""],["kind","text",true,""],["revision","text",true,""],["body","text",true,""]],"recovery_envelopes":[["id","text",true,""],["owner","text",true,""],["ciphertext_resource","text",true,""],["ciphertext_revision","text",true,""],["policy_version","integer",true,""],["encryption_key_id","text",true,""],["purpose","text",true,""],["created_at","text",true,""],["body","text",true,""]],"recovery_policies":[["subject","text",true,""],["version","integer",true,""],["encryption_key_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"relations":[["revision_id","text",true,""],["source_id","text",true,""],["type","text",true,""],["target_id","text",true,""],["target_revision","text",false,""],["body","text",true,""]],"resource_entitlements":[["id","text",true,""],["subject_id","text",true,""],["offer_id","text",true,""],["purchase_request_id","text",true,""],["quantity","bigint",true,""],["entitlement_kind","text",true,""],["granted_at","text",true,""],["expires_at","text",false,""],["redeem_transaction_id","text",false,""]],"resource_path_aliases":[["parent_id","text",true,""],["name","text",true,""],["resource_id","text",true,""]],"resource_tags":[["resource_id","text",true,""],["tag","text",true,""]],"resources":[["id","text",true,""],["type","text",true,""],["name","text",true,""],["parent","text",false,""],["owner","text",true,""],["grp","text",true,""],["mode","integer",true,""],["generation","integer",true,""],["revision","text",false,""],["state","text",true,""],["created_at","text",true,""],["modified_at","text",true,""],["body","text",true,""]],"results":[["subject","text",true,""],["request_id","text",true,""],["digest","text",true,""],["body","text",true,""]],"revisions":[["id","text",true,""],["resource_id","text",true,""],["created_at","text",true,""],["body","text",true,""]],"schema_version":[["version","integer",true,""]],"server_offer_resources":[["offer_id","text",true,""],["listing_id","text",true,""]],"server_offers":[["offer_id","text",true,""],["resource_kind","text",true,""],["unit","text",true,""],["price_minor","bigint",true,""],["min_quantity","bigint",true,""],["max_quantity","bigint",true,""],["entitlement_kind","text",true,""],["duration_seconds","bigint",false,""],["enabled","boolean",true,""],["price_revision","text",true,""]],"settings":[["key","text",true,""],["value","text",true,""]],"share_grants":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_grants_v2":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["grantee","text",true,""],["grantee_kind","text",true,""],["parent_id","text",false,""],["operations","text",true,""],["constraints","text",true,""],["allow_reshare","integer",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"share_links":[["id","text",true,""],["resource_id","text",true,""],["grantor","text",true,""],["credential_id","text",true,""],["verifier","text",true,""],["created_at","text",true,""],["expires_at","text",true,""],["revoked_at","text",false,""]],"store_deliveries":[["id","text",true,""],["order_id","text",true,""],["recipient_subject","text",true,""],["kind","text",true,""],["payload_refs","text",true,""],["manifest","text",true,""],["package_digest","text",false,""],["delivery_digest","text",true,""],["channel","text",true,""],["state","text",true,""],["prepared_at","text",true,""],["claimed_at","text",false,""],["receipt","text",false,""]],"store_order_events":[["seq","bigint",true,"d"],["order_id","text",true,""],["from_state","text",false,""],["to_state","text",true,""],["reason","text",true,""],["actor","text",true,""],["request_id","text",true,""],["occurred_at","text",true,""],["receipt_refs","text",true,""]],"store_orders":[["id","text",true,""],["buyer","text",true,""],["seller","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["package_id","text",false,""],["package_revision","text",false,""],["package_digest","text",false,""],["quantity","bigint",true,""],["unit_price_minor","bigint",true,""],["total_price_minor","bigint",true,""],["currency_id","text",true,""],["escrow_subject","text",true,""],["escrow_policy","text",true,""],["dispute_policy","text",true,""],["terms_digest","text",true,""],["delivery_target","text",true,""],["payment_intent_digest","text",true,""],["payment_transaction_id","text",false,""],["state","text",true,""],["created_at","text",true,""],["funded_at","text",false,""],["delivered_at","text",false,""],["settled_at","text",false,""],["receipt_refs","text",true,""],["delivery_mode","text",true,""],["deadline_at","text",false,""],["quote_digest","text",false,""]],"store_packages":[["id","text",true,""],["listing_id","text",true,""],["listing_revision","text",true,""],["seller","text",true,""],["revision","text",true,""],["kind","text",true,""],["manifest","text",true,""],["payload_refs","text",true,""],["digest","text",true,""],["total_size","bigint",true,""],["delivery_mode","text",true,""],["deposited_at","text",true,""]],"sync_checkpoints":[["id","text",true,""],["subject","text",true,""],["credential_id","text",true,""],["version","integer",true,""],["expires_at","text",true,""],["body","text",true,""]],"system_sources":[["resource_id","text",true,""],["source_path","text",true,""],["rule_id","text",true,""],["source_kind","text",true,""],["source_version","integer",true,""],["source_digest","text",true,""],["revision_id","text",true,""]],"token_deliveries":[["credential_id","text",true,""],["subject","text",true,""],["request_id","text",true,""],["request_digest","text",true,""],["recovery_verifier","text",true,""],["recovery_expires_at","text",true,""],["claimed_at","text",false,""],["consumed_at","text",false,""]],"topic_bans":[["topic","text",true,""],["subject","text",true,""],["actor","text",true,""],["created_at","text",true,""],["expires_at","text",false,""],["reason","text",false,""],["status","text",true,""]],"topic_event_projection":[["seq","bigint",true,""],["topic","text",true,""]],"topic_memberships":[["topic","text",true,""],["subject","text",true,""],["role","text",true,""],["status","text",true,""],["joined_at","text",false,""],["invited_by","text",false,""]],"topic_settings":[["topic","text",true,""],["membership_policy","text",true,""]],"transfers":[["id","text",true,""],["subject","text",true,""],["generation","integer",true,""],["body","text",true,""],["limits","text",true,""]],"watches":[["subject","text",true,""],["resource","text",true,""]],"webhook_endpoints":[["subject","text",true,""],["url","text",true,""],["nonce","text",true,""],["ciphertext","text",true,""],["enabled","integer",true,""],["generation","integer",true,""]]},"triggers":[["arbitration_policies","arbitration_policies_append_only"],["arbitration_votes","arbitration_votes_append_only"],["audit","audit_no_update"],["money_accounts","money_account_register_subject"],["money_ledger","money_ledger_no_update"],["order_contracts","order_contracts_append_only"],["order_escrow_decisions","escrow_decision_no_update"],["order_settlements","order_settlements_append_only"],["order_transitions","order_transitions_append_only"],["store_order_events","store_order_events_append_only"]]} diff --git a/src/msg/extensions/git_publication.py b/src/msg/extensions/git_publication.py new file mode 100644 index 00000000..de5e65f1 --- /dev/null +++ b/src/msg/extensions/git_publication.py @@ -0,0 +1,291 @@ +"""Bridge Git's reference transaction to a current-authority metadata transaction. + +Pack upload happens without holding a PostgreSQL write transaction. The +reference hook reauthorizes at ref preparation. Git and PostgreSQL are not one +atomic storage engine: a lost commit acknowledgement is recorded as uncertain. +""" +from __future__ import annotations + +import asyncio +from dataclasses import replace +import hashlib +import hmac +import os +from pathlib import Path +import secrets +import shlex +import sys +import tempfile + +from msg.core.codec import b64, canonical, loads, wire +from msg.core.errors import Failure, require +from msg.extensions.repositories import (NativeGitStore, MAX_GIT_PACK_BYTES, MAX_GIT_UPLOAD_SECONDS, + require_git_repository_capacity) +from msg.plugins.common import check_access +from msg.storage.git import durable_write +from msg.workers.effects import current_principal, worker_context, effect_request + + +class ReferenceGuard: + def __init__(self, app, job): + self.app, self.job = app, job + self.queue = asyncio.Queue() + self.changed = False + self.uncertain = False + self.failures = [] + + async def authorize(self, tx): + current = await tx.job(self.job.id) + require(current.state == 'running' and current.attempts == self.job.attempts, 'job_lease_lost') + principal = await current_principal(self.app, self.job.principal, tx) + context, request = worker_context(self.app, self.job, principal), effect_request(self.app, self.job, principal) + await check_access(self.app, context, request, tx, self.job.arguments['id'], 'write') + require(tx.setting('runtime_config', {}).get('accept_writes', True), 'writes_paused') + return principal + + @staticmethod + def validate_changes(text): + require(isinstance(text, str) and len(text.encode()) <= 65536, 'git_ref_batch_too_large') + rows = [] + import re + for line in text.splitlines(): + fields = line.split(' ') + require(len(fields) == 3, 'invalid_git_change') + old, new, ref = fields + require(re.fullmatch(r'[0-9a-f]{40}', old) is not None and re.fullmatch(r'[0-9a-f]{40}', new) is not None, + 'invalid_git_change') + require(ref == 'HEAD' or re.fullmatch(r'refs/(heads|tags)/[^\s]+', ref) is not None, 'invalid_git_ref') + rows.append({'ref':ref, 'old':old, 'new':new}) + require(0 < len(rows) <= 128 and len({r['ref'] for r in rows}) == len(rows), 'invalid_git_change') + return rows + + async def run(self): + """One task owns both halves of each PostgreSQL transaction.""" + manager = tx = None + preparing = prepared = None + while True: + message, answer = await self.queue.get() + try: + state = message['state'] + if state == 'stop': + if manager is not None: + self.uncertain = True + await manager.__aexit__(Failure, Failure('external_uncertain'), None) + answer.set_result({'ok':True}) + return + changes = self.validate_changes(message['changes']) + if state == 'preparing': + require(manager is None, 'git_transaction_already_prepared') + # Git 2.54+ invokes this phase before taking ref locks. + # Preflight current authority without holding a PostgreSQL write + # transaction, then re-check inside the prepared phase. + async with self.app.metadata.transaction(write=False) as preflight: + await self.authorize(preflight) + preparing = changes + elif state == 'prepared': + require(manager is None, 'git_transaction_already_prepared') + require(preparing is None or changes == preparing, 'git_transaction_mismatch') + manager = self.app.metadata.transaction(write=True) + tx = await manager.__aenter__() + try: + await self.authorize(tx) + except BaseException: + await manager.__aexit__(*sys.exc_info()) + manager = tx = None + raise + prepared = changes + elif state in {'committed', 'aborted'}: + require(manager is not None and changes == prepared, 'git_transaction_mismatch') + active = manager + manager = None + if state == 'aborted': + await active.__aexit__(Failure, Failure('git_transaction_aborted'), None) + else: + self.changed = True + try: + resource = await tx.resource(self.job.arguments['id']) + await tx.replace(replace(resource, generation=resource.generation + 1, + modified_at=self.app.clock(), modified_by=self.job.principal.actor), resource.generation) + history = tx.setting('git_receive:' + self.job.id, []) + history.append({'time':wire(self.app.clock()), 'refs':changes}) + tx.set_setting('git_receive:' + self.job.id, history) + from msg.core.models import Event, AuditEvent, ResourceRef + from msg.core.codec import digest + from msg.plugins.common import new_id + event = Event(id=new_id('event'), type='git.refs_committed', time=self.app.clock(), + request_id=self.job.arguments['request_id'], actor=self.job.principal.actor, + subject=self.job.principal.subject, resources=(), data={'job_id':self.job.id, 'refs':changes}) + await tx.append_event(event) + await tx.append_audit(AuditEvent(event=event, + authority=tuple(ResourceRef(id=c) for c in self.job.principal.certificates), + before_digest=digest(resource), after_digest=digest(changes), + previous_digest=None, entry_digest='', result='refs_committed')) + await active.__aexit__(None, None, None) + except BaseException: + self.uncertain = True + if not tx.closed: + await active.__aexit__(*sys.exc_info()) + raise + tx = preparing = prepared = None + else: + raise Failure('invalid_git_hook_state') + answer.set_result({'ok':True}) + except BaseException as exc: + code = exc.code if isinstance(exc, Failure) else 'git_guard_failed' + self.failures.append(code) + if not answer.done(): + answer.set_result({'ok':False, 'code':code}) + + async def message(self, value): + answer = asyncio.get_running_loop().create_future() + await self.queue.put((value, answer)) + return await asyncio.shield(answer) + + +def hook_program(socket_path, secret): + """Generated from trusted code, not from a repository or user command.""" + return ('#!' + sys.executable + '\n' + + 'import json,socket,sys\n' + 'data=sys.stdin.buffer.read(65537)\n' + 'if len(data)>65536: sys.exit(1)\n' + 's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);s.settimeout(45)\n' + + 's.connect(' + repr(str(socket_path)) + ')\n' + + 's.sendall((json.dumps({"secret":' + repr(secret) + ',"state":sys.argv[1],"changes":data.decode("ascii")})+"\\n").encode())\n' + 'r=s.makefile("rb").readline(8193)\n' + 'sys.exit(0 if r and len(r)<=8192 and json.loads(r).get("ok") else 1)\n').encode() + + +async def relay_bounded_stdin(process, stream, *, limit, timeout): + """Copy stream to process stdin, rejecting once limit bytes have been seen.""" + loop=asyncio.get_running_loop() + total=0 + async def pump(): + nonlocal total + while True: + chunk=await loop.run_in_executor(None, stream.read, 65536) + if not chunk: + break + total+=len(chunk) + require(total<=limit,'request_too_large') + process.stdin.write(chunk) + await process.stdin.drain() + process.stdin.close() + await asyncio.wait_for(pump(), timeout) + return await asyncio.wait_for(process.wait(), timeout) + + +async def guarded_command(app, job, command_factory, *, stdin=None, stdout=None, stderr=None, timeout=600, + input_data=None,input_file=None,capture_output=False,output_limit=None,cache_result_key=None, + stdin_byte_limit=None,stdin_stream=None): + """Run a fixed Git command with a private reference-transaction hook. + + command_factory is installed adapter code, never a value accepted from wire. + Tests exercise this bridge with real update-ref; the SSH adapter uses receive-pack. + """ + store = NativeGitStore(app) + require(input_data is None or input_file is None,'ambiguous_git_input') + require(stdin_byte_limit is None or (input_data is None and input_file is None),'ambiguous_git_input') + app.settings.server.staging_dir.mkdir(parents=True, exist_ok=True) + # Unix socket path has a small OS limit; the random private directory is not + # an account directory and contains no application credentials. + with tempfile.TemporaryDirectory(prefix='msg-git-') as temporary: + directory = Path(temporary) + socket_path = directory/'guard.sock' + secret = secrets.token_hex(32) + guard = ReferenceGuard(app, job) + task = asyncio.create_task(guard.run()) + async def connection(reader, writer): + try: + raw = await asyncio.wait_for(reader.readline(), 45) + require(len(raw) <= 70000, 'git_hook_message_too_large') + value = loads(raw) + require(hmac.compare_digest(value.get('secret',''), secret), 'git_hook_authentication_failed') + result = await guard.message(value) + writer.write(canonical(result) + b'\n') + await writer.drain() + except Exception: + writer.write(b'{"ok":false}\n') + try:await writer.drain() + except (BrokenPipeError, ConnectionError):pass + finally: + writer.close() + await writer.wait_closed() + server = await asyncio.start_unix_server(connection, str(socket_path), limit=70001) + durable_write(directory/'reference-transaction', hook_program(socket_path, secret), mode=0o700) + env = dict(store.env) + command = ['git','--git-dir',str(store.path(job.arguments['id'])),'-c','core.fsync=all', + '-c','core.hooksPath='+str(directory),'-c','receive.fsckObjects=true', + '-c','receive.denyNonFastForwards=true',*command_factory(store)] + process = None + code = None + output = b'' + try: + process = await asyncio.create_subprocess_exec(*command, + stdin=asyncio.subprocess.PIPE if input_data is not None or input_file is not None or stdin_byte_limit is not None else stdin, + stdout=asyncio.subprocess.PIPE if capture_output else stdout, + stderr=asyncio.subprocess.DEVNULL if capture_output else stderr, + env=env,start_new_session=True) + if input_data is not None or input_file is not None: + async def exchange(): + async def feed(): + try: + if input_file is None: + process.stdin.write(input_data) + await process.stdin.drain() + else: + with open(input_file,'rb') as source: + while chunk:=source.read(65536): + process.stdin.write(chunk) + await process.stdin.drain() + except (BrokenPipeError,ConnectionResetError): + pass + finally: + process.stdin.close() + sender=asyncio.create_task(feed()) + try: + chunks=[] + size=0 + while chunk:=await process.stdout.read(65536): + size+=len(chunk) + require(output_limit is None or size<=output_limit,'response_too_large') + chunks.append(chunk) + await sender + await process.wait() + return b''.join(chunks) + finally: + if not sender.done():sender.cancel() + await asyncio.gather(sender,return_exceptions=True) + output=await asyncio.wait_for(exchange(),timeout) + code=process.returncode + else: + if stdin_byte_limit is not None: + code = await relay_bounded_stdin(process, sys.stdin.buffer if stdin_stream is None else stdin_stream, + limit=stdin_byte_limit, timeout=timeout) + else: + code = await asyncio.wait_for(process.wait(), timeout) + finally: + if process is not None and process.returncode is None: + import signal + os.killpg(process.pid, signal.SIGKILL) + await process.wait() + guard.uncertain = True + await guard.message({'state':'stop'}) + await task + server.close() + await server.wait_closed() + async with app.metadata.transaction(write=True) as tx: + current = await tx.job(job.id) + if current.state == 'running': + # Git ref transactions may have partially succeeded without + # --atomic. Audit the actual committed refs, never invent rollback. + uncertain=guard.uncertain or (guard.changed and (code != 0 or bool(guard.failures))) + state = 'uncertain' if uncertain else 'done' if code == 0 and not guard.failures else 'failed' + if state=='done' and cache_result_key is not None: + require(capture_output,'git_result_cache_requires_output') + tx.set_setting(cache_result_key,b64(output)) + await tx.save_job(replace(current, state=state, lease_until=None)) + tx.set_setting('job_status:'+job.id, {'code':'external_uncertain' if uncertain else + guard.failures[0] if guard.failures else 'ok' if state=='done' else 'git_operation_failed', + 'refs_changed':guard.changed}) + result=code if code is not None and not guard.uncertain and not guard.failures else 1 + return (result,output,guard.changed) if capture_output else result diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index 47f9f7b9..b8f5e2e0 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -12,7 +12,7 @@ from msg.core.errors import Failure,require from msg.core.models import HandlerOutput,ResourceRef from msg.core.requests import request_for -from msg.plugins.common import check_access,create_resource,revise_resource,resolve,output_for,assert_generation,new_id +from msg.plugins.common import check_access,create_resource,revise_resource,resolve,resolve_read,output_for,assert_generation,new_id from msg.plugins.schemas import obj,STRING,IDENTIFIER,REF MAX_HOSTING_ENTRIES=128 @@ -163,8 +163,9 @@ async def serve_hosted(service,request): async with service.metadata.transaction(write=False) as tx: from msg.security.quarantine import require_live_authority require_live_authority(tx) + service.runtime_generation.require_current(tx) try: - rid=await tx.resolve(site_path) + rid=await resolve_read(tx,site_path) except Failure as exc: if exc.code!='not_found':raise rid=None @@ -226,6 +227,13 @@ async def serve_hosted(service,request): ref=decode(ResourceRef,item) await check_access(service,context,packet,tx,ref.id,'read') blob=(await tx.revision(ref)).content + canonical_site=await tx.path(rid) + if canonical_site!=site_path: + # Keep the original file/revision/preview suffix. All current + # website and leaf authority has passed before exposing a path. + suffix=request.url.path[len(site_path):] + return Response(status_code=308,headers={**HOSTED_HEADERS, + 'Location':quote(canonical_site+suffix,safe='/@&')}) etag='"'+blob.digest+'"' headers={**HOSTED_HEADERS,'ETag':etag,'Accept-Ranges':'bytes'} media=blob.media_type.split(';',1)[0].strip().lower() @@ -249,6 +257,8 @@ def hosting_app(service): async def dispatch(request): try: service.require_ready() + async with service.metadata.transaction(write=False) as tx: + service.runtime_generation.require_current(tx) except Failure as exc: return hosted_error(exc.code) expected=urlsplit(service.settings.service_url) diff --git a/src/msg/extensions/repositories.py b/src/msg/extensions/repositories.py index 940a1a1b..fa74fbb5 100644 --- a/src/msg/extensions/repositories.py +++ b/src/msg/extensions/repositories.py @@ -16,8 +16,6 @@ from uuid import uuid4 from urllib.parse import urlsplit -from starlette.requests import ClientDisconnect -from starlette.responses import Response,StreamingResponse from msg.core.codec import canonical,decode,wire,unb64,b64 from msg.core.errors import Failure,require from msg.core.models import HandlerOutput,ResourceRef,EffectJob,ExecutionContext @@ -36,12 +34,10 @@ MAX_GIT_PACK_BYTES=32*1024*1024 MAX_GIT_UPLOAD_SECONDS=120 MAX_GIT_REPOSITORY_BYTES=2*1024*1024*1024 -_GIT_UPLOAD_SLOTS=asyncio.Semaphore(2) MAX_LFS_OBJECT_BYTES=256*1024*1024 # Deployment-wide, not per-account. Operators may lower this for their shared # volume; PostgreSQL records the value so workers with divergent settings fail. DEFAULT_LFS_DEPLOYMENT_BYTES=4*1024*1024*1024 -_LFS_UPLOAD_SLOTS=asyncio.Semaphore(2) _LFS_STAGED=ContextVar('msg_lfs_staged',default=None) @@ -66,48 +62,9 @@ def require_git_repository_capacity(root,incoming=MAX_GIT_PACK_BYTES): require(git_repository_bytes(root)+incoming<=MAX_GIT_REPOSITORY_BYTES,'storage_capacity_exceeded') -async def spool_git_pack(request,path): - require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding') - length=request.headers.get('content-length') - if length is not None: - require(length.isdecimal() and int(length)<=MAX_GIT_PACK_BYTES,'request_too_large') - hasher=hashlib.sha256() - size=0 - try: - with path.open('xb') as stream: - async for chunk in request.stream(): - size+=len(chunk) - require(size<=MAX_GIT_PACK_BYTES,'request_too_large') - hasher.update(chunk) - stream.write(chunk) - stream.flush() - os.fsync(stream.fileno()) - except ClientDisconnect as exc: - raise Failure('request_incomplete') from exc - return 'sha256:'+hasher.hexdigest(),size - - -async def spool_lfs_object(request,path,expected_size): - require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding') - length=request.headers.get('content-length') - if length is not None: - require(length.isdecimal() and int(length)==expected_size,'lfs_size_mismatch') - hasher=hashlib.sha256();size=0 - try: - with path.open('xb') as stream: - async for chunk in request.stream(): - size+=len(chunk) - require(size<=expected_size,'lfs_size_mismatch') - hasher.update(chunk);stream.write(chunk) - stream.flush();os.fsync(stream.fileno()) - except ClientDisconnect as exc:raise Failure('request_incomplete') from exc - require(size==expected_size,'lfs_size_mismatch') - return hasher.hexdigest(),size - - class NativeGitStore: def __init__(self,app): - self.app=app + self.blob_root=app.settings.server.blob_dir self.root=app.settings.server.repositories_dir self.env={'PATH':os.environ.get('PATH','/usr/bin:/bin'),'LANG':'C.UTF-8','HOME':'/nonexistent', 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', @@ -118,7 +75,7 @@ def path(self,id): return self.root/(id+'.git') def lfs(self,id): - return LFSObjectStore(self.path(id),self.app.settings.server.blob_dir) + return LFSObjectStore(self.path(id),self.blob_root) def lfs_capacity(self,tx): configured=os.environ.get('MSG_LFS_DEPLOYMENT_MAX_BYTES',str(DEFAULT_LFS_DEPLOYMENT_BYTES)) @@ -126,7 +83,7 @@ def lfs_capacity(self,tx): limit=int(configured) previous=tx.setting('lfs_deployment_capacity_bytes') require(previous is None or previous==limit,'lfs_deployment_capacity_mismatch') - for root,key in ((self.app.settings.server.blob_dir,'lfs_shared_store_id'), + for root,key in ((self.blob_root,'lfs_shared_store_id'), (self.root,'lfs_shared_repositories_id')): root.mkdir(parents=True,exist_ok=True) sentinel=root/'.msg-shared-store-id' @@ -141,163 +98,6 @@ def lfs_capacity(self,tx): if previous is None:tx.set_setting('lfs_deployment_capacity_bytes',limit) return limit - def _lfs_packet(self,request,operation,arguments): - from msg.transports.packet import path_packet - limits=self.app.settings.server.limits - signed=request.headers.get('x-msg-request') - authorization=request.headers.get('authorization','') - require(not (signed and authorization),'ambiguous_proof') - if signed: - packet=path_packet(signed,'j',limits.max_request_bytes) - require(packet.operation==operation and canonical(packet.arguments)==canonical(arguments), - 'representation_mismatch') - return packet - if authorization: - require(authorization.startswith('Basic '),'authentication_required') - try: - user,password=base64.b64decode(authorization[6:],validate=True).decode('ascii').split(':',1) - token=unb64(password) - except (ValueError,UnicodeDecodeError,Failure) as exc: - raise Failure('invalid_token') from exc - return request_for(operation,arguments,self.app.settings.service_url,token=(user,token), - request_id=uuid4().hex if operation=='git.lfs_publish' else None, - expires_at=self.app.clock()+timedelta(seconds=120)) - return request_for(operation,arguments,self.app.settings.service_url) - - async def _lfs_authorize(self,request,operation,arguments): - result=await self.app.executor.execute(self._lfs_packet(request,operation,arguments),entry='network') - require(result.status=='ok',result.error.code if result.error else 'permission_denied') - return result.data['resource_id'] - - async def _lfs_preflight_publish(self,request,arguments): - """Validate the exact signed publish envelope without committing it.""" - packet=self._lfs_packet(request,'git.lfs_publish',arguments) - spec=self.app.registry.operation(packet.operation,packet.contract_version) - require('network' in spec.entries,'entry_not_allowed') - self.app.registry.validate(spec.input_schema,packet.arguments) - async with self.app.metadata.transaction(write=False) as tx: - principal=await self.app.authenticator.authenticate(packet,tx,entry='network') - require(principal.method=='signature','authentication_required') - context=ExecutionContext(request_id=packet.request_id,principal=principal,entry='network', - now=self.app.clock(),deadline_monotonic=time.monotonic()+30) - rid=await resolve(tx,arguments['id']) - await check_access(self.app,context,packet,tx,rid,'write') - resource=await tx.resource(rid) - require(resource.type=='repo' and resource.state=='active','not_a_repository') - return rid - - async def http_lfs(self,request,repo_id,suffix,*,write=False): - """LFS batch is a read/planning query; only /-/ object PUT commits bytes.""" - from msg.transports.http import body_bytes,json_response - require(not request.query_params,'invalid_lfs_query') - object_match=re.fullmatch(r'objects/([0-9a-f]{64})(?:/([0-9]+))?',suffix) - if object_match: - oid,size_text=object_match.groups() - if write: - require(request.method=='PUT' and size_text is not None,'method_not_allowed') - size=int(size_text) - require(size<=MAX_LFS_OBJECT_BYTES,'request_too_large') - publish_args={'id':repo_id,'oid':oid,'size':size} - if request.headers.get('x-msg-request'): - await self._lfs_preflight_publish(request,publish_args) - else: - await self._lfs_authorize(request,'git.lfs_write_authorize',{'id':repo_id}) - self.app.settings.server.staging_dir.mkdir(parents=True,exist_ok=True) - async with _LFS_UPLOAD_SLOTS: - with tempfile.TemporaryDirectory(prefix='msg-lfs-',dir=self.app.settings.server.staging_dir) as temp: - staged=Path(temp)/'object' - try: - async with asyncio.timeout(MAX_GIT_UPLOAD_SECONDS): - digest,actual=await spool_lfs_object(request,staged,size) - except TimeoutError as exc: - raise Failure('request_incomplete') from exc - require(digest==oid,'lfs_digest_mismatch') - marker=_LFS_STAGED.set(staged) - try: - await self._lfs_authorize(request,'git.lfs_publish',publish_args) - finally:_LFS_STAGED.reset(marker) - return Response(status_code=200,headers={'Cache-Control':'no-store'}) - require(request.method in {'GET','HEAD'} and size_text is None,'method_not_allowed') - rid=await self._lfs_authorize(request,'git.lfs_read',{'id':repo_id,'oid':oid}) - path=self.lfs(rid).path(oid) - require(path.is_file(),'not_found') - size=path.stat().st_size - etag='"sha256:'+oid+'"' - begin,end,status=0,size,200 - requested=request.headers.get('range') - if requested and request.headers.get('if-range') in {None,etag}: - match=re.fullmatch(r'bytes=(\d*)-(\d*)',requested) if len(requested)<=128 else None - if match and any(match.groups()): - left,right=match.groups() - if left: - begin=int(left) - end=min(size,int(right)+1) if right else size - valid=begin0 and begin65536: sys.exit(1)\n' - 's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);s.settimeout(45)\n' + - 's.connect(' + repr(str(socket_path)) + ')\n' + - 's.sendall((json.dumps({"secret":' + repr(secret) + ',"state":sys.argv[1],"changes":data.decode("ascii")})+"\\n").encode())\n' - 'r=s.makefile("rb").readline(8193)\n' - 'sys.exit(0 if r and len(r)<=8192 and json.loads(r).get("ok") else 1)\n').encode() - - -async def relay_bounded_stdin(process, stream, *, limit, timeout): - """Copy stream to process stdin, rejecting once limit bytes have been seen.""" - loop=asyncio.get_running_loop() - total=0 - async def pump(): - nonlocal total - while True: - chunk=await loop.run_in_executor(None, stream.read, 65536) - if not chunk: - break - total+=len(chunk) - require(total<=limit,'request_too_large') - process.stdin.write(chunk) - await process.stdin.drain() - process.stdin.close() - await asyncio.wait_for(pump(), timeout) - return await asyncio.wait_for(process.wait(), timeout) - - -async def guarded_command(app, job, command_factory, *, stdin=None, stdout=None, stderr=None, timeout=600, - input_data=None,input_file=None,capture_output=False,output_limit=None,cache_result_key=None, - stdin_byte_limit=None,stdin_stream=None): - """Run a fixed Git command with a private reference-transaction hook. - - command_factory is installed adapter code, never a value accepted from wire. - Tests exercise this bridge with real update-ref; the SSH adapter uses receive-pack. - """ - store = NativeGitStore(app) - require(input_data is None or input_file is None,'ambiguous_git_input') - require(stdin_byte_limit is None or (input_data is None and input_file is None),'ambiguous_git_input') - app.settings.server.staging_dir.mkdir(parents=True, exist_ok=True) - # Unix socket path has a small OS limit; the random private directory is not - # an account directory and contains no application credentials. - with tempfile.TemporaryDirectory(prefix='msg-git-') as temporary: - directory = Path(temporary) - socket_path = directory/'guard.sock' - secret = secrets.token_hex(32) - guard = ReferenceGuard(app, job) - task = asyncio.create_task(guard.run()) - async def connection(reader, writer): - try: - raw = await asyncio.wait_for(reader.readline(), 45) - require(len(raw) <= 70000, 'git_hook_message_too_large') - value = loads(raw) - require(hmac.compare_digest(value.get('secret',''), secret), 'git_hook_authentication_failed') - result = await guard.message(value) - writer.write(canonical(result) + b'\n') - await writer.drain() - except Exception: - writer.write(b'{"ok":false}\n') - try:await writer.drain() - except (BrokenPipeError, ConnectionError):pass - finally: - writer.close() - await writer.wait_closed() - server = await asyncio.start_unix_server(connection, str(socket_path), limit=70001) - durable_write(directory/'reference-transaction', hook_program(socket_path, secret), mode=0o700) - env = dict(store.env) - command = ['git','--git-dir',str(store.path(job.arguments['id'])),'-c','core.fsync=all', - '-c','core.hooksPath='+str(directory),'-c','receive.fsckObjects=true', - '-c','receive.denyNonFastForwards=true',*command_factory(store)] - process = None - code = None - output = b'' - try: - process = await asyncio.create_subprocess_exec(*command, - stdin=asyncio.subprocess.PIPE if input_data is not None or input_file is not None or stdin_byte_limit is not None else stdin, - stdout=asyncio.subprocess.PIPE if capture_output else stdout, - stderr=asyncio.subprocess.DEVNULL if capture_output else stderr, - env=env,start_new_session=True) - if input_data is not None or input_file is not None: - async def exchange(): - async def feed(): - try: - if input_file is None: - process.stdin.write(input_data) - await process.stdin.drain() - else: - with open(input_file,'rb') as source: - while chunk:=source.read(65536): - process.stdin.write(chunk) - await process.stdin.drain() - except (BrokenPipeError,ConnectionResetError): - pass - finally: - process.stdin.close() - sender=asyncio.create_task(feed()) - try: - chunks=[] - size=0 - while chunk:=await process.stdout.read(65536): - size+=len(chunk) - require(output_limit is None or size<=output_limit,'response_too_large') - chunks.append(chunk) - await sender - await process.wait() - return b''.join(chunks) - finally: - if not sender.done():sender.cancel() - await asyncio.gather(sender,return_exceptions=True) - output=await asyncio.wait_for(exchange(),timeout) - code=process.returncode - else: - if stdin_byte_limit is not None: - code = await relay_bounded_stdin(process, sys.stdin.buffer if stdin_stream is None else stdin_stream, - limit=stdin_byte_limit, timeout=timeout) - else: - code = await asyncio.wait_for(process.wait(), timeout) - finally: - if process is not None and process.returncode is None: - import signal - os.killpg(process.pid, signal.SIGKILL) - await process.wait() - guard.uncertain = True - await guard.message({'state':'stop'}) - await task - server.close() - await server.wait_closed() - async with app.metadata.transaction(write=True) as tx: - current = await tx.job(job.id) - if current.state == 'running': - # Git ref transactions may have partially succeeded without - # --atomic. Audit the actual committed refs, never invent rollback. - uncertain=guard.uncertain or (guard.changed and (code != 0 or bool(guard.failures))) - state = 'uncertain' if uncertain else 'done' if code == 0 and not guard.failures else 'failed' - if state=='done' and cache_result_key is not None: - require(capture_output,'git_result_cache_requires_output') - tx.set_setting(cache_result_key,b64(output)) - await tx.save_job(replace(current, state=state, lease_until=None)) - tx.set_setting('job_status:'+job.id, {'code':'external_uncertain' if uncertain else - guard.failures[0] if guard.failures else 'ok' if state=='done' else 'git_operation_failed', - 'refs_changed':guard.changed}) - result=code if code is not None and not guard.uncertain and not guard.failures else 1 - return (result,output,guard.changed) if capture_output else result +from msg.core.errors import require +from msg.extensions.repositories import ( + NativeGitStore, MAX_GIT_PACK_BYTES, MAX_GIT_UPLOAD_SECONDS, + require_git_repository_capacity, +) +# Historical imports are the exact shared objects, never alternate wrappers. +from msg.extensions.git_publication import ( + ReferenceGuard, hook_program, relay_bounded_stdin, guarded_command, +) async def receive_pack(app, job_id, *, stdin_stream=None): diff --git a/src/msg/extensions/tools.py b/src/msg/extensions/tools.py index 56c27715..06db2f4e 100644 --- a/src/msg/extensions/tools.py +++ b/src/msg/extensions/tools.py @@ -19,6 +19,7 @@ 'headers':{'type':'object','maxProperties':30,'additionalProperties':{'type':'string','maxLength':4096}}, 'body':BYTES,'body_ref':REF},('url',)) TOOL_OUTPUT={'type':'object'} +MAX_TOOL_CONCURRENCY=32 def descriptor(name): @@ -49,15 +50,32 @@ async def resolve_tool_for_invoke(tx,value): return await resolve(tx,value) +def declared_concurrency(value): + # Published revisions predate this field; absence is the tightest limit. + limit=value.get('concurrency',1) + require(type(limit) is int and 1<=limit<=MAX_TOOL_CONCURRENCY,'invalid_tool_concurrency') + return limit + + +async def tool_concurrency(app,tx,ref): + rev=await tx.revision(ref) + return declared_concurrency(loads(await app.contents.read_bytes(rev.content,limit=65536))) + + async def read_tool(app,tx,rid,revision=None): resource=await tx.resource(rid) require(resource.type=='tool' and resource.parent==TOOLS_SPACE,'not_a_tool') rev=await tx.revision(ResourceRef(id=rid,revision=revision)) value=loads(await app.contents.read_bytes(rev.content,limit=65536)) - require(value['executor_key'] in {'dns','curl'} and value['tool_id']==rid,'untrusted_tool_executor') - return ToolSpec(resource=ResourceRef(id=rid,revision=rev.id),operation='tool.run', - input_schema=decode(ResourceRef,value['input_schema']),output_schema=decode(ResourceRef,value['output_schema']), - executor_key=value['executor_key'],network=decode(NetworkPolicy,value['network'])) + declared_concurrency(value) + registered=app.registry.tool(rid,value['version']) + require(value['tool_id']==rid and value['executor_key']==registered.executor_key, + 'untrusted_tool_executor') + require(decode(ResourceRef,value['input_schema'])==registered.input_schema and + decode(ResourceRef,value['output_schema'])==registered.output_schema, + 'tool_schema_mismatch') + return replace(registered,resource=ResourceRef(id=rid,revision=rev.id), + network=decode(NetworkPolicy,value['network'])) async def tool_policies(app,principal,tool,args,tx): @@ -109,6 +127,11 @@ def register(app,op): for name,schema in (('dns',DNS_INPUT),('curl',CURL_INPUT)): app.registry.add_schema(ResourceRef(id='schema:tool.'+name+':input'),schema) app.registry.add_schema(ResourceRef(id='schema:tool.'+name+':output'),TOOL_OUTPUT) + value=descriptor(name) + app.registry.add_tool(ToolSpec(resource=ResourceRef(id=value['tool_id']), + operation=value['operation'],input_schema=decode(ResourceRef,value['input_schema']), + output_schema=decode(ResourceRef,value['output_schema']),executor_key=value['executor_key'], + network=decode(NetworkPolicy,value['network'])),version=value['version']) @op('tool.run',obj({'id':IDENTIFIER,'revision':IDENTIFIER,'arguments':{'type':'object'}},('id','arguments')),effect='external') async def invoke(ctx,request,tx): @@ -126,7 +149,7 @@ async def invoke(ctx,request,tx): require(not ('body' in args and 'body_ref' in args),'ambiguous_request_body') eid=event_id(request,ctx.principal.subject) job=EffectJob(id=new_id('job'),event_id=eid,kind='tool',dedupe_key='tool:'+eid,principal=ctx.principal, - operation=request.operation,arguments={'tool':wire(tool.resource),'input':wire(args), + operation=request.operation,arguments={'contract_version':request.contract_version,'tool':wire(tool.resource),'input':wire(args), 'policies':wire(policies),'request_id':request.request_id},state='pending',attempts=0, next_attempt_at=ctx.now,lease_until=None) await tx.enqueue(job) diff --git a/src/msg/hosting_runtime.py b/src/msg/hosting_runtime.py index bcf9ef20..0a279b48 100644 --- a/src/msg/hosting_runtime.py +++ b/src/msg/hosting_runtime.py @@ -17,7 +17,7 @@ from msg.security.authorization import AuthorizationService from msg.security.capabilities import primary_ceiling, temporary_ceiling from msg.security.certificates import CertificateValidator -from msg.security.quarantine import require_live_authority +from msg.security.quarantine import RuntimeGeneration, require_live_authority from msg.storage.read_only import GitContentReader, ReadOnlyPostgresStore @@ -43,10 +43,21 @@ def add(self, manifest): async def require_prepared_sources(tx): """The installer owns publication; this role only compares its release inputs.""" - from msg.bootstrap import RULE_SPECS, SOURCE_PATH_OVERRIDES, SOURCE_HEADER, system_source_root + from msg.bootstrap import (RULE_SPECS, SOURCE_PATH_OVERRIDES, SOURCE_HEADER, + SOURCE_RETIREMENTS, system_source_root) root = system_source_root() expected = set() for rule_id, rid, _, _, default_path, _ in RULE_SPECS: + pin = SOURCE_RETIREMENTS.get(rule_id) + if pin is not None: + # A retired release has no source file; compare its pinned history. + row = tx.one('''SELECT source_path,rule_id,source_kind,source_version,source_digest + FROM system_sources WHERE resource_id=?''', (rid,)) + require(row == ('docs/system/' + pin['source_path'], rule_id, 'release_retired', + pin['version'], pin['digest']) and + (await tx.resource(rid)).state == 'archived', 'hosting_installation_stale') + expected.add(rid) + continue path = SOURCE_PATH_OVERRIDES.get(rule_id, default_path) raw = root.joinpath(path).read_bytes() header = SOURCE_HEADER.match(raw.decode('utf-8')) @@ -80,6 +91,7 @@ def __init__(self, settings, *, clock=None): install_registry(SimpleNamespace(registry=self.registry), settings.server.plugins) self.metadata = None self.contents = None + self.runtime_generation = None self._loaded = False self._quarantined = False self._marker = settings.hosting_recovery_marker or settings.config_dir / 'recovery-drill.json' @@ -101,6 +113,17 @@ async def load(self): require(not self._loaded, 'hosting_already_loaded') require(self.settings.public_web_origin is not None, 'hosting_origin_not_configured') self.require_ready(loading=True) + self.metadata = ReadOnlyPostgresStore(self.settings.server.postgres_dsn) + try: + async with self.metadata.transaction(write=False) as tx: + require_live_authority(tx) + if self.runtime_generation is None: + self.runtime_generation = RuntimeGeneration.capture(tx) + else: + self.runtime_generation.require_current(tx) + except (psycopg.errors.UndefinedTable, psycopg.errors.UndefinedColumn, + psycopg.errors.InsufficientPrivilege) as exc: + raise Failure('hosting_installation_stale') from exc require(self.settings.trust_file.is_file(), 'root_not_initialized') trust = loads(self.settings.trust_file.read_bytes()) require(type(trust) is dict and set(trust) == {'version', 'public_key', 'certificate'} @@ -110,12 +133,12 @@ async def load(self): require(len(public) == 32, 'invalid_trust_anchor') self.contents = GitContentReader(self.settings.server.content_dir, binary_dir=self.settings.server.blob_dir) - self.metadata = ReadOnlyPostgresStore(self.settings.server.postgres_dsn) self.certificates = CertificateValidator(self.registry, root, public, self.settings.service_url, self.clock) try: async with self.metadata.transaction(write=False) as tx: require_live_authority(tx) + self.runtime_generation.require_current(tx) require(tx.setting('active_root_certificate', root.resource_id) == root.resource_id, 'service_restart_required') await self.certificates.validate(root.resource_id, tx) @@ -128,6 +151,8 @@ async def load(self): self.settings.service_url, self.clock, partial(primary_ceiling, self.registry), partial(temporary_ceiling, self.registry)) self.authorizer = AuthorizationService(self.registry, self.certificates) + self.authenticator.runtime_generation=self.runtime_generation + self.authorizer.runtime_generation=self.runtime_generation self.require_ready(loading=True) self._loaded = True return self diff --git a/src/msg/market/arbitration.py b/src/msg/market/arbitration.py index 03e9d3a3..b339d3a3 100644 --- a/src/msg/market/arbitration.py +++ b/src/msg/market/arbitration.py @@ -15,7 +15,7 @@ from msg.market.rationale import PINNED_REF from msg.market.rationale import verified as verify_rationale from msg.plugins.common import check_access, new_id, no_requirements -from msg.plugins.orders import _row, _subject +from msg.market.order_records import read_order as _row, require_signed_subject as _subject from msg.plugins.schemas import IDENTIFIER, REF, SIGNATURE, obj from msg.security.crypto import verify @@ -198,26 +198,29 @@ async def objective_fault(app, tx, order, now): async def execute(app, tx, case, decision_id, now, actor, request_id): - from msg.market.escrow import settle - order = _row(tx, case['order_id'], case['buyer']) - row = tx.one('SELECT body FROM arbitration_decisions WHERE id=? AND case_id=? AND round=?', - (decision_id,case['id'],case['round'])) - require(row is not None, 'decision_not_current') - decision = loads(row[0]) - existing = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],)) - if existing: - settlement = loads(existing[0]) - require(settlement['decision_id'] == decision_id, 'order_already_settled') - return settlement['receipts'] - receipts = await settle(app, tx, order, now=now, actor=actor, request_id=request_id, - reason='arbitration_decision', refund_minor=decision['refund_minor'], decision=decision) - notice(tx, case, 'executed', now) - return receipts + from msg.market.order_resources import internal_mutation + async with internal_mutation(app, tx, case['order_id'], now=now, actor=actor, request_id=request_id): + from msg.market.escrow import settle + order = _row(tx, case['order_id'], case['buyer']) + row = tx.one('SELECT body FROM arbitration_decisions WHERE id=? AND case_id=? AND round=?', + (decision_id,case['id'],case['round'])) + require(row is not None, 'decision_not_current') + decision = loads(row[0]) + existing = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],)) + if existing: + settlement = loads(existing[0]) + require(settlement['decision_id'] == decision_id, 'order_already_settled') + return settlement['receipts'] + receipts = await settle(app, tx, order, now=now, actor=actor, request_id=request_id, + reason='arbitration_decision', refund_minor=decision['refund_minor'], decision=decision) + notice(tx, case, 'executed', now) + return receipts async def resolve_cases(app, *, limit=100): changed, now = [], app.clock() async with app.metadata.transaction(write=True) as tx: + app.runtime_generation.require_current(tx) for (case_id,) in tx.rows('''SELECT id FROM arbitration_cases WHERE state IN ('open','decided') AND deadline<=? ORDER BY deadline,id LIMIT ?''', (wire(now),limit)): diff --git a/src/msg/market/catalog.py b/src/msg/market/catalog.py new file mode 100644 index 00000000..78fa2cb4 --- /dev/null +++ b/src/msg/market/catalog.py @@ -0,0 +1,39 @@ +"""Authorized catalog reads shared by published checkout versions. + +Version-specific schemas, creation and updates stay at catalog entrypoints. +""" +from __future__ import annotations +from msg.core.codec import loads +from msg.core.errors import Failure, require +from msg.core.models import ResourceRef +from msg.plugins.common import check_access, resolve +from msg.market.order_records import require_signed_subject + + +async def read_listing(app, ctx, request, tx, value, *, seller=False): + try: + rid = await resolve(tx, value) + resource = await tx.resource(rid) + require(resource.type == 'listing' and resource.parent == 't_store', 'listing_not_found') + await check_access(app, ctx, request, tx, rid, 'read') + if seller: + require(resource.owner == require_signed_subject(ctx), 'listing_not_found') + return resource + except Failure as exc: + if exc.code in {'not_found', 'permission_denied', 'credential_ceiling', + 'certificate_gate', 'ancestor_inactive'}: + raise Failure('listing_not_found') from None + raise + + +async def read_listing_body(app, tx, resource, revision=None): + rev = await tx.revision(ResourceRef(id=resource.id, revision=revision)) + body = loads(await app.contents.read_bytes(rev.content)) + # Listings deposited before the sale/bounty split were all sale listings. + return {'mode': 'sale', **body}, rev + + +async def read_package_record(tx, package_id): + return tx.one('''SELECT id,listing_id,listing_revision,seller,revision,kind,manifest, + payload_refs,digest,total_size,delivery_mode,deposited_at FROM store_packages WHERE id=?''', + (package_id,)) diff --git a/src/msg/market/compatibility.py b/src/msg/market/compatibility.py new file mode 100644 index 00000000..273794cf --- /dev/null +++ b/src/msg/market/compatibility.py @@ -0,0 +1,97 @@ +"""Versioned read-only Listing/Order adapters for legacy entitlement records. + +No synthetic Resource, Delivery, acceptance, receipt or second escrow is created. +Mutation handlers must continue to load their own authoritative record type. +""" +from msg.constants import ROOT_SUBJECT +from msg.core.codec import loads +from msg.core.errors import require +from msg.market.ledger import CURRENCY_ID + +LEGACY_POLICY = 'legacy-entitlement-v1' + + +def offer_listing(offer): + return { + 'listing_id': offer['offer_id'], 'listing_revision': offer['price_revision'], + 'source': 'server_offer', 'mode': 'sale', 'seller': ROOT_SUBJECT, + 'item_kind': 'entitlement', 'state': 'active', + 'price_minor': offer['price_minor'], 'currency_id': CURRENCY_ID, + 'unit': offer['unit'], 'min_quantity': offer['min_quantity'], + 'max_quantity': offer['max_quantity'], + 'resource_kind': offer['resource_kind'], + 'entitlement_kind': offer['entitlement_kind'], + 'duration_seconds': offer['duration_seconds'], + 'escrow_policy': LEGACY_POLICY, + 'purchase_operation': 'money.redeem', 'purchase_versions': [1, 2], + 'offer_snapshot': dict(offer), + } + + +def purchase_order(tx, purchase): + """Project actual ledger facts; reject incomplete/unknown legacy records.""" + p, quote = purchase, purchase['offer_snapshot'] + require(p['state'] in {'pending', 'settled', 'refunded'}, 'legacy_purchase_invalid') + require(isinstance(quote, dict) and + {'currency_id', 'price_minor', 'offer_id', 'price_revision', 'provider_version'} <= quote.keys(), + 'legacy_purchase_invalid') + require(quote['currency_id'] == CURRENCY_ID and quote['provider_version'] == 1 and + type(quote['price_minor']) is int and quote['price_minor'] > 0 and + p['quantity'] * quote['price_minor'] == p['total_minor'], + 'legacy_purchase_invalid') + account = tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?', + (p['escrow_account'],)) + require(account == ('purchase_escrow', None, p['id']), 'legacy_purchase_invalid') + funding = tx.one('''SELECT debit_account,credit_account,amount_minor,committed_at + FROM money_ledger WHERE id=?''', (p['funding_transaction_id'],)) + require(funding is not None and funding[:3] == + (p['subject_id'], p['escrow_account'], p['total_minor']), + 'legacy_purchase_invalid') + final_at = None + refs = [p['funding_transaction_id']] + if p['state'] == 'pending': + require(p['final_transaction_id'] is None and p['entitlement_id'] is None, + 'legacy_purchase_invalid') + else: + final = tx.one('''SELECT debit_account,credit_account,amount_minor,committed_at + FROM money_ledger WHERE id=?''', (p['final_transaction_id'],)) + recipient = ROOT_SUBJECT if p['state'] == 'settled' else p['subject_id'] + require(final is not None and final[:3] == + (p['escrow_account'], recipient, p['total_minor']), 'legacy_purchase_invalid') + if p['state'] == 'settled': + entitlement = tx.one('''SELECT subject_id,purchase_request_id,redeem_transaction_id + FROM resource_entitlements WHERE id=?''', (p['entitlement_id'],)) + require(entitlement == (p['subject_id'], p['request_id'], p['final_transaction_id']), + 'legacy_purchase_invalid') + else: + require(p['entitlement_id'] is None, 'legacy_purchase_invalid') + final_at = final[3] + refs.append(p['final_transaction_id']) + return { + 'id': p['id'], 'source': 'legacy_purchase', 'buyer': p['subject_id'], + 'seller': ROOT_SUBJECT, 'listing_id': quote['offer_id'], + 'listing_revision': quote['price_revision'], 'listing_source': 'server_offer', + 'quantity': p['quantity'], 'unit_price_minor': quote['price_minor'], + 'total_price_minor': p['total_minor'], 'currency_id': quote['currency_id'], + 'escrow_account_id': p['escrow_account'], 'escrow_policy': LEGACY_POLICY, + 'state': 'funded' if p['state'] == 'pending' else p['state'], + 'legacy_state': p['state'], 'created_at': p['created_at'], + 'funded_at': funding[3], 'settled_at': final_at if p['state'] == 'settled' else None, + 'refunded_at': final_at if p['state'] == 'refunded' else None, + 'accepted_at': None, 'delivery_id': None, 'delivered_at': None, + 'entitlement_id': p['entitlement_id'], 'receipt_refs': refs, + 'offer_snapshot': dict(quote), + } + + +def read_purchase(tx, purchase_id, subject): + row = tx.one('''SELECT id,subject_id,request_id,offer_snapshot,quantity,total_minor, + escrow_account,state,created_at,expires_at,funding_transaction_id, + final_transaction_id,entitlement_id,reason FROM money_purchases WHERE id=? AND subject_id=?''', + (purchase_id,subject)) + require(row is not None, 'purchase_not_found') + result = dict(zip(('id','subject_id','request_id','offer_snapshot','quantity','total_minor', + 'escrow_account','state','created_at','expires_at','funding_transaction_id', + 'final_transaction_id','entitlement_id','reason'),row)) + result['offer_snapshot'] = loads(result['offer_snapshot']) + return result diff --git a/src/msg/market/delivery.py b/src/msg/market/delivery.py index 940468c9..22b0930b 100644 --- a/src/msg/market/delivery.py +++ b/src/msg/market/delivery.py @@ -11,10 +11,10 @@ from msg.market.policy import contract from msg.market.targets import enqueue_notification, validate_target from msg.plugins.common import check_access, new_id -from msg.plugins.delivery import _buyer_order, _delivery -from msg.plugins.orders import _row, _subject, _viewer +from msg.market.managed_delivery import read_buyer_order as _buyer_order, read_delivery as _delivery +from msg.market.order_records import read_order as _row, require_signed_subject as _subject, require_order_viewer as _viewer from msg.plugins.schemas import IDENTIFIER, REF, obj -from msg.plugins.store import _package_row +from msg.market.catalog import read_package_record as _package_row INLINE = 65536 PART = 65536 @@ -110,7 +110,7 @@ async def prepare(app, tx, ctx, request, order, kind, manifest, refs, *, envelop async def automatic(app, tx, ctx, request, order): - """An authenticated payment authorizes the pinned managed-instant policy. + """Prepare automatically; release only under the pinned settlement policy. Only verified objective storage faults refund. An arbitrary exception rolls back the executor transaction, rather than turning software bugs into payouts. @@ -124,6 +124,8 @@ async def automatic(app, tx, ctx, request, order): request_id=request.request_id, reason=exc.code, refund_minor=order['total_price_minor']) return await prepare(app, tx, ctx, request, order, kind, manifest, refs) + if contract(tx, order['id'])['version'] == 4: + return await transition(tx, order, 'accepted', now=ctx.now, actor=order['buyer'], request_id=request.request_id, reason='managed_instant_verified') await settle(app, tx, order, now=ctx.now, actor=order['buyer'], diff --git a/src/msg/market/delivery_notifications.py b/src/msg/market/delivery_notifications.py index 9a2b4f0c..5b2d0726 100644 --- a/src/msg/market/delivery_notifications.py +++ b/src/msg/market/delivery_notifications.py @@ -85,8 +85,8 @@ async def queue_notification(app, ctx, request, tx, order, *, enabled): async def project_notification(app, tx, job, principal): """Reconstruct the allowlisted message only after every live owner check.""" - from msg.plugins.orders import _row - from msg.plugins.delivery import _delivery, _verified_delivery + from msg.market.order_records import read_order as _row + from msg.market.managed_delivery import read_delivery as _delivery, verify_managed_delivery as _verified_delivery require(mail_enabled(app), 'mail_disabled') require((job.operation, job.arguments.get('contract_version')) in { ('orders.buy', 2), ('delivery.notify', 1)}, 'invalid_delivery_notification') diff --git a/src/msg/market/email.py b/src/msg/market/email.py index b1000832..d9a22010 100644 --- a/src/msg/market/email.py +++ b/src/msg/market/email.py @@ -15,7 +15,7 @@ from msg.core.models import EffectJob, HandlerOutput from msg.market.targets import enqueue_notification, mail_enabled, save_target, validate_target from msg.plugins.common import new_id -from msg.plugins.orders import _row, _subject +from msg.market.order_records import read_order as _row, require_signed_subject as _subject from msg.plugins.schemas import IDENTIFIER, obj @@ -33,7 +33,7 @@ async def verification_message(app, tx, ctx, request, order): **chosen, 'challenge_id':challenge_id, 'state':'pending'}}) await tx.enqueue(EffectJob(id=new_id('job'),event_id=challenge_id,kind='market_email_verify', dedupe_key='market-verify:'+challenge_id,principal=ctx.principal, - operation=request.operation,arguments={'challenge_id':challenge_id,'token':token}, + operation=request.operation,arguments={'contract_version':request.contract_version,'challenge_id':challenge_id,'token':token}, state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None)) diff --git a/src/msg/market/entitlement_orders.py b/src/msg/market/entitlement_orders.py new file mode 100644 index 00000000..0f87951a --- /dev/null +++ b/src/msg/market/entitlement_orders.py @@ -0,0 +1,64 @@ +"""New explicit synchronous redemption contract using the existing Order writer.""" +from msg.constants import ROOT_SUBJECT +from msg.core.codec import b64, canonical, digest, wire +from msg.core.errors import require +from msg.market.escrow import settle +from msg.market.order_records import new_order_id, read_order +from msg.market.orders import fund, view +from msg.market.policy import ENTITLEMENT_POLICY as POLICY +from msg.market.targets import target_for + + + +async def redeem(app, tx, ctx, request, offer, fulfill): + """No Purchase row or Delivery; grant and normal Order settlement commit together.""" + buyer, args = ctx.principal.subject, request.arguments + resource = await tx.resource(offer['offer_id']) + require(resource.owner == ROOT_SUBJECT and resource.revision == args['listing_revision'], + 'offer_listing_changed') + require(args['offer_snapshot_digest'] == digest(offer), 'offer_snapshot_changed') + total = offer['price_minor'] * args['quantity'] + require(total == args['total_price_minor'], 'price_changed') + from msg.market.offer_resources import authoritative_offer, listing_body + source = await authoritative_offer(app, tx, offer['offer_id']) + require(source is not None, 'offer_requires_import') + listing = listing_body(source) + target = await target_for(app, tx, buyer) + order_id, now = new_order_id(), wire(ctx.now) + from msg.market.order_resources import begin_new + begin_new(tx, order_id) + escrow = 'esc_' + order_id[4:] + tx.execute("INSERT INTO ledger_accounts(id,kind,subject_id,source_id) VALUES (?,'order_escrow',NULL,?)", + (escrow, order_id), write=True) + tx.execute('''INSERT INTO store_orders + (id,buyer,seller,listing_id,listing_revision,package_id,package_revision,package_digest, + quantity,unit_price_minor,total_price_minor,currency_id,escrow_subject,escrow_policy, + dispute_policy,terms_digest,delivery_target,payment_intent_digest,payment_transaction_id, + state,created_at,funded_at,receipt_refs) + VALUES (?,?,?,?,?,NULL,NULL,NULL,?,?,?,?,?,?,?,?,?,?,NULL,'created',?,NULL,'[]')''', + (order_id, buyer, ROOT_SUBJECT, resource.id, resource.revision, args['quantity'], + offer['price_minor'], total, 'primary', escrow, listing['escrow_policy'], + listing['dispute_policy'], digest(listing['terms']), canonical(target).decode(), + request.payload_digest, now), write=True) + credential = await tx.credential(ctx.principal.credential_id) + locked = {'order_id': order_id, 'version': 5, 'resource_model': 1, 'buyer': buyer, 'seller': ROOT_SUBJECT, + 'listing': listing, 'listing_id': resource.id, 'listing_revision': resource.revision, + 'escrow_subject': escrow, 'package_id': None, 'package_revision': None, + 'package_digest': None, 'quantity': args['quantity'], 'total_price_minor': total, + 'terms_digest': digest(listing['terms']), + 'policy': {'policy': POLICY, 'policy_digest': digest(POLICY)}, + 'settlement_policy': {'id': 'deterministic-entitlement-v1', 'version': 1}, + 'offer_snapshot': offer, 'redemption_request_id': request.request_id, + 'redemption_request': wire(request), 'redemption_signer': b64(credential.verifier), + 'recipient_key': None, 'created_at': now} + tx.execute('INSERT INTO order_contracts(order_id,body,digest) VALUES (?,?,?)', + (order_id, canonical(locked).decode(), digest(locked)), write=True) + order = read_order(tx, order_id, buyer) + funding = await fund(app, tx, ctx, request, order) + purchase_input = {'subject_id': buyer, 'request_id': request.request_id, + 'quantity': args['quantity'], 'offer_snapshot': offer} + receipts = await settle(app, tx, order, now=ctx.now, actor=buyer, + request_id=request.request_id, reason='deterministic_entitlement', + fulfill=lambda tid: fulfill(app, tx, purchase_input, ctx.now, tid)) + return {'order': view(tx, read_order(tx, order_id, buyer), buyer), + 'funding': funding, 'settlement': receipts[0]} diff --git a/src/msg/market/escrow.py b/src/msg/market/escrow.py index 92e0bb59..4ff0f733 100644 --- a/src/msg/market/escrow.py +++ b/src/msg/market/escrow.py @@ -9,12 +9,13 @@ from msg.core.requests import signing_bytes from msg.market.policy import contract, delivery_snapshot from msg.plugins.common import new_id -from msg.plugins.money import CURRENCY_ID, _balance, _post_transfer +from msg.market.ledger import CURRENCY_ID, balance as _balance +# Preserve the failure-injection seam while using the shared protected release. +from msg.market.ledger import post_escrow_release as _post_transfer +from msg.market.ledger import _ESCROW_WRITE # Compatibility identity; only ledger uses it. from msg.security.crypto import verify -# Not a credential, account or public capability. Only trusted in-process market -# code can reach this token; ordinary signed money.transfer never receives it. -_ESCROW_WRITE = object() + TRANSITIONS = { 'created': {'funded', 'cancelled'}, 'funded': {'delivered', 'refunded', 'disputed'}, @@ -34,18 +35,24 @@ def _post_escrow_transfer(tx, *, order, recipient, amount, actor, request_id, no Legacy signed decisions and versioned arbitration keep their distinct journals, signatures and request IDs, but share this account boundary. """ - account = tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?', - (order['escrow_subject'],)) - require(account == ('order_escrow', None, order['id']), 'escrow_account_mismatch') - require(recipient in {order['buyer'], order['seller']}, 'escrow_recipient_mismatch') - return _post_transfer(tx, sender=order['escrow_subject'], recipient=recipient, - amount=amount, actor=actor, request_id=request_id, now=now, - receipt_signer=receipt_signer, reference=reference, kind=kind, - escrow_authority=_ESCROW_WRITE) + return _post_transfer(tx, escrow_account=order['escrow_subject'], + source_id=order['id'], account_kind='order_escrow', buyer=order['buyer'], + seller=order['seller'], recipient=recipient, amount=amount, actor=actor, + request_id=request_id, now=now, receipt_signer=receipt_signer, + reference=reference, kind=kind) async def transition(tx, order, state, *, now, actor, request_id, reason): - require(state in TRANSITIONS.get(order['state'], ()), 'order_transition_invalid') + from msg.market.order_resources import _active + raw = tx.one('SELECT body FROM order_contracts WHERE order_id=?', (order['id'],)) + if raw and loads(raw[0]).get('resource_model') == 1: + require(order['id'] in _active(tx), 'order_resource_write_forbidden') + if order['state'] == 'funded' and state == 'settled': + locked = contract(tx, order['id']) + require(locked['version'] == 5 and reason == 'deterministic_entitlement', + 'order_transition_invalid') + else: + require(state in TRANSITIONS.get(order['state'], ()), 'order_transition_invalid') changed = tx.execute('UPDATE store_orders SET state=? WHERE id=? AND state=?', (state, order['id'], order['state']), write=True) require(changed.rowcount == 1, 'order_transition_conflict') @@ -62,13 +69,15 @@ async def transition(tx, order, state, *, now, actor, request_id, reason): async def settle(app, tx, order, *, now, actor, request_id, reason, - refund_minor=0, decision=None): + refund_minor=0, decision=None, fulfill=None): """Validate authority *before* appending either leg of a balanced settlement. Caller owns the transaction. Any failure rolls back both legs, the decision consumption, transitions, receipts, events and request result together. """ - contract(tx, order['id']) + locked = contract(tx, order['id']) + require(not (locked['version'] == 4 and reason == 'managed_instant_verified'), + 'escrow_release_forbidden') require(tx.one('SELECT 1 FROM order_escrow_decisions WHERE order_id=?', (order['id'],)) is None, 'order_already_settled') total = order['total_price_minor'] @@ -81,7 +90,11 @@ async def settle(app, tx, order, *, now, actor, request_id, reason, body['decision_id'] == (decision['id'] if decision else None), 'order_already_settled') return body['receipts'] - if decision is not None: + if locked['version'] == 5: + require(decision is None and refund_minor == 0 and order['state'] == 'funded' and + actor == order['buyer'] and reason == 'deterministic_entitlement' and callable(fulfill), + 'escrow_release_forbidden') + elif decision is not None: from msg.market.arbitration import validate_decision await validate_decision(app, tx, order, decision, now) require(refund_minor == decision['refund_minor'], 'decision_amount_mismatch') @@ -89,6 +102,8 @@ async def settle(app, tx, order, *, now, actor, request_id, reason, require(reason in OBJECTIVE_REFUNDS and order['state'] in {'funded', 'delivered'}, 'escrow_release_forbidden') else: + if locked['version'] == 4: + require(reason == 'buyer_acceptance' and actor == order['buyer'], 'escrow_release_forbidden') require(refund_minor == 0 and order['state'] == 'accepted' and reason in {'buyer_acceptance', 'managed_instant_verified'}, 'escrow_release_forbidden') @@ -104,6 +119,9 @@ async def settle(app, tx, order, *, now, actor, request_id, reason, request_id=f'{order["id"]}:{suffix}', now=now, receipt_signer=app.receipt_signer, reference=f'order_{suffix}:{order["id"]}', kind=kind)) + entitlement_id = None + if locked['version'] == 5: + entitlement_id = fulfill(receipts[0]['body']['transaction_id']) final = 'refunded' if refund_minor == total else 'settled' await transition(tx, order, final, now=now, actor=actor, request_id=request_id, reason=reason) refs = order['receipt_refs'] + [r['body']['transaction_id'] for r in receipts] @@ -116,6 +134,8 @@ async def settle(app, tx, order, *, now, actor, request_id, reason, 'order_facts': {name: order[name] for name in ('payment_transaction_id', 'payment_intent_digest', 'funded_at', 'delivered_at')}, 'delivery_snapshot': delivery_snapshot(tx, order['id'])} + if locked['version'] == 5: + statement['entitlement_id'] = entitlement_id tx.execute('INSERT INTO order_settlements(order_id,decision_id,body) VALUES (?,?,?)', (order['id'], statement['decision_id'], canonical(statement).decode()), write=True) if decision: @@ -132,31 +152,35 @@ async def resolve_due(app, *, limit=100): services/manual ciphertext refund; subjective complaints and expired panels remain held. This function has no external side effect. """ - from msg.plugins.orders import _row + from msg.market.order_records import read_order as _row now = app.clock() changed = [] async with app.metadata.transaction(write=True) as tx: + app.runtime_generation.require_current(tx) rows = tx.rows('''SELECT o.id,o.buyer FROM store_orders o JOIN order_deadlines d ON d.order_id=o.id WHERE o.state IN ('created','funded') AND d.expires_at<=? ORDER BY d.expires_at,o.id LIMIT ?''', (wire(now),limit)) for order_id, buyer in rows: - order = _row(tx, order_id, buyer) - terms = contract(tx, order_id) - policy = terms['policy']['policy'] - origin, seconds = ((order['created_at'], policy['funding_timeout_seconds']) - if order['state'] == 'created' else - (order['funded_at'], policy['delivery_timeout_seconds'])) - if now < parse_time(origin) + timedelta(seconds=seconds): - continue - if order['state'] == 'created': - await transition(tx, order, 'cancelled', now=now, actor=buyer, - request_id='timeout:'+order_id, reason='funding_timeout') - else: - await settle(app, tx, order, now=now, actor=buyer, - request_id='timeout:'+order_id, reason='delivery_timeout', - refund_minor=order['total_price_minor']) - changed.append(order_id) + from msg.market.order_resources import internal_mutation + async with internal_mutation(app, tx, order_id, now=now, actor=buyer, + request_id='timeout:' + order_id): + order = _row(tx, order_id, buyer) + terms = contract(tx, order_id) + policy = terms['policy']['policy'] + origin, seconds = ((order['created_at'], policy['funding_timeout_seconds']) + if order['state'] == 'created' else + (order['funded_at'], policy['delivery_timeout_seconds'])) + if now < parse_time(origin) + timedelta(seconds=seconds): + continue + if order['state'] == 'created': + await transition(tx, order, 'cancelled', now=now, actor=buyer, + request_id='timeout:'+order_id, reason='funding_timeout') + else: + await settle(app, tx, order, now=now, actor=buyer, + request_id='timeout:'+order_id, reason='delivery_timeout', + refund_minor=order['total_price_minor']) + changed.append(order_id) return changed @@ -200,8 +224,8 @@ def __init__(self, app): async def settle(self, ctx, request, tx, *, reason, order_id=None): # Import handlers' read-only projections, never their write entry points. - from msg.plugins.delivery import _delivery, _verified_delivery - from msg.plugins.orders import _row, _subject + from msg.market.managed_delivery import read_delivery as _delivery, verify_managed_delivery as _verified_delivery + from msg.market.order_records import read_order as _row, require_signed_subject as _subject buyer=_subject(ctx) require(order_id is None or reason == 'checkout_accept', 'escrow_decision_mismatch') order=_row(tx,order_id or request.arguments['order_id'],buyer) diff --git a/src/msg/market/ledger.py b/src/msg/market/ledger.py new file mode 100644 index 00000000..e0664688 --- /dev/null +++ b/src/msg/market/ledger.py @@ -0,0 +1,159 @@ +"""One append-only ledger posting path in the caller's transaction. + +This module grants no commit rights. Public transfers cannot supply the internal +escrow guard; Root issuance remains a separate local-console use case. +""" +from __future__ import annotations +from uuid import uuid4 +from msg.constants import ROOT_SUBJECT +from msg.core.codec import canonical, digest, wire +from msg.core.errors import require +from msg.core.models import AccessRequirement, SignatureProof + +CURRENCY_ID = 'primary' +CODE = 'MSG' +SCALE = 6 +MAX_MINOR = 2**63 - 1 +POLICY_VERSION = 2 +POLICY_DIGEST = digest({'version': POLICY_VERSION, 'currency_id': CURRENCY_ID, + 'scale': SCALE, 'transfer_fee': 0, 'allow_overdraft': False, + 'posting_identity': 'actor/request_id/entry_key'}) + +# Not a credential, account or public capability. Only trusted in-process market +# code can reach this token; ordinary signed money.transfer never receives it. +_ESCROW_WRITE = object() + + +async def account_requirements(request, tx): + """Require the credential's scope to cover its account, even on replay. + + An operation name in a listing-scoped key is not permission to spend the + owner's balance or read unrelated private orders. Anonymous calls still + reach the handler's uniform not-found/signature response. + """ + if request.proof is None: + return () + key_id = (request.proof.signature.key_id if isinstance(request.proof, SignatureProof) + else request.proof.credential_id) + credential = await tx.credential(key_id) + return (AccessRequirement(resource_id=request.subject or credential.subject_id, + operation=f'{request.operation}@{request.contract_version}', check='read'),) + + +def require_money_subject(ctx): + require(ctx.principal.subject is not None and + ctx.principal.actor == ctx.principal.subject, 'money_subject_required') + return ctx.principal.subject + + +def checked_amount(value): + require(type(value) is int and 0 < value <= MAX_MINOR, 'invalid_money_amount') + return value + + +def balance(tx, subject): + debit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE debit_account=?", + (subject,))[0] + credit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE credit_account=?", + (subject,))[0] + return int(credit) - int(debit) + + +def total_supply(tx): + issued = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='mint'")[0] + burned = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='burn'")[0] + return int(issued) - int(burned) + + +def clearing_decision(*, kind, sender, recipient, amount, sender_balance, + recipient_balance, supply, local_issuer=False): + """Pure policy v2: no clock, UUID, subject privileges or discretionary input.""" + amount = checked_amount(amount) + require(all(type(value) is int and 0 <= value <= MAX_MINOR + for value in (sender_balance, recipient_balance, supply)), 'invalid_money_state') + require(kind in {'mint', 'burn', 'transfer', 'redeem', 'refund'}, 'invalid_money_kind') + if kind in {'mint', 'burn'}: + require(local_issuer, 'root_local_only') + require((kind == 'mint' and sender is None and recipient == ROOT_SUBJECT) or + (kind == 'burn' and sender == ROOT_SUBJECT and recipient is None), + 'invalid_money_accounts') + else: + require(sender is not None and recipient is not None, 'invalid_money_accounts') + require(sender != recipient, 'self_transfer_forbidden') + if sender in {ROOT_SUBJECT, '@root'}: + require(local_issuer, 'root_local_only') + if sender is not None: + require(sender_balance >= amount, 'insufficient_funds') + if recipient is not None: + require(recipient_balance + amount <= MAX_MINOR, 'money_overflow') + if kind == 'burn': + require(supply >= amount, 'invalid_money_state') + if kind == 'mint': + require(supply + amount <= MAX_MINOR, 'money_overflow') + return {'kind': kind, 'debit_account': sender, 'credit_account': recipient, + 'amount_minor': amount, 'currency_id': CURRENCY_ID, + 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST} + + +def append_entry(tx, *, sender, recipient, amount, actor, request_id, now, + receipt_signer, reference=None, kind='transfer', entry_key='primary', + _local_issuer=False): + """Append one named leg in the caller's serialized transaction, never commit. + + Named legs permit atomic fund+release without manufacturing request IDs. + Historical receipts remain byte-for-byte untouched by the schema upgrade. + Only the separate local RootAdmin use case supplies _local_issuer. + """ + require(not _local_issuer or actor == ROOT_SUBJECT, 'root_local_only') + clearing_decision(kind=kind, sender=sender, recipient=recipient, amount=amount, + sender_balance=balance(tx, sender) if sender else 0, + recipient_balance=balance(tx, recipient) if recipient else 0, + supply=total_supply(tx), local_issuer=_local_issuer) + for account in {sender, recipient} - {None}: + tx.execute('INSERT INTO money_accounts(subject_id,currency_id) VALUES (?,?) ' + 'ON CONFLICT(subject_id,currency_id) DO NOTHING', + (account, CURRENCY_ID), write=True) + transaction_id = 'lt_' + uuid4().hex + sequence = tx.one("SELECT nextval(pg_get_serial_sequence('money_ledger','seq'))")[0] + body = {'transaction_id': transaction_id, 'kind': kind, 'currency_id': CURRENCY_ID, + 'amount_minor': amount, 'from_subject': sender, 'to_subject': recipient, + 'actor': actor, 'request_id': request_id, 'entry_key': entry_key, + 'reference': reference, 'committed_at': wire(now), + 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST, + 'ledger_sequence': sequence} + receipt = {'body': body, + 'signature': wire(receipt_signer.sign(canonical(body), purpose='money-receipt'))} + tx.execute('''INSERT INTO money_ledger + (seq,id,kind,currency_id,amount_minor,debit_account,credit_account,actor,request_id, + reference,committed_at,policy_version,policy_digest,receipt,entry_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', + (sequence,transaction_id,kind,CURRENCY_ID,amount,sender,recipient,actor, + request_id,reference,wire(now),POLICY_VERSION,POLICY_DIGEST, + canonical(receipt).decode(),entry_key), write=True) + return receipt + + +def post_transfer(tx, *, sender, recipient, amount, actor, request_id, now, + receipt_signer, reference=None, kind='transfer', entry_key='primary', + escrow_authority=None): + require(kind in {'transfer', 'redeem', 'refund'}, 'invalid_money_kind') + account = tx.one('SELECT kind FROM ledger_accounts WHERE id=?', (sender,)) + if account and account[0] in {'order_escrow', 'purchase_escrow'}: + require(escrow_authority is _ESCROW_WRITE, 'escrow_release_forbidden') + return append_entry(tx, sender=sender, recipient=recipient, amount=amount, + actor=actor, request_id=request_id, now=now, receipt_signer=receipt_signer, + reference=reference, kind=kind, entry_key=entry_key) + + +def post_escrow_release(tx, *, escrow_account, source_id, account_kind, buyer, seller, + recipient, amount, actor, request_id, now, receipt_signer, + reference, kind, entry_key='primary'): + """Shared typed account boundary; callers validate their immutable policy first.""" + require(account_kind in {'order_escrow', 'purchase_escrow'}, 'escrow_account_mismatch') + account = tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?', + (escrow_account,)) + require(account == (account_kind, None, source_id), 'escrow_account_mismatch') + require(recipient in {buyer, seller}, 'escrow_recipient_mismatch') + return post_transfer(tx, sender=escrow_account, recipient=recipient, amount=amount, + actor=actor, request_id=request_id, now=now, receipt_signer=receipt_signer, + reference=reference, kind=kind, entry_key=entry_key, escrow_authority=_ESCROW_WRITE) diff --git a/src/msg/market/managed_delivery.py b/src/msg/market/managed_delivery.py new file mode 100644 index 00000000..e04b8c9e --- /dev/null +++ b/src/msg/market/managed_delivery.py @@ -0,0 +1,137 @@ +"""Shared bounded managed-package verification and local preparation. + +These preserve the managed checkout contracts; versioned manual/service delivery, +arbitration and operation registration retain their existing entrypoints. +""" +from __future__ import annotations +from msg.core.codec import b64, canonical, decode, digest, loads, wire +from msg.core.errors import require +from msg.core.models import BlobRef +from msg.plugins.common import new_id +from msg.market.ledger import CURRENCY_ID, balance +from msg.market.catalog import read_package_record +from msg.market.order_records import read_order +from msg.market.delivery_targets import validate_target + +MAX_INLINE_BYTES = 1024 * 1024 +_COLUMNS = ('id', 'order_id', 'recipient_subject', 'kind', 'payload_refs', + 'manifest', 'package_digest', 'delivery_digest', 'channel', + 'state', 'prepared_at', 'claimed_at', 'receipt') + + +def read_delivery(tx, order_id): + row = tx.one('''SELECT id,order_id,recipient_subject,kind,payload_refs, + manifest,package_digest,delivery_digest,channel,state,prepared_at, + claimed_at,receipt FROM store_deliveries WHERE order_id=?''', (order_id,)) + if row is None: + return None + result = dict(zip(_COLUMNS, row)) + result['payload_refs'] = loads(result['payload_refs']) + result['manifest'] = loads(result['manifest']) + result['receipt'] = loads(result['receipt']) if result['receipt'] else None + return result + + +def read_buyer_order(tx, order_id, buyer): + row = read_order(tx, order_id, buyer) + require(row['buyer'] == buyer, 'order_not_found') + return row + + +async def verify_managed_delivery(app, tx, order, delivery): + validate_target(order, delivery) + require(delivery is not None and + delivery['recipient_subject'] == order['buyer'] and + delivery['package_digest'] == order['package_digest'] and + delivery['channel'] == 'site', 'delivery_mismatch') + kind, manifest, refs = await read_managed_package(app, tx, order) + body = {'delivery_id': delivery['id'], 'order_id': order['id'], + 'recipient_subject': order['buyer'], 'kind': kind, + 'manifest': manifest, 'payload_refs': refs, + 'package_digest': order['package_digest'], 'channel': 'site'} + require(delivery['kind'] == kind and delivery['manifest'] == manifest and + delivery['payload_refs'] == refs and + delivery['delivery_digest'] == digest(body), 'delivery_mismatch') + + +async def read_managed_package(app, tx, order): + require(order['package_id'] and order['package_revision'] and + order['package_digest'], 'delivery_package_mismatch') + row = await read_package_record(tx, order['package_id']) + require(row is not None and row[1] == order['listing_id'] and + row[3] == order['seller'] and row[4] == order['package_revision'] and + row[8] == order['package_digest'] and row[10] == 'managed_instant' and + row[5] in {'file', 'bundle'}, 'delivery_package_mismatch') + manifest, refs = loads(row[6]), loads(row[7]) + snapshot = {'seller': row[3], 'listing_id': row[1], + 'listing_revision': row[2], 'revision': row[4], 'kind': row[5], + 'manifest': manifest, 'payload_refs': refs, + 'total_size': row[9], 'delivery_mode': row[10]} + require(digest(snapshot) == order['package_digest'] and + row[9] <= MAX_INLINE_BYTES and row[9] >= 0, + 'delivery_package_mismatch') + require(refs and (row[5] == 'bundle' or len(refs) == 1), + 'delivery_package_mismatch') + total = 0 + for ref in refs: + require(isinstance(ref, dict) and isinstance(ref.get('blob'), dict), + 'delivery_package_mismatch') + blob = decode(BlobRef, ref['blob']) + require(blob.size >= 0 and blob.size <= MAX_INLINE_BYTES, + 'delivery_package_mismatch') + data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES) + require(len(data) == blob.size and digest(data) == blob.digest, + 'delivery_package_mismatch') + total += blob.size + require(total == row[9], 'delivery_package_mismatch') + return row[5], manifest, refs + + +async def read_managed_payloads(app, delivery): + files = [] + for ref in delivery['payload_refs']: + blob = decode(BlobRef, ref['blob']) + data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES) + require(len(data) == blob.size and digest(data) == blob.digest, + 'delivery_package_mismatch') + files.append({'id': ref['id'], 'revision': ref['revision'], + 'media_type': blob.media_type, 'digest': blob.digest, + 'size': blob.size, 'data': b64(data)}) + return files + + +async def prepare_managed(app, ctx, tx, order): + """Prepare inside the caller's transaction; never authenticate or commit here.""" + buyer = order['buyer'] + require(order['state'] == 'funded' and order['delivered_at'] is None, + 'order_not_deliverable') + validate_target(order) + require(order['currency_id'] == CURRENCY_ID and + balance(tx, order['escrow_subject']) == order['total_price_minor'], + 'escrow_balance_mismatch') + kind, manifest, refs = await read_managed_package(app, tx, order) + delivery_id = new_id('dlv') + body = {'delivery_id': delivery_id, 'order_id': order['id'], + 'recipient_subject': buyer, 'kind': kind, + 'manifest': manifest, 'payload_refs': refs, + 'package_digest': order['package_digest'], 'channel': 'site'} + delivery_digest = digest(body) + tx.execute('''INSERT INTO store_deliveries + (id,order_id,recipient_subject,kind,payload_refs,manifest, + package_digest,delivery_digest,channel,state,prepared_at,claimed_at,receipt) + VALUES (?,?,?,?,?,?,?,?,?,'prepared',?,NULL,NULL)''', + (delivery_id, order['id'], buyer, kind, canonical(refs).decode(), + canonical(manifest).decode(), order['package_digest'], + delivery_digest, 'site', wire(ctx.now)), write=True) + changed = tx.execute('''UPDATE store_orders SET state='delivered',delivered_at=? + WHERE id=? AND buyer=? AND state='funded' AND delivered_at IS NULL''', + (wire(ctx.now), order['id'], buyer), write=True) + require(changed.rowcount == 1, 'order_not_deliverable') + return read_delivery(tx, order['id']) + + +def delivery_summary(delivery): + return {'delivery_id': delivery['id'], 'order_id': delivery['order_id'], + 'state': delivery['state'], 'channel': delivery['channel'], + 'package_digest': delivery['package_digest'], + 'delivery_digest': delivery['delivery_digest']} diff --git a/src/msg/market/offer_resources.py b/src/msg/market/offer_resources.py new file mode 100644 index 00000000..a0f83d27 --- /dev/null +++ b/src/msg/market/offer_resources.py @@ -0,0 +1,116 @@ +"""Local Root offer writer backed by signed ordinary Listing revisions. + +Legacy rows without a mapping remain legacy; observing them never migrates them. +The SQL offer row is only a compatibility projection after a mapping is created. +""" +from dataclasses import replace + +from msg.constants import ROOT_SUBJECT +from msg.core.codec import canonical, digest, loads, wire +from msg.core.errors import require +from msg.core.models import Resource, ResourceRef, Revision +from msg.security.crypto import verify + +OFFER_COLUMNS = ('offer_id', 'resource_kind', 'unit', 'price_minor', 'min_quantity', + 'max_quantity', 'entitlement_kind', 'duration_seconds', 'enabled', + 'price_revision') + + +def mapped_listing(tx, offer_id): + row = tx.one('SELECT listing_id FROM server_offer_resources WHERE offer_id=?', (offer_id,)) + return row[0] if row else None + + +def listing_body(offer): + return {'mode': 'sale', 'item_kind': 'entitlement', 'price_minor': offer['price_minor'], + 'currency_id': 'primary', 'quantity': offer['max_quantity'], + 'delivery_mode': 'managed_instant', 'package_ref': None, + 'escrow_policy': 'legacy-entitlement-v1', 'dispute_policy': 'local-root-refund-v1', + 'terms': 'Server-issued entitlement; settled Root payments require local refund authority.', + 'expires_at': None, 'state': 'active' if offer['enabled'] else 'paused', + 'server_offer': dict(offer), 'server_offer_model': 1} + + +async def authoritative_offer(app, tx, offer_id): + """Return the signed authority, or None for an unmigrated legacy row.""" + listing_id = mapped_listing(tx, offer_id) + if listing_id is None: + return None + resource = await tx.resource(listing_id) + require(resource.id == offer_id and resource.type == 'listing' and + resource.parent == 't_store' and resource.owner == ROOT_SUBJECT and + resource.state == 'active' and resource.mode == 0o444 and + resource.group == 'g_public', 'offer_resource_invalid') + ancestors = await tx.ancestors(resource.id) + require(all(parent.state == 'active' and parent.mode & 0o001 for parent in ancestors), + 'offer_resource_invalid') + revision = await tx.revision(ResourceRef(id=listing_id, revision=resource.revision)) + require(revision.actor == revision.subject == ROOT_SUBJECT and revision.signature is not None, + 'offer_resource_invalid') + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + require(digest(manifest) == revision.manifest_digest, 'offer_resource_invalid') + verify(app.certificates.root_public_key, canonical(manifest), revision.signature, purpose='revision') + data = await app.contents.read_bytes(revision.content) + require(digest(data) == revision.content.digest, 'offer_resource_invalid') + body = loads(data) + require(isinstance(body, dict) and body.get('server_offer_model') == 1 and + isinstance(body.get('server_offer'), dict), 'offer_resource_invalid') + offer = body['server_offer'] + require(set(offer) == set(OFFER_COLUMNS) and offer['offer_id'] == offer_id and + body == listing_body(offer), 'offer_resource_invalid') + return offer + + +async def verify_projection(app, tx, row): + """Fail closed if a mapped compatibility row diverges from the signed source.""" + source = await authoritative_offer(app, tx, row[0]) + if source is not None: + expected = tuple(source[k] for k in ( + 'offer_id', 'resource_kind', 'unit', 'price_minor', 'min_quantity', + 'max_quantity', 'entitlement_kind', 'duration_seconds', 'price_revision')) + require(tuple(row) == expected and source['enabled'], 'offer_projection_mismatch') + return source + + +async def write_offer_revision(app, tx, signer, offer, *, now, request_id, new=False, + revision_id=None, source_digest=None): + """Caller owns the local authorization and database transaction.""" + require(signer.public_key == app.certificates.root_public_key, 'root_key_mismatch') + app.registry.resource_type('listing', 1) + offer_id = offer['offer_id'] + if new: + parent = await tx.resource('t_store') + require(parent.state == 'active', 'ancestor_inactive') + require(app.registry.resource_type(parent.type, parent.type_version).container, 'not_a_container') + require(tx.one('SELECT 1 FROM resources WHERE id=? OR (parent=? AND name=?)', + (offer_id, 't_store', offer_id)) is None, 'offer_resource_conflict') + resource = Resource(id=offer_id, type='listing', type_version=1, name=offer_id, + parent='t_store', owner=ROOT_SUBJECT, group='g_public', mode=0o444, + generation=0, revision=None, state='active', created_at=now, created_by=ROOT_SUBJECT, + modified_at=now, modified_by=ROOT_SUBJECT) + await tx.insert(resource) + else: + resource = await tx.resource(mapped_listing(tx, offer_id)) + # Disable has a new immutable revision but retains the published quote ID. + from uuid import uuid4 + revision_id = revision_id or (offer['price_revision'] if offer['enabled'] else 'v_' + uuid4().hex) + blob = await app.contents.put_bytes(canonical(listing_body(offer)), 'application/json') + revision = Revision(format_version=1, id=revision_id, resource_id=offer_id, + parents=(resource.revision,) if resource.revision else (), content=blob, relations=(), + actor=ROOT_SUBJECT, subject=ROOT_SUBJECT, author=ROOT_SUBJECT, created_at=now, + manifest_digest='', source_kind='operation', source_version=1, + source_digest=source_digest or digest({'request_id': request_id, 'offer': offer})) + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + revision = replace(revision, manifest_digest=digest(manifest), + signature=signer.sign(canonical(manifest), purpose='revision')) + tx.on_rollback(lambda: app.contents.unpin(blob, revision_id)) + await app.contents.pin(blob, revision_id) + await app.contents.commit_revision('t_store', revision) + await tx.append_revision(revision) + await tx.replace(replace(resource, generation=resource.generation + 1, + revision=revision_id, modified_at=now, modified_by=ROOT_SUBJECT), + resource.generation) + if new: + tx.execute('INSERT INTO server_offer_resources(offer_id,listing_id) VALUES (?,?)', + (offer_id, offer_id), write=True) + return revision_id diff --git a/src/msg/market/order_records.py b/src/msg/market/order_records.py new file mode 100644 index 00000000..8c5bd796 --- /dev/null +++ b/src/msg/market/order_records.py @@ -0,0 +1,71 @@ +"""Shared order identity, authorized records and legacy-compatible projections. + +These functions own record access, not operation registration or settlement. +""" +from __future__ import annotations +import base64 +import os +from msg.core.codec import loads +from msg.core.errors import require + +_COLUMNS = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision', + 'package_id', 'package_revision', 'package_digest', 'quantity', + 'unit_price_minor', 'total_price_minor', 'currency_id', + 'escrow_subject', 'escrow_policy', 'dispute_policy', 'terms_digest', + 'delivery_target', 'payment_intent_digest', 'payment_transaction_id', + 'state', 'created_at', 'funded_at', 'delivered_at', 'settled_at', + 'receipt_refs') + + +def require_signed_subject(ctx): + subject = ctx.principal.subject + require(subject is not None and ctx.principal.actor == subject and + ctx.principal.method == 'signature', 'signature_required') + return subject + + +def require_order_viewer(ctx): + subject = ctx.principal.subject + require(subject is not None and ctx.principal.actor == subject, + 'order_not_found') + return subject + + +def new_order_id(): + # 160 random bits, unguessable even if an attacker sees other order IDs. + return 'ord_' + base64.b32encode(os.urandom(20)).decode('ascii').rstrip('=').lower() + + +def read_order(tx, order_id, viewer): + row = tx.one('''SELECT id,buyer,seller,listing_id,listing_revision, + package_id,package_revision,package_digest,quantity,unit_price_minor, + total_price_minor,currency_id,escrow_subject,escrow_policy, + dispute_policy,terms_digest,delivery_target,payment_intent_digest, + payment_transaction_id,state,created_at,funded_at,delivered_at, + settled_at,receipt_refs FROM store_orders WHERE id=?''', (order_id,)) + # A valid ID is not an access grant. Keep nonexistent and unauthorized alike. + require(row is not None and viewer in row[1:3], 'order_not_found') + from msg.market.order_resources import source_metadata + source_metadata(tx, order_id) + result = dict(zip(_COLUMNS, row)) + result['delivery_target'] = loads(result['delivery_target']) + result['receipt_refs'] = loads(result['receipt_refs']) + return result + + +def legacy_order_view(row, viewer): + keys = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision', + 'package_revision', 'package_digest', 'quantity', + 'unit_price_minor', 'total_price_minor', 'currency_id', + 'escrow_policy', 'dispute_policy', 'terms_digest', 'state', + 'created_at', 'funded_at', 'delivered_at', 'settled_at') + result = {key: row[key] for key in keys} + result['payment_status'] = ('refunded' if row['state'] == 'refunded' else + 'funded' if row['payment_transaction_id'] else + 'pending') + result['delivery_channel'] = row['delivery_target']['channel'] + if viewer == row['buyer']: + result['delivery_target'] = row['delivery_target'] + result['payment_intent_digest'] = row['payment_intent_digest'] + result['receipt_refs'] = row['receipt_refs'] + return result diff --git a/src/msg/market/order_resources.py b/src/msg/market/order_resources.py new file mode 100644 index 00000000..f4f89f7f --- /dev/null +++ b/src/msg/market/order_resources.py @@ -0,0 +1,211 @@ +"""Controlled Order Resources; SQL rows are checked projections of revisions. + +Only explicit market operation boundaries may advance the source. A database +projection mismatch is an error, never input for an automatic repair/re-sign. +""" +from dataclasses import replace +from contextlib import asynccontextmanager +from functools import wraps +from uuid import uuid4 + +from msg.core.codec import canonical, decode, digest, loads, parse_time, wire +from msg.core.errors import Failure, require +from msg.core.models import Resource, ResourceRef, Revision +from msg.security.crypto import verify + + +def _active(tx): + if not hasattr(tx, '_order_resource_mutations'): + tx._order_resource_mutations = set() + return tx._order_resource_mutations + + +def begin_new(tx, order_id): + _active(tx).add(order_id) + + +def snapshot(tx, order_id): + from msg.market.order_records import _COLUMNS + row = tx.one('SELECT ' + ','.join(_COLUMNS) + ' FROM store_orders WHERE id=?', (order_id,)) + require(row is not None, 'order_resource_missing') + order = dict(zip(_COLUMNS, row)) + # Notification endpoint/generation/jobs have their own authority and may + # change without changing ownership, price, delivery or acceptance. + order.pop('delivery_target') + order['receipt_refs'] = loads(order['receipt_refs']) + locked = tx.one('SELECT body,digest FROM order_contracts WHERE order_id=?', (order_id,)) + require(locked is not None and digest(loads(locked[0])) == locked[1], 'order_contract_corrupt') + fact = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order_id,)) + return {'model': 1, 'order': order, 'contract': loads(locked[0]), + 'settlement': loads(fact[0]) if fact else None, + 'transitions': [loads(row[0]) for row in tx.rows( + 'SELECT body FROM order_transitions WHERE order_id=? ORDER BY id', (order_id,))]} + + +def source_metadata(tx, order_id, *, force=False): + contract_row = tx.one('SELECT body FROM order_contracts WHERE order_id=?', (order_id,)) + if contract_row is None or loads(contract_row[0]).get('resource_model') != 1: + require(tx.one("SELECT 1 FROM resources WHERE id=? AND type='order'", (order_id,)) is None, + 'order_resource_contract_missing') + return None # Published historical writer; no implicit adoption. + if not force and order_id in _active(tx): + return None # Validated before this dedicated operation began. + row = tx.one('SELECT body FROM resources WHERE id=?', (order_id,)) + require(row is not None, 'order_resource_missing') + resource = decode(Resource, loads(row[0])) + buyer = loads(contract_row[0])['buyer'] + require(resource.type == 'order' and resource.type_version == 1 and resource.owner == buyer and + resource.name == order_id and resource.parent == 'orders_' + buyer and + resource.mode == 0o400 and resource.state == 'active', 'order_resource_invalid') + row = tx.one('SELECT body FROM revisions WHERE id=? AND resource_id=?', (resource.revision, order_id)) + require(row is not None, 'order_resource_missing') + revision = decode(Revision, loads(row[0])) + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + require(digest(manifest) == revision.manifest_digest and revision.signature is not None and + revision.content.digest == digest(snapshot(tx, order_id)), 'order_resource_projection_mismatch') + return resource, revision + + +async def verify_source(app, tx, order_id): + source = source_metadata(tx, order_id, force=True) + if source is None: + return None + resource, revision = source + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + verify(app.receipt_signer.public_key, canonical(manifest), revision.signature, purpose='revision') + try: + body = await app.contents.read_bytes(revision.content) + except FileNotFoundError as exc: + raise Failure('content_missing') from exc + require(body == canonical(snapshot(tx, order_id)), 'order_resource_projection_mismatch') + return resource + + +async def publish(app, tx, order_id, *, now, actor, request_id, request_digest, version): + require(order_id in _active(tx), 'order_resource_write_forbidden') + app.registry.resource_type('order', 1) + app.registry.resource_type('order_collection', 1) + from msg.market.policy import contract + locked = contract(tx, order_id) # All existing finance/acceptance checks still apply. + if locked.get('resource_model') != 1: + return None + body = snapshot(tx, order_id) + row = tx.one('SELECT body FROM resources WHERE id=?', (order_id,)) + buyer = locked['buyer'] + if row is None: + parent_id = 'orders_' + buyer + parent_row = tx.one('SELECT body FROM resources WHERE id=?', (parent_id,)) + if parent_row is None: + require(tx.one('SELECT 1 FROM resources WHERE parent=? AND name=?', (buyer, 'orders')) is None, + 'order_collection_conflict') + owner = await tx.subject(buyer) + parent = Resource(id=parent_id, type='order_collection', type_version=1, name='orders', + parent=buyer, owner=buyer, group=owner.primary_group, mode=0o500, generation=0, + revision=None, state='active', created_at=now, created_by=actor, modified_at=now, modified_by=actor) + await tx.insert(parent) + else: + parent = decode(Resource, loads(parent_row[0])) + require(parent.type == 'order_collection' and parent.parent == buyer and parent.owner == buyer and + parent.mode == 0o500 and parent.state == 'active', 'order_collection_invalid') + resource = Resource(id=order_id, type='order', type_version=1, name=order_id, + parent=parent_id, owner=buyer, group=parent.group, mode=0o400, generation=0, + revision=None, state='active', created_at=parse_time(locked['created_at']), created_by=buyer, + modified_at=now, modified_by=actor) + await tx.insert(resource) + else: + resource = decode(Resource, loads(row[0])) + current = await tx.revision(ResourceRef(id=order_id)) + if current.content.digest == digest(body): + return ResourceRef(id=order_id, revision=resource.revision) + blob = await app.contents.put_bytes(canonical(body), 'application/json') + rid = 'v_' + uuid4().hex + revision = Revision(format_version=1, id=rid, resource_id=order_id, + parents=(resource.revision,) if resource.revision else (), content=blob, relations=(), + actor=actor, subject=actor, author=actor, created_at=now, manifest_digest='', + source_kind='operation', source_version=version, + source_digest=request_digest or digest({'request_id': request_id, 'order_id': order_id})) + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + revision = replace(revision, manifest_digest=digest(manifest), + signature=app.receipt_signer.sign(canonical(manifest), purpose='revision')) + tx.on_rollback(lambda: app.contents.unpin(blob, rid)) + await app.contents.pin(blob, rid) + await app.contents.commit_revision(resource.parent, revision) + await tx.append_revision(revision) + await tx.replace(replace(resource, revision=rid, generation=resource.generation + 1, + modified_at=now, modified_by=actor), resource.generation) + return ResourceRef(id=order_id, revision=rid) + + +def operation_boundary(app, op): + """Wrap only registered market operations, keeping core executor unchanged.""" + def register(name, schema, **options): + def decorate(handler): + @wraps(handler) + async def bounded(ctx, request, tx): + before = set(_active(tx)) + ids = set() + args = request.arguments + order_id = args.get('order_id') + case_id = args.get('case_id') or args.get('proposal', {}).get('case_id') + if case_id: + row = tx.one('SELECT order_id FROM arbitration_cases WHERE id=?', (case_id,)) + order_id = row[0] if row else None + if order_id: + # Authorization and not-found equivalence remain the handler's job. + row = tx.one('SELECT buyer,seller FROM store_orders WHERE id=?', (order_id,)) + if row and (ctx.principal.subject in row or case_id): + await verify_source(app, tx, order_id) + ids.add(order_id) + writing = options.get('effect', 'transaction') != 'read' + if writing: + _active(tx).update(ids) + try: + result = await handler(ctx, request, tx) + ids.update(_active(tx) - before) + data = result.data or {} + for item in data.get('orders', ()): + if item.get('source') != 'legacy_purchase': + ids.add(item['id']) + if writing: + refs = [] + for oid in sorted(ids): + row = tx.one('SELECT body FROM order_contracts WHERE order_id=?', (oid,)) + if row and loads(row[0]).get('resource_model') == 1: + ref = await publish(app, tx, oid, now=ctx.now, actor=ctx.principal.subject, + request_id=request.request_id, request_digest=request.payload_digest, + version=request.contract_version) + if loads(row[0])['buyer'] == ctx.principal.subject: + refs.append(ref) + if refs: + # Existing account-scoped contracts do not grant generic + # Resource.read over descendants. Keep their replay ACL; + # expose references as data, without expanding authority. + result = replace(result, data={**data, 'order_resources': wire(tuple(refs))}) + else: + for oid in ids: + await verify_source(app, tx, oid) + return result + finally: + tx._order_resource_mutations = before + return op(name, schema, **options)(bounded) + return decorate + return register + + +@asynccontextmanager +async def internal_mutation(app, tx, order_id, *, now, actor, request_id): + """Timer/internal entrypoint, using the same verified source boundary.""" + if order_id in _active(tx): + yield + return + before = set(_active(tx)) + await verify_source(app, tx, order_id) + _active(tx).add(order_id) + try: + yield + row = tx.one('SELECT body FROM order_contracts WHERE order_id=?', (order_id,)) + if row and loads(row[0]).get('resource_model') == 1: + await publish(app, tx, order_id, now=now, actor=actor, request_id=request_id, + request_digest=None, version=1) + finally: + tx._order_resource_mutations = before diff --git a/src/msg/market/orders.py b/src/msg/market/orders.py index 308c2d9f..2691c7e8 100644 --- a/src/msg/market/orders.py +++ b/src/msg/market/orders.py @@ -1,4 +1,4 @@ -"""New checkout contracts without changing published orders.buy@1/@2 semantics.""" +"""Versioned checkout, preserving published orders.buy@1/@2/@3 semantics.""" from __future__ import annotations from datetime import timedelta @@ -11,10 +11,10 @@ from msg.market.targets import ( enqueue_notification, notification_view, save_target, target_for, validate_target, ) -from msg.plugins.money import CURRENCY_ID, MAX_MINOR, _post_transfer -from msg.plugins.orders import _order_id, _row, _subject, _view, _viewer +from msg.market.ledger import CURRENCY_ID, MAX_MINOR, post_transfer as _post_transfer +from msg.market.order_records import new_order_id as _order_id, read_order as _row, require_signed_subject as _subject, legacy_order_view as _view, require_order_viewer as _viewer from msg.plugins.schemas import IDENTIFIER, obj -from msg.plugins.store import _body, _listing, _package_row +from msg.market.catalog import read_listing_body as _body, read_listing as _listing, read_package_record as _package_row HASH = {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'} ORDER = obj({'order_id': IDENTIFIER}, ('order_id',)) @@ -35,10 +35,15 @@ def view(tx, order, viewer): row = tx.one('SELECT digest FROM order_contracts WHERE order_id=?', (order['id'],)) if row: result['order_digest'] = row[0] - result['contract_version'] = 3 locked = contract(tx, order['id']) + result['contract_version'] = locked['version'] result['delivery_mode'] = locked['listing']['delivery_mode'] result['policy_digest'] = locked['policy']['policy_digest'] + if locked['version'] == 5: + from msg.core.codec import loads + fact = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],)) + result['settlement_policy'] = locked['settlement_policy'] + result['entitlement_id'] = loads(fact[0])['entitlement_id'] if fact else None # Seller needs the *public encryption subkey*, not buyer's email. if locked['recipient_key']: result['recipient_key'] = locked['recipient_key'] @@ -46,7 +51,7 @@ def view(tx, order, viewer): return result -async def create(app, tx, ctx, request): +async def create(app, tx, ctx, request, *, version=3): buyer, args = _subject(ctx), request.arguments listing = await _listing(app, ctx, request, tx, args['listing_id']) body, revision = await _body(app, tx, listing) @@ -60,6 +65,12 @@ async def create(app, tx, ctx, request): total = body['price_minor'] * args['quantity'] require(0 < total <= MAX_MINOR, 'money_overflow') require(total == args['total_price_minor'], 'price_changed') + # Inventory is a query projection too: validate all Resource-backed orders + # for this immutable listing before using its quantity/state index. + from msg.market.order_resources import verify_source + for (existing_id,) in tx.rows("SELECT order_id FROM order_contracts WHERE " + "body::jsonb->>'listing_id'=? AND body::jsonb->>'resource_model'='1'", (listing.id,)): + await verify_source(app, tx, existing_id) sold = tx.one('''SELECT COALESCE(SUM(quantity),0) FROM store_orders WHERE listing_id=? AND state NOT IN ('cancelled','refunded')''', (listing.id,))[0] require(sold + args['quantity'] <= body['quantity'], 'quantity_unavailable') @@ -82,6 +93,9 @@ async def create(app, tx, ctx, request): recipient_key = {'key_id': row[0], 'recipient': row[1], 'fingerprint': digest(row[2])} target = await target_for(app, tx, buyer, args.get('email')) order_id, now = _order_id(), wire(ctx.now) + if version == 4: + from msg.market.order_resources import begin_new + begin_new(tx, order_id) escrow = 'esc_' + order_id[4:] tx.execute("INSERT INTO ledger_accounts(id,kind,subject_id,source_id) VALUES (?,'order_escrow',NULL,?)", (escrow, order_id), write=True) @@ -95,7 +109,7 @@ async def create(app, tx, ctx, request): package_digest,args['quantity'],body['price_minor'],total,CURRENCY_ID,escrow, body['escrow_policy'],body['dispute_policy'],digest(body['terms']),canonical(target).decode(), request.payload_digest,now), write=True) - locked = {'order_id': order_id, 'version': 3, 'buyer': buyer, 'seller': listing.owner, + locked = {'order_id': order_id, 'version': version, 'buyer': buyer, 'seller': listing.owner, 'listing': body, 'listing_id': listing.id, 'escrow_subject': escrow, 'listing_revision': listing.revision, 'listing_digest': revision.content.digest, 'package_id': package_id, @@ -104,6 +118,10 @@ async def create(app, tx, ctx, request): 'terms_digest': digest(body['terms']), 'policy': policy, 'recipient_key': recipient_key, 'handle_snapshot': target['handle_snapshot'], 'buyer_principal': wire(ctx.principal), 'created_at': now} + if version == 4: + locked['resource_model'] = 1 + locked['creation_request'] = wire(request) + locked['settlement_policy'] = {'id': 'explicit-buyer-acceptance', 'version': 1} tx.execute('INSERT INTO order_contracts(order_id,body,digest) VALUES (?,?,?)', (order_id,canonical(locked).decode(),digest(locked)), write=True) tx.execute('INSERT INTO order_deadlines(order_id,expires_at) VALUES (?,?)', @@ -117,6 +135,8 @@ async def create(app, tx, ctx, request): async def fund(app, tx, ctx, request, order): require(order['state'] == 'created', 'order_not_fundable') locked = contract(tx, order['id']) + require(locked['version'] != 5 or (request.operation == 'money.redeem' and + request.contract_version == 3), 'entitlement_funding_contract_required') require(ctx.now < parse_time(order['created_at']) + timedelta( seconds=locked['policy']['policy']['funding_timeout_seconds']), 'payment_intent_expired') validate_target(tx, order) @@ -134,7 +154,7 @@ async def fund(app, tx, ctx, request, order): request_id=request.request_id, reason='signed_payment_intent') tx.execute('UPDATE order_deadlines SET expires_at=? WHERE order_id=?', (wire(ctx.now+timedelta(seconds=locked['policy']['policy']['delivery_timeout_seconds'])), order['id']), write=True) - if locked['listing']['delivery_mode'] == 'managed_instant': + if locked['version'] != 5 and locked['listing']['delivery_mode'] == 'managed_instant': from msg.market.delivery import automatic await automatic(app, tx, ctx, request, order) return receipt @@ -149,6 +169,11 @@ async def reserve(ctx, request, tx): order = await create(app, tx, ctx, request) return HandlerOutput(data={'order': view(tx, order, order['buyer'])}) + @op('orders.create', obj(INTENT, INTENT_REQUIRED), signature=True, version=2) + async def reserve_explicit(ctx, request, tx): + order = await create(app, tx, ctx, request, version=4) + return HandlerOutput(data={'order': view(tx, order, order['buyer'])}) + @op('orders.buy', obj(INTENT, INTENT_REQUIRED), signature=True, version=3) async def buy(ctx, request, tx): order = await create(app, tx, ctx, request) @@ -156,6 +181,13 @@ async def buy(ctx, request, tx): return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], order['buyer']), order['buyer']), 'payment': receipt}) + @op('orders.buy', obj(INTENT, INTENT_REQUIRED), signature=True, version=4) + async def buy_explicit(ctx, request, tx): + order = await create(app, tx, ctx, request, version=4) + receipt = await fund(app, tx, ctx, request, order) + return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], order['buyer']), order['buyer']), + 'payment': receipt}) + @op('orders.fund', obj({'order_id': IDENTIFIER, 'order_digest': HASH, 'total_price_minor': INTENT['total_price_minor'], 'currency_id': {'const': CURRENCY_ID}}, ('order_id','order_digest','total_price_minor','currency_id')), signature=True) diff --git a/src/msg/market/policy.py b/src/msg/market/policy.py index 9717a4da..68cade11 100644 --- a/src/msg/market/policy.py +++ b/src/msg/market/policy.py @@ -1,7 +1,7 @@ """Small, immutable policies. No expressions, callbacks or model discretion.""" from __future__ import annotations -from msg.core.codec import canonical, digest, loads +from msg.core.codec import canonical, digest, loads, decode, unb64 from msg.core.errors import require from msg.core.query import QuerySession @@ -17,6 +17,10 @@ } +ENTITLEMENT_POLICY = {'id': 'deterministic-entitlement-v1', 'version': 1, + 'funding_timeout_seconds': 900, 'delivery_timeout_seconds': 900} + + def validate(policy): require(isinstance(policy, dict) and set(policy) == set(DEFAULT_POLICY), 'arbitration_policy_invalid') @@ -74,13 +78,15 @@ def contract(tx, order_id): body = loads(row[0]) require(digest(body) == row[1] and body['order_id'] == order_id, 'order_contract_corrupt') + from msg.market.order_resources import source_metadata + source_metadata(tx, order_id) validate_projection(tx, body) return body def delivery_snapshot(tx, order_id): """Commit delivery identity and prepared bytes, excluding subsequent buyer ACK.""" - from msg.plugins.delivery import _delivery + from msg.market.managed_delivery import read_delivery as _delivery delivery = _delivery(tx, order_id) return digest(None if delivery is None else { key: value for key, value in delivery.items() @@ -89,7 +95,40 @@ def delivery_snapshot(tx, order_id): def validate_projection(tx, locked): """A restored mutable projection cannot replace the immutable checkout facts.""" - require(locked.get('version') == 3, 'order_contract_version') + require(locked.get('version') in {3, 4, 5}, 'order_contract_version') + if locked['version'] == 4: + require(locked.get('settlement_policy') == + {'id': 'explicit-buyer-acceptance', 'version': 1}, + 'order_contract_version') + if locked['version'] == 5: + require(locked['policy'] == {'policy': ENTITLEMENT_POLICY, + 'policy_digest': digest(ENTITLEMENT_POLICY)}, 'order_contract_version') + require(locked.get('settlement_policy') == + {'id': 'deterministic-entitlement-v1', 'version': 1} and + locked['seller'] == 'u_root' and locked['listing']['item_kind'] == 'entitlement', + 'order_contract_version') + from msg.core.models import Credential, OperationRequest, SignatureProof + from msg.core.requests import payload_fields, signing_bytes + from msg.security.crypto import verify + signed = decode(OperationRequest, locked['redemption_request']) + quote = locked['offer_snapshot'] + require(isinstance(signed.proof, SignatureProof) and signed.operation == 'money.redeem' and + signed.contract_version == 3 and signed.subject == locked['buyer'] and + signed.request_id == locked['redemption_request_id'] and + digest(payload_fields(signed)) == signed.payload_digest, + 'order_entitlement_intent_mismatch') + require(signed.arguments == { + 'offer_id': locked['listing_id'], 'quantity': locked['quantity'], 'currency_id': 'primary', + 'price_revision': quote['price_revision'], 'listing_revision': locked['listing_revision'], + 'offer_snapshot_digest': digest(quote), 'total_price_minor': locked['total_price_minor'], + 'settlement_policy': 'deterministic-entitlement-v1'}, 'order_entitlement_intent_mismatch') + credential_row = tx.one('SELECT body FROM credentials WHERE id=?', (signed.proof.signature.key_id,)) + require(credential_row is not None, 'order_entitlement_signer_mismatch') + credential = decode(Credential, loads(credential_row[0])) + require(credential.subject_id == locked['buyer'] and credential.kind == 'signing_key' and + credential.verifier == unb64(locked['redemption_signer']), 'order_entitlement_signer_mismatch') + # Revocation blocks new execution/replay, not validation of an old signed fact. + verify(credential.verifier, signing_bytes(signed), signed.proof.signature, purpose='request') listing = locked['listing'] expected = {name: locked[name] for name in ( 'buyer', 'seller', 'listing_id', 'listing_revision', 'package_id', @@ -107,6 +146,8 @@ def validate_projection(tx, locked): order = dict(zip(columns, row)) require(all(order[name] == value for name, value in expected.items()), 'order_contract_mismatch') + if locked['version'] == 5: + require(order['payment_intent_digest'] == signed.payload_digest, 'order_entitlement_intent_mismatch') if order['payment_transaction_id'] is not None: payment = tx.one('''SELECT debit_account,credit_account,amount_minor,currency_id, reference,committed_at FROM money_ledger WHERE id=?''', @@ -141,6 +182,24 @@ def validate_projection(tx, locked): all(order[name] == value for name, value in fact['order_facts'].items()) and delivery_snapshot(tx, locked['order_id']) == fact['delivery_snapshot'], 'order_settlement_mismatch') + if locked['version'] == 4 and fact['decision_id'] is None and fact['release_minor']: + require(fact['reason'] == 'buyer_acceptance' and fact['refund_minor'] == 0, + 'order_acceptance_policy_mismatch') + if locked['version'] == 5: + require(order['state'] == 'settled' and fact['reason'] == 'deterministic_entitlement' and + fact['refund_minor'] == 0 and order['delivered_at'] is None and + delivery_snapshot(tx, locked['order_id']) == digest(None), 'order_entitlement_mismatch') + from datetime import timedelta + from msg.core.codec import parse_time, wire + grant = tx.one('''SELECT subject_id,offer_id,purchase_request_id,quantity,entitlement_kind, + redeem_transaction_id,granted_at,expires_at FROM resource_entitlements WHERE id=?''', + (fact.get('entitlement_id'),)) + quote = locked['offer_snapshot'] + expiry = (wire(parse_time(fact['at']) + timedelta(seconds=quote['duration_seconds'])) + if quote['duration_seconds'] is not None else None) + require(grant == (locked['buyer'], locked['listing_id'], locked['redemption_request_id'], + locked['quantity'], quote['entitlement_kind'], receipts[0]['body']['transaction_id'], fact['at'], expiry), + 'order_entitlement_mismatch') for receipt in receipts: row = tx.one('SELECT receipt FROM money_ledger WHERE id=?', (receipt['body']['transaction_id'],)) diff --git a/src/msg/market/rationale.py b/src/msg/market/rationale.py index 291da1d2..ecd18b4c 100644 --- a/src/msg/market/rationale.py +++ b/src/msg/market/rationale.py @@ -10,7 +10,7 @@ from msg.core.models import BlobRef, HandlerOutput, ResourceRef from msg.market.delivery import verify_blob from msg.plugins.common import check_access -from msg.plugins.orders import _subject +from msg.market.order_records import require_signed_subject as _subject from msg.plugins.schemas import IDENTIFIER, obj PINNED_REF = obj({'id': IDENTIFIER, 'revision': IDENTIFIER}, ('id', 'revision')) diff --git a/src/msg/market/targets.py b/src/msg/market/targets.py index 1833d0aa..4536450a 100644 --- a/src/msg/market/targets.py +++ b/src/msg/market/targets.py @@ -98,7 +98,7 @@ async def enqueue_notification(app, tx, ctx, request, order): return job = EffectJob(id=new_id('job'), event_id='delivery:'+order['id'], kind='market_mail', dedupe_key=dedupe, principal=principal, - operation='orders.buy', arguments={'order_id': order['id'], 'endpoint_id': chosen['endpoint_id']}, + operation='orders.buy', arguments={'contract_version': locked['version'], 'order_id': order['id'], 'endpoint_id': chosen['endpoint_id']}, state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None) await tx.enqueue(job) target = {**order['delivery_target'], 'email': {**chosen, 'state': 'queued'}} @@ -106,8 +106,8 @@ async def enqueue_notification(app, tx, ctx, request, order): async def render_notification(app, tx, job): - from msg.plugins.delivery import _delivery - from msg.plugins.orders import _row + from msg.market.managed_delivery import read_delivery as _delivery + from msg.market.order_records import read_order as _row order = _row(tx, job.arguments['order_id'], job.principal.subject) require(order['buyer'] == job.principal.subject and job.principal.actor == order['buyer'], 'delivery_recipient_mismatch') diff --git a/src/msg/plugins/__init__.py b/src/msg/plugins/__init__.py index 6f1c8086..6a22148d 100644 --- a/src/msg/plugins/__init__.py +++ b/src/msg/plugins/__init__.py @@ -11,6 +11,16 @@ ) +def validate_plugins(plugins): + """Validate configured names before importing any installed plugin.""" + require(isinstance(plugins, (list, tuple)) and + all(isinstance(name, str) for name in plugins), 'invalid_plugin_list') + require(len(set(plugins)) == len(plugins), 'invalid_plugin_list') + require(set(plugins) <= set(BUILTINS), 'unknown_plugin') + require('identity' in plugins, 'identity_plugin_required') + return tuple(plugins) + + def install_registry(context, plugins): """Register installed code only; the context determines available capabilities. @@ -19,10 +29,11 @@ def install_registry(context, plugins): No storage connection, runtime secret or business operation is needed here. """ from msg.security.capabilities import install_capabilities - configured = set(plugins) - require(configured <= set(BUILTINS), 'unknown_plugin') + configured = set(validate_plugins(plugins)) for name in BUILTINS: if name in configured: importlib.import_module('msg.plugins.' + name).install(context) + if {'content','discovery','batch'} <= configured: + importlib.import_module('msg.plugins.files').install(context) install_capabilities(context.registry) context.registry.freeze() diff --git a/src/msg/plugins/achievements.py b/src/msg/plugins/achievements.py index 2572d41b..7e8a31e8 100644 --- a/src/msg/plugins/achievements.py +++ b/src/msg/plugins/achievements.py @@ -155,6 +155,23 @@ def _save_pins(tx, subject, grant_ids): (subject,grant_id,position), write=True) +async def _custodial_signature(app, ctx, tx, confirmation, purpose): + subject = _owner(ctx) + require(ctx.principal.method == 'token' and (await tx.subject(subject)).kind == 'custodial', + 'self_custody_signature_required') + from msg.security.vault import open_signer + signer = open_signer(app, tx, subject) + primary = tx.one('SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1 AND retired_at IS NULL', + (subject,)) + require(primary is not None and primary[0] == signer.key_id, 'custodial_vault_key_mismatch') + credential = await tx.credential(signer.key_id) + require(credential.subject_id == subject and credential.kind == 'signing_key', + 'custodial_vault_key_mismatch') + require(credential.revoked_at is None and credential.not_before <= ctx.now and + (credential.expires_at is None or ctx.now < credential.expires_at), 'credential_revoked') + return wire(signer.sign(canonical(confirmation), purpose=purpose)) + + def install(app): op, finish = registration(app, 'achievements', ('identity',)) @@ -173,6 +190,25 @@ async def pin_owner(ctx, request, tx): await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx) return subject + async def save_display(ctx, request, tx, subject, pins): + data={'subject_id':subject,'pinned_grant_ids':pins} + if ctx.principal.method=='token': + before=digest(_pins(tx,subject)) + confirmation={'subject_id':subject,'operation':operation_id(request), + 'request_id':request.request_id,'arguments':wire(request.arguments), + 'before_digest':before,'pinned_grant_ids':pins,'signature_source':'custodial'} + signature=await _custodial_signature(app,ctx,tx,confirmation,'achievement-display') + event=Event(id=new_id('audit'),type='achievement.display.update',time=ctx.now, + request_id=request.request_id,actor=ctx.principal.actor,subject=subject, + resources=(ResourceRef(id=subject),),data={'signature_source':'custodial', + 'auth_method':ctx.principal.method,'confirmation':confirmation,'signature':signature}) + await tx.append_audit(AuditEvent(event=event,authority=(ResourceRef(id=subject),), + before_digest=before,after_digest=digest(pins),previous_digest=None, + entry_digest='',result='updated')) + data['signature_source']='custodial' + _save_pins(tx,subject,pins) + return HandlerOutput(data=data) + @op('achievement.pin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True) async def pin(ctx, request, tx): subject=await pin_owner(ctx,request,tx) @@ -181,8 +217,7 @@ async def pin(ctx, request, tx): pins=_pins(tx,subject) if gid not in pins: pins.append(gid) - _save_pins(tx,subject,pins) - return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins}) + return await save_display(ctx,request,tx,subject,pins) @op('achievement.unpin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True) async def unpin(ctx, request, tx): @@ -190,8 +225,7 @@ async def unpin(ctx, request, tx): gid=request.arguments['grant_id'] _owned_grant(tx,subject,gid,active=False) pins=[item for item in _pins(tx,subject) if item!=gid] - _save_pins(tx,subject,pins) - return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins}) + return await save_display(ctx,request,tx,subject,pins) @op('achievement.reorder',obj({'grant_ids':{'type':'array','items':IDENTIFIER, 'uniqueItems':True,'maxItems':MAX_PINS}},('grant_ids',)),signature=True) @@ -199,8 +233,7 @@ async def reorder(ctx, request, tx): subject=await pin_owner(ctx,request,tx) pins=list(request.arguments['grant_ids']) require(set(pins)==set(_pins(tx,subject)), 'achievement_pin_set_mismatch') - _save_pins(tx,subject,pins) - return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins}) + return await save_display(ctx,request,tx,subject,pins) @op('achievement.start', obj()) async def start(ctx, request, tx): @@ -214,7 +247,10 @@ async def start(ctx, request, tx): # expired attempts may be replaced; a live challenge is not superseded. for (raw,) in tx.execute("SELECT body FROM achievement_ceremonies WHERE subject=? AND state='active'", (subject,)): - require(ctx.now >= parse_time(loads(raw)['expires_at']), 'achievement_ceremony_active') + previous = loads(raw) + require(ctx.now >= min(parse_time(previous['expires_at']), + parse_time(previous['round_expires_at'])), + 'achievement_ceremony_active') tx.execute('DELETE FROM achievement_ceremonies WHERE subject=?', (subject,), write=True) state = {'id': new_id('achc'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id, 'spec_version': I_AM_NOT_HUMAN.version, 'status': 'active', 'round': 1, @@ -274,9 +310,16 @@ async def complete(ctx, request, tx): return _failure(state, tx, reason) if args['statement'] != FINAL_STATEMENT or args['ceremony_digest'] != ceremony_digest(state): return _failure(state, tx, 'final_confirmation_failed') - # AuthenticationService already verified the signed request bytes with - # the subject's active signing key. Token-only actors cannot reach here. - require(ctx.principal.method == 'signature', 'self_custody_signature_required') + signature_source = 'self-custody' + if ctx.principal.method == 'token': + state['final_confirmation'] = {'subject_id': subject, 'challenge_id': state['id'], + 'ceremony_digest': args['ceremony_digest'], 'statement': args['statement'], + 'request_id': request.request_id, 'signature_source': 'custodial'} + state['final_signature'] = await _custodial_signature(app,ctx,tx, + state['final_confirmation'],'achievement-confirmation') + signature_source = 'custodial' + else: + require(ctx.principal.method == 'signature', 'self_custody_signature_required') evidence = digest({'challenge_id': state['id'], 'answers': state['answers'], 'strategy': state['strategy'], 'strategy_version': state['strategy_version'], 'ceremony_digest': args['ceremony_digest']}) @@ -286,8 +329,10 @@ async def complete(ctx, request, tx): grant = {'id': new_id('achg'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id, 'spec_version': I_AM_NOT_HUMAN.version, 'issuer': app.receipt_signer.key_id, 'issued_at': wire(ctx.now), 'claim': I_AM_NOT_HUMAN.claim, - 'auth_method': 'signature', 'evidence_digest': evidence, 'automatic': True, + 'auth_method': ctx.principal.method, 'evidence_digest': evidence, 'automatic': True, 'revoked_at': None, 'metadata': {'protocol_passed': True}} + if signature_source == 'custodial': + grant['metadata']['signature_source'] = signature_source grant['signature'] = wire(app.receipt_signer.sign(canonical(grant), purpose='achievement-grant')) tx.execute('INSERT INTO achievement_grants (id,subject,achievement_id,spec_version,body) VALUES (?,?,?,?,?)', (grant['id'], subject, grant['achievement_id'], grant['spec_version'], @@ -298,7 +343,7 @@ async def complete(ctx, request, tx): data={'achievement_id': grant['achievement_id'], 'challenge_id': state['id'], 'strategy': state['strategy'], 'strategy_version': state['strategy_version'], 'round_digests': [digest(item) for item in state['answers']], - 'auth_method': 'signature', 'signature_source': 'self-custody', + 'auth_method': ctx.principal.method, 'signature_source': signature_source, 'evidence_digest': evidence, 'automatic': True, 'certificate_id': grant['id']}) await tx.append_audit(AuditEvent(event=audit, authority=(ResourceRef(id=subject),), @@ -311,6 +356,6 @@ async def complete(ctx, request, tx): state.pop('expected_digest', None) state.pop('nonce_digest', None) _save(tx, state) - return HandlerOutput(data={'grant': public_grant(grant), 'signature_source': 'self-custody'}) + return HandlerOutput(data={'grant': public_grant(grant), 'signature_source': signature_source}) finish() diff --git a/src/msg/plugins/bounty.py b/src/msg/plugins/bounty.py index 03610035..4bb075bb 100644 --- a/src/msg/plugins/bounty.py +++ b/src/msg/plugins/bounty.py @@ -81,8 +81,24 @@ def _claim_count(tx, listing_id, claimant=None): (listing_id, claimant))[0]) -def projection(tx, listing_id, now): +async def validate_contract(app, tx, row): + """Mutable accounting state cannot change a publisher's immutable terms.""" + from msg.market.catalog import read_listing_body + resource = await tx.resource(row['listing_id']) + body, _ = await read_listing_body(app, tx, resource) + require(resource.type == 'listing' and resource.owner == row['publisher'] and + body.get('mode') == 'bounty' and body.get('currency_id') == CURRENCY_ID and + all(body.get(name) == row[name] for name in ( + 'reward_minor', 'max_claims', 'claim_limit_per_subject', + 'verifier_id', 'verifier_version', 'eligibility', 'expires_at')), + 'bounty_contract_mismatch') + # Budget and state intentionally differ after funding, claims or pause. + # Their validity is checked against the append-only ledger separately. + + +async def projection(app, tx, listing_id, now): row = _row(tx, listing_id) + await validate_contract(app, tx, row) return _public(row, _balance(tx,row['escrow_subject']), _claim_count(tx,listing_id), now) @@ -175,7 +191,7 @@ async def create(ctx, request, tx): async def get(ctx, request, tx): row = _row(tx, request.arguments['listing_id']) await check_access(app, ctx, request, tx, row['listing_id'], 'read') - return HandlerOutput(data={'bounty': projection(tx,row['listing_id'],ctx.now)}) + return HandlerOutput(data={'bounty': await projection(app,tx,row['listing_id'],ctx.now)}) @op('bounty.top_up', obj({'listing_id': IDENTIFIER, 'amount_minor': amount}, ('listing_id', 'amount_minor')), signature=True, requirements=account_requirements) @@ -184,6 +200,7 @@ async def top_up(ctx, request, tx): row = _row(tx, request.arguments['listing_id']) require(row['publisher'] == publisher, 'bounty_not_found') await check_access(app, ctx, request, tx, row['listing_id'], 'write') + await validate_contract(app, tx, row) require(row['state'] != 'closed', 'bounty_closed') require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now, 'bounty_expired') @@ -212,6 +229,7 @@ async def challenge(ctx, request, tx): row = _row(tx, request.arguments['listing_id']) await check_access(app, ctx, request, tx, row['listing_id'], 'read') _ready(tx, row, ctx.now, claimant) + await validate_contract(app, tx, row) key = tx.one('''SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1 AND retired_at IS NULL''', (claimant,)) require(key is not None and key[0] == ctx.principal.credential_id, @@ -236,6 +254,7 @@ async def close(ctx, request, tx): row = _row(tx, request.arguments['listing_id']) require(row['publisher'] == publisher, 'bounty_not_found') await check_access(app, ctx, request, tx, row['listing_id'], 'write') + await validate_contract(app, tx, row) require(row['state'] != 'closed', 'bounty_closed') remaining = _balance(tx, row['escrow_subject']) refund = None @@ -261,6 +280,7 @@ async def claim(ctx, request, tx): listing = _row(tx, row[0]) await check_access(app, ctx, request, tx, listing['listing_id'], 'read') _ready(tx, listing, ctx.now, claimant) + await validate_contract(app, tx, listing) require(row[6] is None, 'bounty_challenge_consumed') require(parse_time(row[3]) > ctx.now, 'bounty_challenge_expired') require(row[4] == VERIFIER_VERSION, 'bounty_verifier_unsupported') @@ -327,23 +347,25 @@ async def pause(ctx,request,tx): row = _row(tx,request.arguments['listing_id']) require(row['publisher'] == _subject(ctx),'bounty_not_found') await check_access(app,ctx,request,tx,row['listing_id'],'write') + await validate_contract(app, tx, row) require(row['state'] != 'closed','bounty_closed') tx.execute("UPDATE bounty_listings SET state='paused',pause_reason='publisher_paused' WHERE listing_id=?", (row['listing_id'],),write=True) - return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)}) + return HandlerOutput(data={'bounty':await projection(app,tx,row['listing_id'],ctx.now)}) @op('bounty.resume', obj({'listing_id':IDENTIFIER},('listing_id',)), signature=True, requirements=account_requirements) async def resume(ctx,request,tx): row = _row(tx,request.arguments['listing_id']) require(row['publisher'] == _subject(ctx),'bounty_not_found') await check_access(app,ctx,request,tx,row['listing_id'],'write') + await validate_contract(app, tx, row) require(row['state'] != 'closed','bounty_closed') require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now,'bounty_expired') require(_claim_count(tx,row['listing_id']) < row['max_claims'],'bounty_claims_exhausted') require(_balance(tx,row['escrow_subject']) >= row['reward_minor'],'bounty_out_of_budget') tx.execute("UPDATE bounty_listings SET state='active',pause_reason=NULL WHERE listing_id=?", (row['listing_id'],),write=True) - return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)}) + return HandlerOutput(data={'bounty':await projection(app,tx,row['listing_id'],ctx.now)}) @op('bounty.claims',obj({'listing_id':IDENTIFIER,'cursor':IDENTIFIER, 'limit':{'type':'integer','minimum':1,'maximum':100}},('listing_id',)),effect='read', requirements=account_requirements) @@ -352,6 +374,7 @@ async def claims(ctx,request,tx): subject = _owner(ctx) row = _row(tx,request.arguments['listing_id']) await check_access(app,ctx,request,tx,row['listing_id'],'read') + await validate_contract(app, tx, row) args = request.arguments limit = args.get('limit',50) rows = tx.rows('''SELECT id,claimant,challenge_id,proof_digest,status,reward_minor, diff --git a/src/msg/plugins/collaboration.py b/src/msg/plugins/collaboration.py index 95d31c42..f978dc61 100644 --- a/src/msg/plugins/collaboration.py +++ b/src/msg/plugins/collaboration.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +import time from msg.core.codec import canonical, loads, parse_time, wire from msg.core.errors import Failure, require @@ -46,10 +47,11 @@ async def _visible_refs(app, ctx, request, tx, refs): output = [] for rid in refs: try: - await check_access(app, ctx, request, tx, rid, 'read') + await _safe_ref(app, ctx, request, tx, rid) except Failure as exc: if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate', - 'delegation_scope', 'ancestor_inactive', 'not_found'}: + 'delegation_scope', 'ancestor_inactive', 'not_found', + 'collaboration_ref_forbidden'}: continue raise output.append(rid) @@ -202,27 +204,37 @@ async def lease_get(ctx, request, tx): async def lease_list(ctx, request, tx): holder = await _self(app, ctx, request, tx) limit = request.arguments.get('limit', 50) - rows = tx.rows('SELECT id,target,status,expires_at,body FROM collaboration_leases ' - 'WHERE holder=? AND id>? ORDER BY id', - (holder, request.arguments.get('after', ''))) + position = request.arguments.get('after', '') records = [] more = False - for _, target, status, expires, raw in rows: - try: - await _safe_ref(app, ctx, request, tx, target) - except Failure as exc: - if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate', - 'delegation_scope', 'ancestor_inactive', 'not_found', - 'collaboration_ref_forbidden'}: - continue - raise - record = loads(raw) - record['effective_status'] = ('expired' if status == 'active' and - parse_time(expires) <= ctx.now else status) - if len(records) == limit: - more = True + scanned = 0 + while not more: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + rows = tx.rows('SELECT id,target,status,expires_at,body FROM collaboration_leases ' + 'WHERE holder=? AND id>? ORDER BY id LIMIT 128', + (holder, position)) + for lease_id, target, status, expires, raw in rows: + scanned += 1 + require(scanned <= 4096 and time.monotonic() < ctx.deadline_monotonic, + 'query_cost_exceeded') + position = lease_id + try: + await _safe_ref(app, ctx, request, tx, target) + except Failure as exc: + if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate', + 'delegation_scope', 'ancestor_inactive', 'not_found', + 'collaboration_ref_forbidden'}: + continue + raise + if len(records) == limit: + more = True + break + record = loads(raw) + record['effective_status'] = ('expired' if status == 'active' and + parse_time(expires) <= ctx.now else status) + records.append(record) + if len(rows) < 128: break - records.append(record) return HandlerOutput(data={'items': records, 'next_after': records[-1]['id'] if more else None}) diff --git a/src/msg/plugins/collaboration_resources.py b/src/msg/plugins/collaboration_resources.py new file mode 100644 index 00000000..edad323a --- /dev/null +++ b/src/msg/plugins/collaboration_resources.py @@ -0,0 +1,230 @@ +"""Explicit collaboration records backed by ordinary Resource and Revision facts.""" +import time + +from msg.core.codec import canonical, loads, parse_time, wire +from msg.core.errors import Failure, require +from msg.core.models import HandlerOutput, Relation, ResourceRef, ResourceTypeSpec +from msg.plugins.collaboration import _safe_ref, _self +from msg.plugins.common import ( + assert_generation, + check_access, + create_resource, + new_id, + resolve, + revise_resource, +) +from msg.plugins.schemas import IDENTIFIER, STRING, obj + +KINDS = {'request': 'collab_request', 'offer': 'collab_offer', 'checkpoint': 'checkpoint', 'proposal': 'collab_proposal'} +REFS = {'type': 'array', 'items': IDENTIFIER, 'maxItems': 16, 'uniqueItems': True} +TEXT = {'type': 'string', 'minLength': 1, 'maxLength': 4096} + + +def resource_types(app): + types = [] + fields = { + 'request': {'title': TEXT, 'description': TEXT, 'requirements': TEXT, + 'requester': IDENTIFIER, 'assignee': {'type': ['string', 'null']}, + 'due_at': STRING, 'status': {'enum': ['open', 'claimed', 'fulfilled', 'cancelled']}}, + 'offer': {'description': TEXT, 'scope': TEXT, 'availability': TEXT, + 'capability_hint': TEXT, 'status': {'enum': ['active', 'withdrawn']}}, + 'proposal': {'author': IDENTIFIER, 'message': TEXT, + 'status': {'enum': ['open', 'accepted', 'rejected', 'withdrawn', 'superseded']}}, + 'checkpoint': {'summary': TEXT, 'resume_hint': TEXT, 'status': {'const': 'active'}}, + } + required = {'request': ('title', 'description', 'requirements', 'requester', 'assignee'), + 'offer': ('description', 'scope', 'availability'), 'checkpoint': ('summary',), 'proposal': ('author', 'message')} + for kind, name in KINDS.items(): + ref = ResourceRef(id='schema:collaboration-record:' + kind + ':1') + app.registry.add_schema(ref, obj({**fields[kind], 'subject': IDENTIFIER, + 'created_at': STRING, 'updated_at': STRING, 'expires_at': STRING}, + (*required[kind], 'subject', 'created_at', 'status'))) + types.append(ResourceTypeSpec(name=name, version=1, container=False, + content_schema=ref, operations=frozenset(), + relations=frozenset({'reference', 'state', 'target', 'content'}))) + return tuple(types) + + +async def _folder(app, ctx, request, tx, subject, kind): + name = kind + 's' + row = tx.one('SELECT id FROM resources WHERE parent=? AND name=?', (subject, name)) + if row: + folder = await tx.resource(row[0]) + require(folder.type == 'topic' and folder.owner == subject and + folder.state == 'active' and folder.mode == 0o700, + 'collaboration_folder_conflict') + return folder.id + folder = await create_resource(app, ctx, request, tx, parent=subject, + type='topic', name=name, mode=0o700) + return folder.id + + +async def _load(app, ctx, request, tx, kind): + rid = await resolve(tx, request.arguments['id']) + resource = await tx.resource(rid) + require(resource.type == KINDS[kind], 'collaboration_not_found') + await check_access(app, ctx, request, tx, rid, 'read') + revision = await tx.revision(ResourceRef(id=rid)) + record = loads(await app.contents.read_bytes(revision.content)) + return resource, revision, record + + +async def _project(app, ctx, request, tx, resource, revision, record): + result = {**record, 'id': resource.id, 'revision': revision.id, + 'generation': resource.generation, 'resource_refs': []} + for relation in revision.relations: + try: + await _safe_ref(app, ctx, request, tx, relation.target.id) + except Failure as exc: + if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate', + 'delegation_scope', 'ancestor_inactive', 'not_found', + 'collaboration_ref_forbidden'}: + continue + raise + if relation.type in {'target', 'content'}: + result[relation.type + '_ref'] = wire(relation.target) + elif relation.type == 'state': + result['state_ref'] = relation.target.id + else: + result['resource_refs'].append(relation.target.id) + if (record.get('expires_at') and parse_time(record['expires_at']) <= ctx.now + and record['status'] in {'open', 'claimed', 'active'}): + result['effective_status'] = 'expired' + result.setdefault('effective_status', record['status']) + return result + + +def _result(resource, record): + # Replay stores no target refs or authored prose; reads recheck current ACLs. + return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=resource.revision),), + data={'id': resource.id, 'generation': resource.generation, 'status': record['status']}) + + +def install(app, op): + async def create(ctx, request, tx, kind): + subject = await _self(app, ctx, request, tx) + args = request.arguments + for name in ('expires_at', 'due_at'): + if args.get(name): + require(parse_time(args[name]) > ctx.now, 'collaboration_expiry_invalid') + if args.get('due_at') and args.get('expires_at'): + require(parse_time(args['due_at']) <= parse_time(args['expires_at']), + 'collaboration_expiry_invalid') + relations = [Relation(type='reference', target=ResourceRef( + id=await _safe_ref(app, ctx, request, tx, rid))) for rid in args.get('resource_refs', ())] + if args.get('state_ref'): + relations.append(Relation(type='state', target=ResourceRef( + id=await _safe_ref(app, ctx, request, tx, args['state_ref'])))) + record = {key: value for key, value in args.items() if key not in {'resource_refs', 'state_ref'}} + record.update(subject=subject, created_at=wire(ctx.now), + status='open' if kind == 'request' else 'active') + if kind == 'request': + record.update(requester=subject, assignee=None) + parent = await _folder(app, ctx, request, tx, subject, kind) + resource = await create_resource(app, ctx, request, tx, parent=parent, + type=KINDS[kind], name=new_id(kind), body=canonical(record), + media_type='application/json', relations=relations, mode=0o600) + return _result(resource, record) + + @op('communication.request_create', obj({'title': TEXT, 'description': TEXT, + 'resource_refs': REFS, 'requirements': TEXT, 'due_at': STRING, + 'expires_at': STRING}, ('title', 'description', 'requirements')), signature=True) + async def request_create(ctx, request, tx): + return await create(ctx, request, tx, 'request') + + @op('communication.offer_create', obj({'description': TEXT, 'capability_hint': TEXT, + 'scope': TEXT, 'availability': TEXT, 'resource_refs': REFS, + 'expires_at': STRING}, ('description', 'scope', 'availability')), signature=True) + async def offer_create(ctx, request, tx): + return await create(ctx, request, tx, 'offer') + + @op('communication.checkpoint_create', obj({'summary': TEXT, 'resume_hint': TEXT, + 'resource_refs': REFS, 'state_ref': IDENTIFIER, 'expires_at': STRING}, + ('summary', 'resource_refs')), signature=True) + async def checkpoint_create(ctx, request, tx): + return await create(ctx, request, tx, 'checkpoint') + + def install_reads(kind): + @op('communication.' + kind + '_get', obj({'id': IDENTIFIER}, ('id',)), effect='read') + async def get(ctx, request, tx): + resource, revision, record = await _load(app, ctx, request, tx, kind) + return HandlerOutput(data={kind: await _project(app, ctx, request, tx, + resource, revision, record)}) + + @op('communication.' + kind + '_list', obj({ + 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100}, + 'after': IDENTIFIER}), effect='read') + async def list_records(ctx, request, tx): + subject = await _self(app, ctx, request, tx) + limit = request.arguments.get('limit', 50) + position = request.arguments.get('after', '') + items = [] + scanned = 0 + while len(items) <= limit: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + rows = tx.rows('SELECT id FROM resources WHERE owner=? AND type=? ' + "AND state='active' AND id>? ORDER BY id LIMIT 128", + (subject, KINDS[kind], position)) + for (rid,) in rows: + scanned += 1 + require(scanned <= 4096 and time.monotonic() < ctx.deadline_monotonic, + 'query_cost_exceeded') + position = rid + try: + await check_access(app, ctx, request, tx, rid, 'read') + except Failure as exc: + if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate', + 'delegation_scope', 'ancestor_inactive', 'not_found'}: + continue + raise + resource = await tx.resource(rid) + revision = await tx.revision(ResourceRef(id=rid)) + record = loads(await app.contents.read_bytes(revision.content)) + items.append(await _project(app, ctx, request, tx, resource, revision, record)) + if len(items) > limit: + break + if len(rows) < 128: + break + return HandlerOutput(data={'items': items[:limit], + 'next_after': items[limit - 1]['id'] if len(items) > limit else None}) + + for kind in KINDS: + install_reads(kind) + + from msg.plugins.proposals import install as install_proposals + install_proposals(app, op) + + def install_transition(kind, action): + @op('communication.' + kind + '_' + action, + obj({'id': IDENTIFIER}, ('id',)), signature=True) + async def transition(ctx, request, tx): + subject = await _self(app, ctx, request, tx) + resource, revision, record = await _load(app, ctx, request, tx, kind) + await assert_generation(request, resource) + require(not record.get('expires_at') or parse_time(record['expires_at']) > ctx.now, + 'collaboration_expired') + if action == 'claim': + require(record['status'] == 'open', 'request_not_open') + record.update(status='claimed', assignee=subject) + elif action == 'fulfill': + require(record['status'] == 'claimed', 'request_not_claimed') + require(subject == record['assignee'], 'collaboration_actor_forbidden') + record['status'] = 'fulfilled' + else: + require(subject == resource.owner, 'collaboration_actor_forbidden') + require(record['status'] in ({'open', 'claimed'} if kind == 'request' else {'active'}), + 'collaboration_inactive') + record['status'] = 'cancelled' if action == 'cancel' else 'withdrawn' + record['updated_at'] = wire(ctx.now) + # The preceding immutable revision retains historical references. + # The new revision carries only references visible to this actor. + visible = await _project(app, ctx, request, tx, resource, revision, record) + refs = set(visible['resource_refs']) | {visible.get('state_ref')} + relations = tuple(r for r in revision.relations if r.target.id in refs) + resource = await revise_resource(app, ctx, request, tx, resource, + canonical(record), 'application/json', relations=relations) + return _result(resource, record) + + for kind, action in (('request', 'claim'), ('request', 'fulfill'), + ('request', 'cancel'), ('offer', 'withdraw')): + install_transition(kind, action) diff --git a/src/msg/plugins/common.py b/src/msg/plugins/common.py index 7cbd811e..36851c17 100644 --- a/src/msg/plugins/common.py +++ b/src/msg/plugins/common.py @@ -1,6 +1,7 @@ """Explicit resource use cases shared by content and extensions.""" from __future__ import annotations import re +import hashlib from dataclasses import replace from uuid import uuid4 from msg.constants import * @@ -26,6 +27,16 @@ async def resolve(session,value): return (await session.resource(value)).id +async def resolve_read(session,value): + """Read-only migration lookup; authorization still uses the current object.""" + try: + return await resolve(session,value) + except Failure as exc: + if exc.code!='not_found' or not isinstance(value,str) or not value.startswith('/'): + raise + return await session.resolve_migrated(value) + + async def check_access(app,context,request,session,rid,check): return await app.authorizer.require(context,request,(AccessRequirement(resource_id=rid, operation=operation_id(request),check=check),),session) @@ -61,6 +72,8 @@ async def protect_namespace(app,ctx,request,tx,parent,name): async def create_resource(app,ctx,request,tx, *, parent,type,name=None,body=None,media_type='text/markdown', relations=(),mode=None,resource_id=None,author=None,content_signature=None,revision_id=None): parent=await tx.resource(parent) + require(type not in {'order', 'order_collection'} and parent.type != 'order_collection', + 'order_controlled_resource') require(parent.state=='active','ancestor_inactive') require(app.registry.resource_type(parent.type,parent.type_version).container,'not_a_container') app.registry.resource_type(type,1) @@ -110,6 +123,7 @@ async def create_resource(app,ctx,request,tx, *, parent,type,name=None,body=None async def revise_resource(app,ctx,request,tx,resource,body,media_type='text/markdown', *, relations=(),author=None, signature=None,revision_id=None,change_note=None,source_kind=None, source_version=None,source_digest=None,content_created_at=None): + require(resource.type not in {'order', 'order_collection'}, 'order_controlled_resource') from msg.core.models import BlobRef # Internal operation labels (e.g. transfer.seal publishing) carry no client arguments. timestamp=(content_created_at if content_created_at is not None else @@ -121,6 +135,18 @@ async def revise_resource(app,ctx,request,tx,resource,body,media_type='text/mark require(signature is None or revision_id is not None,'revision_id_required') if isinstance(body,BlobRef): blob=body + # A retained index/reference is not evidence the payload survived. + # Verify copies and reused blobs before publishing another Revision. + hasher=hashlib.sha256() + size=0 + try: + async for chunk in app.contents.read(blob): + size+=len(chunk) + hasher.update(chunk) + except FileNotFoundError as exc: + raise Failure('content_missing') from exc + require(size==blob.size and 'sha256:'+hasher.hexdigest()==blob.digest, + 'content_digest_mismatch') else: blob=await app.contents.put_bytes(body.encode('utf-8') if isinstance(body,str) else body,media_type) for relation in relations: @@ -174,6 +200,8 @@ async def revise_resource(app,ctx,request,tx,resource,body,media_type='text/mark text=(await app.contents.read_bytes(blob)).decode('utf-8',errors='replace') tx.execute('INSERT INTO projections VALUES (?,?) ON CONFLICT(resource_id) DO UPDATE SET text=excluded.text', (resource.id,text),write=True) + else: + tx.execute('DELETE FROM projections WHERE resource_id=?',(resource.id,),write=True) return updated @@ -194,6 +222,29 @@ async def topic_policy(tx,resource): return tx.setting('policy:'+topic.id,{}) +def default_operation_rules(name): + """Compatibility defaults captured when constructing an operation.""" + if name.startswith('identity.'): + rules=('identity','auth') + elif name.startswith('content.topic_') or name.startswith('discussion.'): + rules=('topics','read-write') + elif name.startswith('content.'): + rules=('read-write',) + elif name.startswith('file.'): + rules=('files','read-write','protocol') + elif name.startswith(('transfer.','keystore.','git.')): + rules=('files','protocol') + elif name.startswith(('cert.','group.')): + rules=('auth',) + elif name.startswith(('system.','tool.')): + rules=('security','protocol') + elif name.startswith('discovery.'): + rules=('read-write','protocol') + else: + rules=('protocol',) + return tuple('msg.'+rule for rule in rules) + + def registration(app,name,dependencies=()): from msg.core.models import OperationSpec,PluginManifest,ResourceRef from msg.plugins.schemas import OUTPUT @@ -201,17 +252,20 @@ def registration(app,name,dependencies=()): output_ref=ResourceRef(id='schema:operation-result') if output_ref.id not in app.registry._schemas: app.registry.add_schema(output_ref,OUTPUT) - def operation(opname,schema, *, effect='transaction',requirements=no_requirements,signature=False,version=1): + def operation(opname,schema, *, effect='transaction',requirements=no_requirements,signature=False,version=1,requires_rules=None): def decorate(handler): ref=ResourceRef(id='schema:'+opname+':'+str(version)) app.registry.add_schema(ref,schema) operations.append(OperationSpec(name=opname,version=version,input_schema=ref,output_schema=output_ref, effect=effect,entries=frozenset({'network','worker'}),require_signature=signature, - requirements=requirements,handler=handler)) + requirements=requirements,handler=handler, + requires_rules=default_operation_rules(opname) if requires_rules is None else tuple(requires_rules))) return handler return decorate def finish(resource_types=()): + from msg.plugins.features import feature_ids manifest=PluginManifest(name=name,version='1',dependencies=tuple(dependencies), - resource_types=tuple(resource_types),capabilities=(),operations=tuple(operations),migrations=()) + resource_types=tuple(resource_types),capabilities=(),operations=tuple(operations),migrations=(), + feature_ids=feature_ids(name)) app.registry.add(manifest) return operation,finish diff --git a/src/msg/plugins/communication.py b/src/msg/plugins/communication.py index a97ce67a..d613e601 100644 --- a/src/msg/plugins/communication.py +++ b/src/msg/plugins/communication.py @@ -33,8 +33,28 @@ def _webhook_subscription_key(subject,resource_id): return 'webhook_subscription:'+subject+':'+resource_id +def _domain_delivery_exists(tx,event_id,recipient,resource_id,category): + # New keys use the unique index directly. Older installations also include + # scope_id, so match that event's literal prefix when checking legacy jobs. + # Punctuation ranges are not prefix ranges under linguistic DB collations. + key=f'{event_id}:{recipient}:{resource_id}:{category}:webhook' + exact=tx.one("SELECT body FROM jobs WHERE kind='webhook' AND dedupe=?",(key,)) + prefix=event_id.replace('!','!!').replace('%','!%').replace('_','!_')+':%' + rows=(exact,) if exact is not None else tx.rows( + "SELECT body FROM jobs WHERE kind='webhook' AND dedupe LIKE ? ESCAPE '!'",(prefix,)) + for (raw,) in rows: + job=decode(EffectJob,loads(raw)) + if (job.event_id==event_id and job.operation=='communication.webhook_subscribe' and + job.arguments.get('recipient_subject')==recipient and + job.arguments.get('resource_id')==resource_id and + job.arguments.get('category')==category): + return True + return False + + async def enqueue_domain_webhooks(app,tx,event): """Project an explicit owner's Event subscription inside the Event transaction.""" + from msg.workers.effects import current_principal, effect_request, worker_context category=WEBHOOK_DOMAIN_EVENTS.get(event.type) if category is None: return @@ -55,14 +75,31 @@ async def enqueue_domain_webhooks(app,tx,event): # that can be retargeted by an event emitted from another account. if principal.subject!=owner or principal.actor!=owner or principal.method!='signature': continue - require_webhook_capacity(tx) - await tx.enqueue(EffectJob(id=new_id('job'),event_id=event.id,kind='webhook', - dedupe_key=f'{event.id}:{owner}:{scope.id}:{ref.id}:{category}:webhook', - principal=principal,operation='communication.webhook_subscribe', + # A resource subscription wins over its parent only if its captured + # authority is still valid. Once queued, that exact subscription and + # endpoint generation remain pinned; revocation never selects a new one. + dedupe_key=f'{event.id}:{owner}:{ref.id}:{category}:webhook' + if _domain_delivery_exists(tx,event.id,owner,ref.id,category): + continue + job=EffectJob(id=new_id('job'),event_id=event.id,kind='webhook', + dedupe_key=dedupe_key,principal=principal,operation='communication.webhook_subscribe', arguments={'recipient_subject':owner,'resource_id':ref.id,'scope_id':scope.id, 'category':category,'endpoint_generation':endpoint[1], 'subscription_generation':subscription['generation']}, - state='pending',attempts=0,next_attempt_at=event.time,lease_until=None)) + state='pending',attempts=0,next_attempt_at=event.time,lease_until=None) + try: + current=await current_principal(app,principal,tx) + require(resource.state=='active' and scope.state=='active','webhook_scope_changed') + require(await app.authorizer.has(current,'webhook.domain', + 'communication.webhook_subscribe@1',scope.id,tx),'capability_required') + context=worker_context(app,job,current) + request=effect_request(app,job,current) + await check_access(app,context,request,tx,scope.id,'read') + await check_access(app,context,request,tx,resource.id,'read') + except Failure: + continue + require_webhook_capacity(tx) + await tx.enqueue(job) def sync_seen_context(subject,sequence,expires_at): @@ -243,6 +280,7 @@ async def webhook_subscription(ctx,request,tx): async def presence_get(ctx,request,tx): subject=await resolve(tx,request.arguments['subject_id']) await tx.subject(subject) + await check_access(app,ctx,request,tx,subject,'read') row=tx.one('SELECT expires_at,body FROM presence WHERE subject=?',(subject,)) if row is None or parse_time(row[0])<=ctx.now: return HandlerOutput(data={'subject_id':subject,'state':'unknown'}) @@ -378,7 +416,7 @@ async def dm_request(ctx,request,tx): require(row[1]!='rejected','dm_rejected') return HandlerOutput(resources=(ResourceRef(id=row[0]),), data={'conversation_id':row[0],'state':row[1],'participant_pair':[first,second]}) - topic=await create_resource(app,ctx,request,tx,parent=ROOT_SPACE,type='topic', + topic=await create_resource(app,ctx,request,tx,parent=app.namespace_root,type='topic', name='dm-'+new_id('c'),mode=0o700) tx.set_setting('policy:'+topic.id,{'post_mode':'0600','editable':True}) tx.execute('''INSERT INTO dm_conversations @@ -494,7 +532,7 @@ async def send(ctx,request,tx): require_webhook_capacity(tx) await tx.enqueue(EffectJob(id=new_id('job'),event_id=eid,kind='webhook', dedupe_key=f'{eid}:{recipient}:webhook',principal=ctx.principal, - operation=request.operation,arguments={'recipient_subject':recipient, + operation=request.operation,arguments={'contract_version':request.contract_version,'recipient_subject':recipient, 'message_id':record['id'],'endpoint_generation':row[1]}, state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None)) # Notification is an external projection; it never includes private body content. @@ -505,20 +543,26 @@ async def send(ctx,request,tx): if email.verified_at and 'communication.send' in email.enabled_events: await tx.enqueue(EffectJob(id=new_id('job'),event_id=eid,kind='mail', dedupe_key=f'{eid}:{recipient}:mail',principal=ctx.principal,operation=request.operation, - arguments={'recipient':email.address,'recipient_subject':recipient,'subject':'New msg reference', + arguments={'contract_version':request.contract_version,'recipient':email.address,'recipient_subject':recipient,'subject':'New msg reference', 'text':app.settings.service_url+'/_id/'+ref.id},state='pending',attempts=0, next_attempt_at=ctx.now,lease_until=None)) return HandlerOutput(resources=(ref,),data={'message_id':record['id'],'recipient':recipient}) async def watch(ctx,request,tx): + require(ctx.principal.subject is not None,'authentication_required') rid=await resolve(tx,request.arguments['id']) - await check_access(app,ctx,request,tx,rid,'read') + if request.operation=='communication.watch': + await check_access(app,ctx,request,tx,rid,'read') + await app.authorizer.require_base(ctx.principal,operation_id(request),ctx.principal.subject,tx) await app.authorizer._ceiling(ctx.principal,operation_id(request),rid,tx) + from msg.plugins.watches import legacy + await legacy(app,ctx,request,tx,rid,request.operation=='communication.watch') if request.operation=='communication.watch': tx.execute('INSERT OR IGNORE INTO watches VALUES (?,?)',(ctx.principal.subject,rid),write=True) else: tx.execute('DELETE FROM watches WHERE subject=? AND resource=?',(ctx.principal.subject,rid),write=True) - return HandlerOutput(resources=(ResourceRef(id=rid),),data={'watching':request.operation=='communication.watch'}) + return HandlerOutput(resources=(ResourceRef(id=rid),) if request.operation=='communication.watch' else (), + data={'watching':request.operation=='communication.watch'}) for name in ('communication.watch','communication.unwatch'): op(name,obj({'id':IDENTIFIER},('id',)))(watch) @@ -847,7 +891,15 @@ async def can_read(rid): 'next_requires_auth':True}) from msg.plugins.collaboration import install as install_collaboration install_collaboration(app, op) + from msg.plugins.collaboration_resources import install as install_records, resource_types + install_records(app, op) from msg.plugins.following import install as install_following install_following(app, op) - finish((ResourceTypeSpec(name='claim',version=1,container=False,content_schema=None, - operations=frozenset(),relations=frozenset()),)) + from msg.plugins.watches import install as install_watches + install_watches(app, op) + from msg.plugins.receipts import install as install_receipts + install_receipts(app, op) + finish((ResourceTypeSpec(name='watch',version=1,container=False,content_schema=None, + operations=frozenset(),relations=frozenset()), + ResourceTypeSpec(name='claim',version=1,container=False,content_schema=None, + operations=frozenset(),relations=frozenset()), *resource_types(app))) diff --git a/src/msg/plugins/content.py b/src/msg/plugins/content.py index 0d72d725..3c5c43a0 100644 --- a/src/msg/plugins/content.py +++ b/src/msg/plugins/content.py @@ -6,7 +6,7 @@ from msg.constants import ROOT_SPACE,ROOT_SUBJECT from msg.core.codec import canonical,decode,loads,wire,unb64,digest,parse_time from msg.core.errors import Failure,require -from msg.core.models import HandlerOutput,ResourceRef,Relation,EffectJob,BlobRef,Event +from msg.core.models import HandlerOutput,ResourceRef,Relation,EffectJob,BlobRef,Event,TemplateSpec from msg.core.tags import normalize_tags from msg.core.text_patch import (PATCH_LIMIT,PATCH_CONTEXT_LIMIT,PATCH_CANDIDATE_LIMIT, PATCH_SCHEMA,apply_text_patch,apply_patch,validate_patch) @@ -85,7 +85,16 @@ async def template_content(app,ctx,request,tx,parent,template,values): rev=await tx.revision(ref) source=(await app.contents.read_bytes(rev.content)).decode('utf-8') parsed=parse_template(source) - normalized=normalize_values(parsed,values or {}) + # Installed defaults have a frozen field contract. User-owned templates and + # later revisions remain resource data, parsed under the same finite DSL. + try: + registered=app.registry.template(rid,parsed.version) + except Failure as exc: + if exc.code!='unknown_template': + raise + registered=None + fields=registered if registered is not None and registered.digest==rev.content.digest else parsed + normalized=normalize_values(fields,values or {}) content={'template_id':rid,'template_version':parsed.version,'template_digest':rev.content.digest,'values':normalized} return canonical(content),'application/json',content,Relation(type='template',target=ref) @@ -152,6 +161,7 @@ async def removable(app,ctx,request,tx,resource): async def require_unmanaged_personal(tx,resource): + require(resource.type not in {'order', 'order_collection'}, 'order_controlled_resource') chain=(*await tx.ancestors(resource.id),resource) require(not any(parent.type=='user' and child.name in {'SOUL.md','AGENTS.md','notes','todos'} for parent,child in zip(chain,chain[1:])), @@ -177,11 +187,9 @@ async def ensure_public_repositories(tx,resource, *, mode=None,parent=None): require(p.mode&1 and all(a.mode&1 for a in await tx.ancestors(parent)),'repo_public_read_required') -async def prepare_text_patch(app,ctx,request,tx,args, *, post_only=False): - """Prepare an authorized edit without publishing any content or SQL reference.""" - resource=await tx.resource(await resolve(tx,args['id'])) - if post_only: - require(resource.type=='post','not_editable') +async def editable_resource(app,ctx,request,tx,identifier): + """One authority and lifecycle boundary for body replacement and patch.""" + resource=await tx.resource(await resolve(tx,identifier)) require(resource.type in {'post','file'} and resource.state=='active','not_editable') chain=(*await tx.ancestors(resource.id),resource) require(not any(item.id=='t_last_will' for item in chain),'legacy_directive_only') @@ -191,6 +199,14 @@ async def prepare_text_patch(app,ctx,request,tx,args, *, post_only=False): await check_access(app,ctx,request,tx,resource.id,'write') await assert_generation(request,resource) require((await topic_policy(tx,resource)).get('editable',True),'content_frozen') + return resource + + +async def prepare_text_patch(app,ctx,request,tx,args, *, post_only=False): + """Prepare an authorized edit without publishing any content or SQL reference.""" + resource=await editable_resource(app,ctx,request,tx,args['id']) + if post_only: + require(resource.type=='post','not_editable') current=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision)) media=current.content.media_type require(media in {'text/plain','text/markdown'},'text_patch_required') @@ -237,6 +253,13 @@ async def prepare_text_patch(app,ctx,request,tx,args, *, post_only=False): def install(app): + from msg.bootstrap import manifest + for name,source in manifest()['templates'].items(): + parsed=parse_template(source) + require(name==parsed.name,'template_name_mismatch') + app.registry.add_template(TemplateSpec(resource=ResourceRef(id='tpl_'+name), + digest='sha256:'+sha256(source.encode()).hexdigest(),fields=parsed.fields, + renderer_version=1),version=parsed.version) op,finish=registration(app,'content',('identity',)) post_fields={'parent':IDENTIFIER,'name':STRING,'body':STRING,'template':{'anyOf':[STRING,obj({'id':IDENTIFIER,'version':INTEGER},('id',))]}, 'values':{'type':'object'},'source':REF,'content_created_at':STRING,'resource_id':IDENTIFIER,'revision_id':IDENTIFIER,'content_signature':SIGNATURE} @@ -659,7 +682,7 @@ async def lifecycle(ctx,request,tx): await assert_generation(request,resource) await removable(app,ctx,request,tx,resource) require(resource.state!='purged','resource_purged') - state='archived' if request.operation=='content.archive' else 'active' + state='archived' if request.operation in {'content.archive','file.delete'} else 'active' if state=='active' and resource.type=='website': from msg.plugins.hosting_capacity import manifest_size,require_capacity await require_capacity(app,tx,resource,await manifest_size(app,tx,resource),ctx.now) @@ -684,6 +707,7 @@ async def move(ctx,request,tx): require(await direct_ancestor(tx,resource.id) is None,'dm_controlled_resource') target=await resolve(tx,request.arguments['parent']) require(target!='t_store','store_controlled_resource') + require((await tx.resource(target)).type != 'order_collection', 'order_controlled_resource') await check_access(app,ctx,request,tx,target,'create') parent=await tx.resource(target) require(app.registry.resource_type(parent.type,1).container,'not_a_container') @@ -772,7 +796,7 @@ async def purge(ctx,request,tx): tx.execute('DELETE FROM projections WHERE resource_id=?',(resource.id,),write=True) from msg.plugins.communication import event_id job=EffectJob(id=new_id('job'),event_id=event_id(request,ctx.principal.subject),kind='gc.resource',dedupe_key='purge:'+ctx.principal.subject+':'+request.request_id, - principal=ctx.principal,operation=request.operation,arguments={'id':resource.id,'revisions':wire(revisions)}, + principal=ctx.principal,operation=request.operation,arguments={'contract_version':request.contract_version,'id':resource.id,'revisions':wire(revisions)}, state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None) await tx.enqueue(job) return output_for(updated,state='purged',physical_cleanup=job.id,backup_scope='backups_require_separate_retention') diff --git a/src/msg/plugins/delivery.py b/src/msg/plugins/delivery.py index 4500a65e..81821163 100644 --- a/src/msg/plugins/delivery.py +++ b/src/msg/plugins/delivery.py @@ -12,140 +12,30 @@ from msg.core.errors import require from msg.core.models import BlobRef, HandlerOutput from msg.plugins.common import new_id, registration -from msg.plugins.money import CURRENCY_ID, _balance, account_requirements +from msg.market.ledger import CURRENCY_ID, balance as _balance, account_requirements from msg.market.escrow import EscrowEngine from msg.market.delivery_targets import validate_target -from msg.plugins.orders import _row as order_row, _subject +from msg.market.order_records import read_order as order_row, require_signed_subject as _subject from msg.plugins.schemas import IDENTIFIER, obj -from msg.plugins.store import _package_row +from msg.market.catalog import read_package_record as _package_row -MAX_INLINE_BYTES = 1024 * 1024 -_COLUMNS = ('id', 'order_id', 'recipient_subject', 'kind', 'payload_refs', - 'manifest', 'package_digest', 'delivery_digest', 'channel', - 'state', 'prepared_at', 'claimed_at', 'receipt') - - -def _delivery(tx, order_id): - row = tx.one('''SELECT id,order_id,recipient_subject,kind,payload_refs, - manifest,package_digest,delivery_digest,channel,state,prepared_at, - claimed_at,receipt FROM store_deliveries WHERE order_id=?''', (order_id,)) - if row is None: - return None - result = dict(zip(_COLUMNS, row)) - result['payload_refs'] = loads(result['payload_refs']) - result['manifest'] = loads(result['manifest']) - result['receipt'] = loads(result['receipt']) if result['receipt'] else None - return result - - -def _buyer_order(tx, order_id, buyer): - row = order_row(tx, order_id, buyer) - require(row['buyer'] == buyer, 'order_not_found') - return row - - -async def _verified_delivery(app, tx, order, delivery): - validate_target(order, delivery) - require(delivery is not None and - delivery['recipient_subject'] == order['buyer'] and - delivery['package_digest'] == order['package_digest'] and - delivery['channel'] == 'site', 'delivery_mismatch') - kind, manifest, refs = await _package(app, tx, order) - body = {'delivery_id': delivery['id'], 'order_id': order['id'], - 'recipient_subject': order['buyer'], 'kind': kind, - 'manifest': manifest, 'payload_refs': refs, - 'package_digest': order['package_digest'], 'channel': 'site'} - require(delivery['kind'] == kind and delivery['manifest'] == manifest and - delivery['payload_refs'] == refs and - delivery['delivery_digest'] == digest(body), 'delivery_mismatch') - - -async def _package(app, tx, order): - require(order['package_id'] and order['package_revision'] and - order['package_digest'], 'delivery_package_mismatch') - row = await _package_row(tx, order['package_id']) - require(row is not None and row[1] == order['listing_id'] and - row[3] == order['seller'] and row[4] == order['package_revision'] and - row[8] == order['package_digest'] and row[10] == 'managed_instant' and - row[5] in {'file', 'bundle'}, 'delivery_package_mismatch') - manifest, refs = loads(row[6]), loads(row[7]) - snapshot = {'seller': row[3], 'listing_id': row[1], - 'listing_revision': row[2], 'revision': row[4], 'kind': row[5], - 'manifest': manifest, 'payload_refs': refs, - 'total_size': row[9], 'delivery_mode': row[10]} - require(digest(snapshot) == order['package_digest'] and - row[9] <= MAX_INLINE_BYTES and row[9] >= 0, - 'delivery_package_mismatch') - require(refs and (row[5] == 'bundle' or len(refs) == 1), - 'delivery_package_mismatch') - total = 0 - for ref in refs: - require(isinstance(ref, dict) and isinstance(ref.get('blob'), dict), - 'delivery_package_mismatch') - blob = decode(BlobRef, ref['blob']) - require(blob.size >= 0 and blob.size <= MAX_INLINE_BYTES, - 'delivery_package_mismatch') - data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES) - require(len(data) == blob.size and digest(data) == blob.digest, - 'delivery_package_mismatch') - total += blob.size - require(total == row[9], 'delivery_package_mismatch') - return row[5], manifest, refs - - -async def _payload(app, delivery): - files = [] - for ref in delivery['payload_refs']: - blob = decode(BlobRef, ref['blob']) - data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES) - require(len(data) == blob.size and digest(data) == blob.digest, - 'delivery_package_mismatch') - files.append({'id': ref['id'], 'revision': ref['revision'], - 'media_type': blob.media_type, 'digest': blob.digest, - 'size': blob.size, 'data': b64(data)}) - return files - - -async def prepare_managed(app, ctx, tx, order): - """Prepare inside the caller's transaction; never authenticate or commit here.""" - buyer = order['buyer'] - require(order['state'] == 'funded' and order['delivered_at'] is None, - 'order_not_deliverable') - validate_target(order) - require(order['currency_id'] == CURRENCY_ID and - _balance(tx, order['escrow_subject']) == order['total_price_minor'], - 'escrow_balance_mismatch') - kind, manifest, refs = await _package(app, tx, order) - delivery_id = new_id('dlv') - body = {'delivery_id': delivery_id, 'order_id': order['id'], - 'recipient_subject': buyer, 'kind': kind, - 'manifest': manifest, 'payload_refs': refs, - 'package_digest': order['package_digest'], 'channel': 'site'} - delivery_digest = digest(body) - tx.execute('''INSERT INTO store_deliveries - (id,order_id,recipient_subject,kind,payload_refs,manifest, - package_digest,delivery_digest,channel,state,prepared_at,claimed_at,receipt) - VALUES (?,?,?,?,?,?,?,?,?,'prepared',?,NULL,NULL)''', - (delivery_id, order['id'], buyer, kind, canonical(refs).decode(), - canonical(manifest).decode(), order['package_digest'], - delivery_digest, 'site', wire(ctx.now)), write=True) - changed = tx.execute('''UPDATE store_orders SET state='delivered',delivered_at=? - WHERE id=? AND buyer=? AND state='funded' AND delivered_at IS NULL''', - (wire(ctx.now), order['id'], buyer), write=True) - require(changed.rowcount == 1, 'order_not_deliverable') - return _delivery(tx, order['id']) - - -def delivery_summary(delivery): - return {'delivery_id': delivery['id'], 'order_id': delivery['order_id'], - 'state': delivery['state'], 'channel': delivery['channel'], - 'package_digest': delivery['package_digest'], - 'delivery_digest': delivery['delivery_digest']} +# Compatibility imports; shared implementation has one market owner. +from msg.market.managed_delivery import read_delivery as _delivery +from msg.market.managed_delivery import read_buyer_order as _buyer_order +from msg.market.managed_delivery import verify_managed_delivery as _verified_delivery +from msg.market.managed_delivery import read_managed_package as _package +from msg.market.managed_delivery import read_managed_payloads as _payload +from msg.market.managed_delivery import prepare_managed +from msg.market.managed_delivery import delivery_summary +from msg.market.managed_delivery import MAX_INLINE_BYTES +from msg.market.managed_delivery import _COLUMNS def install(app): op, finish = registration(app, 'delivery', ('orders',)) + from msg.market.order_resources import operation_boundary + op = operation_boundary(app, op) # Account authority belongs to the operation contract, not just the handler: # the executor must apply it before returning an idempotent cached result. op = partial(op, requirements=account_requirements) diff --git a/src/msg/plugins/discovery.py b/src/msg/plugins/discovery.py index 6a1e23c3..985c0c22 100644 --- a/src/msg/plugins/discovery.py +++ b/src/msg/plugins/discovery.py @@ -1,5 +1,6 @@ """ACL-filtered reads and rebuildable discovery projections.""" from __future__ import annotations +from collections.abc import Mapping import difflib import fnmatch import re @@ -11,6 +12,7 @@ from msg.core.errors import Failure,require from msg.core.models import Resource,ResourceRef,Revision,HandlerOutput,Credential from msg.core.tags import normalize_tag +from msg.core.search_query import search_query_version, spelling_distance from msg.core.read_query import (ReadBudget,MAX_READ_DEPTH,NESTED_FIELDS,ROOT_FIELDS, expansion_schema,read_query_version) from msg.core.requests import request_for @@ -19,6 +21,45 @@ from msg.plugins.schemas import * +def original_casefold_span(text, folded_start, folded_end): + """Translate a nonempty casefold match back to whole original characters.""" + offset = 0 + start = None + for index, char in enumerate(text): + offset += len(char.casefold()) + if start is None and offset > folded_start: + start = index + if offset >= folded_end: + return start, index + 1 + raise ValueError('casefold match outside source') + + +async def normalize_search_scope(app,ctx,request,tx,scope_spec): + if isinstance(scope_spec,Mapping): + kind=next(iter(scope_spec)) + supplied=([ref['id'] for ref in scope_spec['resource_refs']] if kind=='resource_refs' + else [scope_spec[kind]]) + else: + kind=None + supplied=[scope_spec] + scopes=[] + scope_resources=[] + for supplied_scope in supplied: + scope=await resolve(tx,supplied_scope) + scope_resource=await tx.resource(scope) + require(kind not in {'subject','org'} or + scope_resource.type==('user' if kind=='subject' else 'organization'), + 'invalid_search_scope') + await check_access(app,ctx,request,tx,scope,'list') + if scope not in scopes: + scopes.append(scope) + scope_resources.append(scope_resource) + scopes.sort() + normalized=({'resource_refs':[{'id':rid} for rid in scopes]} if kind=='resource_refs' else + {kind:scopes[0]} if kind else scopes[0]) + return normalized,scopes,scope_resources + + async def visible(app,ctx,request,tx,rid): try: await check_access(app,ctx,request,tx,rid,'read') @@ -216,7 +257,13 @@ async def read_projection(app,ctx,request,tx,rid, *, revision=None,fields=()): else: meta['raw_url']=f'/_id/{rid}/revisions/{rev.id}/raw' meta['transfer_operation']='transfer.open' - meta['relations']=wire(rev.relations,compact=True) + if resource.type in {'collab_request', 'collab_offer', 'checkpoint', 'collab_proposal'}: + from msg.plugins.collaboration import _visible_refs + permitted = set(await _visible_refs(app, ctx, request, tx, + [r.target.id for r in rev.relations])) + meta['relations'] = wire(tuple(r for r in rev.relations if r.target.id in permitted), compact=True) + else: + meta['relations']=wire(rev.relations,compact=True) if resource.type=='post' and (not fields or 'links' in fields): active=await tx.revision(ResourceRef(id=rid,revision=revision)) meta['links']=await basic_links(app,ctx,request,tx,resource,active) @@ -318,7 +365,7 @@ def install(app): 'view':{'enum':['json','meta','history']},'known_digest':STRING,'cursor':STRING,'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read') async def get(ctx,request,tx): a=request.arguments - rid=await resolve(tx,a['id']) + rid=await resolve_read(tx,a['id']) resource=await tx.resource(rid) if a.get('view') in {'meta','history'}: await check_access(app,ctx,request,tx,rid,'read') @@ -458,34 +505,23 @@ async def list_items(ctx,request,tx, *, arguments=None,budget=None): position,snapshot=app.cursors.decode_page(a['cursor'],request.operation, query_args,principal,ctx.now) else: - position=app.cursors.decode(a['cursor'],'page',query_hash) if a.get('cursor') else (['\uffff','\uffff'] if descending else ['', '']) + position=app.cursors.decode(a['cursor'],'page',query_hash) if a.get('cursor') else None snapshot=ctx.now - filters=['r.state=?'] - parameters=[a.get('state','active')] + from msg.plugins.read_predicates import read_predicates + filters,parameters=await read_predicates(app,tx,a,parent) if stable: filters.append('r.created_at<=?');parameters.append(wire(snapshot)) - if parent: - filters.append('r.parent=?'); parameters.append(parent) - if a.get('type'): - app.registry.resource_type(a['type'],1) - filters.append('r.type=?'); parameters.append(a['type']) - if a.get('author'): - filters.append('r.owner=?'); parameters.append(await resolve(tx,a['author'])) - if a.get('query'): - text=a['query'].replace('\\','\\\\').replace('%','\\%').replace('_','\\_') - filters.append("(r.name LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM projections p WHERE p.resource_id=r.id AND p.text LIKE ? ESCAPE '\\'))") - parameters.extend(['%'+text+'%','%'+text+'%']) - if a.get('tag'): - filters.append('EXISTS (SELECT 1 FROM resource_tags rt WHERE rt.resource_id=r.id AND rt.tag=?)') - parameters.append(a['tag']) values=[] last_position=position more=False scanned=0 while len(values)<=limit: require(time.monotonic()a.get('depth',5) or (not a.get('recursive',True) and distance>1): continue if a.get('type') and resource.type!=a['type']: @@ -878,18 +943,69 @@ async def lexical_search(ctx,request,tx): # These predicates inspect only the current revision of an already # readable resource. Historical relations and source metadata must # not affect rank, facets or page positions. + if a.get('revision') and (revision is None or revision.id!=a['revision']): + continue + if 'source_version' in a and (revision is None or + revision.source_version!=a['source_version']): + continue if a.get('source_kind') and (revision is None or revision.source_kind!=a['source_kind']): continue - if a.get('relation_type') and (revision is None or not any( + if request.contract_version>=5: + outgoing=[] + if {'relation_to','relation_type','has_attachment'}&a.keys(): + for relation in revision.relations if revision else (): + relation_scanned+=1 + require(relation_scanned<=4096 and time.monotonic()limit: cursor=app.cursors.encode_page(request.operation,normalized,page[-1][0],snapshot, principal,ctx.now+timedelta(minutes=15)) @@ -1250,7 +1385,7 @@ async def diff_view(ctx,request,tx): @op('discovery.raw',obj({'id':IDENTIFIER,'revision':IDENTIFIER,'offset':INTEGER,'length':INTEGER},('id',)),effect='read') async def raw(ctx,request,tx): a=request.arguments - rid=await resolve(tx,a['id']) + rid=await resolve_read(tx,a['id']) await check_access(app,ctx,request,tx,rid,'read') resource=await tx.resource(rid) require(resource.state!='purged','resource_purged') diff --git a/src/msg/plugins/features.py b/src/msg/plugins/features.py new file mode 100644 index 00000000..7bae63dd --- /dev/null +++ b/src/msg/plugins/features.py @@ -0,0 +1,58 @@ +"""Installed feature ownership; defaults remain owned by bootstrap.json. + +These are diagnostic result keys, never import paths or executable resource data. +One plugin can own several independently reported features. Dependencies remain +owned by PluginManifest and are checked by Registry.add before registration. +""" +from msg.core.errors import require + + +FEATURE_SOURCES = { + 'content_editing': ('file', 'content_editing', 'content_editing'), + 'private_dm': ('communication', 'private_dm', 'private_dm'), + 'transfer': ('transfer', 'transfer', 'transfer_state_machine'), + 'tool_sandbox': ('extensions', 'tool_sandbox', 'tool_sandbox'), + 'following': ('communication', 'following', 'following'), + 'collaboration': ('communication', 'collaboration', 'collaboration'), + 'honors': ('achievements', 'honors', 'honor_ceremony_display'), + 'money': ('money', 'market_clearing', 'market_e2e'), + 'bounty': ('bounty', 'market_clearing', 'market_e2e'), + 'orders': ('orders', 'market_clearing', 'market_e2e'), + 'bootstrap': ('system', 'bootstrap', 'isolated_namespace'), + 'market': ('store', 'market', 'market_lifecycle'), + 'postgres': ('system', 'storage', 'stable_id_read'), + 'root_trust': ('identity', 'root_trust', 'root_network_rejected'), + 'credential_delivery': ('identity', 'credential_delivery', 'credential_delivery_recovery'), + 'custodial_history': ('identity', 'custodial_history', 'custodial_history_recovery'), + 'identity_upgrade': ('identity', 'authority_snapshot', 'identity_upgrade_recovery'), + 'online_ca': ('identity', 'online_ca', 'online_registration'), + 'authorization': ('identity', 'authority_snapshot', 'certgate'), + 'audit': ('system', 'audit', 'online_delegation'), + 'content': ('content', 'content_layout', 'idempotency'), + 'git_content': ('content', 'git', 'git_push_read_cas'), + 'valkey_signal': ('system', 'valkey', None), + 'recovery': ('recovery', 'recovery_checkpoint', 'recovery_checkpoint_replay'), + 'search': ('discovery', 'lexical_search', 'lexical_search_access'), + 'hosting': ('extensions', 'hosting', 'hosting_deploy'), +} + + +def feature_ids(plugin_name): + return tuple(key for key, source in FEATURE_SOURCES.items() if source[0] == plugin_name) + + +def validate_feature_sources(rows): + require({row['feature_id'] for row in rows} == set(FEATURE_SOURCES), + 'feature_source_mismatch') + for row in rows: + require((row['doctor_check'], row['selftest_case']) == + FEATURE_SOURCES[row['feature_id']][1:], 'feature_source_mismatch') + + +def validate_feature_claims(manifest): + claims = manifest.feature_ids + require(isinstance(claims, tuple) and all(isinstance(key, str) for key in claims), + 'feature_source_mismatch') + require(len(set(claims)) == len(claims) and + all(key in FEATURE_SOURCES and FEATURE_SOURCES[key][0] == manifest.name + for key in claims), 'feature_source_mismatch') diff --git a/src/msg/plugins/files.py b/src/msg/plugins/files.py new file mode 100644 index 00000000..40662aa3 --- /dev/null +++ b/src/msg/plugins/files.py @@ -0,0 +1,64 @@ +"""File vocabulary over the existing resource use cases and transaction boundary.""" +from dataclasses import replace + +from msg.core.codec import unb64, decode +from msg.core.errors import require +from msg.core.models import ResourceRef +from msg.plugins.common import (registration, requirement, check_access, + revise_resource, output_for) +from msg.plugins.schemas import obj, IDENTIFIER, BYTES, STRING, REF + + +def install(app): + op, finish = registration(app, 'file', ('content', 'discovery', 'batch')) + + def alias(name, target, version=1): + spec = app.registry.operation(target, version) + op(name, app.registry.schema(spec.input_schema), effect=spec.effect, + requirements=spec.requirements, signature=spec.require_signature, + requires_rules=('msg.files','msg.read-write','msg.protocol'))(spec.handler) + + alias('file.create', 'content.file_put') + copy = app.registry.operation('content.file_put') + op('file.copy', obj({'parent':IDENTIFIER,'name':STRING,'source':REF}, + ('parent','name','source')), requirements=copy.requirements)(copy.handler) + alias('file.mkdir', 'content.topic_create') + alias('file.move', 'content.move') + alias('file.delete', 'content.archive') + alias('file.read', 'discovery.get') + alias('file.list', 'discovery.list') + alias('file.patch', 'content.text_patch', 3) + alias('file.batch', 'batch.atomic') + + @op('file.stat', obj({'id':IDENTIFIER}, ('id',)), effect='read') + async def stat(ctx, request, tx): + spec = app.registry.operation('discovery.get') + return await spec.handler(ctx, replace(request, + arguments={**request.arguments, 'view':'meta'}), tx) + + @op('file.write', obj({'id':IDENTIFIER,'base_revision':IDENTIFIER, + 'data':BYTES,'source':REF,'media_type':STRING}, ('id','base_revision')), + requirements=requirement('id','write')) + async def write(ctx, request, tx): + from msg.plugins.content import editable_resource + args = request.arguments + resource = await editable_resource(app, ctx, request, tx, args['id']) + require(resource.type == 'file', 'not_editable') + require(resource.revision == args['base_revision'], 'revision_conflict', + details={'revision':resource.revision}) + old = await tx.revision(ResourceRef(id=resource.id)) + require(('data' in args) != ('source' in args), 'one_content_source_required') + if 'source' in args: + ref = decode(ResourceRef, args['source']) + await check_access(app, ctx, request, tx, ref.id, 'read') + body = (await tx.revision(ref)).content + media = body.media_type + else: + body = unb64(args['data'], limit=app.settings.server.limits.max_request_bytes) + media = args.get('media_type', old.content.media_type) + require('\r' not in media and '\n' not in media, 'invalid_media_type') + updated = await revise_resource(app, ctx, request, tx, resource, body, media, + relations=old.relations, author=old.author) + return output_for(updated) + + finish() diff --git a/src/msg/plugins/identity.py b/src/msg/plugins/identity.py index 50aca5f3..5a4a5df6 100644 --- a/src/msg/plugins/identity.py +++ b/src/msg/plugins/identity.py @@ -1595,7 +1595,7 @@ async def email_set(ctx,request,tx): if app.settings.server.mail is not None: from msg.plugins.communication import event_id job=EffectJob(id=new_id('job'),event_id=event_id(request,subject.resource_id),kind='mail',dedupe_key='email-verify:'+subject.resource_id+':'+request.request_id, - principal=ctx.principal,operation=request.operation,arguments={'recipient':address,'recipient_subject':subject.resource_id,'verification':True,'subject':'Verify msg email', + principal=ctx.principal,operation=request.operation,arguments={'contract_version':request.contract_version,'recipient':address,'recipient_subject':subject.resource_id,'verification':True,'subject':'Verify msg email', 'text':'Verify with msg call identity.email_verify using JSON token: '+b64(token)},state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None) await tx.enqueue(job) return HandlerOutput(data={'verified':False,'delivery':'queued' if app.settings.server.mail else 'mail_disabled'}) diff --git a/src/msg/plugins/money.py b/src/msg/plugins/money.py index 424a03f1..89f25df7 100644 --- a/src/msg/plugins/money.py +++ b/src/msg/plugins/money.py @@ -16,136 +16,21 @@ from msg.plugins.schemas import IDENTIFIER, obj -CURRENCY_ID = 'primary' -CODE = 'MSG' -SCALE = 6 -MAX_MINOR = 2**63 - 1 -POLICY_VERSION = 2 -POLICY_DIGEST = digest({'version': POLICY_VERSION, 'currency_id': CURRENCY_ID, - 'scale': SCALE, 'transfer_fee': 0, 'allow_overdraft': False, - 'posting_identity': 'actor/request_id/entry_key'}) - - -async def account_requirements(request, tx): - """Require the credential's scope to cover its account, even on replay. - - An operation name in a listing-scoped key is not permission to spend the - owner's balance or read unrelated private orders. Anonymous calls still - reach the handler's uniform not-found/signature response. - """ - if request.proof is None: - return () - key_id = (request.proof.signature.key_id if isinstance(request.proof, SignatureProof) - else request.proof.credential_id) - credential = await tx.credential(key_id) - return (AccessRequirement(resource_id=request.subject or credential.subject_id, - operation=f'{request.operation}@{request.contract_version}', check='read'),) - - -def _owner(ctx): - require(ctx.principal.subject is not None and - ctx.principal.actor == ctx.principal.subject, 'money_subject_required') - return ctx.principal.subject - - -def _amount(value): - require(type(value) is int and 0 < value <= MAX_MINOR, 'invalid_money_amount') - return value - - -def _balance(tx, subject): - debit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE debit_account=?", - (subject,))[0] - credit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE credit_account=?", - (subject,))[0] - return int(credit) - int(debit) - - -def _supply(tx): - issued = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='mint'")[0] - burned = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='burn'")[0] - return int(issued) - int(burned) - - -def clearing_decision(*, kind, sender, recipient, amount, sender_balance, - recipient_balance, supply, local_issuer=False): - """Pure policy v2: no clock, UUID, subject privileges or discretionary input.""" - amount = _amount(amount) - require(all(type(value) is int and 0 <= value <= MAX_MINOR - for value in (sender_balance, recipient_balance, supply)), 'invalid_money_state') - require(kind in {'mint', 'burn', 'transfer', 'redeem', 'refund'}, 'invalid_money_kind') - if kind in {'mint', 'burn'}: - require(local_issuer, 'root_local_only') - require((kind == 'mint' and sender is None and recipient == ROOT_SUBJECT) or - (kind == 'burn' and sender == ROOT_SUBJECT and recipient is None), - 'invalid_money_accounts') - else: - require(sender is not None and recipient is not None, 'invalid_money_accounts') - require(sender != recipient, 'self_transfer_forbidden') - if sender in {ROOT_SUBJECT, '@root'}: - require(local_issuer, 'root_local_only') - if sender is not None: - require(sender_balance >= amount, 'insufficient_funds') - if recipient is not None: - require(recipient_balance + amount <= MAX_MINOR, 'money_overflow') - if kind == 'burn': - require(supply >= amount, 'invalid_money_state') - if kind == 'mint': - require(supply + amount <= MAX_MINOR, 'money_overflow') - return {'kind': kind, 'debit_account': sender, 'credit_account': recipient, - 'amount_minor': amount, 'currency_id': CURRENCY_ID, - 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST} - - -def _post_entry(tx, *, sender, recipient, amount, actor, request_id, now, - receipt_signer, reference=None, kind='transfer', entry_key='primary', - _local_issuer=False): - """Append one named leg in the caller's serialized transaction, never commit. - - Named legs permit atomic fund+release without manufacturing request IDs. - Historical receipts remain byte-for-byte untouched by the schema upgrade. - Only the separate local RootAdmin use case supplies _local_issuer. - """ - require(not _local_issuer or actor == ROOT_SUBJECT, 'root_local_only') - clearing_decision(kind=kind, sender=sender, recipient=recipient, amount=amount, - sender_balance=_balance(tx, sender) if sender else 0, - recipient_balance=_balance(tx, recipient) if recipient else 0, - supply=_supply(tx), local_issuer=_local_issuer) - for account in {sender, recipient} - {None}: - tx.execute('INSERT INTO money_accounts(subject_id,currency_id) VALUES (?,?) ' - 'ON CONFLICT(subject_id,currency_id) DO NOTHING', - (account, CURRENCY_ID), write=True) - transaction_id = 'lt_' + uuid4().hex - sequence = tx.one("SELECT nextval(pg_get_serial_sequence('money_ledger','seq'))")[0] - body = {'transaction_id': transaction_id, 'kind': kind, 'currency_id': CURRENCY_ID, - 'amount_minor': amount, 'from_subject': sender, 'to_subject': recipient, - 'actor': actor, 'request_id': request_id, 'entry_key': entry_key, - 'reference': reference, 'committed_at': wire(now), - 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST, - 'ledger_sequence': sequence} - receipt = {'body': body, - 'signature': wire(receipt_signer.sign(canonical(body), purpose='money-receipt'))} - tx.execute('''INSERT INTO money_ledger - (seq,id,kind,currency_id,amount_minor,debit_account,credit_account,actor,request_id, - reference,committed_at,policy_version,policy_digest,receipt,entry_key) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', - (sequence,transaction_id,kind,CURRENCY_ID,amount,sender,recipient,actor, - request_id,reference,wire(now),POLICY_VERSION,POLICY_DIGEST, - canonical(receipt).decode(),entry_key), write=True) - return receipt - - -def _post_transfer(tx, *, sender, recipient, amount, actor, request_id, now, - receipt_signer, reference=None, kind='transfer', entry_key='primary', - escrow_authority=None): - require(kind in {'transfer', 'redeem', 'refund'}, 'invalid_money_kind') - account = tx.one('SELECT kind FROM ledger_accounts WHERE id=?', (sender,)) - if account and account[0] == 'order_escrow': - from msg.market.escrow import _ESCROW_WRITE - require(escrow_authority is _ESCROW_WRITE, 'escrow_release_forbidden') - return _post_entry(tx, sender=sender, recipient=recipient, amount=amount, - actor=actor, request_id=request_id, now=now, receipt_signer=receipt_signer, - reference=reference, kind=kind, entry_key=entry_key) +# Compatibility imports; shared implementation has one market owner. +from msg.market.ledger import account_requirements +from msg.market.ledger import require_money_subject as _owner +from msg.market.ledger import checked_amount as _amount +from msg.market.ledger import balance as _balance +from msg.market.ledger import total_supply as _supply +from msg.market.ledger import clearing_decision +from msg.market.ledger import append_entry as _post_entry +from msg.market.ledger import post_transfer as _post_transfer +from msg.market.ledger import CURRENCY_ID +from msg.market.ledger import CODE +from msg.market.ledger import SCALE +from msg.market.ledger import MAX_MINOR +from msg.market.ledger import POLICY_VERSION +from msg.market.ledger import POLICY_DIGEST def install(app): diff --git a/src/msg/plugins/offers.py b/src/msg/plugins/offers.py index 976eb666..d16bedcb 100644 --- a/src/msg/plugins/offers.py +++ b/src/msg/plugins/offers.py @@ -6,7 +6,8 @@ from datetime import timedelta from msg.constants import ROOT_SUBJECT -from msg.core.codec import canonical, digest, loads, parse_time, wire +from msg.core.codec import canonical, digest, parse_time, wire +from msg.market.compatibility import read_purchase as _purchase from msg.core.errors import Failure, require from msg.core.models import HandlerOutput from msg.plugins.common import new_id, registration @@ -65,25 +66,23 @@ def validate_local_offer(app, *, resource_kind: str, entitlement_kind: str, 'invalid_offer_duration') +def _valid_catalog_offer(app, row): + """Apply current issuer bounds to old rows without rewriting signed history.""" + try: + validate_local_offer(app, resource_kind=row[1], unit=row[2], price_minor=row[3], + min_quantity=row[4], max_quantity=row[5], + entitlement_kind=row[6], duration_seconds=row[7]) + except Failure: + return False + return True + + def _public_offer(row): return dict(zip(('offer_id','resource_kind','unit','price_minor','min_quantity', 'max_quantity','entitlement_kind','duration_seconds','price_revision'),row)) | { 'currency_id': CURRENCY_ID, 'provider_version': 1} -def _purchase(tx, purchase_id, subject): - row = tx.one('''SELECT id,subject_id,request_id,offer_snapshot,quantity,total_minor, - escrow_account,state,created_at,expires_at,funding_transaction_id, - final_transaction_id,entitlement_id,reason FROM money_purchases WHERE id=? AND subject_id=?''', - (purchase_id,subject)) - require(row is not None, 'purchase_not_found') - result = dict(zip(('id','subject_id','request_id','offer_snapshot','quantity','total_minor', - 'escrow_account','state','created_at','expires_at','funding_transaction_id', - 'final_transaction_id','entitlement_id','reason'),row)) - result['offer_snapshot'] = loads(result['offer_snapshot']) - return result - - def _finalize(app, tx, purchase, now, request_id, *, cancel=False): require(purchase['state'] == 'pending', 'purchase_not_pending') snapshot = purchase['offer_snapshot'] @@ -92,7 +91,10 @@ def _finalize(app, tx, purchase, now, request_id, *, cancel=False): else 'provider_unavailable' if not purchasable(app,snapshot['resource_kind'],snapshot['entitlement_kind']) or provider[2] != snapshot['provider_version'] else None) require(_balance(tx,purchase['escrow_account']) == purchase['total_minor'], 'escrow_balance_mismatch') - receipt = _post_transfer(tx,sender=purchase['escrow_account'], + from msg.market.ledger import post_escrow_release + receipt = post_escrow_release(tx,escrow_account=purchase['escrow_account'], + source_id=purchase['id'], account_kind='purchase_escrow', + buyer=purchase['subject_id'], seller=ROOT_SUBJECT, recipient=purchase['subject_id'] if reason else ROOT_SUBJECT, amount=purchase['total_minor'],actor=purchase['subject_id'],request_id=request_id, now=now,receipt_signer=app.receipt_signer,kind='refund' if reason else 'redeem', @@ -108,14 +110,55 @@ def _finalize(app, tx, purchase, now, request_id, *, cancel=False): def install(app): op,finish = registration(app,'offers',('money',)) + from msg.market.order_resources import operation_boundary + op = operation_boundary(app, op) + @op('money.offers',obj(),effect='read', version=2) @op('money.offers',obj(),effect='read') async def offers(ctx,request,tx): rows = tx.rows('''SELECT offer_id,resource_kind,unit,price_minor,min_quantity, max_quantity,entitlement_kind,duration_seconds,price_revision FROM server_offers WHERE enabled=TRUE ORDER BY offer_id''') - return HandlerOutput(data={'currency_id':CURRENCY_ID,'offers':[ - _public_offer(row) for row in rows if purchasable(app,row[1],row[6])]}) + from msg.market.offer_resources import verify_projection + public = [] + for row in rows: + if _valid_catalog_offer(app, row): + await verify_projection(app, tx, row) + public.append(_public_offer(row)) + data = {'currency_id': CURRENCY_ID, 'offers': public} + if request.contract_version == 2: + from msg.market.compatibility import offer_listing + data['listings'] = [offer_listing(offer) for offer in public] + return HandlerOutput(data=data) + + @op('money.redeem', obj({'offer_id': IDENTIFIER, + 'quantity': {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}, + 'currency_id': {'const': CURRENCY_ID}, 'price_revision': IDENTIFIER, + 'listing_revision': IDENTIFIER, + 'offer_snapshot_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}, + 'total_price_minor': {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}, + 'settlement_policy': {'const': 'deterministic-entitlement-v1'}}, + ('offer_id', 'quantity', 'currency_id', 'price_revision', 'listing_revision', + 'offer_snapshot_digest', 'total_price_minor', 'settlement_policy')), + signature=True, version=3, requirements=account_requirements) + async def redeem_order(ctx, request, tx): + owner, args = _owner(ctx), request.arguments + row = tx.one("""SELECT offer_id,resource_kind,unit,price_minor,min_quantity, + max_quantity,entitlement_kind,duration_seconds,price_revision FROM server_offers + WHERE offer_id=? AND enabled=TRUE""", (args['offer_id'],)) + require(row is not None and _valid_catalog_offer(app, row), 'offer_not_found') + from msg.market.offer_resources import verify_projection + require(await verify_projection(app, tx, row) is not None, 'offer_requires_import') + require(args['price_revision'] == row[8], 'offer_price_changed') + require(row[4] <= args['quantity'] <= row[5] and row[3] * args['quantity'] <= MAX_MINOR, + 'invalid_offer_quantity') + require(extra_capacity(tx, owner, ctx.now) + args['quantity'] + app.settings.hosting_base_capacity_bytes + <= MAX_CAPACITY_BYTES, 'entitlement_capacity_exceeded') + require(ENTITLEMENT_FULFILLERS[row[6]][2] == 1, 'offer_provider_unavailable') + from msg.market.entitlement_orders import redeem as redeem_into_order + result = await redeem_into_order(app, tx, ctx, request, _public_offer(row), + ENTITLEMENT_FULFILLERS[row[6]][3]) + return HandlerOutput(data=result) @op('money.redeem',obj({'offer_id':IDENTIFIER,'quantity':{'type':'integer','minimum':1,'maximum':MAX_MINOR}, 'currency_id':{'const':CURRENCY_ID},'price_revision':IDENTIFIER}, @@ -129,7 +172,9 @@ async def redeem(ctx,request,tx): row = tx.one('''SELECT offer_id,resource_kind,unit,price_minor,min_quantity, max_quantity,entitlement_kind,duration_seconds,price_revision FROM server_offers WHERE offer_id=? AND enabled=TRUE''',(args['offer_id'],)) - require(row is not None and purchasable(app,row[1],row[6]),'offer_not_found') + require(row is not None and _valid_catalog_offer(app, row),'offer_not_found') + from msg.market.offer_resources import verify_projection + await verify_projection(app, tx, row) require(args['price_revision'] == row[8],'offer_price_changed') require(row[4] <= args['quantity'] <= row[5] and row[3]*args['quantity'] <= MAX_MINOR, 'invalid_offer_quantity') @@ -154,9 +199,15 @@ async def redeem(ctx,request,tx): return HandlerOutput(data={'purchase':_purchase(tx,purchase_id,owner), 'funding':funding,'settlement':settlement}) + @op('money.purchase_get',obj({'purchase_id':IDENTIFIER},('purchase_id',)),effect='read', version=2, requirements=account_requirements) @op('money.purchase_get',obj({'purchase_id':IDENTIFIER},('purchase_id',)),effect='read', requirements=account_requirements) async def get(ctx,request,tx): - return HandlerOutput(data={'purchase':_purchase(tx,request.arguments['purchase_id'],_owner(ctx))}) + purchase = _purchase(tx,request.arguments['purchase_id'],_owner(ctx)) + data = {'purchase': purchase} + if request.contract_version == 2: + from msg.market.compatibility import purchase_order + data['order'] = purchase_order(tx, purchase) + return HandlerOutput(data=data) @op('money.purchase_settle',obj({'purchase_id':IDENTIFIER},('purchase_id',)),signature=True, requirements=account_requirements) async def settle(ctx,request,tx): diff --git a/src/msg/plugins/orders.py b/src/msg/plugins/orders.py index f9abe3da..64c3843b 100644 --- a/src/msg/plugins/orders.py +++ b/src/msg/plugins/orders.py @@ -12,76 +12,26 @@ from msg.market.escrow import EscrowEngine, validate_policy from msg.core.codec import canonical, digest, loads, parse_time, wire from msg.core.errors import require -from msg.core.models import HandlerOutput +from msg.core.models import HandlerOutput, ResourceTypeSpec from msg.plugins.common import registration -from msg.plugins.money import CURRENCY_ID, MAX_MINOR, _balance, _post_transfer, account_requirements +from msg.market.ledger import CURRENCY_ID, MAX_MINOR, balance as _balance, post_transfer as _post_transfer, account_requirements from msg.plugins.schemas import IDENTIFIER, obj -from msg.plugins.store import _body, _listing, _package_row +from msg.market.catalog import read_listing_body as _body, read_listing as _listing, read_package_record as _package_row -_COLUMNS = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision', - 'package_id', 'package_revision', 'package_digest', 'quantity', - 'unit_price_minor', 'total_price_minor', 'currency_id', - 'escrow_subject', 'escrow_policy', 'dispute_policy', 'terms_digest', - 'delivery_target', 'payment_intent_digest', 'payment_transaction_id', - 'state', 'created_at', 'funded_at', 'delivered_at', 'settled_at', - 'receipt_refs') - - -def _subject(ctx): - subject = ctx.principal.subject - require(subject is not None and ctx.principal.actor == subject and - ctx.principal.method == 'signature', 'signature_required') - return subject - - -def _viewer(ctx): - subject = ctx.principal.subject - require(subject is not None and ctx.principal.actor == subject, - 'order_not_found') - return subject - - -def _order_id(): - # 160 random bits, unguessable even if an attacker sees other order IDs. - return 'ord_' + base64.b32encode(os.urandom(20)).decode('ascii').rstrip('=').lower() - - -def _row(tx, order_id, viewer): - row = tx.one('''SELECT id,buyer,seller,listing_id,listing_revision, - package_id,package_revision,package_digest,quantity,unit_price_minor, - total_price_minor,currency_id,escrow_subject,escrow_policy, - dispute_policy,terms_digest,delivery_target,payment_intent_digest, - payment_transaction_id,state,created_at,funded_at,delivered_at, - settled_at,receipt_refs FROM store_orders WHERE id=?''', (order_id,)) - # A valid ID is not an access grant. Keep nonexistent and unauthorized alike. - require(row is not None and viewer in row[1:3], 'order_not_found') - result = dict(zip(_COLUMNS, row)) - result['delivery_target'] = loads(result['delivery_target']) - result['receipt_refs'] = loads(result['receipt_refs']) - return result - - -def _view(row, viewer): - keys = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision', - 'package_revision', 'package_digest', 'quantity', - 'unit_price_minor', 'total_price_minor', 'currency_id', - 'escrow_policy', 'dispute_policy', 'terms_digest', 'state', - 'created_at', 'funded_at', 'delivered_at', 'settled_at') - result = {key: row[key] for key in keys} - result['payment_status'] = ('refunded' if row['state'] == 'refunded' else - 'funded' if row['payment_transaction_id'] else - 'pending') - result['delivery_channel'] = row['delivery_target']['channel'] - if viewer == row['buyer']: - result['delivery_target'] = row['delivery_target'] - result['payment_intent_digest'] = row['payment_intent_digest'] - result['receipt_refs'] = row['receipt_refs'] - return result +# Compatibility imports; shared implementation has one market owner. +from msg.market.order_records import require_signed_subject as _subject +from msg.market.order_records import require_order_viewer as _viewer +from msg.market.order_records import new_order_id as _order_id +from msg.market.order_records import read_order as _row +from msg.market.order_records import legacy_order_view as _view +from msg.market.order_records import _COLUMNS def install(app): op, finish = registration(app, 'orders', ('store', 'money')) + from msg.market.order_resources import operation_boundary + op = operation_boundary(app, op) # Account authority belongs to the operation contract, not just the handler: # the executor must apply it before returning an idempotent cached result. op = partial(op, requirements=account_requirements) @@ -188,7 +138,7 @@ async def buy(ctx, request, tx): canonical([receipt_id]).decode()), write=True) data = {'payment': receipt} if instant: - from msg.plugins.delivery import prepare_managed, delivery_summary + from msg.market.managed_delivery import prepare_managed, delivery_summary from msg.market.delivery_notifications import initialize_notification order = _row(tx, order_id, buyer) delivery = await prepare_managed(app, ctx, tx, order) @@ -216,6 +166,23 @@ async def get(ctx, request, tx): from msg.market.orders import view return HandlerOutput(data={'order': view(tx, _row(tx, request.arguments['order_id'], viewer), viewer)}) + @op('orders.get', obj({'order_id': IDENTIFIER, + 'source': {'enum': ['store_order', 'legacy_purchase']}}, ('order_id',)), + effect='read', version=2) + async def get_compatible(ctx, request, tx): + if request.arguments.get('source', 'store_order') == 'store_order': + return await get(ctx, request, tx) + from msg.core.errors import Failure + from msg.market.compatibility import read_purchase as _purchase + from msg.market.compatibility import purchase_order + try: + purchase = _purchase(tx, request.arguments['order_id'], _viewer(ctx)) + except Failure as exc: + if exc.code == 'purchase_not_found': + raise Failure('order_not_found') from None + raise + return HandlerOutput(data={'order': purchase_order(tx, purchase)}) + @op('orders.list', obj({'role': {'enum': ['buy', 'sell']}, 'status': {'enum': ['open', 'completed', 'disputed']}, 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100}}), effect='read') @@ -242,6 +209,28 @@ async def list_orders(ctx, request, tx): return HandlerOutput(data={'orders': [_view(_row(tx, id, viewer), viewer) for (id,) in rows]}) + @op('orders.list', obj({'role': {'enum': ['buy', 'sell']}, + 'status': {'enum': ['open', 'completed', 'disputed']}, + 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100}}), effect='read', version=2) + async def list_compatible(ctx, request, tx): + viewer = _viewer(ctx) + native = await list_orders(ctx, request, tx) + projected = [{**row, 'source': 'store_order'} for row in native.data['orders']] + args = request.arguments + limit = args.get('limit', 50) + if args.get('role') != 'sell' and args.get('status') != 'disputed': + from msg.market.compatibility import read_purchase as _purchase + from msg.market.compatibility import purchase_order + where, values = 'subject_id=?', [viewer] + if args.get('status'): + where += (" AND state='pending'" if args['status'] == 'open' else + " AND state IN ('settled','refunded')") + rows = tx.rows('SELECT id FROM money_purchases WHERE ' + where + + ' ORDER BY created_at DESC,id DESC LIMIT ?', (*values, limit)) + projected.extend(purchase_order(tx, _purchase(tx, id_, viewer)) for (id_,) in rows) + projected.sort(key=lambda row: (row['created_at'], row['id']), reverse=True) + return HandlerOutput(data={'orders': projected[:limit]}) + @op('orders.payment', obj({'order_id': IDENTIFIER}, ('order_id',)), effect='read') async def payment(ctx, request, tx): @@ -270,4 +259,6 @@ async def payment(ctx, request, tx): install_market(app, op) from msg.market.arbitration import install as install_arbitration install_arbitration(app, op) - finish() + finish(tuple(ResourceTypeSpec(name=name, version=1, container=container, + content_schema=None, operations=frozenset(), relations=frozenset()) + for name, container in (('order', False), ('order_collection', True)))) diff --git a/src/msg/plugins/proposals.py b/src/msg/plugins/proposals.py new file mode 100644 index 00000000..c579c6a2 --- /dev/null +++ b/src/msg/plugins/proposals.py @@ -0,0 +1,106 @@ +"""Version one proposals replace a text post with an immutable content revision.""" +from msg.core.codec import canonical, wire +from msg.core.errors import Failure, require +from msg.core.models import HandlerOutput, Relation, ResourceRef +from msg.plugins.collaboration import _safe_ref, _self +from msg.plugins.collaboration_resources import _folder, _load, _result +from msg.plugins.common import ( + assert_generation, + check_access, + create_resource, + new_id, + revise_resource, +) +from msg.plugins.schemas import IDENTIFIER, obj + +PINNED_REF = obj({'id': IDENTIFIER, 'revision': IDENTIFIER}, ('id', 'revision')) + + +def install(app, op): + @op('communication.proposal_create', obj({ + 'target': IDENTIFIER, 'base_revision': IDENTIFIER, 'content_ref': PINNED_REF, + 'message': {'type': 'string', 'minLength': 1, 'maxLength': 4096}, + }, ('target', 'base_revision', 'content_ref', 'message')), signature=True) + async def create(ctx, request, tx): + subject = await _self(app, ctx, request, tx) + args = request.arguments + target_id = await _safe_ref(app, ctx, request, tx, args['target']) + target = await tx.resource(target_id) + require(target.type == 'post', 'proposal_target_kind_unsupported') + require(target.revision == args['base_revision'], 'revision_conflict') + target_ref = ResourceRef(id=target.id, revision=args['base_revision']) + await tx.revision(target_ref) + source_id = await _safe_ref(app, ctx, request, tx, args['content_ref']['id']) + source_ref = ResourceRef(id=source_id, revision=args['content_ref']['revision']) + source = await tx.revision(source_ref) + require(source.content.media_type.startswith('text/'), 'proposal_content_kind_unsupported') + require(source.content.size <= app.settings.server.limits.max_response_bytes, + 'proposal_content_too_large') + # Verify textual content now. Acceptance reuses the pinned BlobRef, never + # the source's mutable latest revision or client-provided replacement bytes. + try: + (await app.contents.read_bytes(source.content)).decode('utf-8') + except UnicodeDecodeError as exc: + raise Failure('proposal_content_encoding_invalid') from exc + parent = await _folder(app, ctx, request, tx, subject, 'proposal') + record = {'subject': subject, 'author': subject, 'message': args['message'], + 'created_at': wire(ctx.now), 'status': 'open'} + resource = await create_resource(app, ctx, request, tx, parent=parent, + type='collab_proposal', name=new_id('proposal'), body=canonical(record), + media_type='application/json', mode=0o600, + relations=(Relation(type='target', target=target_ref), + Relation(type='content', target=source_ref))) + return _result(resource, record) + + decision_schema = obj({'id': IDENTIFIER, 'proposal_revision': IDENTIFIER}, + ('id', 'proposal_revision')) + accept_schema = obj({'id': IDENTIFIER, 'proposal_revision': IDENTIFIER, + 'base_revision': IDENTIFIER}, + ('id', 'proposal_revision', 'base_revision')) + + def install_decision(action): + @op('communication.proposal_' + action, + accept_schema if action == 'accept' else decision_schema, signature=True) + async def decide(ctx, request, tx): + subject = await _self(app, ctx, request, tx) + proposal, revision, record = await _load(app, ctx, request, tx, 'proposal') + await assert_generation(request, proposal) + require(revision.id == request.arguments['proposal_revision'], 'revision_conflict') + require(record['status'] == 'open', 'proposal_not_open') + refs = {r.type: r.target for r in revision.relations} + require(set(refs) == {'target', 'content'}, 'proposal_record_invalid') + changed = None + if action == 'withdraw': + require(subject == record['author'], 'collaboration_actor_forbidden') + else: + target_id = await _safe_ref(app, ctx, request, tx, refs['target'].id) + await check_access(app, ctx, request, tx, target_id, 'write') + if action == 'accept': + from msg.plugins.content import editable_resource + target = await editable_resource(app, ctx, request, tx, target_id) + require(target.type == 'post', 'proposal_target_kind_unsupported') + require(request.arguments['base_revision'] == refs['target'].revision == + target.revision, 'revision_conflict') + await _safe_ref(app, ctx, request, tx, refs['content'].id) + source = await tx.revision(refs['content']) + require(source.content.media_type.startswith('text/'), + 'proposal_content_kind_unsupported') + previous = await tx.revision(ResourceRef(id=target.id)) + changed = await revise_resource(app, ctx, request, tx, target, + source.content, source.content.media_type, + relations=previous.relations, author=previous.author) + record.update(status={'accept': 'accepted', 'reject': 'rejected', + 'withdraw': 'withdrawn'}[action], updated_at=wire(ctx.now)) + # Decisions can withdraw/reject after target access is lost. The old + # immutable revision still proves the proposal's originally pinned refs. + relations = revision.relations if action == 'accept' else () + proposal = await revise_resource(app, ctx, request, tx, proposal, + canonical(record), 'application/json', relations=relations) + output = _result(proposal, record) + if changed is None: + return output + return HandlerOutput(resources=(*output.resources, + ResourceRef(id=changed.id, revision=changed.revision)), data=output.data) + + for action in ('accept', 'reject', 'withdraw'): + install_decision(action) diff --git a/src/msg/plugins/read_predicates.py b/src/msg/plugins/read_predicates.py new file mode 100644 index 00000000..ecef9dc0 --- /dev/null +++ b/src/msg/plugins/read_predicates.py @@ -0,0 +1,22 @@ +"""Shared ReadQuery membership predicates; callers enforce current authorization.""" +from msg.plugins.common import resolve + + +async def read_predicates(app, tx, a, parent): + filters=['r.state=?'] + parameters=[a.get('state','active')] + if parent: + filters.append('r.parent=?'); parameters.append(parent) + if a.get('type'): + app.registry.resource_type(a['type'],1) + filters.append('r.type=?'); parameters.append(a['type']) + if a.get('author'): + filters.append('r.owner=?'); parameters.append(await resolve(tx,a['author'])) + if a.get('query'): + text=a['query'].replace('\\','\\\\').replace('%','\\%').replace('_','\\_') + filters.append("(r.name LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM projections p WHERE p.resource_id=r.id AND p.text LIKE ? ESCAPE '\\'))") + parameters.extend(['%'+text+'%','%'+text+'%']) + if a.get('tag'): + filters.append('EXISTS (SELECT 1 FROM resource_tags rt WHERE rt.resource_id=r.id AND rt.tag=?)') + parameters.append(a['tag']) + return filters, parameters diff --git a/src/msg/plugins/receipts.py b/src/msg/plugins/receipts.py new file mode 100644 index 00000000..f9460f91 --- /dev/null +++ b/src/msg/plugins/receipts.py @@ -0,0 +1,76 @@ +"""A private, read-only projection of the caller's persisted OperationResults. + +Receipts are the signed results the executor already committed; this view adds +no receipt state, table or writable resource. Stored bytes are never rewritten. +""" +from msg.core.codec import decode, digest, loads +from msg.core.errors import Failure, require +from msg.core.models import HandlerOutput, ResourceRef +from msg.plugins.common import operation_id +from msg.plugins.discovery import next_link, visible +from msg.plugins.schemas import STRING, obj + +REQUEST_ID = {'type': 'string', 'minLength': 1, 'maxLength': 128} + + +async def _subject(app, ctx, request, tx): + subject = ctx.principal.subject + require(subject is not None, 'authentication_required') + await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx) + return subject + + +async def _project(app, ctx, request, tx, raw): + saved = loads(raw) + resources = [] + # A receipt names resources as they were at commit time; access may have + # been revoked since, so only currently readable references are disclosed. + for value in saved.get('resources') or (): + ref = decode(ResourceRef, value) + try: + shown = await visible(app, ctx, request, tx, ref.id) + except Failure as exc: + if exc.code != 'not_found': + raise + shown = False + if shown: + resources.append(value) + # data/output may carry delivered credentials or other presentation-only + # material, so they are deliberately not part of this projection. + item = {'request_id': saved['request_id'], 'operation': saved['operation'], + 'status': saved['status'], 'committed_at': saved.get('committed_at'), + 'resources': resources, 'signed': saved.get('receipt') is not None} + error = saved.get('error') + if error: + item['error_code'] = error['code'] + return item + + +def install(app, op): + @op('communication.receipt_list', obj({'limit': {'type': 'integer', 'minimum': 1, 'maximum': 200}, + 'cursor': STRING}), effect='read') + async def receipt_list(ctx, request, tx): + subject = await _subject(app, ctx, request, tx) + binding = digest({'subject': subject}) + cursor = request.arguments.get('cursor') + after = app.cursors.decode(cursor, request.operation, binding) if cursor else '' + require(type(after) is str, 'invalid_cursor') + limit = request.arguments.get('limit', 50) + rows = tx.rows('SELECT request_id,body FROM results WHERE subject=? AND request_id>? ' + 'ORDER BY request_id LIMIT ?', (subject, after, limit + 1)) + items = [await _project(app, ctx, request, tx, raw) for _, raw in rows[:limit]] + data = {'items': items} + if len(rows) > limit: + token = app.cursors.encode(request.operation, binding, rows[limit - 1][0]) + data.update(cursor=token, + next=next_link(app, request.operation, {**request.arguments, 'cursor': token}), + next_requires_auth=True) + return HandlerOutput(data=data) + + @op('communication.receipt_get', obj({'request_id': REQUEST_ID}, ('request_id',)), effect='read') + async def receipt_get(ctx, request, tx): + subject = await _subject(app, ctx, request, tx) + row = tx.one('SELECT body FROM results WHERE subject=? AND request_id=?', + (subject, request.arguments['request_id'])) + require(row is not None, 'not_found') + return HandlerOutput(data={'receipt': await _project(app, ctx, request, tx, row[0])}) diff --git a/src/msg/plugins/saved_queries.py b/src/msg/plugins/saved_queries.py new file mode 100644 index 00000000..d1507cec --- /dev/null +++ b/src/msg/plugins/saved_queries.py @@ -0,0 +1,149 @@ +"""Explicit durable query descriptors, without extending temporary read authority.""" +from dataclasses import replace + +from msg.core.codec import canonical, decode, digest, loads, parse_time, wire +from msg.core.errors import require +from msg.core.models import HandlerOutput, Principal, ResourceRef, ResourceTypeSpec +from msg.core.read_query import read_query_version +from msg.core.requests import request_for +from msg.core.search_query import search_query_version +from msg.plugins.common import ( + assert_generation, + check_access, + create_resource, + new_id, + operation_id, + resolve, +) +from msg.plugins.schemas import IDENTIFIER, STRING, obj + +PINNED_REF = obj({'id': IDENTIFIER, 'revision': IDENTIFIER}, ('id', 'revision')) + + +async def _query_scope(app, ctx, tx, descriptor, principal): + operation, version = descriptor['operation'], descriptor['contract_version'] + require(operation in {'discovery.read_query', 'discovery.lexical_search'}, 'saved_query_invalid') + spec = app.registry.operation(operation, version) + require(spec.effect == 'read', 'saved_query_invalid') + arguments = descriptor['arguments'] + app.registry.validate(spec.input_schema, arguments) + require('cursor' not in arguments, 'saved_query_invalid') + scope_id = arguments.get('parent' if operation == 'discovery.read_query' else 'scope') + require(scope_id is not None, 'saved_query_invalid') + source_ctx = replace(ctx, principal=principal) + # This is an authorization query, never a replacement signed operation or + # execution of a stored result. It checks the original read contract's scope. + probe = request_for(operation, arguments, app.settings.service_url, + subject=principal.subject, contract_version=version) + if operation == 'discovery.lexical_search': + from msg.plugins.discovery import normalize_search_scope + normalized, _, resources = await normalize_search_scope(app, source_ctx, probe, tx, scope_id) + require(all(resource.state == 'active' for resource in resources), 'ancestor_inactive') + return normalized + scope_id = await resolve(tx, scope_id) + scope = await tx.resource(scope_id) + require(scope.state == 'active', 'ancestor_inactive') + check = 'list' if app.registry.resource_type(scope.type, scope.type_version).container else 'read' + await check_access(app, source_ctx, probe, tx, scope_id, check) + return scope_id + + +async def load_saved_query(app, ctx, request, tx, ref): + """Internal loader; returned principal must never be serialized to clients.""" + from msg.workers.effects import current_principal + require(ref.revision is not None, 'saved_query_revision_required') + resource = await tx.resource(ref.id) + require(resource.type == 'saved_query' and resource.state == 'active', 'saved_query_not_found') + require(ctx.principal.subject == resource.owner and ctx.principal.actor == resource.owner, + 'permission_denied') + require(resource.mode & 0o400 and all(item.state == 'active' for item in await tx.ancestors(resource.id)), + 'permission_denied') + await check_access(app, ctx, request, tx, resource.parent, 'list') + await app.authorizer.require_base(ctx.principal, operation_id(request), resource.id, tx) + revision = await tx.revision(ref) + saved = loads(await app.contents.read_bytes(revision.content, limit=131072)) + require(saved.get('subject') == resource.owner and set(saved) == { + 'subject', 'created_at', 'descriptor', 'descriptor_digest', 'principal', 'source_digest'}, + 'saved_query_invalid') + app.registry.validate(app.registry.resource_type('saved_query').content_schema, saved) + descriptor = saved['descriptor'] + require(type(descriptor) is dict and set(descriptor) == {'operation', 'contract_version', 'arguments'}, + 'saved_query_invalid') + require(digest(descriptor) == saved['descriptor_digest'], 'saved_query_digest_mismatch') + principal = await current_principal(app, decode(Principal, saved['principal']), tx) + require(principal.actor == principal.subject == resource.owner, 'permission_denied') + await app.authorizer.require_base(principal, 'query.save@1', resource.id, tx) + await _query_scope(app, ctx, tx, descriptor, principal) + await _query_scope(app, ctx, tx, descriptor, ctx.principal) + return {**descriptor, 'digest': saved['descriptor_digest'], 'principal': principal} + + +def install(app, op): + @op('query.save', obj({'query_ref': STRING}, ('query_ref',)), signature=True) + async def save(ctx, request, tx): + from msg.plugins.transfer import query_ref_principal, sealed_read_query + require(not request.return_fields, 'projection_unavailable') + subject = ctx.principal.subject + require(subject is not None and subject == ctx.principal.actor, 'permission_denied') + await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx) + token = app.cursors.inspect(request.arguments['query_ref']) + require(token.get('kind') == 'query-ref', 'invalid_query_ref') + query, position = token['query'], token['position'] + require(query.get('service') == app.settings.service_url, 'wrong_service') + require(position.get('principal') == query_ref_principal(ctx.principal), 'query_ref_principal_mismatch') + require(ctx.now < parse_time(position['expires_at']), 'query_ref_expired') + transfer = await tx.transfer(query['transfer_id']) + ref, revision, kind, args = await sealed_read_query(app, ctx, request, tx, transfer) + require(query.get('output') == wire(ref) and query.get('digest') == revision.content.digest and + query.get('query_kind') == kind, 'query_ref_digest_mismatch') + operation = 'discovery.read_query' if kind == 'read' else 'discovery.lexical_search' + version = read_query_version(args) if kind == 'read' else search_query_version(args) + arguments = dict(args) + for field in ('parent', 'author', 'owner', 'relation_to', 'relation_from'): + if field in arguments: + arguments[field] = await resolve(tx, arguments[field]) + descriptor = {'operation': operation, 'contract_version': version, 'arguments': arguments} + normalized_scope = await _query_scope(app, ctx, tx, descriptor, ctx.principal) + if kind == 'search': + arguments['scope'] = normalized_scope + saved = {'subject': subject, 'created_at': wire(ctx.now), 'descriptor': descriptor, + 'descriptor_digest': digest(descriptor), 'principal': wire(ctx.principal), + 'source_digest': revision.content.digest} + resource = await create_resource(app, ctx, request, tx, parent=subject, + type='saved_query', name=new_id('query'), mode=0o600, + body=canonical(saved), media_type='application/json') + output = ResourceRef(id=resource.id, revision=resource.revision) + return HandlerOutput(resources=(output,), data={'saved_ref': wire(output), + 'descriptor_digest': saved['descriptor_digest']}) + + @op('query.saved_get', obj({'ref': PINNED_REF}, ('ref',)), effect='read') + async def get(ctx, request, tx): + descriptor = await load_saved_query(app, ctx, request, tx, decode(ResourceRef, request.arguments['ref'])) + return HandlerOutput(data={key: value for key, value in descriptor.items() if key != 'principal'}) + + @op('query.saved_archive', obj({'ref': PINNED_REF}, ('ref',)), signature=True) + async def archive(ctx, request, tx): + # Owner may revoke this saved source even if its creation credential or + # referenced scope is no longer usable. This cannot restore authority. + ref = decode(ResourceRef, request.arguments['ref']) + resource = await tx.resource(ref.id) + require(resource.type == 'saved_query' and resource.owner == ctx.principal.subject == ctx.principal.actor, + 'permission_denied') + await app.authorizer.require_base(ctx.principal, operation_id(request), resource.id, tx) + require(resource.revision == ref.revision, 'revision_conflict') + await assert_generation(request, resource) + updated = replace(resource, state='archived', generation=resource.generation + 1, + modified_at=ctx.now, modified_by=ctx.principal.actor) + await tx.replace(updated, resource.generation) + return HandlerOutput(data={'id': resource.id, 'state': 'archived', 'generation': updated.generation}) + + schema_ref = ResourceRef(id='schema:saved-query-record:1') + app.registry.add_schema(schema_ref, obj({ + 'subject': IDENTIFIER, 'created_at': STRING, 'source_digest': STRING, + 'descriptor_digest': STRING, 'principal': {'type': 'object'}, + 'descriptor': obj({'operation': {'enum': ['discovery.read_query', 'discovery.lexical_search']}, + 'contract_version': {'type': 'integer', 'minimum': 1}, 'arguments': {'type': 'object'}}, + ('operation', 'contract_version', 'arguments'))}, + ('subject', 'created_at', 'source_digest', 'descriptor_digest', 'principal', 'descriptor'))) + return ResourceTypeSpec(name='saved_query', version=1, container=False, content_schema=schema_ref, + operations=frozenset(), relations=frozenset()) diff --git a/src/msg/plugins/store.py b/src/msg/plugins/store.py index 09a1e143..3c2a790a 100644 --- a/src/msg/plugins/store.py +++ b/src/msg/plugins/store.py @@ -17,36 +17,6 @@ MAX_MANIFEST_BYTES = 65536 -def _subject(ctx): - subject = ctx.principal.subject - require(subject is not None and ctx.principal.actor == subject and - ctx.principal.method == 'signature', 'signature_required') - return subject - - -async def _listing(app, ctx, request, tx, value, *, seller=False): - try: - rid = await resolve(tx, value) - resource = await tx.resource(rid) - require(resource.type == 'listing' and resource.parent == 't_store', 'listing_not_found') - await check_access(app, ctx, request, tx, rid, 'read') - if seller: - require(resource.owner == _subject(ctx), 'listing_not_found') - return resource - except Failure as exc: - if exc.code in {'not_found', 'permission_denied', 'credential_ceiling', - 'certificate_gate', 'ancestor_inactive'}: - raise Failure('listing_not_found') from None - raise - - -async def _body(app, tx, resource, revision=None): - rev = await tx.revision(ResourceRef(id=resource.id, revision=revision)) - body = loads(await app.contents.read_bytes(rev.content)) - # Listings deposited before the sale/bounty split were all sale listings. - return {'mode': 'sale', **body}, rev - - def _public(resource, body): # A package identifier is not a capability; package payloads are seller-only. return {'listing_id': resource.id, 'listing_revision': resource.revision, @@ -74,12 +44,6 @@ def _validate(body, now): require(body['package_ref'] is not None, 'package_required') -async def _package_row(tx, package_id): - return tx.one('''SELECT id,listing_id,listing_revision,seller,revision,kind,manifest, - payload_refs,digest,total_size,delivery_mode,deposited_at FROM store_packages WHERE id=?''', - (package_id,)) - - def _package_public(row): return dict(zip(('id', 'listing_id', 'listing_revision', 'seller', 'revision', 'kind', 'manifest', 'payload_refs', 'digest', 'total_size', @@ -87,6 +51,13 @@ def _package_public(row): (*row[:6], loads(row[6]), loads(row[7]), *row[8:]))) +# Compatibility imports; shared implementation has one market owner. +from msg.market.catalog import read_listing as _listing +from msg.market.catalog import read_listing_body as _body +from msg.market.catalog import read_package_record as _package_row +from msg.market.order_records import require_signed_subject as _subject + + def install(app): op, finish = registration(app, 'store', ('content',)) listing_fields = { @@ -164,7 +135,7 @@ async def listing_get(ctx, request, tx): result = _public(resource, body) if body['mode'] == 'bounty': from msg.plugins.bounty import projection - live = projection(tx,resource.id,ctx.now) + live = await projection(app,tx,resource.id,ctx.now) result['current_state'] = live['state'] result['pause_reason'] = live['pause_reason'] result['current_budget_minor'] = live['budget_minor'] @@ -178,6 +149,22 @@ async def listing_get(ctx, request, tx): return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=rev.id),), data={'listing':result}) + @op('store.listing_get', obj({'id': IDENTIFIER, 'revision': IDENTIFIER, + 'source': {'enum': ['resource', 'server_offer']}}, ('id',)), effect='read', version=2) + async def listing_get_compatible(ctx, request, tx): + if request.arguments.get('source', 'resource') == 'resource': + return await listing_get(ctx, request, tx) + from msg.plugins.offers import _public_offer, _valid_catalog_offer + from msg.market.compatibility import offer_listing + row = tx.one("""SELECT offer_id,resource_kind,unit,price_minor,min_quantity, + max_quantity,entitlement_kind,duration_seconds,price_revision + FROM server_offers WHERE offer_id=? AND enabled=TRUE""", (request.arguments['id'],)) + require(row is not None and _valid_catalog_offer(app, row), 'listing_not_found') + from msg.market.offer_resources import verify_projection + await verify_projection(app, tx, row) + require(request.arguments.get('revision', row[8]) == row[8], 'listing_not_found') + return HandlerOutput(data={'listing': offer_listing(_public_offer(row))}) + @op('store.package_deposit', obj({'listing_id':IDENTIFIER, 'listing_revision':IDENTIFIER, 'manifest':{'type':'object'}, diff --git a/src/msg/plugins/system.py b/src/msg/plugins/system.py index d0bca7d8..2c6cb311 100644 --- a/src/msg/plugins/system.py +++ b/src/msg/plugins/system.py @@ -68,7 +68,7 @@ async def maintenance(ctx, request, tx): eid = event_id(request, ctx.principal.subject) job = EffectJob(id=new_id('job'), event_id=eid, kind='maintenance', dedupe_key='maintenance:'+eid, principal=ctx.principal, operation=request.operation, - arguments={'action':request.arguments['action'],'request_id':request.request_id}, + arguments={'contract_version':request.contract_version,'action':request.arguments['action'],'request_id':request.request_id}, state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None) await tx.enqueue(job) return HandlerOutput(data={'job_id':job.id,'state':'pending'}) diff --git a/src/msg/plugins/transfer.py b/src/msg/plugins/transfer.py index f1c0dd8c..8d14ec99 100644 --- a/src/msg/plugins/transfer.py +++ b/src/msg/plugins/transfer.py @@ -9,6 +9,7 @@ from msg.core.models import ResourceRef,HandlerOutput from msg.core.tags import normalize_tag from msg.core.read_query import read_query_version +from msg.core.search_query import search_query_version from msg.core.transfer import TransferService from msg.plugins.common import registration,resolve,create_resource,check_access from msg.plugins.schemas import obj,INTEGER,STRING,IDENTIFIER,BYTES,REF @@ -43,10 +44,7 @@ async def sealed_read_query(app,ctx,request,tx,transfer): require(args.get('parent' if kind=='read' else 'scope') is not None and 'cursor' not in args,'invalid_query_ref') operation='discovery.read_query' if kind=='read' else 'discovery.lexical_search' - contract_version=(read_query_version(args) if kind=='read' else - 4 if kind=='search' and 'suggest' in args else - 3 if kind=='search' and any(name in args for name in - ('source_kind','relation_type')) else 2 if kind=='search' and 'facets' in args else 1) + contract_version=read_query_version(args) if kind=='read' else search_query_version(args) try: app.registry.validate(app.registry.operation(operation,contract_version).input_schema,args) except Failure as exc: @@ -194,16 +192,16 @@ async def query_get(ctx,request,tx): 'query_ref_digest_mismatch') require(query.get('query_kind')==kind,'query_ref_digest_mismatch') operation='discovery.read_query' if kind=='read' else 'discovery.lexical_search' - contract_version=(read_query_version(args) if kind=='read' else - 4 if kind=='search' and 'suggest' in args else - 3 if kind=='search' and any(name in args for name in - ('source_kind','relation_type')) else 2 if kind=='search' and 'facets' in args else 1) + contract_version=read_query_version(args) if kind=='read' else search_query_version(args) principal=query_ref_principal(ctx.principal) normalized=dict(args) if kind=='read' and contract_version==3: normalized['query_version']=3 scope_field='parent' if kind=='read' else 'scope' - if normalized.get(scope_field): + if kind=='search': + from msg.plugins.discovery import normalize_search_scope + normalized['scope'],_,_=await normalize_search_scope(app,ctx,request,tx,normalized['scope']) + elif normalized.get(scope_field): normalized[scope_field]=await resolve(tx,normalized[scope_field]) if normalized.get('tag'): normalized['tag']=normalize_tag(normalized['tag']) @@ -246,4 +244,5 @@ async def query_get(ctx,request,tx): data['pageInfo']={**data['pageInfo'],'endCursor':short} return replace(output,data=data) - finish() + from msg.plugins.saved_queries import install as install_saved_queries + finish((install_saved_queries(app, op),)) diff --git a/src/msg/plugins/watches.py b/src/msg/plugins/watches.py new file mode 100644 index 00000000..1c299fef --- /dev/null +++ b/src/msg/plugins/watches.py @@ -0,0 +1,265 @@ +"""Pinned target and saved-query watches on the existing Event transaction.""" +import time +import logging +import re +from dataclasses import replace +from datetime import timedelta +from pathlib import Path + +from msg.core.codec import canonical, decode, digest, loads, parse_time, wire +from msg.core.errors import Failure, require +from msg.core.models import ExecutionContext, HandlerOutput, Principal, ResourceRef +from msg.core.requests import request_for +from msg.plugins.common import check_access, create_resource, new_id, operation_id, resolve, revise_resource +from msg.plugins.schemas import IDENTIFIER, STRING, REF, obj + +EVENT_TYPES = ('content.post_create', 'content.post_edit', 'discussion.reply', 'content.archive') +QUERY_EVENT_TYPES = (*EVENT_TYPES, 'content.tags_set', 'content.move', 'content.chown') +log = logging.getLogger(__name__) +RECORD_CORRUPTION = frozenset({'content_missing', 'content_truncated', 'content_size_mismatch', + 'content_digest_mismatch', 'invalid_json', 'duplicate_json_key', 'non_finite_float', + 'revision_not_found', 'watch_record_invalid', 'watch_content_invalid', 'use_transfer'}) + + +async def record(app, tx, rid): + resource = await tx.resource(rid) + require(resource.type == 'watch', 'watch_not_found') + revision = await tx.revision(ResourceRef(id=rid)) + try: + raw = await app.contents.read_bytes(revision.content) + except FileNotFoundError as exc: + # A missing Git executable or unrelated file is an operational error. + missing = Path(exc.filename) if exc.filename else None + if missing is None or not any(missing.is_relative_to(root) for root in + (app.contents.index, app.contents.binary)): + raise + raise Failure('content_missing') from exc + except (KeyError, TypeError) as exc: + # Malformed content-index JSON can fail before payload decoding. + raise Failure('watch_content_invalid') from exc + require(digest(raw) == revision.content.digest, 'content_digest_mismatch') + saved = loads(raw) + # Parsing is confined to stored watch data; database and filesystem I/O + # failures are deliberately outside this conversion boundary. + try: + require(type(saved) is dict and {'id', 'subject', 'target', 'query_ref', + 'event_types', 'delivery', 'created_at', 'expires_at', 'status', 'legacy', + 'principal', 'operation'} <= saved.keys(), 'watch_record_invalid') + require(saved['id'] == rid and saved['subject'] == resource.owner and + type(saved['target']) is str and saved['status'] in ('active', 'cancelled') and + saved['delivery'] == 'inbox' and type(saved['legacy']) is bool, + 'watch_record_invalid') + require(type(saved['event_types']) is list and bool(saved['event_types']) and + all(type(item) is str and item in QUERY_EVENT_TYPES for item in saved['event_types']), + 'watch_record_invalid') + require(type(saved['operation']) is str and + re.fullmatch(r'communication\.watch(?:_create)?@[1-9][0-9]*', saved['operation']) is not None, + 'watch_record_invalid') + parse_time(saved['created_at']) + if saved['expires_at'] is not None: + parse_time(saved['expires_at']) + principal = decode(Principal, saved['principal']) + require(principal.subject == resource.owner, 'watch_record_invalid') + if saved['query_ref'] is not None: + ref = decode(ResourceRef, saved['query_ref']) + require(ref.revision is not None and type(saved.get('query_binding')) is str, + 'watch_record_invalid') + except Failure as exc: + raise Failure('watch_record_invalid') from exc + return resource, saved + + +async def create(app, ctx, request, tx, target, *, legacy=False, query_ref=None, query_binding=None): + subject = ctx.principal.subject + require(subject is not None, 'authentication_required') + await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx) + await check_access(app, ctx, request, tx, target, 'read') + await app.authorizer._ceiling(ctx.principal, operation_id(request), target, tx) + expires = request.arguments.get('expires_at') + if expires: + require(ctx.now < parse_time(expires) <= ctx.now + timedelta(days=365), 'watch_expiry_invalid') + if legacy: + for (rid,) in tx.rows("SELECT id FROM resources WHERE type='watch' AND owner=? AND state='active'", (subject,)): + resource, saved = await record(app, tx, rid) + if saved.get('legacy') and saved['target'] == target and saved['status'] == 'active': + return resource, saved + rid = new_id('watch') + saved = {'id': rid, 'subject': subject, 'target': target, 'query_ref': wire(query_ref) if query_ref else None, + 'event_types': list(EVENT_TYPES) if legacy else request.arguments['event_types'], + 'delivery': 'inbox', 'created_at': wire(ctx.now), 'expires_at': expires, + 'status': 'active', 'legacy': legacy, 'principal': wire(ctx.principal), + 'operation': operation_id(request), 'query_binding': query_binding} + resource = await create_resource(app, ctx, request, tx, parent=subject, type='watch', + name=rid, resource_id=rid, mode=0o600, + body=canonical(saved), media_type='application/json') + return resource, saved + + +async def cancel(app, ctx, request, tx, resource, saved): + updated = await revise_resource(app, ctx, request, tx, resource, canonical({**saved, 'status': 'cancelled'}), 'application/json') + # Generic lifecycle operations cannot archive watches, so cancellation must + # leave the active set that listing bounds and every Event scans. + await tx.replace(replace(updated, state='archived', generation=updated.generation + 1, + modified_at=ctx.now, modified_by=ctx.principal.actor), updated.generation) + if saved.get('legacy'): + tx.execute('DELETE FROM watches WHERE subject=? AND resource=?', (saved['subject'], saved['target']), write=True) + + +async def legacy(app, ctx, request, tx, target, enabled): + if enabled: + await create(app, ctx, request, tx, target, legacy=True) + else: + for (rid,) in tx.rows("SELECT id FROM resources WHERE type='watch' AND owner=? AND state='active'", (ctx.principal.subject,)): + resource, saved = await record(app, tx, rid) + if saved.get('legacy') and saved['target'] == target and saved['status'] == 'active': + await cancel(app, ctx, request, tx, resource, saved) + + +async def query_contract(app, ctx, request, tx, ref): + from msg.plugins.saved_queries import load_saved_query + from msg.core.tags import normalize_tag + query = await load_saved_query(app, ctx, request, tx, ref) + require(query['operation'] == 'discovery.read_query' and query['contract_version'] == 1, + 'watch_query_unsupported') + args = dict(query['arguments']) + require('parent' in args and set(args) <= {'parent', 'type', 'author', 'state', 'tag'}, + 'watch_query_unsupported') + args['parent'] = await resolve(tx, args['parent']) + if 'author' in args: + args['author'] = await resolve(tx, args['author']) + if 'tag' in args: + args['tag'] = normalize_tag(args['tag']) + read = replace(request, operation=query['operation'], contract_version=query['contract_version']) + app.registry.operation(read.operation, read.contract_version) + await check_access(app, ctx, read, tx, args['parent'], 'list') + return query, args, read + + +async def enqueue(app, tx, event): + from msg.security.quarantine import active + from msg.workers.effects import current_principal + if active(tx) or event.type not in QUERY_EVENT_TYPES: + return + for (rid,) in tx.rows("SELECT id FROM resources WHERE type='watch' AND state='active' ORDER BY id"): + try: + resource, saved = await record(app, tx, rid) + except Failure as exc: + if exc.code not in RECORD_CORRUPTION: + raise + log.warning('watch_projection_corrupt watch_digest=%s error_code=%s', digest(rid), exc.code, + extra={'watch_digest': digest(rid), 'error_code': exc.code}) + continue + if saved['status'] != 'active' or event.type not in saved['event_types']: + continue + if saved['expires_at'] and app.clock() >= parse_time(saved['expires_at']): + continue + try: + principal = await current_principal(app, decode(Principal, saved['principal']), tx) + operation, version = saved['operation'].rsplit('@', 1) + # A captured credential cannot revive a removed operation version. + app.registry.operation(operation, int(version)) + request = request_for(operation, {}, subject=principal.subject, service=app.settings.service_url, contract_version=int(version)) + ctx = ExecutionContext(request_id=event.id, principal=principal, entry='worker', + now=app.clock(), deadline_monotonic=time.monotonic()+30) + await check_access(app, ctx, request, tx, saved['target'], 'read') + query = None + if saved.get('query_ref'): + query, args, read = await query_contract(app, ctx, request, tx, decode(ResourceRef, saved['query_ref'])) + require(args['parent'] == saved['target'] and digest(args) == saved.get('query_binding'), + 'watch_query_scope_changed') + from msg.plugins.read_predicates import read_predicates + filters, parameters = await read_predicates(app, tx, args, args['parent']) + for ref in event.resources: + if query is not None: + if not tx.one('SELECT 1 FROM resources r WHERE r.id=? AND ' + ' AND '.join(filters), + (ref.id, *parameters)): + continue + await check_access(app, ctx, read, tx, ref.id, 'read') + source_ctx = replace(ctx, principal=query['principal']) + await check_access(app, source_ctx, read, tx, ref.id, 'read') + else: + ancestors = await tx.ancestors(ref.id) + if ref.id != saved['target'] and not any(a.id == saved['target'] for a in ancestors): + continue + await check_access(app, ctx, request, tx, ref.id, 'read') + if tx.one('SELECT 1 FROM messages WHERE event_id=? AND recipient=? AND resource=?', (event.id, saved['subject'], ref.id)): + continue + mid = new_id('message') + body = {'id': mid, 'sender': event.subject, 'recipient': saved['subject'], + 'resource': {'id': ref.id}, 'source': 'follow' if saved['legacy'] else 'watch', 'time': wire(event.time)} + tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)', + (mid, event.subject, saved['subject'], ref.id, event.id, canonical(body).decode()), write=True) + except Failure: + continue + + +def install(app, op): + @op('communication.watch_create', obj({'target': IDENTIFIER, 'query_ref': STRING, + 'event_types': {'type': 'array', 'items': {'enum': list(EVENT_TYPES)}, 'minItems': 1, + 'maxItems': len(EVENT_TYPES), 'uniqueItems': True}, + 'delivery': {'enum': ['inbox']}, 'expires_at': STRING}, ('event_types', 'delivery'))) + async def watch_create(ctx, request, tx): + require('query_ref' not in request.arguments, 'watch_query_unsupported') + require('target' in request.arguments, 'watch_target_required') + target = await resolve(tx, request.arguments['target']) + resource, saved = await create(app, ctx, request, tx, target) + return HandlerOutput(data={'id': resource.id, 'status': saved['status']}) + + @op('communication.watch_create', obj({'query_ref': REF, + 'event_types': {'type': 'array', 'items': {'enum': list(QUERY_EVENT_TYPES)}, 'minItems': 1, + 'maxItems': len(QUERY_EVENT_TYPES), 'uniqueItems': True}, + 'delivery': {'enum': ['inbox']}, 'expires_at': STRING}, + ('query_ref', 'event_types', 'delivery')), version=2, signature=True) + async def watch_query_create(ctx, request, tx): + ref = decode(ResourceRef, request.arguments['query_ref']) + require(ref.revision is not None, 'watch_query_revision_required') + _, args, _ = await query_contract(app, ctx, request, tx, ref) + resource, saved = await create(app, ctx, request, tx, args['parent'], query_ref=ref, query_binding=digest(args)) + return HandlerOutput(data={'id': resource.id, 'status': saved['status']}) + + async def owned(ctx, request, tx, rid): + resource, saved = await record(app, tx, await resolve(tx, rid)) + require(ctx.principal.subject == resource.owner == saved['subject'], 'permission_denied') + await app.authorizer.require_base(ctx.principal, operation_id(request), resource.id, tx) + return resource, saved + + async def public(ctx, request, tx, saved): + result = {k: v for k, v in saved.items() if k not in {'principal', 'operation', 'legacy', 'query_binding'}} + try: + await check_access(app, ctx, request, tx, saved['target'], 'read') + except Failure: + result.pop('target', None) + result.pop('query_ref', None) + if saved.get('query_ref'): + try: + await query_contract(app, ctx, request, tx, decode(ResourceRef, saved['query_ref'])) + except Failure: + result.pop('query_ref', None) + result.pop('target', None) + if saved['expires_at'] and ctx.now >= parse_time(saved['expires_at']) and saved['status'] == 'active': + result['status'] = 'expired' + return result + + @op('communication.watch_get', obj({'id': IDENTIFIER}, ('id',)), effect='read') + async def watch_get(ctx, request, tx): + _, saved = await owned(ctx, request, tx, request.arguments['id']) + return HandlerOutput(data=await public(ctx, request, tx, saved)) + + @op('communication.watch_cancel', obj({'id': IDENTIFIER}, ('id',))) + async def watch_cancel(ctx, request, tx): + resource, saved = await owned(ctx, request, tx, request.arguments['id']) + if saved['status'] != 'cancelled': + await cancel(app, ctx, request, tx, resource, saved) + return HandlerOutput(data={'id': resource.id, 'status': 'cancelled'}) + + @op('communication.watch_list', obj(), effect='read') + async def watch_list(ctx, request, tx): + require(ctx.principal.subject is not None, 'authentication_required') + await app.authorizer.require_base(ctx.principal, operation_id(request), ctx.principal.subject, tx) + rows = tx.rows("SELECT id FROM resources WHERE type='watch' AND owner=? AND state='active' ORDER BY id LIMIT 101", (ctx.principal.subject,)) + require(len(rows) <= 100, 'watch_list_limit') + items = [] + for (rid,) in rows: + _, saved = await owned(ctx, request, tx, rid) + items.append(await public(ctx, request, tx, saved)) + return HandlerOutput(data={'items': items}) diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index 3a7291b1..a32e4cf9 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -10,16 +10,19 @@ from msg.core.models import Principal,SignatureProof,TokenProof from msg.core.requests import payload_fields,signing_bytes from msg.security.crypto import key_id,subject_id,verify -from msg.security.quarantine import active as quarantine_active +from msg.security.quarantine import RuntimeGeneration, active as quarantine_active from msg.security.token_delivery import recovery_verifier CUSTODIAL_SIGNED_WRITES=frozenset({ 'content.post_create','content.post_edit','content.file_put','content.attach', + 'file.create','file.copy','file.write','file.patch', 'discussion.reply','discussion.quote','discussion.repost','identity.token_rotate', 'identity.custodial_upgrade_start','identity.custodial_upgrade_finish', 'identity.custodial_rewrap_entry','identity.custodial_rewrap_revision', 'identity.custodial_rewrap_ack', + 'achievement.start','achievement.answer','achievement.finish', + 'achievement.pin','achievement.unpin','achievement.reorder', }) @@ -27,8 +30,10 @@ class AuthenticationService: def __init__(self,registry,certificates,service,clock,primary_ceiling,temporary_ceiling): self.registry,self.certificates,self.service,self.clock=registry,certificates,service,clock self.primary_ceiling,self.temporary_ceiling=primary_ceiling,temporary_ceiling + self.runtime_generation=RuntimeGeneration() async def authenticate(self,request,session, *, entry): + self.runtime_generation.require_current(session) require(session.setting('active_root_certificate',self.certificates.root_certificate.resource_id)== self.certificates.root_certificate.resource_id,'service_restart_required') spec=self.registry.operation(request.operation,request.contract_version) @@ -127,7 +132,13 @@ async def authenticate(self,request,session, *, entry): request.arguments.get('direction')=='download') require(spec.name in CUSTODIAL_SIGNED_WRITES or download_only, 'custodial_operation_not_supported') - require(not spec.require_signature or method=='signature','signature_required') + # Achievement handlers sign explicit confirmations with the current + # vault key; ordinary token subjects still need a signature. + custodial_confirmation=(actor.kind=='custodial' and method=='token' and + spec.name in {'achievement.finish','achievement.pin', + 'achievement.unpin','achievement.reorder'}) + require(not spec.require_signature or method=='signature' or custodial_confirmation, + 'signature_required') valid=[] for cid in ids: cert=await self.certificates.validate(cid,session) diff --git a/src/msg/security/authorization.py b/src/msg/security/authorization.py index fa52179c..4344dd43 100644 --- a/src/msg/security/authorization.py +++ b/src/msg/security/authorization.py @@ -5,7 +5,7 @@ from msg.core.codec import loads,parse_time,wire from msg.core.models import ResourceRef from msg.security.sharing_policy import share_target_error -from msg.security.quarantine import active as quarantine_active, require_live_authority +from msg.security.quarantine import RuntimeGeneration, active as quarantine_active, require_live_authority from msg.security.policy import allows,grant_covers,scope_contains,CERTGATE _WRITE_CHECKS={'write','create','remove','chmod','chgrp','chown','manage','certgate','purge','tool_use'} @@ -17,8 +17,10 @@ class AuthorizationService: def __init__(self,registry,certificates): self.registry,self.certificates=registry,certificates + self.runtime_generation=RuntimeGeneration() async def grants(self,principal,session): + self.runtime_generation.require_current(session) require_live_authority(session) grants=[] for cid in principal.certificates: @@ -29,6 +31,7 @@ async def grants(self,principal,session): return tuple(grants) async def has(self,principal,capability,operation,resource,session): + self.runtime_generation.require_current(session) if principal.method=='local' and principal.subject==ROOT_SUBJECT: return True if not any([await grant_covers(g,capability,operation,resource,session) for g in principal.ceiling]): @@ -37,6 +40,7 @@ async def has(self,principal,capability,operation,resource,session): for g in await self.grants(principal,session)]) async def _ceiling(self,principal,operation,resource,session): + self.runtime_generation.require_current(session) require_live_authority(session) if principal.method=='anonymous': return @@ -145,6 +149,7 @@ async def share_link_read_allowed(self,grantor,credential_id,resource,chain,now, The original signed owner's credential is a revocation boundary. A link cannot borrow that principal for other reads or outlive its current scope. """ + self.runtime_generation.require_current(session) if quarantine_active(session): return False if resource.owner!=grantor or share_target_error(self.registry,resource,chain,session): @@ -173,6 +178,7 @@ async def require_base(self,principal,operation,resource,session): async def require(self,context,request,checks,session): principal=context.principal + self.runtime_generation.require_current(session) require_live_authority(session) if principal.subject==ROOT_SUBJECT or principal.actor==ROOT_SUBJECT: require(context.entry=='local_admin' and principal.method=='local','local_only') @@ -180,6 +186,12 @@ async def require(self,context,request,checks,session): for check in checks: resource=await session.resource(check.resource_id) operation=check.operation + # Watch revisions capture authority and stale target references; only + # the dedicated clipped contract may expose them. + require(resource.type != 'watch', 'permission_denied') + if resource.type == 'saved_query': + require(operation in {'query.save@1', 'query.saved_archive@1'} and + principal.subject == principal.actor == resource.owner, 'permission_denied') await self._ceiling(principal,operation,resource.id,session) chain=(*await session.ancestors(resource.id),resource) preview=next((marker for item in chain diff --git a/src/msg/security/backup_retirement.py b/src/msg/security/backup_retirement.py index f0001e80..fcd10c90 100644 --- a/src/msg/security/backup_retirement.py +++ b/src/msg/security/backup_retirement.py @@ -16,6 +16,7 @@ from msg.core.codec import canonical, decode, digest, parse_time, wire from msg.core.errors import Failure, require from msg.core.models import Signature +from msg.security.certificates import certificate_body from msg.security.crypto import key_id, verify DOMAIN = 'msg-custodial-backup-retirement-v1' @@ -65,18 +66,25 @@ def sign_record(body, signer): return {'statement': body, 'signature': wire(signer.sign(canonical(body), purpose=PURPOSE))} -async def root_verifier(tx): - """The key of the active, unrevoked root certificate; rotation retires old records.""" +async def root_verifier(tx, *, now): + """Recheck the current self-signed Root and its credential, not just historical validity.""" try: certificate_id = tx.setting('active_root_certificate') certificate = await tx.certificate(certificate_id) credential = await tx.credential(certificate.key_id) - require(certificate.subject_id == ROOT_SUBJECT and + require(certificate.resource_id == certificate_id and + certificate.subject_id == certificate.issuer_id == ROOT_SUBJECT and + certificate.parent_certificate_id is None and certificate.kind == 'ca' and + certificate.issuance is not None and + certificate.not_before <= now < certificate.expires_at and not await tx.certificate_revoked(certificate_id) and credential.subject_id == ROOT_SUBJECT and credential.kind == 'signing_key' and - credential.revoked_at is None and + credential.revoked_at is None and credential.not_before <= now and + (credential.expires_at is None or now < credential.expires_at) and key_id(credential.verifier) == credential.id == certificate.key_id, 'backup_retirement_root_unavailable') + verify(credential.verifier, canonical(certificate_body(certificate)), + certificate.signature, purpose='certificate') except (Failure, KeyError, TypeError, ValueError) as exc: raise Failure('backup_retirement_root_unavailable') from exc return credential.verifier @@ -100,8 +108,9 @@ def check(record, verifier, subject_id, details, *, now): attested, not_after = parse_time(body['attested_at']), parse_time(body['not_after']) signature = decode(Signature, record['signature']) verify(verifier, canonical(body), signature, purpose=PURPOSE) - require(attested < not_after <= attested + MAX_VALIDITY, 'backup_retirement_invalid') - except (Failure, KeyError, TypeError, ValueError) as exc: + # Subtract instead of adding to an untrusted date near datetime.max. + require(timedelta(0) < not_after - attested <= MAX_VALIDITY, 'backup_retirement_invalid') + except (Failure, KeyError, TypeError, ValueError, OverflowError) as exc: raise Failure('backup_retirement_invalid') from exc require(isinstance(details, dict) and body['subject_id'] == subject_id and body['old_identity_key_id'] == details.get('old_identity_key_id') and @@ -130,6 +139,6 @@ async def verified(tx, subject_id, details, *, now): if now is None: return 'clock_unavailable', None try: - return 'verified', check(record, await root_verifier(tx), subject_id, details, now=now) + return 'verified', check(record, await root_verifier(tx, now=now), subject_id, details, now=now) except Failure as exc: return ('expired' if exc.code == 'backup_retirement_expired' else 'invalid'), None diff --git a/src/msg/security/capabilities.py b/src/msg/security/capabilities.py index 22a5b444..152dbec3 100644 --- a/src/msg/security/capabilities.py +++ b/src/msg/security/capabilities.py @@ -33,7 +33,7 @@ 'identity.recovery_envelope_list','identity.recovery_custodians', 'achievement.start', 'achievement.answer', 'achievement.finish', 'achievement.pin','achievement.unpin','achievement.reorder'), - 'resource.basic': ('content.',), + 'resource.basic': ('content.', 'file.'), 'sharing.basic': ('sharing.',), 'discussion.basic': ('discussion.',), 'communication.basic': ('communication.',), @@ -43,7 +43,7 @@ 'orders.basic': ('orders.',), 'delivery.basic': ('delivery.',), 'discovery.basic': ('discovery.', 'cert.get', 'job.get', 'achievement.list'), - 'transfer.basic': ('transfer.',), + 'transfer.basic': ('transfer.', 'query.'), 'group.basic': ('group.',), 'cert.request': ('cert.request', 'cert.cancel'), 'git.basic': ('git.',), @@ -73,6 +73,7 @@ 'keystore.get', 'batch.independent','batch.atomic','job.get', 'achievement.start','achievement.answer','achievement.finish','achievement.list', + 'achievement.pin','achievement.unpin','achievement.reorder', }) @@ -129,6 +130,8 @@ def install_capabilities(registry): # destination, owner, etc.), rather than inferring it from this directory. for key,spec in tuple(registry._types.items()): relations=frozenset({'reply_to','thread_root','quote','repost','attachment','template'}) if spec.name=='post' else frozenset() + if spec.name in {'collab_request', 'collab_offer', 'checkpoint', 'collab_proposal'}: + relations = spec.relations registry._types[key]=replace(spec,operations=all_ops,relations=relations) diff --git a/src/msg/security/custodial_migration.py b/src/msg/security/custodial_migration.py index bcff8257..022d59af 100644 --- a/src/msg/security/custodial_migration.py +++ b/src/msg/security/custodial_migration.py @@ -13,6 +13,7 @@ from msg.core.errors import Failure, require from msg.core.models import ResourceRef, Signature from msg.security.crypto import key_id, verify +from msg.security.custodial_protocol import stage_statement, ack_statement from msg.security.age_keys import encryption_key_id, public_from_recipient from msg.security.backup_retirement import BackupRetirement, verified as verified_backup @@ -43,22 +44,6 @@ def inventory_commitment(subject, details, challenge): 'source_keys': details.get('source_keys', {})}) -def stage_statement(subject, arguments, request_id): - """Sign every decision field, not a caller-selected subset of the manifest.""" - return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, - 'request_id': request_id, - 'decision': {key: value for key, value in arguments.items() - if key != 'migration_ack'}} - - -def ack_statement(subject, challenge_id, inventory_digest, source, target, - plaintext_digest, request_id, *, method='rewrap'): - return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, - 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, - 'source': source, 'target': target, 'method': method, - 'plaintext_digest': plaintext_digest, 'request_id': request_id} - - def keyed_source(item, details): return dict(item, key_id=details.get('source_keys', {}).get( item['revision'], details['old_encryption_key_id'])) diff --git a/src/msg/security/custodial_protocol.py b/src/msg/security/custodial_protocol.py new file mode 100644 index 00000000..80821ca1 --- /dev/null +++ b/src/msg/security/custodial_protocol.py @@ -0,0 +1,49 @@ +"""Published custodial proof and statement bytes shared by both endpoints. + +This module has no server-held key, transaction, current-authority or retirement +state. Producing a proof does not verify authority or attest key destruction. +""" +from __future__ import annotations + +import hmac +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +from msg.core.codec import b64, canonical, unb64 +from msg.security.age_keys import private_from_identity + + +UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', + 'encryption_recipient','server_public','nonce','expires_at') + + +def _proof(shared,challenge): + context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} + key=HKDF(algorithm=hashes.SHA256(),length=32, + salt=unb64(context['nonce'],limit=64), + info=b'msg-custodial-upgrade-pop-v1').derive(shared) + return b64(hmac.digest(key,canonical(context),'sha256')) + + +def client_upgrade_proof(age_identity,challenge): + private=private_from_identity(age_identity) + shared=private.exchange(X25519PublicKey.from_public_bytes( + unb64(challenge['server_public'],limit=32))) + return _proof(shared,challenge) + + + +def stage_statement(subject, arguments, request_id): + """Sign every decision field, not a caller-selected subset of the manifest.""" + return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject, + 'request_id': request_id, + 'decision': {key: value for key, value in arguments.items() + if key != 'migration_ack'}} + + +def ack_statement(subject, challenge_id, inventory_digest, source, target, + plaintext_digest, request_id, *, method='rewrap'): + return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject, + 'challenge_id': challenge_id, 'inventory_digest': inventory_digest, + 'source': source, 'target': target, 'method': method, + 'plaintext_digest': plaintext_digest, 'request_id': request_id} diff --git a/src/msg/security/quarantine.py b/src/msg/security/quarantine.py index 85d12aa2..49e27755 100644 --- a/src/msg/security/quarantine.py +++ b/src/msg/security/quarantine.py @@ -4,7 +4,7 @@ may clear it. Absence alone means an ordinary installation; malformed, false or unknown values fail closed. This is containment, not a revocation replay proof. """ -from msg.core.errors import require +from msg.core.errors import Failure, require SETTING = 'recovery_quarantine' @@ -17,3 +17,36 @@ def require_live_authority(session): # Even anonymous access to formerly public content may revive an ACL that # was revoked after the snapshot. Only the transport health probe remains. require(not active(session), 'recovery_quarantined') + + +RUNTIME_GENERATION = 'recovery_runtime_generation' + + +def _generation(session): + missing = object() + value = session.setting(RUNTIME_GENERATION, missing) + if value is missing: + return None + require(isinstance(value, str) and bool(value), 'recovery_runtime_stale') + return value + + +class RuntimeGeneration: + """Pin authority generation at startup; mismatch stays latched until restart.""" + def __init__(self, generation=None): + self.generation = generation + self.stale = False + + @classmethod + def capture(cls, session): + return cls(_generation(session)) + + def require_current(self, session): + try: + current = _generation(session) + except Failure: + self.stale = True + raise + if current != self.generation: + self.stale = True + require(not self.stale, 'recovery_runtime_stale') diff --git a/src/msg/security/token_delivery.py b/src/msg/security/token_delivery.py index e60bc6b0..d3ce071d 100644 --- a/src/msg/security/token_delivery.py +++ b/src/msg/security/token_delivery.py @@ -37,6 +37,8 @@ def __init__(self, *, metadata: MetadataStore, token_secret: bytes, self._token_secret=token_secret self.clock=clock self.recovery_window=recovery_window + from msg.security.quarantine import RuntimeGeneration + self.runtime_generation=RuntimeGeneration() def issued_token(self,request,subject): # Nonces are high-entropy claims, not random tokens persisted in results. @@ -73,6 +75,7 @@ async def release(self,request,result): 'credential_delivery_upgrade_required') token=self.issued_token(request,result.subject) async with self.metadata.transaction(write=True) as tx: + self.runtime_generation.require_current(tx) credential=await tx.credential(result.data['credential_id']) require(credential.revoked_at is None and credential.expires_at>self.clock(),'credential_expired') require(hmac.compare_digest(credential.verifier,hashlib.sha256(token).digest()),'invalid_token_result') diff --git a/src/msg/security/trust_files.py b/src/msg/security/trust_files.py new file mode 100644 index 00000000..5e47870b --- /dev/null +++ b/src/msg/security/trust_files.py @@ -0,0 +1,52 @@ +"""Public Root JSON path compatibility; .crt does not imply PEM encoding. + +Readers reject conflicting aliases. Only an approved rotation journal may resume +an interrupted alias update, after each existing value is bound to old/new trust. +""" +from pathlib import Path +from msg.core.codec import canonical, loads +from msg.core.errors import require + + +def trust_paths(config_dir): + directory=Path(config_dir)/'trust' + require(not directory.is_symlink(),'unsafe_root_trust_path') + paths=(directory/'root.crt',directory/'root.json') + for path in paths: + require(not path.is_symlink() and (not path.exists() or path.is_file()), + 'unsafe_root_trust_path') + return paths + + +def trust_values(config_dir): + return {path: loads(path.read_bytes()) for path in trust_paths(config_dir) if path.exists()} + + +def trust_file(config_dir): + paths=trust_paths(config_dir) + values=trust_values(config_dir) + require(len({canonical(value) for value in values.values()})<=1,'root_trust_alias_mismatch') + return next(iter(values),paths[0]) + + +def write_trust(config_dir,value,*,writer,approved_values=None): + paths=trust_paths(config_dir) + values=trust_values(config_dir) + if approved_values is None: + trust_file(config_dir) + else: + allowed={canonical(item) for item in approved_values} + require(all(canonical(item) in allowed for item in values.values()),'rotation_trust_changed') + for path in values or {paths[0]:None}: + writer(path,canonical(value),mode=0o444) + + +def reserved_plugins_directory(config_dir,*,create=False): + directory=Path(config_dir)/'plugins.d' + require(not directory.is_symlink(),'unsafe_plugins_directory') + if create: + directory.mkdir(mode=0o755,parents=True,exist_ok=True) + if directory.exists(): + require(directory.is_dir() and directory.stat().st_mode & 0o022==0, + 'unsafe_plugins_directory') + return directory diff --git a/src/msg/security/vault.py b/src/msg/security/vault.py index baff9a43..c61f454e 100644 --- a/src/msg/security/vault.py +++ b/src/msg/security/vault.py @@ -2,7 +2,6 @@ from __future__ import annotations import os -import hmac import shutil import subprocess import tempfile @@ -11,12 +10,11 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey,X25519PublicKey -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes from msg.core.codec import b64,canonical,unb64,wire from msg.core.errors import Failure,require -from msg.security.age_keys import recipient_from_identity,private_from_identity +from msg.security.age_keys import recipient_from_identity +from msg.security.custodial_protocol import UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof from msg.security.crypto import Ed25519Signer @@ -128,25 +126,6 @@ def open_upgrade_private(app,subject,challenge_id,nonce,ciphertext): raise Failure('custodial_upgrade_challenge_corrupt') from exc -UPGRADE_CONTEXT_FIELDS=('subject_id','challenge_id','request_id','handle','public_key', - 'encryption_recipient','server_public','nonce','expires_at') - - -def _proof(shared,challenge): - context={name:challenge[name] for name in UPGRADE_CONTEXT_FIELDS} - key=HKDF(algorithm=hashes.SHA256(),length=32, - salt=unb64(context['nonce'],limit=64), - info=b'msg-custodial-upgrade-pop-v1').derive(shared) - return b64(hmac.digest(key,canonical(context),'sha256')) - - -def client_upgrade_proof(age_identity,challenge): - private=private_from_identity(age_identity) - shared=private.exchange(X25519PublicKey.from_public_bytes( - unb64(challenge['server_public'],limit=32))) - return _proof(shared,challenge) - - def server_upgrade_proof(app,subject,challenge,nonce,ciphertext): from msg.security.age_keys import public_from_recipient private=open_upgrade_private(app,subject,challenge['challenge_id'],nonce,ciphertext) diff --git a/src/msg/storage/git.py b/src/msg/storage/git.py index 8c0223eb..ed674ad2 100644 --- a/src/msg/storage/git.py +++ b/src/msg/storage/git.py @@ -17,26 +17,7 @@ from msg.core.codec import canonical, digest, loads from msg.core.errors import Failure, require from msg.core.models import BlobRef - - -def durable_write(path: Path, data: bytes, mode: int = 0o600): - path.parent.mkdir(parents=True,exist_ok=True) - fd,temporary=tempfile.mkstemp(prefix='.pending-',dir=path.parent) - try: - os.fchmod(fd,mode) - with os.fdopen(fd,'wb') as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary,path) - directory=os.open(path.parent,os.O_RDONLY|os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - finally: - if os.path.exists(temporary): - os.unlink(temporary) +from msg.atomic_file import durable_write class LFSObjectStore: @@ -216,6 +197,16 @@ def collect_unlinked(self, *, older_than: float, index: Path) -> tuple[int,int]: return removed,bytes_removed +def _file_matches(path,size,expected): + hasher=hashlib.sha256() + count=0 + with open(path,'rb') as stream: + while chunk:=stream.read(1024*1024): + count+=len(chunk) + hasher.update(chunk) + return count==size and 'sha256:'+hasher.hexdigest()==expected + + class GitContentStore: def __init__(self,path: Path, *, binary_dir: Path | None = None, staging_dir: Path | None = None, group_read: bool = False): @@ -303,7 +294,14 @@ def save_git(): entry={'kind':'git','oid':oid,'size':size} else: destination=self.binary/self._key(ref) - if not destination.exists(): + # An existing path is not evidence its bytes still match the + # digest. Keep its inode (LFS links share it) only if intact. + try: + intact=await asyncio.to_thread(_file_matches,destination,size,hashed) + except FileNotFoundError: + intact=None + require(intact is not False,'content_digest_mismatch') + if intact is None: if self.group_read: # Rename retains the staging GID, so explicitly use the # prepared destination group. Staging itself stays private. diff --git a/src/msg/storage/legacy_git_capture.py b/src/msg/storage/legacy_git_capture.py new file mode 100644 index 00000000..f3dd5cdc --- /dev/null +++ b/src/msg/storage/legacy_git_capture.py @@ -0,0 +1,106 @@ +"""Read-only SSH fixed-ref bundle capture. Never writes files on the source host.""" + +from __future__ import annotations +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +from msg.core.errors import require +from msg.storage.legacy_git_import import sha256_file, verify_bundle +from msg.storage.legacy_rehearsal import _write + +REMOTE_PROGRAM = r"""import os,sys,subprocess,json,re +path=SOURCE_PATH +env={'PATH':'/usr/bin:/bin','HOME':'/nonexistent','LANG':'C.UTF-8','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_OPTIONAL_LOCKS':'0','GIT_NO_REPLACE_OBJECTS':'1','GIT_TERMINAL_PROMPT':'0'} +command=['git','--git-dir',path,'-c','safe.directory='+path,'-c','core.hooksPath=/dev/null','-c','core.fsmonitor=false','-c','pack.threads=1'] +def run(*args): + p=subprocess.run(command+list(args),capture_output=True,env=env,timeout=120) + if p.returncode:raise RuntimeError('git read failed') + return p.stdout +def refs(): + result={} + for line in run('for-each-ref','--format=%(objectname) %(refname)').decode().splitlines(): + oid,name=line.split(' ',1) + if not re.fullmatch(r'[0-9a-f]{40}',oid) or not re.fullmatch(r'refs/(heads|tags)/[^\s]+',name):raise RuntimeError('unsupported refs') + result[name]=oid + if not 0 85: + parser.error("protected-target path is too long for a local PostgreSQL socket") + cluster = target / "pg" + started = False + summary = None + try: + subprocess.run( + ["initdb", "-D", str(cluster), "-A", "trust", "--no-instructions"], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "pg_ctl", + "-D", + str(cluster), + "-l", + str(target / "postgres.log"), + "-o", + f'-k {socket} -h "" -p 5432', + "-w", + "start", + ], + check=True, + capture_output=True, + ) + started = True + import psycopg + + dsn = f"postgresql://{quote(pwd.getpwuid(os.getuid()).pw_name)}@localhost:5432/postgres?host={quote(str(socket), safe='')}" + with psycopg.connect(dsn, autocommit=True) as connection: + connection.execute("CREATE DATABASE legacy_rehearsal") + dsn = dsn.replace("/postgres?", "/legacy_rehearsal?") + summary = asyncio.run(_run(source, manifest, target, dsn)) + except BaseException as exc: + # No raw exception messages: DB errors can include original row contents. + from msg.core.errors import Failure + + summary = { + "format": "msg-test-only-legacy-git-rehearsal-v1", + "test_only": True, + "production_approval": False, + "result": "failed", + "error_type": type(exc).__name__, + } + if isinstance(exc, Failure): + summary["error_code"] = exc.code + finally: + if started: + stopped = subprocess.run( + ["pg_ctl", "-D", str(cluster), "-m", "fast", "-w", "stop"], + capture_output=True, + check=False, + ) + if summary is not None: + summary["cluster_stopped"] = stopped.returncode == 0 + if stopped.returncode != 0: + summary["result"] = "failed" + if summary is not None: + _write(target / "summary.json", summary) + print(json.dumps(summary, sort_keys=True)) + if summary is None or summary["result"] != "passed": + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/src/msg/storage/legacy_identity_plan.py b/src/msg/storage/legacy_identity_plan.py new file mode 100644 index 00000000..d1f4b0be --- /dev/null +++ b/src/msg/storage/legacy_identity_plan.py @@ -0,0 +1,516 @@ +"""Public-proof inventory for 0.22.1; never imports authentication authority. + +Wire formats match installed crypto.py SHA256 +235d2736927dbdf2a0d37cd28d78b4b46324b6aba902461f9eca5b1ec22fc83e. +This inventory verifies signatures, not a grant of current authorization. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import sqlite3 +from collections import Counter +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from msg.core.codec import canonical, decode, digest, parse_time +from msg.core.errors import Failure, require +from msg.core.models import CapabilityGrant, Principal, Scope, Signature +from msg.plugins.common import create_resource +from msg.security.crypto import verify +from msg.security.quarantine import require_live_authority + +PURPOSE = "legacy-identity-archive-v1" + + +def _json(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _public(value): + raw = base64.b64decode(value, validate=True) + if len(raw) != 32: + raise ValueError("invalid public key") + return raw, hashlib.sha256(raw).hexdigest() + + +def _verify(key, signature, payload): + try: + raw, _ = _public(key) + Ed25519PublicKey.from_public_bytes(raw).verify( + base64.b64decode(signature, validate=True), payload + ) + return True + except (ValueError, TypeError, InvalidSignature): + return False + + +def _post_payload(row, files): + version = row["sig_version"] + signer = row["actor_id"] or row["author_id"] + fields = [ + ("action", "post.create" if version == 1 else "post.edit"), + ("signer_id", signer), + ("version", str(version)), + ] + if version == 1: + fields += [ + ("nonce", row["sig_nonce"]), + ("issued", str(row["sig_issued"])), + ("owner_id", signer), + ] + else: + fields += [("post_id", str(row["id"])), ("owner_id", row["author_id"])] + fields += [(key, row[key]) for key in ("board", "name", "title", "body")] + fields += [ + ("reply_to", "" if row["reply_to"] is None else str(row["reply_to"])), + ("files", _json(files)), + ] + return b"msg.lmm.best/request/v1\n" + b"".join( + key.encode() + b":" + str(len(value.encode())).encode() + b":" + value.encode() + b"\n" + for key, value in fields + ) + + +def _certificate_body(body): + data = json.loads(body) + require(isinstance(data, dict) and data.get("v") == 1, "legacy_certificate_invalid") + _, fingerprint = _public(data["subject_key"]) + require(fingerprint == data["subject_id"], "legacy_certificate_invalid") + grants = {} + for grant in data["grants"]: + grants.setdefault(grant["topic"], set()).update(grant["actions"]) + normalized = { + key: data[key] + for key in ( + "delegate", + "issuer_id", + "issuer_serial", + "not_after", + "not_before", + "serial", + "subject_id", + "v", + ) + } + normalized["subject_key"] = base64.b64encode(_public(data["subject_key"])[0]).decode() + normalized["grants"] = [ + {"topic": topic, "actions": sorted(actions)} for topic, actions in sorted(grants.items()) + ] + require( + type(data["not_before"]) is int + and type(data["not_after"]) is int + and data["not_after"] > data["not_before"], + "legacy_certificate_invalid", + ) + return normalized + + +def identity_plan(snapshot: Path, sha256: str, *, at: int, root_public_key: str | None = None): + """Read selected public columns only; return sensitive identifiers to the caller. + + Write the returned plan ONLY to a protected file. summary() is safe for logs. + Old token hashes, custody keys/ciphertext and keystore blobs are never selected. + Root anchor is explicit; missing anchor yields unknown, never trusted. + """ + path = Path(snapshot).resolve() + + def check(): + with path.open("rb") as stream: + require( + hashlib.file_digest(stream, "sha256").hexdigest() == sha256, + "legacy_snapshot_changed", + ) + + check() + db = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=2) + db.row_factory = sqlite3.Row + identities = {} + + def observe(subject, key=None): + subject = subject or "__anonymous__" + record = identities.setdefault( + subject, + { + "old_id": subject, + "key_fingerprints": set(), + "key_mismatch": False, + "post_signatures": Counter(), + "certificate_serials": [], + "authority_enabled": False, + "kind": "historical_record", + }, + ) + if key: + try: + _, fingerprint = _public(key) + record["key_fingerprints"].add(fingerprint) + record["key_mismatch"] |= fingerprint != subject + except (ValueError, TypeError): + record["key_mismatch"] = True + return record + + try: + db.execute("PRAGMA query_only=ON") + db.execute("BEGIN") + revoked = {row[0] for row in db.execute("SELECT serial FROM revocations")} + certs = {} + for row in db.execute( + "SELECT serial,issuer_serial,issuer_id,subject_id,subject_key,body,signature FROM certificates ORDER BY serial" + ): + row = dict(row) + observe(row["subject_id"], row["subject_key"])["certificate_serials"].append( + row["serial"] + ) + try: + body = _certificate_body(row["body"]) + require( + all( + body[k] == row[k] + for k in ( + "serial", + "issuer_serial", + "issuer_id", + "subject_id", + "subject_key", + ) + ), + "legacy_certificate_columns_mismatch", + ) + except (Failure, ValueError, TypeError, KeyError, AttributeError): + body = None + certs[row["serial"]] = (row, body) + certificates = [] + for serial, (row, body) in certs.items(): + chain = [] + current = serial + reason = "root_anchor_unknown" + seen = set() + while current != "root": + if current in seen or current not in certs: + reason = "chain_cycle_or_missing" + break + seen.add(current) + item, normalized = certs[current] + chain.append(current) + if normalized is None: + reason = "invalid_certificate" + break + if current in revoked: + reason = "revoked" + break + if at < normalized["not_before"]: + reason = "not_yet_valid" + break + if at >= normalized["not_after"]: + reason = "expired" + break + parent = item["issuer_serial"] + key = ( + root_public_key + if parent == "root" + else (certs[parent][0]["subject_key"] if parent in certs else None) + ) + if key is None: + reason = "root_anchor_unknown" if parent == "root" else "chain_missing" + break + try: + matches = _public(key)[1] == item["issuer_id"] + except (ValueError, TypeError): + matches = False + if not matches or not _verify( + key, item["signature"], b"msg.lmm.best/cert/v1\n" + _json(normalized).encode() + ): + reason = "invalid_signature" + break + current = parent + else: + reason = "signature_chain_verified" + # This status is cryptographic continuity, NOT grant/delegation authorization. + certificates.append( + { + "serial": serial, + "subject_id": row["subject_id"], + "issuer_serial": row["issuer_serial"], + "chain": chain, + "status": reason, + "authority_enabled": False, + } + ) + for table, attachment_table in ( + ("posts", "attachments"), + ("archived_posts", "archived_attachments"), + ): + files = {} + for attachment in db.execute( + "SELECT post_id,name,content_type,nbytes,sha256 FROM " + + attachment_table + + " ORDER BY post_id,slot" + ): + files.setdefault(attachment["post_id"], []).append( + { + "name": attachment["name"], + "type": attachment["content_type"], + "bytes": attachment["nbytes"], + "sha256": attachment["sha256"], + } + ) + fields = "id,board,name,title,body,author_id,author_key,actor_id,actor_key,signature,sig_version,sig_nonce,sig_issued,reply_to" + for row in db.execute("SELECT " + fields + " FROM " + table + " ORDER BY id"): + record = observe(row["author_id"], row["author_key"]) + actor = observe( + row["actor_id"] or row["author_id"], row["actor_key"] or row["author_key"] + ) + status = "unsigned" + if row["signature"]: + try: + key = row["actor_key"] or row["author_key"] + _, fingerprint = _public(key) + payload = _post_payload(row, files.get(row["id"], [])) + status = ( + "verified" + if ( + fingerprint == (row["actor_id"] or row["author_id"]) + and _verify(key, row["signature"], payload) + ) + else "invalid" + ) + except (ValueError, TypeError, AttributeError): + status = "unprovable" + record["post_signatures"][status] += 1 + if actor is not record: + actor["post_signatures"]["actor_" + status] += 1 + for table, column, key_column in ( + ("identity_names", "author_id", None), + ("name_claims", "author_id", "public_key"), + ("profiles", "author_id", None), + ("custody_identities", "author_id", "public_key"), + ): + columns = column + ("," + key_column if key_column else "") + for row in db.execute("SELECT " + columns + " FROM " + table): + observe(row[column], row[key_column] if key_column else None) + check() + finally: + db.close() + records = [] + for subject, record in sorted(identities.items()): + record["key_fingerprints"] = sorted(record["key_fingerprints"]) + record["post_signatures"] = dict(sorted(record["post_signatures"].items())) + record["classification"] = ( + "anonymous" + if subject == "__anonymous__" + else "key_conflict" + if record["key_mismatch"] or len(record["key_fingerprints"]) > 1 + else "public_key_observed" + if record["key_fingerprints"] + else "unproven" + ) + records.append(record) + result = { + "format": PURPOSE, + "source_sha256": sha256, + "evaluated_at": at, + "root_fingerprint": _public(root_public_key)[1] if root_public_key else None, + "identities": records, + "certificates": certificates, + "authority_enabled": False, + "private_ciphertext_imported": False, + } + return result + + +def summary(plan): + return { + "plan_digest": digest(plan), + "identity_count": len(plan["identities"]), + "classifications": dict(Counter(r["classification"] for r in plan["identities"])), + "certificate_statuses": dict(Counter(r["status"] for r in plan["certificates"])), + "post_signature_statuses": dict( + sum((Counter(r["post_signatures"]) for r in plan["identities"]), Counter()) + ), + "authority_enabled": False, + } + + +def approval_payload( + *, plan_digest, service, parent, parent_generation, operator, mappings, expires_at +): + return { + "format": PURPOSE, + "plan_digest": plan_digest, + "target_service": service, + "parent": parent, + "parent_generation": parent_generation, + "operator": operator, + "mappings": mappings, + "expires_at": expires_at, + "visibility": "private", + "authority_enabled": False, + } + + +async def import_identity_records(app, plan, approval, signature): + """Archive historical identities as private files, NEVER Subject/auth records. + + Root chooses either null (unmapped historical identity) or an existing registered + subject for each old ID. Association is recorded as a Root decision, not proof + that the destination subject owns the historical private key. + """ + verify( + app.certificates.root_public_key, + canonical(approval), + decode(Signature, signature), + purpose=PURPOSE, + ) + expected = approval_payload( + **{ + k: approval.get(k) + for k in ( + "plan_digest", + "parent", + "parent_generation", + "operator", + "mappings", + "expires_at", + ) + }, + service=approval.get("target_service"), + ) + require( + approval == expected and approval["plan_digest"] == digest(plan), + "legacy_identity_approval_invalid", + ) + require(approval["target_service"] == app.settings.service_url, "legacy_service_mismatch") + now = app.clock() + require( + now < parse_time(approval["expires_at"]) <= now + timedelta(hours=24), + "legacy_approval_expired", + ) + require( + plan.get("format") == PURPOSE and plan.get("authority_enabled") is False, + "legacy_identity_plan_invalid", + ) + mappings = approval["mappings"] + require( + isinstance(mappings, dict) and set(mappings) == {r["old_id"] for r in plan["identities"]}, + "legacy_identity_mapping_incomplete", + ) + context = SimpleNamespace( + now=now, + entry="local_admin", + principal=Principal( + actor=approval["operator"], + subject=approval["operator"], + credential_id=None, + method="local", + certificates=(), + ceiling=( + CapabilityGrant( + capability="content.basic", + version=1, + scope=Scope(resource_id=approval["parent"], descendants=True), + operations=frozenset({"legacy.identity_archive@1"}), + constraints={}, + ), + ), + ), + ) + request = SimpleNamespace(operation="legacy.identity_archive", contract_version=1, arguments={}) + async with app.metadata.transaction(write=True) as tx: + require_live_authority(tx) + operator = await tx.subject(approval["operator"]) + require(operator.kind == "registered", "legacy_operator_must_be_registered") + for target in mappings.values(): + require(target is None or isinstance(target, str), "legacy_identity_mapping_invalid") + if target is not None: + require( + (await tx.subject(target)).kind == "registered", "legacy_mapping_target_invalid" + ) + parent = await tx.resource(approval["parent"]) + require( + parent.owner == operator.resource_id + and parent.mode == 0o700 + and parent.generation == approval["parent_generation"], + "legacy_parent_not_private", + ) + require( + not tx.one("SELECT id FROM resources WHERE parent=?", (parent.id,)), + "legacy_parent_not_empty", + ) + key = "legacy-identity-archive:" + approval["plan_digest"] + require(tx.setting(key) is None, "legacy_already_imported") + for index, record in enumerate(plan["identities"]): + body = { + "record": record, + "source_sha256": plan["source_sha256"], + "root_selected_target": mappings[record["old_id"]], + "mapping_is_key_ownership_proof": False, + "authority_enabled": False, + } + await create_resource( + app, + context, + request, + tx, + parent=parent.id, + type="file", + name=f"historical-identity-{index}.json", + mode=0o600, + body=canonical(body), + media_type="application/json", + ) + await create_resource( + app, + context, + request, + tx, + parent=parent.id, + type="file", + name="historical-certificate-status.json", + mode=0o600, + body=canonical({"certificates": plan["certificates"], "authority_enabled": False}), + media_type="application/json", + ) + report = {**summary(plan), "parent": parent.id, "approval_digest": digest(approval)} + tx.set_setting(key, {"report": report, "approval": approval, "signature": signature}) + return report + + +def main(): + """Offline plan command: exclusive 0600 output, counts/digest only on stdout.""" + import argparse + import os + import time + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--snapshot", required=True, type=Path) + parser.add_argument("--sha256", required=True) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--root-public-key", + type=Path, + help="Explicit old Root public key file, never a private key", + ) + parser.add_argument("--at", type=int, default=None) + args = parser.parse_args() + root = args.root_public_key.read_text().strip() if args.root_public_key else None + plan = identity_plan( + args.snapshot, + args.sha256, + at=int(time.time()) if args.at is None else args.at, + root_public_key=root, + ) + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(plan, stream, indent=2) + print(json.dumps(summary(plan), sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/msg/storage/legacy_rehearsal.py b/src/msg/storage/legacy_rehearsal.py new file mode 100644 index 00000000..9746fc8e --- /dev/null +++ b/src/msg/storage/legacy_rehearsal.py @@ -0,0 +1,226 @@ +"""TEST ONLY: isolated local PostgreSQL rehearsal; never supplies production approval.""" + +from __future__ import annotations + +import argparse +import asyncio +from datetime import UTC, datetime, timedelta +import json +import os +import pwd +from pathlib import Path +import subprocess +from urllib.parse import quote + +from msg.core.codec import b64, canonical, wire +from msg.core.requests import request_for +from msg.security.age_keys import generate_age_key +from msg.security.crypto import Ed25519Signer, subject_id +from msg.storage.legacy_resource_import import ( + PURPOSE, + approval_payload, + identity_requirements, + import_content, +) +from msg.storage.legacy_sqlite import preserve + + +def _write(path, value): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(value, stream, indent=2) + stream.write("\n") + + +async def _run(source, sha256, target, dsn): + from dataclasses import replace + from msg.admin.root import _provision, _approve_csr + from msg.application import Application + from msg.config import write_example + + before = preserve(source, expected_sha256=sha256) + mapping_report = identity_requirements(source, sha256, {}, summary_only=False) + settings = write_example( + target / "etc", + target / "data", + "https://test-only-legacy-rehearsal.invalid", + postgres_dsn=dsn, + ) + app = Application(settings) + try: + csr, root = await _provision(app, "test-only-disposable-rehearsal-passphrase") + await _approve_csr(app, csr, root, expected_digest=None, operator="test-only-rehearsal") + signer = Ed25519Signer.generate() + uid = subject_id(signer.public_key) + _, recipient = generate_age_key() + registered = await app.executor.execute( + request_for( + "identity.register", + { + "handle": "test-only-importer", + "public_key": b64(signer.public_key), + "encryption_recipient": recipient, + }, + settings.service_url, + signer=signer, + subject=uid, + contract_version=2, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + ) + if registered.status != "ok": + raise RuntimeError("test-only registration failed") + created = await app.executor.execute( + request_for( + "content.topic_create", + {"parent": "/main", "name": "test-only-legacy-import"}, + settings.service_url, + signer=signer, + subject=uid, + certificates=(registered.data["certificate_id"],), + expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + ) + if created.status != "ok": + raise RuntimeError("test-only namespace creation failed") + parent = created.resources[0].id + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(parent) + await tx.replace( + replace(resource, mode=0o700, generation=resource.generation + 1), + resource.generation, + ) + generation = resource.generation + 1 + baseline = { + table: tx.one("SELECT count(*) FROM " + table)[0] + for table in ("credentials", "certificates", "jobs") + } + identities = {old: uid for old in mapping_report["required"]} + approval = approval_payload( + sha256=sha256, + service=settings.service_url, + parent=parent, + parent_generation=generation, + operator=uid, + identities=identities, + expires_at=wire(datetime.now(UTC) + timedelta(hours=2)), + ) + # This local Root is freshly generated for this disposable installation. + # Its signature is never reusable against the production pinned Root/service. + report = await import_content( + app, source, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + async with app.metadata.transaction(write=False) as tx: + final = {table: tx.one("SELECT count(*) FROM " + table)[0] for table in baseline} + after = preserve(source, expected_sha256=sha256) + summary = { + "format": "msg-test-only-legacy-rehearsal-v1", + "test_only": True, + "production_approval": False, + "source_sha256": sha256, + "source_unchanged": before == after, + "source_tables": len(before["tables"]), + "source_rows": sum(item["count"] for item in before["tables"].values()), + "identity_mapping_required": len(mapping_report["required"]), + "identity_mapping_missing": 0, + "test_only_identity_targets": 1, + "boards": report["boards"], + "posts": report["posts"], + "attachments": report["attachments"], + "url_mapping_count": len(report["url_map"]), + "authority_and_jobs_unchanged": baseline == final, + "visibility": "private", + "result": "passed", + } + if not summary["source_unchanged"] or not summary["authority_and_jobs_unchanged"]: + raise RuntimeError("rehearsal invariant failed") + return summary + finally: + await app.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--snapshot", type=Path, required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument("--protected-target", type=Path, required=True) + args = parser.parse_args() + source = args.snapshot.resolve() + target = args.protected_target.absolute() + if target.is_relative_to("/tmp") or target.is_relative_to("/var/tmp"): + parser.error("target must be protected persistent storage, not a temporary directory") + if args.snapshot.is_symlink() or source.stat().st_mode & 0o077: + parser.error("source must be a non-symlink private snapshot (0600 or stricter)") + if target.parent.resolve() != target.parent or target.parent.stat().st_mode & 0o077: + parser.error("target parent must be a private real directory (0700 or stricter)") + target.mkdir(mode=0o700, parents=False, exist_ok=False) + # Socket contains no backup payload. Put it under the same protected directory. + socket = target / "sock" + socket.mkdir(mode=0o700) + if len(str(socket).encode()) > 85: + parser.error("protected-target path is too long for a local PostgreSQL socket") + cluster = target / "pg" + started = False + summary = None + try: + subprocess.run( + ["initdb", "-D", str(cluster), "-A", "trust", "--no-instructions"], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "pg_ctl", + "-D", + str(cluster), + "-l", + str(target / "postgres.log"), + "-o", + f'-k {socket} -h "" -p 5432', + "-w", + "start", + ], + check=True, + capture_output=True, + ) + started = True + import psycopg + + dsn = f"postgresql://{quote(pwd.getpwuid(os.getuid()).pw_name)}@localhost:5432/postgres?host={quote(str(socket), safe='')}" + with psycopg.connect(dsn, autocommit=True) as connection: + connection.execute("CREATE DATABASE legacy_rehearsal") + dsn = dsn.replace("/postgres?", "/legacy_rehearsal?") + summary = asyncio.run(_run(source, args.sha256, target, dsn)) + except BaseException as exc: + # No raw exception messages: DB errors can include original row contents. + from msg.core.errors import Failure + + summary = { + "format": "msg-test-only-legacy-rehearsal-v1", + "test_only": True, + "production_approval": False, + "result": "failed", + "error_type": type(exc).__name__, + } + if isinstance(exc, Failure): + summary["error_code"] = exc.code + finally: + if started: + stopped = subprocess.run( + ["pg_ctl", "-D", str(cluster), "-m", "fast", "-w", "stop"], + capture_output=True, + check=False, + ) + if summary is not None: + summary["cluster_stopped"] = stopped.returncode == 0 + if stopped.returncode != 0: + summary["result"] = "failed" + if summary is not None: + _write(target / "summary.json", summary) + print(json.dumps(summary, sort_keys=True)) + if summary is None or summary["result"] != "passed": + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/src/msg/storage/legacy_resource_import.py b/src/msg/storage/legacy_resource_import.py new file mode 100644 index 00000000..70f9aa33 --- /dev/null +++ b/src/msg/storage/legacy_resource_import.py @@ -0,0 +1,413 @@ +"""Root-approved offline import of legacy content into a private v4 namespace.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import replace +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace + +from msg.core.codec import b64, canonical, decode, digest, parse_time, wire +from msg.core.errors import Failure, require +from msg.core.models import CapabilityGrant, Principal, Relation, ResourceRef, Scope, Signature +from msg.plugins.common import create_resource, revise_resource, validate_name +from msg.security.crypto import verify +from msg.security.quarantine import require_live_authority +from msg.storage.legacy_sqlite import _digest, preserve + +PURPOSE = "legacy-sqlite-content-import-v1" + + +def content_plan(snapshot: Path, sha256: str): + manifest = preserve(snapshot, expected_sha256=sha256) + db = sqlite3.connect(snapshot.resolve().as_uri() + "?mode=ro", uri=True) + db.row_factory = sqlite3.Row + try: + db.execute("PRAGMA query_only=ON") + db.execute("BEGIN") + boards = [dict(row) for row in db.execute("SELECT * FROM boards")] + posts = [ + (table, dict(row)) + for table in ("posts", "archived_posts") + for row in db.execute("SELECT * FROM " + table) + ] + attachments = [ + (table, dict(row)) + for table in ("attachments", "archived_attachments") + for row in db.execute("SELECT * FROM " + table) + ] + tags = [(row["post_id"], row["tag"]) for row in db.execute("SELECT * FROM post_tags")] + require(_digest(snapshot) == sha256, "legacy_snapshot_changed") + return manifest, boards, posts, attachments, tags + finally: + db.close() + + +def approval_payload( + *, sha256, service, parent, parent_generation, operator, identities, expires_at +): + """Sign these exact fields with the destination's existing offline Root key.""" + return dict( + format=PURPOSE, + source_sha256=sha256, + target_service=service, + parent=parent, + parent_generation=parent_generation, + operator=operator, + identities=identities, + expires_at=expires_at, + visibility="private", + legacy_authority="disabled", + external_jobs="disabled", + ) + + +async def import_content(app, snapshot: Path, approval: dict, signature: dict): + """Import atomically using normal resource/CAS writes; never issue credentials. + + No signing key is generated or loaded here. The caller must supply an approval + signed by the Root already pinned by the destination installation. + """ + verify( + app.certificates.root_public_key, + canonical(approval), + decode(Signature, signature), + purpose=PURPOSE, + ) + require( + approval + == approval_payload( + **{ + "sha256": approval.get("source_sha256"), + "service": approval.get("target_service"), + **{ + key: approval.get(key) + for key in ( + "parent", + "parent_generation", + "operator", + "identities", + "expires_at", + ) + }, + } + ), + "legacy_approval_invalid", + ) + require(approval["target_service"] == app.settings.service_url, "legacy_service_mismatch") + now = app.clock() + expiry = parse_time(approval["expires_at"]) + require(now < expiry <= now + timedelta(hours=24), "legacy_approval_expired") + sha256 = approval["source_sha256"] + manifest, boards, posts, attachments, tags = content_plan(Path(snapshot), sha256) + identities = approval["identities"] + require(isinstance(identities, dict), "legacy_identity_mapping_invalid") + required = {row.get("author_id") or "__anonymous__" for _, row in posts} + require(required == set(identities), "legacy_identity_mapping_incomplete") + board_names = {row["name"] for row in boards} + require(all(row["board"] in board_names for _, row in posts), "legacy_board_missing") + keys = {(table, row["id"]) for table, row in posts} + require( + all( + (("posts" if table == "attachments" else "archived_posts"), row["post_id"]) in keys + for table, row in attachments + ), + "legacy_attachment_post_missing", + ) + prefix = "legacy_" + sha256[:24] + + def resource_id(table, key): + return prefix + "_" + digest((table, key))[7:31] + + destinations = {(table, row["id"]): resource_id(table, row["id"]) for table, row in posts} + url_map = {} + request = SimpleNamespace(operation="legacy.content_import", contract_version=1, arguments={}) + context = SimpleNamespace( + now=now, + entry="local_admin", + principal=Principal( + actor=approval["operator"], + subject=approval["operator"], + credential_id=None, + method="local", + certificates=(), + ceiling=( + CapabilityGrant( + capability="content.basic", + version=1, + scope=Scope(resource_id=approval["parent"], descendants=True), + operations=frozenset({"legacy.content_import@1"}), + constraints={}, + ), + ), + ), + ) + async with app.metadata.transaction(write=True) as tx: + require_live_authority(tx) + operator = await tx.subject(approval["operator"]) + require(operator.kind == "registered", "legacy_operator_must_be_registered") + for target in identities.values(): + require(target is None or isinstance(target, str), "legacy_mapping_target_invalid") + if target is not None: + require( + (await tx.subject(target)).kind == "registered", "legacy_mapping_target_invalid" + ) + parent = await tx.resource(approval["parent"]) + require( + parent.owner == operator.resource_id + and parent.mode == 0o700 + and parent.generation == approval["parent_generation"], + "legacy_parent_not_private", + ) + require( + not tx.one("SELECT id FROM resources WHERE parent=?", (parent.id,)), + "legacy_parent_not_empty", + ) + require(tx.setting("legacy-import:" + sha256) is None, "legacy_already_imported") + topics = {} + for board in boards: + try: + topic_name = validate_name(board["name"]) + except Failure: + # Legacy boards can use names reserved by v4 views, e.g. meta. + topic_name = "legacy-board-" + digest(board["name"])[7:31] + topic = await create_resource( + app, + context, + request, + tx, + parent=parent.id, + type="topic", + name=topic_name, + mode=0o700, + resource_id=resource_id("boards", board["name"]), + body=board["description"], + ) + tx.execute( + "INSERT INTO topic_settings(topic,membership_policy) VALUES (?,?)", + (topic.id, "closed"), + write=True, + ) + tx.execute( + "INSERT INTO topic_memberships(topic,subject,role,status,joined_at) VALUES (?,?,?,?,?)", + (topic.id, operator.resource_id, "admin", "active", wire(now)), + write=True, + ) + topics[board["name"]] = topic.id + url_map["/" + board["name"]] = topic.id + # Allocate all post resources before mapping replies and attachments. + resources = {} + for table, row in posts: + resource = await create_resource( + app, + context, + request, + tx, + parent=topics[row["board"]], + type="post", + name=f"{table}-{row['id']}.md", + mode=0o600, + resource_id=destinations[table, row["id"]], + ) + resources[table, row["id"]] = resource + # Installed find_in_board resolves BOTH global id and board sequence, + # choosing the lowest global id on a collision. Reproduce that exactly. + for table, row in sorted(posts, key=lambda item: item[1]["id"]): + for number in (row["id"], row["seq"]): + path = f"/{row['board']}/{number}" + if table == "archived_posts": + path = "/_legacy/archive" + path + for suffix in ("", "/raw", "/meta"): + url_map.setdefault(path + suffix, destinations[table, row["id"]]) + attached = {} + for table, row in attachments: + key = ("posts" if table == "attachments" else "archived_posts", row["post_id"]) + target = resources[key] + resource = await create_resource( + app, + context, + request, + tx, + parent=target.parent, + type="attachment", + name=f"{table}-{row['id']}", + mode=0o600, + body=row["data"], + media_type=row["content_type"], + resource_id=resource_id(table, row["id"]), + ) + attached.setdefault(key, []).append( + Relation( + type="attachment", + target=ResourceRef(id=resource.id, revision=resource.revision), + ) + ) + url_map[f"/_legacy/{table}/{row['id']}"] = resource.id + if table == "attachments": + url_map[f"/file/{row['id']}"] = resource.id + url_map[f"/file/{row['id']}/meta"] = resource.id + for table, row in posts: + key = (table, row["id"]) + reply = row.get("reply_to") + reply_target = None + if reply is not None: + candidates = [ + value for (kind, old_id), value in destinations.items() if old_id == reply + ] + require(len(candidates) == 1, "legacy_reply_mapping_ambiguous") + reply_target = candidates[0] + # Historical claims stay separate from the unsigned v4 import revision. + allowed = ( + "id", + "board", + "seq", + "name", + "title", + "created", + "updated", + "author_id", + "author_key", + "actor_id", + "actor_key", + "signature", + "sig_version", + "sig_nonce", + "sig_issued", + "reply_to", + "deleted", + "hidden", + "system", + ) + provenance = { + "format": PURPOSE, + "source_sha256": sha256, + "table": table, + "old": {field: row[field] for field in allowed if field in row}, + "historical_author_target": identities[row.get("author_id") or "__anonymous__"], + "reply_target": reply_target, + "legacy_signature_verified": False, + "archived": table == "archived_posts", + } + record = await create_resource( + app, + context, + request, + tx, + parent=resources[key].parent, + type="file", + name=f"provenance-{table}-{row['id']}.json", + mode=0o600, + body=canonical(provenance), + media_type="application/json", + ) + revision = await revise_resource( + app, + context, + request, + tx, + resources[key], + row["body"], + relations=tuple(attached.get(key, ())), + change_note="Legacy content import; original claims retained separately", + source_kind="operation", + source_version=1, + source_digest="sha256:" + sha256, + ) + tx.set_setting( + "legacy-provenance:" + revision.id, + { + "resource": record.id, + "reply_target": reply_target, + "source_table": table, + "source_id": row["id"], + }, + ) + selected_tags = ( + tuple(sorted({tag for old_id, tag in tags if old_id == row["id"]})) + if table == "posts" + else () + ) + if selected_tags: + await tx.replace( + replace(revision, tags=selected_tags, generation=revision.generation + 1), + revision.generation, + ) + require(_digest(Path(snapshot)) == sha256, "legacy_snapshot_changed") + report = { + "format": PURPOSE, + "source_sha256": sha256, + "parent": parent.id, + "approval_digest": digest(approval), + "boards": len(boards), + "posts": len(posts), + "attachments": len(attachments), + "url_map": url_map, + "authority_enabled": False, + "external_jobs_enabled": False, + "visibility": "private", + "source_table_counts": { + name: data["count"] for name, data in manifest["tables"].items() + }, + } + tx.set_setting( + "legacy-import-approval:" + sha256, + { + "approval": approval, + "signature": signature, + "root_public_key": b64(app.certificates.root_public_key), + }, + ) + tx.set_setting("legacy-import:" + sha256, report) + return report + + +def identity_requirements(snapshot: Path, sha256: str, identities: dict, *, summary_only=True): + """Return missing/unexpected mapping keys without exposing bodies or key material.""" + _, _, posts, _, _ = content_plan(snapshot, sha256) + required = {row.get("author_id") or "__anonymous__" for _, row in posts} + if summary_only: + return { + "required_count": len(required), + "missing_count": len(required - set(identities)), + "unexpected_count": len(set(identities) - required), + "requirements_digest": digest(sorted(required)), + } + return { + "required": sorted(required), + "missing": sorted(required - set(identities)), + "unexpected": sorted(set(identities) - required), + } + + +def main(): + import argparse + import asyncio + import json + + from msg.application import Application + from msg.config import load_settings + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--snapshot", type=Path, required=True) + parser.add_argument("--signed-approval", type=Path, required=True) + args = parser.parse_args() + envelope = json.loads(args.signed_approval.read_text()) + + async def run(): + app = Application(load_settings(args.config)) + try: + await app.load() + result = await import_content( + app, args.snapshot, envelope["approval"], envelope["signature"] + ) + print(json.dumps(result, indent=2)) + finally: + await app.close() + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/src/msg/storage/legacy_sqlite.py b/src/msg/storage/legacy_sqlite.py new file mode 100644 index 00000000..fb2f7ba7 --- /dev/null +++ b/src/msg/storage/legacy_sqlite.py @@ -0,0 +1,203 @@ +"""Offline, inert preservation of the source-pinned SQLite generation. + +This is NOT a v4 restore or authority conversion. Nothing here opens a live +service database, schedules work, decrypts custody keys, or enables identities. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import math +import os +from pathlib import Path +import sqlite3 +import time + +CONTRACT = Path(__file__).parents[1] / "data/legacy/sqlite-0221.json" +MAX_BYTES = 256 * 1024 * 1024 +MAX_ROWS = 1_000_000 +MAX_CELL = 16 * 1024 * 1024 + + +def _json(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _cell(value): + if value is None: + return ["null", None] + if isinstance(value, bytes): + if len(value) > MAX_CELL: + raise ValueError("cell exceeds preservation limit") + return ["blob", base64.b64encode(value).decode("ascii")] + if isinstance(value, str): + if len(value.encode("utf-8")) > MAX_CELL: + raise ValueError("cell exceeds preservation limit") + return ["text", value] + if isinstance(value, int): + return ["integer", str(value)] + if isinstance(value, float) and math.isfinite(value): + return ["real", value.hex()] + raise ValueError("unsupported SQLite value") + + +def _quote(name): + return '"' + name.replace('"', '""') + '"' + + +def _digest(path): + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def preserve(source: Path, *, expected_sha256: str, destination: Path | None = None): + """Preflight a protected, checkpointed offline copy; optionally import inert rows. + + expected_sha256 must come from the operator's protected snapshot manifest. + A digest is binding, not proof of independent provenance or backup validity. + """ + source = Path(source) + if source.is_symlink() or not source.is_file(): + raise ValueError("source must be a regular offline snapshot") + if source.stat().st_size > MAX_BYTES: + raise ValueError("snapshot exceeds preservation limit") + if any(Path(str(source) + suffix).exists() for suffix in ("-wal", "-shm", "-journal")): + raise ValueError("snapshot has live SQLite sidecars; obtain a consistent offline copy") + if len(expected_sha256) != 64 or _digest(source) != expected_sha256: + raise ValueError("snapshot digest mismatch") + contract = json.loads(CONTRACT.read_text()) + source_db = sqlite3.connect(source.resolve().as_uri() + "?mode=ro", uri=True) + output = None + created = False + deadline = time.monotonic() + 60 + source_db.set_progress_handler(lambda: int(time.monotonic() > deadline), 10000) + source_db.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, MAX_CELL) + try: + source_db.execute("PRAGMA query_only=ON") + source_db.execute("PRAGMA trusted_schema=OFF") + source_db.execute("BEGIN") + if source_db.execute("PRAGMA quick_check").fetchall() != [("ok",)]: + raise ValueError("snapshot integrity check failed") + objects = source_db.execute( + "SELECT type,name FROM sqlite_schema WHERE type IN ('table','view','trigger')" + ).fetchall() + if any(kind != "table" for kind, _ in objects): + raise ValueError("unrecognized executable schema objects") + if {name for _, name in objects} != set(contract["tables"]): + raise ValueError("unrecognized table set") + if source_db.execute("PRAGMA user_version").fetchone()[0] != 0: + raise ValueError("unrecognized user_version") + for name, columns in contract["tables"].items(): + actual = [list(row) for row in source_db.execute(f"PRAGMA table_xinfo({_quote(name)})")] + if actual != columns: + raise ValueError(f"unrecognized columns: {name}") + if destination is not None: + destination = Path(destination) + fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(fd) + created = True + output = sqlite3.connect(destination) + output.execute("CREATE TABLE provenance (manifest TEXT NOT NULL)") + output.execute( + "CREATE TABLE legacy_rows (source_table TEXT NOT NULL, ordinal INTEGER NOT NULL, envelope TEXT NOT NULL, PRIMARY KEY(source_table,ordinal))" + ) + output.execute("BEGIN") + tables = {} + total = 0 + encoded_bytes = 0 + for name, columns in contract["tables"].items(): + if name in ("attachments", "archived_attachments"): + for data, nbytes, sha256 in source_db.execute( + f"SELECT data,nbytes,sha256 FROM {_quote(name)}" + ): + if ( + not isinstance(data, bytes) + or len(data) != nbytes + or hashlib.sha256(data).hexdigest() != sha256 + ): + raise ValueError("legacy attachment digest mismatch") + count = source_db.execute(f"SELECT count(*) FROM {_quote(name)}").fetchone()[0] + total += count + if total > MAX_ROWS: + raise ValueError("snapshot exceeds row limit") + digest = hashlib.sha256() + # Physical row order is stable for this exact digest-bound snapshot. + for ordinal, row in enumerate(source_db.execute(f"SELECT * FROM {_quote(name)}")): + envelope = _json([_cell(value) for value in row]) + encoded = envelope.encode("ascii") + encoded_bytes += len(encoded) + if encoded_bytes > MAX_BYTES * 4: + raise ValueError("encoded archive exceeds size limit") + digest.update(len(encoded).to_bytes(8, "big") + encoded) + if output is not None: + output.execute( + "INSERT INTO legacy_rows VALUES (?,?,?)", (name, ordinal, envelope) + ) + tables[name] = { + "count": count, + "rows_sha256": digest.hexdigest(), + "columns": [column[1] for column in columns], + } + if _digest(source) != expected_sha256: + raise ValueError("snapshot changed during preservation") + manifest = { + "format": "msg-legacy-sqlite-preservation-v1", + "source_sha256": expected_sha256, + "schema_contract_sha256": _digest(CONTRACT), + "reference_source_revision": contract["reference_source_revision"], + "status": "inert-preservation-only", + "authority_enabled": False, + "external_jobs_enabled": False, + "source_provenance_verified": False, + "tables": tables, + "remaining_gates": [ + "installed-source-and-schema-provenance", + "protected-snapshot-provenance", + "independent-current-revocation-checkpoint", + "root-approved-identity-and-authority-mapping", + "signed-post-and-stable-url-mapping", + "custody-reenrollment", + "filesystem-repository-inventory", + "isolated-v4-acceptance", + ], + } + if output is not None: + output.execute("INSERT INTO provenance VALUES (?)", (_json(manifest),)) + output.commit() + return manifest + except BaseException: + if output is not None: + output.rollback() + output.close() + output = None + if created: + destination.unlink(missing_ok=True) + raise + finally: + source_db.close() + if output is not None: + output.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("snapshot", type=Path) + parser.add_argument("--sha256", required=True) + parser.add_argument("--destination", type=Path) + args = parser.parse_args() + print( + json.dumps( + preserve(args.snapshot, expected_sha256=args.sha256, destination=args.destination), + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/msg/storage/market_migration.py b/src/msg/storage/market_migration.py index 9c80cb23..5706059a 100644 --- a/src/msg/storage/market_migration.py +++ b/src/msg/storage/market_migration.py @@ -47,3 +47,8 @@ def migrate_market(conn): FOR EACH ROW EXECUTE FUNCTION msg_money_ledger_append_only(); END IF; END $$''') + + conn.execute("""CREATE TABLE IF NOT EXISTS server_offer_resources ( + offer_id TEXT PRIMARY KEY REFERENCES server_offers(offer_id), + listing_id TEXT UNIQUE NOT NULL REFERENCES resources(id))""") + conn.execute('ALTER TABLE store_orders ALTER COLUMN quantity TYPE BIGINT') diff --git a/src/msg/storage/postgres.py b/src/msg/storage/postgres.py index 624326e8..3e50da88 100644 --- a/src/msg/storage/postgres.py +++ b/src/msg/storage/postgres.py @@ -42,6 +42,11 @@ CREATE INDEX IF NOT EXISTS resources_time ON resources(created_at,id); CREATE INDEX IF NOT EXISTS resources_owner ON resources(owner,id); CREATE INDEX IF NOT EXISTS resources_type_state ON resources(type,state,id); +CREATE TABLE IF NOT EXISTS resource_path_aliases ( + parent_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + name TEXT NOT NULL, + resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + PRIMARY KEY(parent_id,name)); CREATE TABLE IF NOT EXISTS resource_tags ( resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY(tag,resource_id)); diff --git a/src/msg/storage/read_only.py b/src/msg/storage/read_only.py index deed3d7f..c8c8d8a6 100644 --- a/src/msg/storage/read_only.py +++ b/src/msg/storage/read_only.py @@ -16,7 +16,7 @@ HOSTING_READ_TABLES = frozenset({ 'schema_version', 'resources', 'revisions', 'identities', 'memberships', 'credentials', 'certificates', 'settings', 'share_grants', 'share_grants_v2', - 'dm_conversations', 'system_sources', + 'dm_conversations', 'system_sources', 'resource_path_aliases', }) diff --git a/src/msg/storage/session.py b/src/msg/storage/session.py index 554b754d..334a8d63 100644 --- a/src/msg/storage/session.py +++ b/src/msg/storage/session.py @@ -97,6 +97,27 @@ async def resolve(self, path): rid = row[0] return rid + async def resolve_migrated(self, path): + """Read-only old names in stable parent namespaces; never used by writes. + + Current children shadow old names, including a recreated directory. + A renamed parent does not require copying aliases for every descendant. + Resolution identifies a target only: callers must authorize it currently. + """ + require(isinstance(path,str) and path.startswith('/'), 'invalid_path') + parts=path.rstrip('/').split('/')[1:] + require(len(parts)<=256 and all(p and p not in {'.','..'} and + '\\' not in p and '\x00' not in p for p in parts), 'invalid_path') + rid=await self.resolve('/') + for part in parts: + row=self.one('SELECT id FROM resources WHERE parent=? AND name=?',(rid,part)) + if row is None: + row=self.one('SELECT resource_id FROM resource_path_aliases WHERE parent_id=? AND name=?', + (rid,part)) + require(row is not None,'not_found') + rid=row[0] + return (await self.resource(rid)).id + async def path(self, id): segments, seen = [], set() r = await self.resource(id) @@ -163,6 +184,10 @@ async def replace(self, resource, expected_generation): require(all(a.id != resource.id for a in await self.ancestors(parent.id)), "parent_cycle") if any(getattr(resource,k)!=getattr(old,k) for k in ('parent','owner','group','mode','state')): self.set_setting('authorization_epoch',self.setting('authorization_epoch',0)+1) + if old.parent is not None and (old.parent,old.name)!=(resource.parent,resource.name): + self.execute('INSERT INTO resource_path_aliases (parent_id,name,resource_id) VALUES (?,?,?) ' + 'ON CONFLICT(parent_id,name) DO UPDATE SET resource_id=excluded.resource_id', + (old.parent,old.name,old.id),write=True) data = wire(resource) changed = self.execute("""UPDATE resources SET name=?,parent=?,owner=?,grp=?,mode=?, generation=?,revision=?,state=?,modified_at=?,body=? WHERE id=? AND generation=?""", diff --git a/src/msg/storage/sqlite.py b/src/msg/storage/sqlite.py index 2ec62cdf..f6edfdc9 100644 --- a/src/msg/storage/sqlite.py +++ b/src/msg/storage/sqlite.py @@ -32,6 +32,11 @@ CREATE INDEX IF NOT EXISTS resources_time ON resources(created_at,id); CREATE INDEX IF NOT EXISTS resources_owner ON resources(owner,id); CREATE INDEX IF NOT EXISTS resources_type_state ON resources(type,state,id); +CREATE TABLE IF NOT EXISTS resource_path_aliases ( + parent_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + name TEXT NOT NULL, + resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, + PRIMARY KEY(parent_id,name)); CREATE TABLE IF NOT EXISTS resource_tags ( resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY(tag,resource_id)); diff --git a/src/msg/transports/client.py b/src/msg/transports/client.py index dee3f632..546fbcf2 100644 --- a/src/msg/transports/client.py +++ b/src/msg/transports/client.py @@ -163,7 +163,7 @@ class MCPHTTPTransport(HTTPTransport): async def call(self, request): self._secure_delivery(request) await self._effect(request.operation) - from msg.transports.mcp import PROTOCOL_VERSION + from msg.transports.mcp_protocol import PROTOCOL_VERSION tool_name = request.operation if request.contract_version==1 else ( f'{request.operation}@{request.contract_version}') value = await self._json('POST','/-/mcp',body={'jsonrpc':'2.0','id':request.request_id, diff --git a/src/msg/transports/git_http.py b/src/msg/transports/git_http.py new file mode 100644 index 00000000..934708ef --- /dev/null +++ b/src/msg/transports/git_http.py @@ -0,0 +1,434 @@ +"""Native Git/LFS HTTP adaptation over the shared store and publication guard.""" +from __future__ import annotations +import asyncio +import base64 +from datetime import timedelta +import hashlib +import os +from pathlib import Path +import re +import tempfile +import time +from uuid import uuid4 + +from starlette.requests import ClientDisconnect +from starlette.responses import Response, StreamingResponse +from msg.core.codec import canonical, wire, unb64 +from msg.core.errors import Failure, require +from msg.core.models import ExecutionContext +from msg.core.requests import request_for +from msg.plugins.common import check_access, resolve +from msg.extensions.repositories import ( + NativeGitStore, MAX_GIT_PACK_BYTES, MAX_GIT_UPLOAD_SECONDS, MAX_LFS_OBJECT_BYTES, + require_git_repository_capacity, _HTTP_RECEIVE, _LFS_STAGED, +) +from msg.extensions.git_publication import guarded_command +from msg.transports.http_common import body_bytes, json_response, error_status +from msg.transports.packet import path_packet + +_GIT_UPLOAD_SLOTS=asyncio.Semaphore(2) +_LFS_UPLOAD_SLOTS=asyncio.Semaphore(2) + +async def spool_git_pack(request,path): + require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding') + length=request.headers.get('content-length') + if length is not None: + require(length.isdecimal() and int(length)<=MAX_GIT_PACK_BYTES,'request_too_large') + hasher=hashlib.sha256() + size=0 + try: + with path.open('xb') as stream: + async for chunk in request.stream(): + size+=len(chunk) + require(size<=MAX_GIT_PACK_BYTES,'request_too_large') + hasher.update(chunk) + stream.write(chunk) + stream.flush() + os.fsync(stream.fileno()) + except ClientDisconnect as exc: + raise Failure('request_incomplete') from exc + return 'sha256:'+hasher.hexdigest(),size + +async def spool_lfs_object(request,path,expected_size): + require(request.headers.get('content-encoding','identity')=='identity','unknown_encoding') + length=request.headers.get('content-length') + if length is not None: + require(length.isdecimal() and int(length)==expected_size,'lfs_size_mismatch') + hasher=hashlib.sha256();size=0 + try: + with path.open('xb') as stream: + async for chunk in request.stream(): + size+=len(chunk) + require(size<=expected_size,'lfs_size_mismatch') + hasher.update(chunk);stream.write(chunk) + stream.flush();os.fsync(stream.fileno()) + except ClientDisconnect as exc:raise Failure('request_incomplete') from exc + require(size==expected_size,'lfs_size_mismatch') + return hasher.hexdigest(),size + +class GitHTTPAdapter: + def __init__(self, app, *, store: NativeGitStore | None = None): + self.app = app + self.store = store if store is not None else NativeGitStore(app) + + def _lfs_packet(self,request,operation,arguments): + limits=self.app.settings.server.limits + signed=request.headers.get('x-msg-request') + authorization=request.headers.get('authorization','') + require(not (signed and authorization),'ambiguous_proof') + if signed: + packet=path_packet(signed,'j',limits.max_request_bytes) + require(packet.operation==operation and canonical(packet.arguments)==canonical(arguments), + 'representation_mismatch') + return packet + if authorization: + require(authorization.startswith('Basic '),'authentication_required') + try: + user,password=base64.b64decode(authorization[6:],validate=True).decode('ascii').split(':',1) + token=unb64(password) + except (ValueError,UnicodeDecodeError,Failure) as exc: + raise Failure('invalid_token') from exc + return request_for(operation,arguments,self.app.settings.service_url,token=(user,token), + request_id=uuid4().hex if operation=='git.lfs_publish' else None, + expires_at=self.app.clock()+timedelta(seconds=120)) + return request_for(operation,arguments,self.app.settings.service_url) + + async def _lfs_authorize(self,request,operation,arguments): + result=await self.app.executor.execute(self._lfs_packet(request,operation,arguments),entry='network') + require(result.status=='ok',result.error.code if result.error else 'permission_denied') + return result.data['resource_id'] + + async def _lfs_preflight_publish(self,request,arguments): + """Validate the exact signed publish envelope without committing it.""" + packet=self._lfs_packet(request,'git.lfs_publish',arguments) + spec=self.app.registry.operation(packet.operation,packet.contract_version) + require('network' in spec.entries,'entry_not_allowed') + self.app.registry.validate(spec.input_schema,packet.arguments) + async with self.app.metadata.transaction(write=False) as tx: + principal=await self.app.authenticator.authenticate(packet,tx,entry='network') + require(principal.method=='signature','authentication_required') + context=ExecutionContext(request_id=packet.request_id,principal=principal,entry='network', + now=self.app.clock(),deadline_monotonic=time.monotonic()+30) + rid=await resolve(tx,arguments['id']) + await check_access(self.app,context,packet,tx,rid,'write') + resource=await tx.resource(rid) + require(resource.type=='repo' and resource.state=='active','not_a_repository') + return rid + + async def http_lfs(self,request,repo_id,suffix,*,write=False): + """LFS batch is a read/planning query; only /-/ object PUT commits bytes.""" + require(not request.query_params,'invalid_lfs_query') + object_match=re.fullmatch(r'objects/([0-9a-f]{64})(?:/([0-9]+))?',suffix) + if object_match: + oid,size_text=object_match.groups() + if write: + require(request.method=='PUT' and size_text is not None,'method_not_allowed') + size=int(size_text) + require(size<=MAX_LFS_OBJECT_BYTES,'request_too_large') + publish_args={'id':repo_id,'oid':oid,'size':size} + if request.headers.get('x-msg-request'): + await self._lfs_preflight_publish(request,publish_args) + else: + await self._lfs_authorize(request,'git.lfs_write_authorize',{'id':repo_id}) + self.app.settings.server.staging_dir.mkdir(parents=True,exist_ok=True) + async with _LFS_UPLOAD_SLOTS: + with tempfile.TemporaryDirectory(prefix='msg-lfs-',dir=self.app.settings.server.staging_dir) as temp: + staged=Path(temp)/'object' + try: + async with asyncio.timeout(MAX_GIT_UPLOAD_SECONDS): + digest,actual=await spool_lfs_object(request,staged,size) + except TimeoutError as exc: + raise Failure('request_incomplete') from exc + require(digest==oid,'lfs_digest_mismatch') + marker=_LFS_STAGED.set(staged) + try: + await self._lfs_authorize(request,'git.lfs_publish',publish_args) + finally:_LFS_STAGED.reset(marker) + return Response(status_code=200,headers={'Cache-Control':'no-store'}) + require(request.method in {'GET','HEAD'} and size_text is None,'method_not_allowed') + rid=await self._lfs_authorize(request,'git.lfs_read',{'id':repo_id,'oid':oid}) + path=self.store.lfs(rid).path(oid) + require(path.is_file(),'not_found') + size=path.stat().st_size + etag='"sha256:'+oid+'"' + begin,end,status=0,size,200 + requested=request.headers.get('range') + if requested and request.headers.get('if-range') in {None,etag}: + match=re.fullmatch(r'bytes=(\d*)-(\d*)',requested) if len(requested)<=128 else None + if match and any(match.groups()): + left,right=match.groups() + if left: + begin=int(left) + end=min(size,int(right)+1) if right else size + valid=begin0 and begin @@ -75,6 +76,8 @@ 'facets':'fc'} SEARCH_V3_SEGMENTS={**SEARCH_V2_SEGMENTS,'source_kind':'sk','relation_type':'rt'} SEARCH_V4_SEGMENTS={**SEARCH_V3_SEGMENTS,'suggest':'sg'} +SEARCH_V5_SEGMENTS={**SEARCH_V4_SEGMENTS,'revision':'rv','source_version':'sv', + 'relation_to':'to','relation_from':'fr','has_replies':'hr','has_references':'hf','spell':'sp'} GREP_V1_SEGMENTS={'scope':'s','pattern':'t','regex':'r','glob':'g', 'exclude_glob':'x','case_sensitive':'i','before':'b','after':'a', 'max_matches':'m','max_files':'f','files_with_matches':'w', @@ -303,20 +306,29 @@ def compile_lexical_search(query): 'owner','author','tag','state','created_after','created_before', 'updated_after','updated_before','has_attachment','order','limit', 'cursor','snippet','explain','fields','facets','source_kind', - 'relation_type','suggest','depth','recursive'}, + 'relation_type','suggest','revision','source_version','relation_to','relation_from', + 'has_replies','has_references','spell','depth','recursive'}, 'unknown_query_parameter') if 'cursor' in query: require(set(query)=={'cursor'},'cursor_query_mismatch') return {'cursor':query['cursor']} require(bool(query.get('scope')),'search_scope_required') args=dict(query) + if args.get('scope','').startswith('{'): + require(len(args['scope'].encode('utf-8'))<=8192,'query_cost_exceeded') + args['scope']=loads(args['scope']) + if 'source_version' in args: + require(args['source_version'].isascii() and args['source_version'].isdecimal() and + len(args['source_version'])<=10 and 1<=int(args['source_version'])<=2147483647, + 'invalid_search_source_version') + args['source_version']=int(args['source_version']) for name in ('limit','depth'): if name in args: require(args[name].isdecimal() and (0<=int(args[name])<=5 if name=='depth' else 1<=int(args[name])<=100), 'query_cost_exceeded') args[name]=int(args[name]) - for name in ('snippet','has_attachment','recursive','suggest'): + for name in ('snippet','has_attachment','recursive','suggest','has_replies','has_references','spell'): if name in args: require(args[name] in {'0','1'},'invalid_search_flag') args[name]=args[name]=='1' @@ -332,11 +344,15 @@ def compile_lexical_search(query): def decode_search_v2_path(raw_path,version=b'2'): prefix=raw_path.split(b'/',3)[1] - segments=(SEARCH_V4_SEGMENTS if version==b'4' else + segments=(SEARCH_V5_SEGMENTS if version==b'5' else + SEARCH_V4_SEGMENTS if version==b'4' else SEARCH_V3_SEGMENTS if version==b'3' else SEARCH_V2_SEGMENTS) values,proof=decode_query_path(raw_path,prefix,segments,version) modes={'a':'all','n':'any'} - fields={'a':'all','b':'body','n':'name','m':'metadata'} + require(version==b'5' or (not values.get('scope','').startswith('{') and + 'title' not in values.get('fields','').split(',') and + values.get('relation_type') not in SEARCH_V5_RELATIONS), 'invalid_search_scope') + fields={'a':'all','b':'body','n':'name','m':'metadata',**({'t':'title'} if version==b'5' else {})} order={'r':'relevance','u':'updated','c':'created','n':'name'} if 'mode' in values: require(values['mode'] in modes,'invalid_search_mode') @@ -374,41 +390,6 @@ def path_read_proof(encoded,operation,args,service,limit): return packet -def json_response(value,status=200,headers=None): - return Response(canonical(value),status_code=status,media_type='application/json',headers={**BASE_HEADERS,**(headers or {})}) - - -def error_status(code): - if code=='range_not_satisfiable':return 416 - if code in {'not_found','resource_purged','revision_not_found','csr_not_found', - 'certificate_not_found','listing_not_found','package_not_found', - 'bounty_not_found','order_not_found','delivery_not_found', - 'offer_not_found'}: return 404 - if code in {'authentication_required','invalid_token','invalid_signature','credential_revoked','credential_expired','request_expired'}: return 401 - if code in {'permission_denied','local_only','credential_ceiling','certificate_gate','tool_certificate_required','forbidden_origin','forbidden_host','passive_client_forbidden','query_ref_principal_mismatch','cursor_principal_mismatch'}: return 403 - if code in {'generation_conflict','revision_conflict','idempotency_conflict','chunk_conflict','constraint_conflict'}: return 409 - if code in {'request_too_large','path_too_large','response_too_large','use_transfer','part_too_large'}: return 413 - if code in {'method_not_allowed','effect_mismatch'}: return 405 - if code=='secure_channel_required': return 400 - if code in {'server_busy','issuer_not_ready','dependency_unavailable','service_restart_required','writes_paused'}: return 503 - if code=='storage_capacity_exceeded': return 507 - if code=='internal_error': return 500 - return 400 - - -async def body_bytes(request,limit): - length=request.headers.get('content-length') - if length is not None: - require(length.isdecimal() and int(length)<=limit,'request_too_large') - body=bytearray() - async for data in request.stream(): - require(len(body)+len(data)<=limit,'request_too_large') - body.extend(data) - encoding=request.headers.get('content-encoding','identity') - require(encoding in {'identity','gzip'},'unknown_encoding') - return gunzip(bytes(body),limit) if encoding=='gzip' else bytes(body) - - def describe_resource(data): if 'content' in data: content=data['content'] @@ -496,26 +477,26 @@ async def dispatch(request:Request): native=re.fullmatch(r'(/[@&][^/]+/[^/]+\.git)/(.*)',path) if native: require(service.registry.operation('git.refs').effect=='read','effect_mismatch') - from msg.extensions.repositories import NativeGitStore + from msg.transports.git_http import GitHTTPAdapter if native.group(2).startswith('info/lfs/'): require(service.registry.operation('git.lfs_read').effect=='read' and service.registry.operation('git.lfs_read_batch').effect=='read', 'effect_mismatch') - return await NativeGitStore(service).http_lfs(request,native.group(1), + return await GitHTTPAdapter(service).http_lfs(request,native.group(1), native.group(2)[9:]) - return await NativeGitStore(service).http(request,native.group(1),native.group(2)) + return await GitHTTPAdapter(service).http(request,native.group(1),native.group(2)) # git-lfs derives .git/info/lfs even when the advertised # push URL is /-/git/; both spellings remain inside /-/. lfs_write=re.fullmatch(r'/-/git/([A-Za-z0-9_-]{1,128})(?:\.git)?/info/lfs/(objects(?:/batch|/[0-9a-f]{64}/[0-9]+))',path) if lfs_write: require(raw_path==path.encode('ascii'),'not_found') - from msg.extensions.repositories import NativeGitStore - return await NativeGitStore(service).http_lfs(request,*lfs_write.groups(),write=True) + from msg.transports.git_http import GitHTTPAdapter + return await GitHTTPAdapter(service).http_lfs(request,*lfs_write.groups(),write=True) git_push=re.fullmatch(r'/-/git/([A-Za-z0-9_-]{1,128})/(info/refs|git-receive-pack)',path) if git_push: require(raw_path==path.encode('ascii'),'not_found') - from msg.extensions.repositories import NativeGitStore - return await NativeGitStore(service).http_push(request,*git_push.groups()) + from msg.transports.git_http import GitHTTPAdapter + return await GitHTTPAdapter(service).http_push(request,*git_push.groups()) if path.startswith(('/!','/~','/run/j/','/run/gz/')) or path=='/mcp': raise Failure('not_found') if request.method=='OPTIONS': @@ -740,10 +721,7 @@ async def dispatch(request:Request): 'cursor_kind_mismatch') if operation=='discovery.lexical_search': saved_args=query.get('arguments',{}) - contract_version=(4 if 'suggest' in saved_args else - 3 if any(name in saved_args for name in - ('source_kind','relation_type')) else - 2 if 'facets' in saved_args else 1) + contract_version=search_query_version(saved_args) elif operation=='discovery.read_query': contract_version=read_query_version(query.get('arguments',{})) except Failure as exc: @@ -848,14 +826,15 @@ async def dispatch(request:Request): lexical_path_v2=raw_path.startswith((b'/_search/q/2/',b'/_s/q/2/')) lexical_path_v3=raw_path.startswith((b'/_search/q/3/',b'/_s/q/3/')) lexical_path_v4=raw_path.startswith((b'/_search/q/4/',b'/_s/q/4/')) - lexical_path=lexical_path_v2 or lexical_path_v3 or lexical_path_v4 + lexical_path_v5=raw_path.startswith((b'/_search/q/5/',b'/_s/q/5/')) + lexical_path=lexical_path_v2 or lexical_path_v3 or lexical_path_v4 or lexical_path_v5 if path in {'/_search','/_s'} or search_path or lexical_path or raw_path.startswith(( b'/_index/by-tag/',b'/_i/by-tag/')): require(request.method in {'GET','HEAD'},'method_not_allowed') if search_path or lexical_path: require(not request.url.query,'unknown_query_parameter') query,path_proof=(decode_search_v2_path(raw_path, - b'4' if lexical_path_v4 else b'3' if lexical_path_v3 else b'2') + b'5' if lexical_path_v5 else b'4' if lexical_path_v4 else b'3' if lexical_path_v3 else b'2') if lexical_path else decode_search_query_path(raw_path)) else: @@ -868,7 +847,8 @@ async def dispatch(request:Request): lexical=lexical_path or (not is_index and bool(set(query)&{'terms','exact','not_terms','scope','mode','field','order', 'snippet','explain','has_attachment','facets', - 'source_kind','relation_type','suggest'})) + 'source_kind','relation_type','suggest','revision','source_version', + 'relation_to','relation_from','has_replies','has_references','spell'})) if lexical: args=compile_lexical_search(query) operation='discovery.lexical_search' @@ -911,11 +891,9 @@ async def dispatch(request:Request): else: packet=request_for(operation,args,service.settings.service_url, source='manual', - contract_version=(4 if lexical and (lexical_path_v4 or - 'suggest' in args) else - 3 if lexical and (lexical_path_v3 or - 'source_kind' in args or 'relation_type' in args) else - 2 if lexical and 'facets' in args else 1)) + contract_version=(max(search_query_version(args), + 5 if lexical_path_v5 else 4 if lexical_path_v4 else + 3 if lexical_path_v3 else 1) if lexical else 1)) result=await service.executor.execute(packet,entry='network') if result.error: return json_response(result_wire(result),error_status(result.error.code)) @@ -1005,18 +983,26 @@ async def dispatch(request:Request): require(request.method in {'GET','HEAD'},'method_not_allowed') require(service.registry.operation('discovery.lexical_search').effect=='read', 'effect_mismatch') - document={'version':4,'operation':'discovery.lexical_search', + document={'version':5,'operation':'discovery.lexical_search', 'segments':SEARCH_V2_SEGMENTS,'segments_v3':SEARCH_V3_SEGMENTS, - 'segments_v4':SEARCH_V4_SEGMENTS, + 'segments_v4':SEARCH_V4_SEGMENTS,'segments_v5':SEARCH_V5_SEGMENTS, 'mode':{'all':'a','any':'n'}, 'field':{'all':'a','body':'b','name':'n','metadata':'m'}, + 'field_v5':{'all':'a','body':'b','name':'n','metadata':'m','title':'t'}, + 'title_semantics':'Resource display name (alias of name)', + 'scope_v5':'path or JSON object: subject, org, or resource_refs (up to 32 id refs)', + 'spell_v5':{'enabled_by':'spell=true', 'source':'currently readable resource names', + 'algorithm':'Levenshtein; distance 1 for terms up to 4 characters, otherwise 2', + 'max_terms':4,'term_length':[2,32],'max_dictionary_words':512, + 'max_suggestions_per_term':5,'rewrites_query':False}, 'order':{'relevance':'r','updated':'u','created':'c','name':'n'}, 'facets':['type','tag'], 'contract_version':{'default':1,'with_facets':2, - 'with_source_or_relation':3,'with_suggest':4}, + 'with_source_or_relation':3,'with_suggest':4,'with_revision_or_source_version':5}, 'template':'/_search/q/2/s/{percent-encoded-scope}/t/{terms}/m/{mode}/f/{field}/n/{limit}', 'template_v3':'/_search/q/3/s/{percent-encoded-scope}/t/{terms}/sk/{source-kind}/rt/{relation-type}', 'template_v4':'/_search/q/4/s/{percent-encoded-scope}/t/{terms}/sg/1', + 'template_v5':'/_search/q/5/s/{percent-encoded-scope}/t/{terms}/rv/{revision}/sv/{source-version}', 'proof_suffix':'/p/{short-lived-signed-OperationRequest}'} etag='"'+digest(document)[7:]+'"' headers={**BASE_HEADERS,'ETag':etag,'Cache-Control':'private, no-cache'} @@ -1347,7 +1333,7 @@ async def dispatch(request:Request): return Response(status_code=304,headers=headers) return Response(b'' if request.method=='HEAD' else payload, media_type='application/json',headers=headers) - if name in {'handoffs','leases'}: + if name in {'handoffs','leases','requests','offers','checkpoints','proposals','watches'}: require(request.method in {'GET','HEAD'},'method_not_allowed') require(raw_path.decode('utf-8')==request.url.path and b'%' not in raw_path, 'not_found') @@ -1356,7 +1342,7 @@ async def dispatch(request:Request): if not listing: require(re.fullmatch(r'[A-Za-z0-9_.:-]{1,160}(?:/json)?',tail) is not None,'not_found') - singular='handoff' if name=='handoffs' else 'lease' + singular='watch' if name=='watches' else name[:-1] operation='communication.'+singular+('_list' if listing else '_get') pairs=request.query_params.multi_items() require(len(pairs)==len({key for key,_ in pairs}), @@ -1398,6 +1384,59 @@ async def dispatch(request:Request): require(len(payload)<=limits.max_response_bytes,'response_too_large') return Response(b'' if request.method=='HEAD' else payload, media_type='application/json',headers=headers) + if name=='receipts': + require(request.method in {'GET','HEAD'},'method_not_allowed') + require(raw_path.decode('utf-8')==request.url.path and b'%' not in raw_path, + 'not_found') + tail=(remainder or '').strip('/') + listing=tail in {'','json'} + if not listing: + require(re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}(?:/json)?',tail) + is not None,'not_found') + operation='communication.receipt_'+('list' if listing else 'get') + pairs=request.query_params.multi_items() + require(len(pairs)==len({key for key,_ in pairs}), + 'duplicate_query_parameter') + query=dict(pairs) + require(not query or listing,'unknown_query_parameter') + require(set(query)<={'limit','cursor'},'unknown_query_parameter') + args={} if listing else {'request_id':tail.removesuffix('/json')} + if 'limit' in query: + require(query['limit'].isdecimal(),'invalid_limit') + args['limit']=int(query['limit']) + if 'cursor' in query: args['cursor']=query['cursor'] + async with service.metadata.transaction(write=False) as tx: + subject_id=await tx.resolve('/@'+handle) + require((await tx.resource(subject_id)).type=='user','not_found') + require(service.registry.operation(operation).effect=='read', + 'effect_mismatch') + header=request.headers.get('x-msg-request') + if header: + packet=path_packet(header,'j',limits.max_request_bytes) + require(packet.operation==operation and + canonical(packet.arguments)==canonical(args), + 'representation_mismatch') + else: + packet=request_for(operation,args,service.settings.service_url, + source='manual') + result=await service.executor.execute(packet,entry='network') + # Another subject's lookup must not reveal whether its own + # request_id exists; the path subject is the only reader. + require(result.subject is None or result.subject==subject_id, + 'permission_denied') + if result.error: + return json_response(result_wire(result),error_status(result.error.code)) + value=wire(result.data) + value['path']='/@'+handle+'/receipts'+('/'+args['request_id'] if not listing else '') + etag='"'+digest(value)[7:]+'"' + headers={**BASE_HEADERS,'ETag':etag, + 'Cache-Control':'private, no-cache'} + if request.headers.get('if-none-match')==etag: + return Response(status_code=304,headers=headers) + payload=canonical(value) + require(len(payload)<=limits.max_response_bytes,'response_too_large') + return Response(b'' if request.method=='HEAD' else payload, + media_type='application/json',headers=headers) certificate_detail=(name in {'cert','certificates'} and remainder not in {None,'/','/json','/meta','/history'}) if name in SUBJECT_KEY_ALIASES or name in SUBJECT_OPERATION_ALIASES or certificate_detail: @@ -1485,8 +1524,25 @@ async def dispatch(request:Request): path='/@'+handle+'/'+SUBJECT_RESOURCE_ALIASES[name]+(remainder or '') stable=parse_stable_view(path,raw_path) resource_path,view,revision=stable if stable is not None else parse_view(path) + # A public representation must be read-only before even resolving + # its old/migrated alias. Missing targets cannot bypass this gate. + initial_operation='discovery.raw' if view=='raw' else 'discovery.get' + require(service.registry.operation(initial_operation).effect=='read','effect_mismatch') redirect_target=None - if view=='markdown' and revision is None and not resource_path.endswith('.md'): + redirect_resource_id=None + legacy_target=None + if path.startswith('/_legacy/'): + from msg.transports.legacy_content_http import resolve_legacy_read + async with service.metadata.transaction(write=False) as tx: + legacy_target=await resolve_legacy_read(tx,path,raw_path) + if legacy_target is not None: + resource_path='/_id/'+legacy_target.resource_id + view=legacy_target.view + revision=None + stable=(resource_path,view,None) + redirect_resource_id=legacy_target.resource_id + redirect_target=legacy_target.canonical_path + if redirect_target is None and view=='markdown' and revision is None and not resource_path.endswith('.md'): # Old Post links omitted .md. Resolve the candidate only to find # the stable resource; disclose its canonical path after the # normal discovery.get authorization check succeeds. @@ -1498,14 +1554,30 @@ async def dispatch(request:Request): raise candidate=resource_path+'.md' try: - rid=await tx.resolve(candidate) + rid=await resolve_read(tx,candidate) except Failure as candidate_error: if candidate_error.code!='not_found': raise else: if (await tx.resource(rid)).type=='post': + redirect_resource_id=rid redirect_target=await tx.path(rid) resource_path=redirect_target + if redirect_target is None and stable is None: + async with service.metadata.transaction(write=False) as tx: + try: + await tx.resolve(resource_path) + except Failure as exc: + if exc.code!='not_found': + raise + rid=await tx.resolve_migrated(resource_path) + canonical_path=await tx.path(rid) + suffix=('/revisions/'+revision if revision else '') + if view!='markdown': + suffix+='/'+view + redirect_resource_id=rid + redirect_target=canonical_path+suffix + resource_path='/_id/'+rid op='discovery.raw' if view=='raw' else 'discovery.get' require(service.registry.operation(op).effect=='read','effect_mismatch') args={'id':resource_path} @@ -1518,7 +1590,7 @@ async def dispatch(request:Request): # Resolve both aliases to the same stable resource before comparing. async with service.metadata.transaction(write=False) as tx: header_id=packet.arguments.get('id') - rid=await tx.resolve(header_id) if isinstance(header_id,str) and header_id.startswith('/') else header_id + rid=await resolve_read(tx,header_id) if isinstance(header_id,str) and header_id.startswith('/') else header_id require(rid==await tx.resolve(resource_path),'resource_mismatch') require(packet.arguments.get('revision')==revision,'revision_mismatch') require(packet.arguments.get('view')==args.get('view'),'representation_mismatch') @@ -1528,8 +1600,27 @@ async def dispatch(request:Request): if result.error: return json_response(result_wire(result),error_status(result.error.code)) if redirect_target is not None: - return Response(status_code=308,headers={**BASE_HEADERS, - 'Location':quote(redirect_target,safe='/')}) + # A signed old-path packet may resolve a newly occupying object + # between routing and execution. Its successful authorization + # must not disclose the previous target's canonical name. + authorized_id=(result.resources[0].id if result.resources else + result.data.get('id',result.data.get('metadata',{}).get('id'))) + require(authorized_id==redirect_resource_id,'resource_mismatch') + if legacy_target is not None: + async with service.metadata.transaction(write=False) as tx: + current_legacy=await resolve_legacy_read(tx,path,raw_path) + require(current_legacy is not None and + current_legacy.resource_id==authorized_id and + current_legacy.view==legacy_target.view,'resource_mismatch') + legacy_target=current_legacy + redirect_target=current_legacy.canonical_path + headers={**BASE_HEADERS,'Location':quote(redirect_target,safe='/@&')} + if legacy_target is not None: + headers['X-Msg-Legacy-Source']=legacy_target.source_sha256 + headers['X-Msg-Legacy-Signature']='unverified-historical-claim' + if legacy_target.provenance_path is not None: + headers['Link']='<'+quote(legacy_target.provenance_path,safe='/')+'>; rel="describedby"' + return Response(status_code=308,headers=headers) value=wire(result.data) if ssh_projection: value={'id':value['id'],'keys':[key for key in value.get('keys',()) diff --git a/src/msg/transports/legacy_content_http.py b/src/msg/transports/legacy_content_http.py new file mode 100644 index 00000000..bf4603cf --- /dev/null +++ b/src/msg/transports/legacy_content_http.py @@ -0,0 +1,73 @@ +"""Locate explicitly namespaced legacy imports; caller must authorize the target.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from msg.core.errors import require +from msg.storage.legacy_resource_import import PURPOSE + + +@dataclass(frozen=True) +class LegacyReadTarget: + resource_id: str + canonical_path: str + view: str + source_sha256: str + provenance_path: str | None + + +async def resolve_legacy_read(tx, path: str, raw_path: bytes): + if not path.startswith("/_legacy/"): + return None + # No alternate escaped spellings or normalization-dependent routing. + require(raw_path == path.encode("utf-8") and b"%" not in raw_path, "not_found") + match = re.fullmatch(r"/_legacy/([0-9a-f]{64})(/[^?#]+)", path) + require(match is not None, "not_found") + sha256, old_path = match.groups() + require(all(part and part not in {".", ".."} for part in old_path.split("/")[1:]), "not_found") + report = tx.setting("legacy-import:" + sha256) + require( + isinstance(report, dict) + and report.get("format") == PURPOSE + and report.get("source_sha256") == sha256 + and report.get("visibility") == "private" + and report.get("authority_enabled") is False + and report.get("external_jobs_enabled") is False, + "not_found", + ) + mapping = report.get("url_map", {}) + require(isinstance(mapping, dict), "not_found") + rid = mapping.get(old_path) + provenance = False + base_path = old_path + if rid is None and old_path.endswith("/provenance"): + base_path = old_path.removesuffix("/provenance") + rid = mapping.get(base_path) + require(isinstance(rid, str), "not_found") + record = tx.setting("legacy-provenance:" + rid) + require(isinstance(record, dict) and isinstance(record.get("resource"), str), "not_found") + rid = record["resource"] + provenance = True + require(isinstance(rid, str), "not_found") + resource = await tx.resource(rid) + ancestors = await tx.ancestors(rid) + require(any(parent.id == report.get("parent") for parent in ancestors), "not_found") + view = "json" if provenance else "markdown" + if not provenance: + suffix = old_path.rsplit("/", 1)[-1] + prefix = old_path.rsplit("/", 1)[0] + if suffix in {"raw", "meta"} and mapping.get(prefix) == rid: + view = suffix + base_path = prefix + elif re.fullmatch(r"/file/[0-9]+", old_path): + view = "raw" + canonical_path = await tx.path(resource.id) + if view != "markdown": + canonical_path += "/" + view + record = tx.setting("legacy-provenance:" + rid) if not provenance else None + provenance_path = ( + "/_legacy/" + sha256 + base_path + "/provenance" if isinstance(record, dict) else None + ) + return LegacyReadTarget(rid, canonical_path, view, sha256, provenance_path) diff --git a/src/msg/transports/mcp.py b/src/msg/transports/mcp.py index 8474f57e..aa81f77d 100644 --- a/src/msg/transports/mcp.py +++ b/src/msg/transports/mcp.py @@ -9,11 +9,10 @@ from msg import __version__ from msg.core.codec import canonical,digest,loads from msg.core.errors import Failure,require -from msg.core.executor import result_wire +from msg.core.codec import result_wire from msg.transports.packet import decode_packet,REQUEST_SCHEMA,RESULT_SCHEMA -PROTOCOL_VERSION='2025-11-25' -SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) +from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS class MCPServer: diff --git a/src/msg/transports/mcp_protocol.py b/src/msg/transports/mcp_protocol.py new file mode 100644 index 00000000..7eca1c5b --- /dev/null +++ b/src/msg/transports/mcp_protocol.py @@ -0,0 +1,4 @@ +"""Published MCP negotiation versions; no server or client runtime imports.""" + +PROTOCOL_VERSION='2025-11-25' +SUPPORTED_VERSIONS=frozenset({PROTOCOL_VERSION,'2025-06-18','2025-03-26'}) diff --git a/src/msg/tui.py b/src/msg/tui.py index 23605b41..df408870 100644 --- a/src/msg/tui.py +++ b/src/msg/tui.py @@ -166,7 +166,7 @@ def _show_items(self, items): name = item.get('title') or item.get('name') or item.get('path') or item.get('source') or rid self._write(f'{index}. {name} {rid}') snippet = item.get('snippet') - if isinstance(snippet, dict) and snippet.get('text'): + if isinstance(snippet, Mapping) and isinstance(snippet.get('text'), str) and snippet['text']: self._write(' ' + snippet['text']) async def _show_page(self, operation, arguments, title): diff --git a/src/msg/workers/effects.py b/src/msg/workers/effects.py index 108614a4..78e943b2 100644 --- a/src/msg/workers/effects.py +++ b/src/msg/workers/effects.py @@ -109,28 +109,49 @@ def __init__(self, app, *, tool_runner: ToolRunner | None = None, mail_sender=No self.lease_seconds = lease_seconds async def _claim(self): + from msg.extensions.tools import tool_concurrency async with self.app.metadata.transaction(write=True) as tx: + self.app.runtime_generation.require_current(tx) if quarantine_active(tx): return None, False # Orphaned external work cannot be assumed not to have executed. + running = {} for (raw_id,) in tx.execute("SELECT id FROM jobs WHERE state='running' ORDER BY next_at,id"): job = await tx.job(raw_id) if job.lease_until is None or job.lease_until <= self.app.clock(): await tx.save_job(replace(job, state='uncertain', lease_until=None)) tx.set_setting('job_status:' + job.id, {'code': 'expired_execution_lease'}) return job, False - row = tx.one("SELECT id FROM jobs WHERE state='pending' AND next_at<=? ORDER BY next_at,id LIMIT 1", - (wire(self.app.clock()),)) - if row is None: - return None, False - job = await tx.job(row[0]) - job = replace(job, state='running', attempts=job.attempts+1, - lease_until=self.app.clock()+timedelta(seconds=self.lease_seconds)) - await tx.save_job(job) - return job, True + if job.kind == 'tool': + running[job.arguments['tool']['id']] = running.get(job.arguments['tool']['id'], 0) + 1 + # Other workers may hold this tool's jobs; only the running rows seen + # under this write transaction are a shared fact, never a local lock. + limits = {} + for (raw_id,) in tx.execute("SELECT id FROM jobs WHERE state='pending' AND next_at<=? ORDER BY next_at,id", + (wire(self.app.clock()),)): + job = await tx.job(raw_id) + if job.kind == 'tool': + ref = decode(ResourceRef, job.arguments['tool']) + if (ref.id, ref.revision) not in limits: + try: + limits[ref.id, ref.revision] = await tool_concurrency(self.app, tx, ref) + except Failure as exc: + if exc.retryable: + raise + await tx.save_job(replace(job, state='failed', lease_until=None)) + tx.set_setting('job_status:' + job.id, {'code': exc.code}) + return job, False + if running.get(ref.id, 0) >= limits[ref.id, ref.revision]: + continue + job = replace(job, state='running', attempts=job.attempts+1, + lease_until=self.app.clock()+timedelta(seconds=self.lease_seconds)) + await tx.save_job(job) + return job, True + return None, False async def _finish(self, job, state, code, *, status=None): async with self.app.metadata.transaction(write=True) as tx: + self.app.runtime_generation.require_current(tx) current = await current_attempt(self.app, tx, job) if current is None: return @@ -140,6 +161,7 @@ async def _finish(self, job, state, code, *, status=None): async def _retry(self, job, retry_code, exhausted_code): async with self.app.metadata.transaction(write=True) as tx: + self.app.runtime_generation.require_current(tx) current = await current_attempt(self.app, tx, job) if current is None: return @@ -162,6 +184,7 @@ async def _tool(self, job, directory): from msg.core.models import NetworkPolicy require(job.operation == 'tool.run', 'unknown_operation') async with self.app.metadata.transaction(write=False) as tx: + self.app.runtime_generation.require_current(tx) principal = await current_principal(self.app, job.principal, tx) context, request = worker_context(self.app, job, principal), effect_request(self.app, job, principal) ref = decode(ResourceRef, job.arguments['tool']) @@ -211,6 +234,7 @@ async def pieces(): blob = await self.app.contents.put(pieces(), result.media_type) await self.app.contents.pin(blob, 'job:'+job.id) async with self.app.metadata.transaction(write=True) as tx: + self.app.runtime_generation.require_current(tx) current = await current_attempt(self.app, tx, job) if current is None: return @@ -241,6 +265,7 @@ async def _mail(self, job): if job.arguments.get('order_notification'): from msg.market.delivery_notifications import project_notification async with self.app.metadata.transaction(write=False) as tx: + self.app.runtime_generation.require_current(tx) principal = await current_principal(self.app, job.principal, tx) projected = await project_notification(self.app, tx, job, principal) state = await self.mail_sender.send(projected) @@ -248,6 +273,7 @@ async def _mail(self, job): await self._finish(job, 'done' if state == 'sent' else 'uncertain', state) return async with self.app.metadata.transaction(write=False) as tx: + self.app.runtime_generation.require_current(tx) await current_principal(self.app, job.principal, tx) subject = job.arguments['recipient_subject'] row = tx.one('SELECT body FROM emails WHERE subject=?', (subject,)) @@ -269,6 +295,7 @@ async def _market_mail(self, job): from msg.market.email import render_verification require(mail_enabled(self.app), 'mail_disabled') async with self.app.metadata.transaction(write=False) as tx: + self.app.runtime_generation.require_current(tx) await current_principal(self.app,job.principal,tx) outgoing=await (render_verification(self.app,tx,job) if job.kind=='market_email_verify' else render_notification(self.app,tx,job)) @@ -284,6 +311,7 @@ async def _webhook(self, job): require(job.operation in {'communication.send','communication.webhook_subscribe'}, 'invalid_webhook_event') async with self.app.metadata.transaction(write=False) as tx: + self.app.runtime_generation.require_current(tx) principal=await current_principal(self.app,job.principal,tx) recipient=job.arguments['recipient_subject'] subject=await tx.subject(recipient) @@ -347,6 +375,7 @@ async def _webhook(self, job): await self._finish(job,'done' if state=='delivered' else 'failed',state) async def run_once(self): + await self.app.executor.require_current_runtime() if self.app.executor.recovery_drill_active(): return False if 'orders' in self.app.settings.server.plugins: @@ -362,6 +391,11 @@ async def run_once(self): if not execute: return True try: + # A queued effect must not outlive its installed operation contract. + # Legacy jobs predate version recording and used the v1 worker path. + version=job.arguments.get('contract_version',1) + require(type(version) is int and version>=1,'invalid_job_contract_version') + self.app.registry.operation(job.operation,version) if job.kind == 'tool': self.app.settings.server.staging_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='effect-', dir=self.app.settings.server.staging_dir) as temp: @@ -384,6 +418,7 @@ async def run_once(self): # idempotent cleanup cannot change another resource's retention. from msg.workers.maintenance import _collect async with self.app.metadata.transaction(write=True) as tx: + self.app.runtime_generation.require_current(tx) resource=await tx.resource(job.arguments['id']) require(resource.state=='purged','purge_not_committed') await _collect(self.app,tx) diff --git a/src/msg/workers/leases.py b/src/msg/workers/leases.py index 6217fcbd..70dec53d 100644 --- a/src/msg/workers/leases.py +++ b/src/msg/workers/leases.py @@ -9,6 +9,7 @@ async def current_attempt(app, tx, job): A stale callback must not overwrite another attempt's status, even when no separate worker has swept the expired lease yet. """ + app.runtime_generation.require_current(tx) current = await tx.job(job.id) if current.state != 'running' or current.attempts != job.attempts: return None diff --git a/src/msg/workers/maintenance.py b/src/msg/workers/maintenance.py index 1e79944b..7533ce3f 100644 --- a/src/msg/workers/maintenance.py +++ b/src/msg/workers/maintenance.py @@ -286,6 +286,7 @@ async def _collect(app,tx, *, grace_seconds=3600): async def run_maintenance(app,action, *, scheduled=False,principal=None): require(action in {'cleanup_expired','deliver_due_todos','rebuild_search','collect_garbage'},'unknown_maintenance_action') async with app.metadata.transaction(write=True) as tx: + app.runtime_generation.require_current(tx) from msg.security.quarantine import active as quarantine_active if quarantine_active(tx): return {'skipped':'recovery_quarantine'} diff --git a/src/msg/workers/sandbox.py b/src/msg/workers/sandbox.py index ea3cf00e..09e8f2ee 100644 --- a/src/msg/workers/sandbox.py +++ b/src/msg/workers/sandbox.py @@ -26,9 +26,19 @@ async def __call__(self, tool: ToolSpec, arguments: JsonMap, for path in ('/usr', '/bin', '/lib', '/lib64'): if Path(path).exists(): command += ['--ro-bind', path, path] - prefix = str(Path(sys.prefix).resolve()) - if not prefix.startswith('/usr') and prefix not in {'/','/etc','/home','/mnt'}: - command += ['--ro-bind', prefix, prefix] + # A virtualenv may symlink its executable into a separate managed + # Python installation (for example uv). It needs that interpreter and + # stdlib tree as well as the virtualenv's installed dependencies. + for prefix in dict.fromkeys(str(Path(p).resolve()) for p in (sys.prefix, sys.base_prefix)): + if not Path(prefix).is_relative_to('/usr') and prefix not in {'/','/etc','/home','/mnt'}: + command += ['--ro-bind', prefix, prefix] + # Preserve a managed interpreter's alias path used by the venv's + # symlink, without exposing the rest of the user's installation tree. + base_executable = Path(getattr(sys, '_base_executable', sys.executable)) + interpreter_alias = base_executable.parent.parent + if (interpreter_alias != interpreter_alias.resolve() and + interpreter_alias.resolve() == Path(sys.base_prefix).resolve()): + command += ['--ro-bind', str(interpreter_alias.resolve()), str(interpreter_alias)] command += ['--dir','/etc'] for path in ('/etc/resolv.conf', '/etc/hosts', '/etc/nsswitch.conf', '/etc/ssl/certs'): if Path(path).exists(): diff --git a/tests/fixtures/legacy_sqlite_0221.sql b/tests/fixtures/legacy_sqlite_0221.sql new file mode 100644 index 00000000..2c71ae32 --- /dev/null +++ b/tests/fixtures/legacy_sqlite_0221.sql @@ -0,0 +1,315 @@ +-- Schema-only reproduction of observed archczy 0.22.1. No user data. +CREATE TABLE agent_state ( + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + updated REAL NOT NULL, + PRIMARY KEY(owner_id, name) +); +CREATE TABLE archived_attachments ( + id INTEGER PRIMARY KEY, + post_id INTEGER NOT NULL REFERENCES archived_posts(id) ON DELETE CASCADE, + slot INTEGER NOT NULL, + name TEXT NOT NULL, + content_type TEXT NOT NULL, + data BLOB NOT NULL, + nbytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, created REAL NOT NULL DEFAULT 0, uploader_name TEXT NOT NULL DEFAULT 'anonymous', uploader_id TEXT, downloads INTEGER NOT NULL DEFAULT 0, + UNIQUE(post_id, slot) +); +CREATE TABLE archived_posts ( + id INTEGER PRIMARY KEY, + board TEXT NOT NULL, + seq INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT 'anonymous', + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + created REAL NOT NULL, + updated REAL NOT NULL, + nbytes INTEGER NOT NULL, + author_key TEXT, + author_id TEXT, + actor_key TEXT, + actor_id TEXT, + signature TEXT, + sig_version INTEGER NOT NULL DEFAULT 0, + sig_nonce TEXT, + sig_issued INTEGER, + reply_to INTEGER, + system INTEGER NOT NULL DEFAULT 0, + custody_id TEXT, + archived_at REAL NOT NULL, + archived_by TEXT +); +CREATE TABLE attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + slot INTEGER NOT NULL, + name TEXT NOT NULL, + content_type TEXT NOT NULL, + data BLOB NOT NULL, + nbytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, created REAL NOT NULL DEFAULT 0, uploader_name TEXT NOT NULL DEFAULT 'anonymous', uploader_id TEXT, downloads INTEGER NOT NULL DEFAULT 0, + UNIQUE(post_id, slot) +); +CREATE TABLE boards ( + name TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + locked INTEGER NOT NULL DEFAULT 0, + created REAL NOT NULL +); +CREATE TABLE certificate_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_key TEXT NOT NULL, + subject_id TEXT NOT NULL, + requested_issuer TEXT NOT NULL DEFAULT '', + grants TEXT NOT NULL, + delegate INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + created REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + decided REAL, + decision_by TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + certificate_serial TEXT +); +CREATE TABLE certificates ( + serial TEXT PRIMARY KEY, + issuer_serial TEXT NOT NULL, + issuer_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + subject_key TEXT NOT NULL, + body TEXT NOT NULL, + signature TEXT NOT NULL, + created REAL NOT NULL +); +CREATE TABLE custody_identities ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + author_id TEXT NOT NULL UNIQUE, + key_nonce BLOB NOT NULL, + key_ciphertext BLOB NOT NULL, + created REAL NOT NULL, + last_used REAL NOT NULL +); +CREATE TABLE exchange_tasks ( + post_id INTEGER PRIMARY KEY, + owner_id TEXT NOT NULL, + assignee_id TEXT, + status TEXT NOT NULL, + created REAL NOT NULL, + updated REAL NOT NULL +); +CREATE TABLE identity_names ( + author_id TEXT NOT NULL, + name TEXT NOT NULL, + first_seen REAL NOT NULL, + last_seen REAL NOT NULL, + PRIMARY KEY(author_id, name) +); +CREATE TABLE inbox_events ( + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY(post_id, subject_id, kind) +); +CREATE TABLE inbox_receipts ( + subject_id TEXT NOT NULL, + post_id INTEGER NOT NULL, + status TEXT NOT NULL, + updated REAL NOT NULL, read_at REAL NOT NULL DEFAULT 0, public_key TEXT NOT NULL DEFAULT '', + PRIMARY KEY(subject_id, post_id) +); +CREATE TABLE keystore_entries ( + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + ciphertext BLOB NOT NULL, + sha256 TEXT NOT NULL, + version INTEGER NOT NULL, + created REAL NOT NULL, + updated REAL NOT NULL, + PRIMARY KEY(owner_id, name) +); +CREATE TABLE name_claims ( + name_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + author_id TEXT NOT NULL, + public_key TEXT NOT NULL, + claim_post_id INTEGER REFERENCES posts(id) ON DELETE SET NULL, + claim_signature TEXT NOT NULL DEFAULT '', + claimed REAL NOT NULL, + last_used REAL NOT NULL +); +CREATE TABLE path_get_chunks ( + request_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + chunk_count INTEGER NOT NULL, + data BLOB NOT NULL, + created REAL NOT NULL, + PRIMARY KEY (request_id, chunk_index) +); +CREATE TABLE path_get_receipts ( + request_id TEXT PRIMARY KEY, + payload_sha256 TEXT NOT NULL, + operation TEXT NOT NULL, + status INTEGER, + content_type TEXT, + body BLOB, + headers TEXT NOT NULL DEFAULT '{}', + created REAL NOT NULL +); +CREATE TABLE post_likes ( + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + author_id TEXT NOT NULL, + created REAL NOT NULL, + PRIMARY KEY(post_id, author_id) +); +CREATE TABLE post_tags ( + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY(post_id, tag) +); +CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board TEXT NOT NULL REFERENCES boards(name) ON DELETE CASCADE, + seq INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT 'anonymous', + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + token_hash TEXT NOT NULL DEFAULT '', + created REAL NOT NULL, + updated REAL NOT NULL, + edit_count INTEGER NOT NULL DEFAULT 0, + deleted INTEGER NOT NULL DEFAULT 0, + deleted_by TEXT NOT NULL DEFAULT '', + nbytes INTEGER NOT NULL DEFAULT 0 +, fingerprint TEXT NOT NULL DEFAULT '', client_hash TEXT NOT NULL DEFAULT '', hidden INTEGER NOT NULL DEFAULT 0, flags INTEGER NOT NULL DEFAULT 0, vouches INTEGER NOT NULL DEFAULT 0, author_key TEXT, author_id TEXT, actor_key TEXT, actor_id TEXT, signature TEXT, sig_version INTEGER NOT NULL DEFAULT 0, sig_nonce TEXT, sig_issued INTEGER, reply_to INTEGER, system INTEGER NOT NULL DEFAULT 0, custody_id TEXT); +CREATE TABLE profiles ( + author_id TEXT PRIMARY KEY, + primary_name_key TEXT NOT NULL, + bio TEXT NOT NULL DEFAULT '', + version INTEGER NOT NULL DEFAULT 0, + payload_b64 TEXT NOT NULL DEFAULT '', + signature TEXT NOT NULL DEFAULT '', + updated REAL NOT NULL +); +CREATE TABLE purge_tombstones ( + post_id INTEGER PRIMARY KEY, + board TEXT NOT NULL, + purged_at REAL NOT NULL, + purged_by TEXT, + reason TEXT NOT NULL DEFAULT '' +); +CREATE TABLE revocations ( + serial TEXT PRIMARY KEY REFERENCES certificates(serial) ON DELETE CASCADE, + revoked_at REAL NOT NULL, + revoked_by TEXT NOT NULL +, reason TEXT NOT NULL DEFAULT ''); +CREATE TABLE signature_nonces ( + signer_id TEXT NOT NULL, + nonce TEXT NOT NULL, + issued INTEGER NOT NULL, + PRIMARY KEY(signer_id, nonce) +); +CREATE TABLE ssh_authorized_keys ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + key_type TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL, + created REAL NOT NULL, + updated REAL NOT NULL, + last_used REAL, + expires REAL, + revoked REAL, + created_by TEXT NOT NULL + ); +CREATE TABLE subscriptions ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + kind TEXT NOT NULL, + target TEXT NOT NULL, + created REAL NOT NULL, + UNIQUE(owner_id, kind, target) +); +CREATE TABLE topic_policies ( + board TEXT PRIMARY KEY, + anonymous TEXT NOT NULL, + version INTEGER NOT NULL, + updated REAL NOT NULL +, signed TEXT NOT NULL DEFAULT '["post.create","post.delete.self","post.edit.self"]'); +CREATE TABLE webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL, + event TEXT NOT NULL, + data TEXT NOT NULL, + created REAL NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL, + delivered REAL, + last_error TEXT NOT NULL DEFAULT '' +); +CREATE TABLE webhooks ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + url TEXT NOT NULL, + events TEXT NOT NULL, + secret_nonce BLOB NOT NULL, + secret_ciphertext BLOB NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created REAL NOT NULL, + updated REAL NOT NULL, + last_error TEXT NOT NULL DEFAULT '' +); +CREATE TABLE websub_deliveries ( + id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES websub_subscriptions(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + created REAL NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL, + delivered REAL, + last_error TEXT NOT NULL DEFAULT '' +); +CREATE TABLE websub_hub_pings ( + id TEXT PRIMARY KEY, + hub TEXT NOT NULL, + topic TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, + created REAL NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL, + delivered REAL, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE(hub, topic) +); +CREATE TABLE websub_subscriptions ( + id TEXT PRIMARY KEY, + topic TEXT NOT NULL, + callback TEXT NOT NULL, + secret_nonce BLOB NOT NULL DEFAULT X'', + secret_ciphertext BLOB NOT NULL DEFAULT X'', + created REAL NOT NULL, + updated REAL NOT NULL, + expires REAL NOT NULL, + UNIQUE(topic, callback) +); +CREATE TABLE websub_verifications ( + id TEXT PRIMARY KEY, + mode TEXT NOT NULL, + topic TEXT NOT NULL, + callback TEXT NOT NULL, + lease_seconds INTEGER NOT NULL, + challenge TEXT NOT NULL, + secret_nonce BLOB NOT NULL DEFAULT X'', + secret_ciphertext BLOB NOT NULL DEFAULT X'', + created REAL NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt REAL NOT NULL, + last_error TEXT NOT NULL DEFAULT '' +); diff --git a/tests/http_read_cache_matrix.py b/tests/http_read_cache_matrix.py new file mode 100644 index 00000000..be170a2a --- /dev/null +++ b/tests/http_read_cache_matrix.py @@ -0,0 +1,50 @@ +"""Conditional byte reads must cross the same live authority as projections.""" +from datetime import timedelta +from msg.core.codec import b64, canonical +from msg.core.requests import request_for +from test_service import NOW +from msg.transports.http_common import error_status + + +async def assert_read_cache_matrix(http,app,reader,certs,rid,revision,expected,cache): + denial_code='certificate_revoked' if certs else 'permission_denied' + denial_status=error_status(denial_code) + representations=(('json',None),('meta',None),('history',None),('raw',None),('raw',revision)) + for view,version in representations: + path='/_id/'+rid+('/revisions/'+version if version else '')+'/'+view + args={'id':rid} + if version:args['revision']=version + if view in {'meta','history'}:args['view']=view + packet=request_for('discovery.raw' if view=='raw' else 'discovery.get',args, + app.settings.service_url,signer=reader[0],subject=reader[1],certificates=certs, + expires_at=NOW+timedelta(seconds=60)) + auth={'x-msg-request':b64(canonical(packet))} + first=await http.get(path,headers=auth) + if expected=='ok': + assert first.status_code==200,(view,version,first.text) + cache[path]=first.headers['etag'] + if view=='raw':assert first.content==b'source-matrix-private' + else: + assert first.status_code==denial_status,(view,version,first.text) + assert first.json()['error']['code']==denial_code + tag=cache[path] + cases=[({},200),({'if-none-match':tag},304)] + if view=='raw': + cases += [({'range':'bytes=0-5'},206), + ({'range':'bytes=0-5','if-range':tag},206), + ({'range':'bytes=0-5','if-range':'"outdated"'},200), + ({'range':'bytes=999999-'},416)] + for method in ('GET','HEAD'): + for extra,status in cases: + response=await http.request(method,path,headers={**auth,**extra}) + assert response.status_code==(status if expected=='ok' else denial_status),( + method,view,version,extra,response.status_code,response.text) + if method=='HEAD':assert response.content==b'' + if expected!='ok': + assert not any(name in response.headers for name in ( + 'etag','location','content-range','content-disposition')) + assert 'source-matrix-private' not in response.text + elif status==206: + assert response.headers['content-range']=='bytes 0-5/21' + assert response.headers['content-length']=='6' + if method=='GET':assert response.content==b'source' diff --git a/tests/test_achievement_pins.py b/tests/test_achievement_pins.py index 340cd22f..5b451579 100644 --- a/tests/test_achievement_pins.py +++ b/tests/test_achievement_pins.py @@ -71,3 +71,72 @@ async def test_foreign_revoked_or_incomplete_pin_requests_do_not_leak_or_mutate( assert not public.data['pinned_grant_ids'] rejected=await call(app,'achievement.pin',{'grant_id':grants[0]['id']},key=owner_key,subject=owner) assert rejected.status=='error' and rejected.error.code=='achievement_grant_not_found' + + +@pytest.mark.asyncio +async def test_custodial_pin_changes_are_signed_audited_and_keep_grants(installed): + import os + from msg.core.codec import b64, decode, loads, unb64 + from msg.core.models import Signature + from msg.security.crypto import verify + app,_=installed + created=await call(app,'identity.custodial_create',{ + 'handle':'custodial-pin-owner','nonce':b64(os.urandom(32)), + 'recovery_secret':b64(os.urandom(32))},contract_version=2) + assert created.status=='ok',wire(created) + subject=created.data['subject_id'] + token=(created.data['credential_id'],unb64(created.data['token'])) + grants=await _fixture_grants(app,subject) + ids=[g['id'] for g in grants] + async with app.metadata.transaction(write=False) as tx: + before=(tx.rows('SELECT body FROM certificates ORDER BY id'), + tx.rows('SELECT body FROM credentials ORDER BY id')) + first=await call(app,'achievement.pin',{'grant_id':ids[0]},subject=subject,token=token,rid='custodial-pin-first') + assert first.status=='ok' and first.data['signature_source']=='custodial',wire(first) + replay=await call(app,'achievement.pin',{'grant_id':ids[0]},subject=subject,token=token,rid='custodial-pin-first') + assert replay.status=='ok' and replay.replayed + second=await call(app,'achievement.pin',{'grant_id':ids[1]},subject=subject,token=token) + assert second.status=='ok',wire(second) + reordered=await call(app,'achievement.reorder',{'grant_ids':ids[::-1]},subject=subject,token=token) + assert reordered.status=='ok' and list(reordered.data['pinned_grant_ids'])==ids[::-1] + removed=await call(app,'achievement.unpin',{'grant_id':ids[0]},subject=subject,token=token) + assert removed.status=='ok' and list(removed.data['pinned_grant_ids'])==[ids[1]] + async with app.metadata.transaction(write=False) as tx: + assert (tx.rows('SELECT body FROM certificates ORDER BY id'), + tx.rows('SELECT body FROM credentials ORDER BY id'))==before + assert [row[0] for row in tx.rows('SELECT body FROM achievement_grants ORDER BY id')]==[ + canonical(g).decode() for g in grants] + audits=[loads(row[0]) for row in tx.rows('SELECT body FROM audit')] + displays=[row['event'] for row in audits if row['event']['type']=='achievement.display.update'] + assert len(displays)==4 + for event in displays: + data=event['data'] + assert data['signature_source']=='custodial' and data['auth_method']=='token' + assert data['confirmation']['subject_id']==subject + assert data['confirmation']['request_id']==event['request_id'] + credential=await tx.credential(data['signature']['key_id']) + verify(credential.verifier,canonical(data['confirmation']), + decode(Signature,data['signature']),purpose='achievement-display') + async with app.metadata.transaction(write=True) as tx: + tx.execute("UPDATE custodial_vault SET status='decrypt_only',signing_nonce=NULL,signing_ciphertext=NULL WHERE subject=?",(subject,),write=True) + denied=await call(app,'achievement.unpin',{'grant_id':ids[1]},subject=subject,token=token) + assert denied.status=='error' and denied.error.code=='custodial_vault_unavailable' + public=await call(app,'achievement.list',{'subject_id':subject}) + assert list(public.data['pinned_grant_ids'])==[ids[1]] + assert 'confirmation' not in public.data and 'signature_source' not in public.data + private=await call(app,'discovery.get',{'id':'/private'},subject=subject,token=token) + assert private.status=='error' + + +@pytest.mark.asyncio +async def test_temporary_token_still_cannot_sign_honor_display(installed): + from msg.core.codec import unb64 + from test_service import temporary_v3_args + app,_=installed + args,rid,_,_=temporary_v3_args() + created=await call(app,'identity.temporary',args,rid=rid,contract_version=3) + assert created.status=='ok',wire(created) + subject=created.data['subject_id'] + token=(created.data['credential_id'],unb64(created.data['token'])) + denied=await call(app,'achievement.pin',{'grant_id':'achg_absent'},subject=subject,token=token) + assert denied.status=='error' and denied.error.code=='signature_required' diff --git a/tests/test_achievements.py b/tests/test_achievements.py index d7dd183e..1aaa133c 100644 --- a/tests/test_achievements.py +++ b/tests/test_achievements.py @@ -134,3 +134,111 @@ async def test_total_ttl_and_replayed_nonce(installed): 'statement': FINAL_STATEMENT, }, key=key, subject=subject) assert expired.data['reason'] == 'ceremony_expired' + + +@pytest.mark.asyncio +async def test_expired_round_can_restart_without_submitting_old_answer(installed): + app, _ = installed + key, subject, _ = await register(app, 'achievement-round-restart') + started = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert started.status == 'ok', wire(started) + app.executor.clock = lambda: NOW + timedelta(seconds=59) + live = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert live.status == 'error' and live.error.code == 'achievement_ceremony_active' + + # At the inclusive round deadline no answer can succeed. Restart must not + # require a doomed answer or waiting for the longer ceremony deadline. + app.executor.clock = lambda: NOW + timedelta(seconds=60) + restarted = await call(app, 'achievement.start', {}, key=key, subject=subject) + assert restarted.status == 'ok', wire(restarted) + assert restarted.data['round'] == 1 + assert restarted.data['challenge_id'] != started.data['challenge_id'] + assert restarted.data['nonce'] != started.data['nonce'] + stale = await advance(app, key, subject, started.data) + assert stale.status == 'error' and stale.error.code == 'achievement_ceremony_not_found' + advanced = await advance(app, key, subject, restarted.data) + assert advanced.status == 'ok' and advanced.data['round'] == 2 + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_grants WHERE subject=?', (subject,))[0] == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('retired', [False, True]) +async def test_custodial_ceremony_signs_final_request_and_labels_source(installed, retired): + import os + from msg.core.codec import b64, canonical, decode, unb64 + from msg.core.models import Signature + from msg.security.crypto import verify + + app, _ = installed + created = await call(app, 'identity.custodial_create', { + 'handle': 'achievement-custodial', 'nonce': b64(os.urandom(32)), + 'recovery_secret': b64(os.urandom(32)), + }, contract_version=2) + assert created.status == 'ok', wire(created) + subject = created.data['subject_id'] + token = (created.data['credential_id'], unb64(created.data['token'])) + challenge = await call(app, 'achievement.start', {}, subject=subject, token=token) + assert challenge.status == 'ok', wire(challenge) + for _ in range(4): + data = challenge.data + challenge = await call(app, 'achievement.answer', { + 'challenge_id': data['challenge_id'], 'round': data['round'], + 'question_digest': data['question_digest'], 'nonce': data['nonce'], + 'answer': answer_for(data), + }, subject=subject, token=token) + assert challenge.status == 'ok', wire(challenge) + data = challenge.data + args = {name: data[name] for name in ('challenge_id', 'question_digest', 'nonce', 'ceremony_digest')} + args['statement'] = FINAL_STATEMENT + if retired: + async with app.metadata.transaction(write=True) as tx: + tx.execute("UPDATE custodial_vault SET status='decrypt_only', signing_nonce=NULL, signing_ciphertext=NULL WHERE subject=?", (subject,), write=True) + finished = await call(app, 'achievement.finish', args, subject=subject, token=token) + if retired: + assert finished.status == 'error' and finished.error.code == 'custodial_vault_unavailable' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_grants WHERE subject=?', (subject,))[0] == 0 + return + assert finished.status == 'ok', wire(finished) + assert finished.data['signature_source'] == 'custodial' + grant = finished.data['grant'] + assert grant['auth_method'] == 'token' + assert grant['metadata'] == {'protocol_passed': True, 'signature_source': 'custodial'} + async with app.metadata.transaction(write=False) as tx: + state = loads(tx.one('SELECT body FROM achievement_ceremonies WHERE id=?', (data['challenge_id'],))[0]) + credential = await tx.credential(state['final_signature']['key_id']) + verify(credential.verifier, canonical(state['final_confirmation']), + decode(Signature, state['final_signature']), purpose='achievement-confirmation') + assert state['final_confirmation']['ceremony_digest'] == data['ceremony_digest'] + audit = [loads(row[0]) for row in tx.rows('SELECT body FROM audit')] + issued = [row for row in audit if row['event']['type'] == 'achievement.auto.issue'] + assert len(issued) == 1 + assert issued[0]['event']['data']['signature_source'] == 'custodial' + assert issued[0]['event']['data']['auth_method'] == 'token' + repeat = await call(app, 'achievement.start', {}, subject=subject, token=token) + assert repeat.status == 'error' and repeat.error.code == 'achievement_already_granted' + private = await call(app, 'discovery.get', {'id': '/private'}, subject=subject, token=token) + assert private.status == 'error' + + +@pytest.mark.asyncio +async def test_non_custodial_token_cannot_finish_ceremony(installed): + from msg.core.codec import unb64 + from test_service import temporary_v3_args + app, _ = installed + args, rid, _, _ = temporary_v3_args() + created = await call(app, 'identity.temporary', args, rid=rid, contract_version=3) + assert created.status == 'ok', wire(created) + subject = created.data['subject_id'] + token = (created.data['credential_id'], unb64(created.data['token'])) + started = await call(app, 'achievement.start', {}, subject=subject, token=token) + assert started.status == 'ok', wire(started) + denied = await call(app, 'achievement.finish', { + 'challenge_id': started.data['challenge_id'], + 'question_digest': started.data['question_digest'], 'nonce': started.data['nonce'], + 'ceremony_digest': 'sha256:' + '0' * 64, 'statement': FINAL_STATEMENT, + }, subject=subject, token=token) + assert denied.status == 'error' and denied.error.code == 'signature_required' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_grants WHERE subject=?', (subject,))[0] == 0 diff --git a/tests/test_attachment_http_cache.py b/tests/test_attachment_http_cache.py new file mode 100644 index 00000000..ec6d036c --- /dev/null +++ b/tests/test_attachment_http_cache.py @@ -0,0 +1,41 @@ +"""An authorized parent/LinkSet never makes a revoked attachment cache valid.""" +import httpx +from msg.core.codec import b64, wire +from msg.transports.http import create_app +from test_authorization_sources import private_post, grant +from test_share_grants_v2 import invoke, ok +from test_service import register +from read_only_evidence import readonly_evidence +from http_read_cache_matrix import assert_read_cache_matrix + + +async def test_attachment_bytes_history_and_linkset_recheck_current_grant(installed,monkeypatch): + app,_=installed + owner=await register(app,'attachment-cache-owner') + reader=await register(app,'attachment-cache-reader') + post,_=await private_post(app,owner) + source=ok(await invoke(app,owner,'content.file_put',{'parent':'/@attachment-cache-owner/files', + 'name':'payload.txt','data':b64(b'source-matrix-private'),'media_type':'text/plain'})) + async with app.metadata.transaction(write=False) as tx: + generation=(await tx.resource(post)).generation + attached=ok(await invoke(app,owner,'content.attach',{'post':post,'source':wire(source.resources[0])}, + expected=((post,generation),))) + rid=attached.data['attachment']['id'] + async with app.metadata.transaction(write=False) as tx: + revision=(await tx.resource(rid)).revision + await grant(app,owner,post,reader[1]) + child_grant=await grant(app,owner,rid,reader[1]) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + cache={} + async with readonly_evidence(app,monkeypatch): + await assert_read_cache_matrix(http,app,reader,(),rid,revision,'ok',cache) + links=ok(await invoke(app,reader,'discovery.links',{'id':post,'rel':'f'})) + assert rid in str(links.data),wire(links) + ok(await invoke(app,owner,'sharing.revoke',{'grant_id':child_grant},version=2)) + async with readonly_evidence(app,monkeypatch): + await assert_read_cache_matrix(http,app,reader,(),rid,revision,'error',cache) + ok(await invoke(app,reader,'discovery.get',{'id':post})) + links=ok(await invoke(app,reader,'discovery.links',{'id':post,'rel':'f'})) + assert not links.data['items'] and rid not in str(links.data),wire(links) + await assert_read_cache_matrix(http,app,owner,(),rid,revision,'ok',{}) diff --git a/tests/test_authorization_matrix.py b/tests/test_authorization_matrix.py index a9df7482..70965d23 100644 --- a/tests/test_authorization_matrix.py +++ b/tests/test_authorization_matrix.py @@ -14,6 +14,7 @@ from test_authorization_sources import private_post, grant from test_service import NOW, call, register from read_only_evidence import readonly_evidence +from http_read_cache_matrix import assert_read_cache_matrix SOURCES = ('owner','mode','inheritance','group-member','group-maintainer','group-owner', @@ -70,7 +71,7 @@ async def change(operation,args,identity=owner,**kw): elif source == 'share-user': gid = await grant(app,owner,rid,reader[1]) elif source == 'certificate': - ops = ('discovery.get@1','discovery.search@1','communication.sync@1', + ops = ('discovery.get@1','discovery.raw@1','discovery.search@1','communication.sync@1', 'communication.inbox@1','communication.outbox@1') cert = await approve(app,root,reader[1],reader[0],( scoped(app,'resource.read_override',rid,ops),)) @@ -85,8 +86,11 @@ async def change(operation,args,identity=owner,**kw): monkeypatch.setitem(cli.TRANSPORTS,'http',lambda server:HTTPTransport(server,http=http)) monkeypatch.setattr(cli,'MsgClient',lambda saved,adapter:MsgClient(saved,adapter,clock=lambda:NOW)) + http_cache={} + async def matrix(expected): async with readonly_evidence(app, monkeypatch): + await assert_read_cache_matrix(http,app,reader,certs,rid,revision,expected,http_cache) for args in ({'id':rid},{'id':rid,'view':'history'},{'id':rid,'revision':revision}): packet = request_for('discovery.get',args,app.settings.service_url, signer=reader[0],subject=reader[1],certificates=certs,source='msg', diff --git a/tests/test_backup_retirement_cli.py b/tests/test_backup_retirement_cli.py new file mode 100644 index 00000000..25a5014a --- /dev/null +++ b/tests/test_backup_retirement_cli.py @@ -0,0 +1,99 @@ +"""Installed administrative entry and real descriptor boundaries, not console success.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from msg.core.errors import Failure +from msg.daemon import parser + + +@pytest.mark.parametrize('action', ['sign', 'import']) +def test_retirement_command_parser(action, tmp_path): + command = ['root', 'backup-retirement', action, str(tmp_path/'statement.json')] + if action == 'sign': + command.append(str(tmp_path/'record.json')) + args = parser().parse_args(command) + assert args.retirement_command == action + assert args.source == tmp_path/'statement.json' + + +@pytest.mark.parametrize('action', ['sign', 'import']) +def test_retirement_cli_refuses_nonconsole_before_reading_files(action, tmp_path): + command = [str(Path(sys.executable).with_name('msgd')), + '--config-dir', str(tmp_path), 'root', 'backup-retirement', action, + str(tmp_path/'missing.json')] + if action == 'sign': + command.append(str(tmp_path/'output.json')) + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1]/'src')) + for key in ('SSH_CONNECTION', 'SSH_CLIENT', 'SSH_TTY'): + env.pop(key, None) + result = subprocess.run(command, env=env, capture_output=True, text=True) + assert result.returncode == 1 + expected = 'local_os_administrator_required' if os.geteuid() else 'local_console_required' + assert json.loads(result.stderr) == {'status': 'error', 'error': {'code': expected}} + assert result.stdout == '' + assert not (tmp_path/'output.json').exists() + + +@pytest.mark.parametrize('kind', ['symlink', 'hardlink', 'permissions', 'fifo', 'oversize']) +def test_statement_file_boundary(kind, tmp_path): + from msg.admin.backup_retirement import read_statement + path = tmp_path/'statement.json' + path.write_bytes(b'{}') + path.chmod(0o600) + if kind == 'symlink': + target = tmp_path/'target' + path.rename(target) + path.symlink_to(target) + elif kind == 'hardlink': + os.link(path, tmp_path/'other') + elif kind == 'permissions': + path.chmod(0o644) + elif kind == 'fifo': + path.unlink() + os.mkfifo(path, 0o600) + else: + path.write_bytes(b' ' * (1024 * 1024 + 1)) + with pytest.raises(Failure, match='unsafe_root_private_file'): + read_statement(path) + + +def test_statement_file_valid(tmp_path): + from msg.admin.backup_retirement import read_statement + path = tmp_path/'statement.json' + path.write_bytes(b'{"statement": {}}') + path.chmod(0o600) + assert read_statement(path) == {'statement': {}} + + +@pytest.mark.parametrize('existing', [False, True]) +def test_record_output_never_overwrites(existing, tmp_path): + from msg.admin.backup_retirement import write_record + target = tmp_path/'record.json' + if existing: + target.write_bytes(b'preserve') + with pytest.raises(Failure, match='backup_retirement_destination_exists'): + write_record(target, {'statement': {}}) + assert target.read_bytes() == b'preserve' + else: + write_record(target, {'statement': {}}) + assert json.loads(target.read_bytes()) == {'statement': {}} + assert target.stat().st_mode & 0o777 == 0o600 + assert not list(tmp_path.glob('.retirement-*')) + + +def test_record_output_refuses_symlink(tmp_path): + from msg.admin.backup_retirement import write_record + victim = tmp_path/'victim.json' + victim.write_bytes(b'preserve') + target = tmp_path/'record.json' + target.symlink_to(victim) + with pytest.raises(Failure, match='backup_retirement_destination_exists'): + write_record(target, {'statement': {}}) + assert target.is_symlink() + assert victim.read_bytes() == b'preserve' + assert not list(tmp_path.glob('.retirement-*')) diff --git a/tests/test_bounty_snapshot_integrity.py b/tests/test_bounty_snapshot_integrity.py new file mode 100644 index 00000000..d4f65c6b --- /dev/null +++ b/tests/test_bounty_snapshot_integrity.py @@ -0,0 +1,73 @@ +"""A restored mutable bounty projection cannot replace published payout terms.""" +import pytest + +from msg.admin.diagnostics import doctor +from msg.core.codec import canonical, wire +from msg.plugins.money import _balance +from test_market_72 import fixture, proof +from test_service import NOW, call + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field,value', [ + ('reward_minor', 20), ('max_claims', 2), ('claim_limit_per_subject', 2), + ('eligibility', canonical({'kind': 'allowlist', 'subjects': []}).decode()), +]) +async def test_mixed_bounty_projection_cannot_change_published_terms(installed, field, value): + app, root = installed + _, identity, created = await fixture(app, root) + key, buyer = identity + lid = created.data['bounty']['listing_id'] + args = await proof(app, identity, lid) + # Emulate a mixed restore of the materialized accounting projection. The + # immutable Resource revision still commits the publisher's original terms. + async with app.metadata.transaction(write=True) as tx: + tx.execute(f'UPDATE bounty_listings SET {field}=? WHERE listing_id=?', (value, lid), write=True) + result = await call(app, 'bounty.claim', args, key=key, subject=buyer) + assert result.status == 'error', wire(result) + async with app.metadata.transaction(write=False) as tx: + assert _balance(tx, buyer) == 0 + assert tx.one('SELECT COUNT(*) FROM bounty_claims')[0] == 0 + assert tx.one('SELECT consumed_at FROM bounty_challenges WHERE id=?', + (args['challenge_id'],))[0] is None + for operation, params in [('bounty.get', {'listing_id': lid}), + ('store.listing_get', {'id': lid})]: + read = await call(app, operation, params) + assert read.status == 'error' and read.error.code == 'bounty_contract_mismatch', wire(read) + + +@pytest.mark.asyncio +async def test_doctor_rejects_mixed_bounty_terms_without_repair(installed): + app, root = installed + _, _, created = await fixture(app, root) + lid = created.data['bounty']['listing_id'] + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE bounty_listings SET reward_minor=20 WHERE listing_id=?', (lid,), write=True) + before = tx.rows('SELECT * FROM money_ledger ORDER BY id') + report = doctor(app.settings.config_dir, clock=lambda: NOW) + assert report['checks']['market_clearing'] == {'ok': False, 'code': 'bounty_contract_mismatch'} + async with app.metadata.transaction(write=False) as tx: + assert tx.rows('SELECT * FROM money_ledger ORDER BY id') == before + assert tx.one('SELECT reward_minor FROM bounty_listings WHERE listing_id=?', (lid,))[0] == 20 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation,extra', [ + ('bounty.top_up', {'amount_minor': 1}), ('bounty.close', {}), + ('bounty.pause', {}), ('bounty.resume', {}), ('bounty.challenge', {}), +]) +async def test_mixed_terms_block_management_without_ledger_changes(installed, operation, extra): + app, root = installed + publisher, identity, created = await fixture(app, root) + lid = created.data['bounty']['listing_id'] + key, subject = identity if operation == 'bounty.challenge' else publisher + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE bounty_listings SET reward_minor=20 WHERE listing_id=?', (lid,), write=True) + before = tx.rows('SELECT * FROM money_ledger ORDER BY id') + before_listing = tx.one('SELECT * FROM bounty_listings WHERE listing_id=?', (lid,)) + result = await call(app, operation, {'listing_id': lid, **extra}, key=key, subject=subject) + assert result.status == 'error' and result.error.code == 'bounty_contract_mismatch', wire(result) + async with app.metadata.transaction(write=False) as tx: + assert tx.rows('SELECT * FROM money_ledger ORDER BY id') == before + assert tx.one('SELECT * FROM bounty_listings WHERE listing_id=?', (lid,)) == before_listing + assert tx.one('SELECT COUNT(*) FROM bounty_challenges')[0] == 0 diff --git a/tests/test_cli_search_grep.py b/tests/test_cli_search_grep.py index 0f0dc3a8..1a6813cb 100644 --- a/tests/test_cli_search_grep.py +++ b/tests/test_cli_search_grep.py @@ -1,13 +1,14 @@ """Search and grep CLI use the signed operations and explicit bounded pages.""" import httpx import pytest +from test_service import NOW from msg import cli from msg.client import ClientState, MsgClient from msg.core.codec import loads +from msg.core.errors import Failure from msg.transports.client import HTTPTransport from msg.transports.http import create_app -from test_service import NOW @pytest.mark.asyncio @@ -48,6 +49,47 @@ async def invoke(*command): assert status==0 and counted['data']['count']==2 +@pytest.mark.asyncio +async def test_cli_v5_search_flags_typed_scope_and_cursor_use_signed_contract(installed,tmp_path,monkeypatch,capsys): + from msg.core.codec import canonical + app,_=installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + directory=tmp_path/'client-v5' + client=MsgClient(ClientState(directory,server=app.settings.service_url), + HTTPTransport(app.settings.service_url,http=http),clock=lambda:NOW) + assert (await client.register('cli-search-v5')).status=='ok' + refs=[] + for name in ('gadget-one','gadget-two'): + created=await client.call('content.post_create',{'parent':'/main','name':name,'body':'body'}) + assert created.status=='ok' + refs.append({'id':created.resources[0].id}) + monkeypatch.setitem(cli.TRANSPORTS,'http',lambda server:HTTPTransport(server,http=http)) + monkeypatch.setattr(cli,'MsgClient',lambda state,transport:MsgClient(state,transport,clock=lambda:NOW)) + async def invoke(*command): + args=cli.parser().parse_args(['--config-dir',str(directory),'--server',app.settings.service_url,*command]) + status=await cli.run(args) + return status,loads(capsys.readouterr().out.encode().strip()) + scope=canonical({'resource_refs':refs}).decode() + status,first=await invoke('search',scope,'gadget','--field','title','--limit','1', + '--facet','type','--spell','--fields','id,title') + assert status==0 and first['status']=='ok',first + assert first['data']['spelling']['source']=='name' + assert 'title' in first['data']['items'][0] + assert first['data']['facets']=={'type':[{'value':'post','count':2}]} + status,second=await invoke('search','--cursor',first['data']['cursor']) + assert status==0 and second['status']=='ok',second + assert first['data']['items'][0]['id']!=second['data']['items'][0]['id'] + with pytest.raises(Failure, match='cursor_query_mismatch'): + await invoke('search','--cursor',first['data']['cursor'],'--spell') + payload,tag=first['data']['cursor'].split('.') + tampered=payload+'.'+('A' if tag[0]!='A' else 'B')+tag[1:] + status,denied=await invoke('search','--cursor',tampered) + assert status!=0 and denied['error']['code']=='invalid_cursor' + status,negative=await invoke('search',scope,'gadget','--no-replies') + assert status==0 and len(negative['data']['items'])==2 + + def test_cli_search_grep_reject_unbounded_or_mixed_options(): p=cli.parser() parsed=p.parse_args(['search','--cursor','opaque','--limit','100']) diff --git a/tests/test_client_boundary.py b/tests/test_client_boundary.py new file mode 100644 index 00000000..02e46139 --- /dev/null +++ b/tests/test_client_boundary.py @@ -0,0 +1,123 @@ +"""Public helper ownership, unchanged bytes and supported client/server installs.""" +from __future__ import annotations + +import hashlib +import hmac +import importlib.util +from pathlib import Path +import subprocess +import sys +import tomllib + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def test_result_serialization_has_one_protocol_owner(): + from msg.core.codec import result_wire + from msg.core.executor import result_wire as compatibility_export + from msg.core.models import OperationResult + from msg.cli import result_wire as client_export + + assert result_wire is compatibility_export is client_export + assert result_wire.__module__ == 'msg.core.codec' + result = OperationResult(request_id='r', operation='content.read', status='ok', actor=None, + subject=None, resources=(), data={'text': 'plain'}) + value = result_wire(result) + assert value['data'] == {'text': 'plain'} + assert 'replayed' not in value and 'prefer_cli' not in value + + +def test_atomic_file_owner_keeps_compatibility_and_failed_replace_protection(tmp_path, monkeypatch): + from msg.atomic_file import durable_write + from msg.storage.git import durable_write as compatibility_export + import msg.atomic_file as atomic + + assert durable_write is compatibility_export + path = tmp_path / 'state' / 'identity' + durable_write(path, b'first') + assert path.read_bytes() == b'first' and path.stat().st_mode & 0o777 == 0o600 + def fail_replace(*args): + raise OSError('simulated replace refusal') + monkeypatch.setattr(atomic.os, 'replace', fail_replace) + with pytest.raises(OSError, match='simulated replace refusal'): + durable_write(path, b'not committed') + assert path.read_bytes() == b'first' + assert list(path.parent.iterdir()) == [path] + + +def test_custodial_proof_and_statements_are_shared_without_vault_state(): + from msg.security.custodial_protocol import ( + UPGRADE_CONTEXT_FIELDS, _proof, client_upgrade_proof, stage_statement, ack_statement) + from msg.security.vault import client_upgrade_proof as old_proof + from msg.security.custodial_migration import stage_statement as old_stage, ack_statement as old_ack + from msg.core.codec import b64, canonical + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + assert client_upgrade_proof is old_proof + assert stage_statement is old_stage and ack_statement is old_ack + assert client_upgrade_proof.__module__ == 'msg.security.custodial_protocol' + expected_fields = ('subject_id', 'challenge_id', 'request_id', 'handle', 'public_key', + 'encryption_recipient', 'server_public', 'nonce', 'expires_at') + assert UPGRADE_CONTEXT_FIELDS == expected_fields + challenge = {name: name + '-fixture' for name in expected_fields} + challenge['nonce'] = b64(bytes(range(32))) + challenge['not_signed'] = 'not part of this published proof context' + context = {name: challenge[name] for name in expected_fields} + shared = bytes(range(32, 64)) + key = HKDF(algorithm=hashes.SHA256(), length=32, salt=bytes(range(32)), + info=b'msg-custodial-upgrade-pop-v1').derive(shared) + expected = b64(hmac.digest(key, canonical(context), 'sha256')) + assert _proof(shared, challenge) == expected + assert stage_statement('u', {'choice': 'rewrap', 'migration_ack': 'omit'}, 'r') == { + 'domain': 'msg-custodial-decision-v1', 'subject_id': 'u', 'request_id': 'r', + 'decision': {'choice': 'rewrap'}} + assert ack_statement('u', 'c', 'd', {'id': 'old'}, {'id': 'new'}, 'p', 'r') == { + 'domain': 'msg-custodial-history-ack-v1', 'subject_id': 'u', 'challenge_id': 'c', + 'inventory_digest': 'd', 'source': {'id': 'old'}, 'target': {'id': 'new'}, + 'method': 'rewrap', 'plaintext_digest': 'p', 'request_id': 'r'} + + +def test_mcp_client_uses_neutral_version_declaration(): + from msg.transports.mcp_protocol import PROTOCOL_VERSION, SUPPORTED_VERSIONS + from msg.transports import mcp + assert PROTOCOL_VERSION == mcp.PROTOCOL_VERSION == '2025-11-25' + assert SUPPORTED_VERSIONS is mcp.SUPPORTED_VERSIONS + assert SUPPORTED_VERSIONS == frozenset({'2025-11-25', '2025-06-18', '2025-03-26'}) + + +def test_fresh_client_import_guard_covers_actual_four_transport_calls(): + completed = subprocess.run([sys.executable, str(ROOT / 'scripts/check_client_install.py')], + capture_output=True, text=True, timeout=30) + assert completed.returncode == 0, completed.stdout + completed.stderr + import json + report = json.loads(completed.stdout) + assert report['transports'] == ['graphql', 'http', 'mcp_http', 'path_get'] + assert report['server_implementation_imported'] is False + + +def test_install_metadata_separates_roles_but_dev_keeps_full_server(): + project = tomllib.loads((ROOT / 'pyproject.toml').read_text())['project'] + def name(requirement): + return requirement.split('>=')[0].split('[')[0] + server_names = {'starlette', 'uvicorn', 'psycopg', 'valkey', 'aiohttp', 'dnspython', 'graphql-core'} + assert not server_names & {name(value) for value in project['dependencies']} + extras = project['optional-dependencies'] + assert server_names <= {name(value) for value in extras['server']} + assert 'msg-lmm-best[server]' in extras['dev'] + assert project['scripts'] == {'msg': 'msg.cli:main', 'msgd': 'msg.daemon:main'} + + +def test_missing_server_dependencies_are_reported_before_local_state(tmp_path, monkeypatch, capsys): + import msg.daemon as daemon + original = importlib.util.find_spec + monkeypatch.setattr(importlib.util, 'find_spec', + lambda name, *args, **kwargs: None if name == 'psycopg' else original(name, *args, **kwargs)) + directory = tmp_path / 'not-created' + assert daemon.main(['--config-dir', str(directory), 'serve']) == 2 + captured = capsys.readouterr() + assert 'server_dependencies_required' in captured.err + assert 'msg-lmm-best[server]' in captured.err + assert not directory.exists() diff --git a/tests/test_client_part_bytes.py b/tests/test_client_part_bytes.py new file mode 100644 index 00000000..6d5f49e0 --- /dev/null +++ b/tests/test_client_part_bytes.py @@ -0,0 +1,127 @@ +"""Invalid local chunk sizes cannot touch resumable state or cause zero progress.""" +import hashlib + +import httpx +import pytest + +from msg.client import ClientState, MsgClient +from msg.core.codec import loads +from msg.core.errors import Failure +from msg.transports.client import ( + GraphQLTransport, HTTPTransport, MCPHTTPTransport, PathGETTransport, +) +from msg.transports.http import create_app +from read_only_evidence import readonly_evidence +from test_service import NOW + + +INVALID_PART_BYTES = (0, -1, False, True, 1.5, '1', None) +TRANSPORTS = (HTTPTransport, PathGETTransport, GraphQLTransport, MCPHTTPTransport) + + +class InterruptedDownload(Exception): + pass + + +def local_snapshot(directory, partial): + paths = sorted(p for p in directory.rglob('*') if p.is_file()) + if partial.exists(): + paths.append(partial) + # Hash fixture keys rather than printing their contents in assertion diffs. + return {str(p): (hashlib.sha256(p.read_bytes()).hexdigest(), + p.stat().st_mode, p.stat().st_mtime_ns) for p in paths} + + +async def start_interrupted_download(app, tmp_path, http, monkeypatch): + state = ClientState(tmp_path / 'client', server=app.settings.service_url) + transport = HTTPTransport(app.settings.service_url, http=http) + client = MsgClient(state, transport, clock=lambda: NOW) + registered = await client.register('part-size-agent') + assert registered.status == 'ok' + source = tmp_path / 'source.bin' + source.write_bytes(b'resume\x00exact bytes\n') + sealed = await client.upload(source, part_bytes=7) + destination = tmp_path / 'destination.bin' + actual_call, parts = transport.call, 0 + + async def interrupt_second_part(packet): + nonlocal parts + if packet.operation == 'transfer.part_get': + parts += 1 + if parts == 2: + raise InterruptedDownload() + return await actual_call(packet) + + with monkeypatch.context() as patch: + patch.setattr(transport, 'call', interrupt_second_part) + with pytest.raises(InterruptedDownload): + await client.download(sealed.output, destination, part_bytes=3) + partial = destination.with_name(destination.name + '.msg-part') + assert partial.read_bytes() == source.read_bytes()[:3] + journals = list(state.directory.glob('download-*.json')) + assert len(journals) == 1 and loads(journals[0].read_bytes())['offset'] == 3 + assert not destination.exists() + return client, sealed.output, source, destination, partial + + +@pytest.mark.parametrize('part_bytes', INVALID_PART_BYTES) +async def test_resume_rejects_invalid_local_chunk_size_before_state_or_network( + installed, tmp_path, monkeypatch, part_bytes): + app, _ = installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + client, ref, _, destination, partial = await start_interrupted_download( + app, tmp_path, http, monkeypatch) + before = local_snapshot(client.state.directory, partial) + actual_call, requests = client.transport.call, [] + + async def bounded_real_calls(packet): + requests.append((packet.operation, dict(packet.arguments))) + # Let the original bug perform two actual signed zero-byte reads, + # then stop deterministically instead of hanging the CI worker. + if len(requests) > 2: + raise AssertionError(f'non-progressing download requests: {requests}') + return await actual_call(packet) + + with monkeypatch.context() as patch: + patch.setattr(client.transport, 'call', bounded_real_calls) + async with readonly_evidence(app, monkeypatch): + with pytest.raises(Failure, match='^invalid_part_bytes$'): + await client.download(ref, destination, part_bytes=part_bytes) + assert requests == [] + assert local_snapshot(client.state.directory, partial) == before + assert not destination.exists() + + +@pytest.mark.parametrize('part_bytes', INVALID_PART_BYTES) +async def test_upload_rejects_invalid_local_chunk_size_before_source_access(tmp_path, part_bytes): + def unexpected_network(request): + raise AssertionError('invalid local arguments reached the network') + + async with httpx.AsyncClient(transport=httpx.MockTransport(unexpected_network)) as http: + state = ClientState(tmp_path / 'client', server='http://testserver') + client = MsgClient(state, HTTPTransport(state.server, http=http), clock=lambda: NOW) + missing_source = tmp_path / 'does-not-exist.bin' + before = local_snapshot(state.directory, missing_source) + with pytest.raises(Failure, match='^invalid_part_bytes$'): + await client.upload(missing_source, part_bytes=part_bytes) + assert local_snapshot(state.directory, missing_source) == before + assert not missing_source.exists() + + +@pytest.mark.parametrize('transport_class', TRANSPORTS) +async def test_download_resume_with_valid_chunk_size_preserves_real_bytes( + installed, tmp_path, monkeypatch, transport_class): + app, _ = installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + client, ref, source, destination, partial = await start_interrupted_download( + app, tmp_path, http, monkeypatch) + resumed = MsgClient(client.state, transport_class(app.settings.service_url, http=http), + clock=lambda: NOW) + async with readonly_evidence(app, monkeypatch): + result = await resumed.download(ref, destination, part_bytes=1) + assert destination.read_bytes() == source.read_bytes() + assert result['size'] == source.stat().st_size + assert not partial.exists() + assert not list(client.state.directory.glob('download-*.json')) diff --git a/tests/test_collaboration_current_visibility.py b/tests/test_collaboration_current_visibility.py new file mode 100644 index 00000000..ed94a299 --- /dev/null +++ b/tests/test_collaboration_current_visibility.py @@ -0,0 +1,55 @@ +"""Collaboration views obey current visibility, including after target moves.""" +import pytest + +from msg.core.codec import wire +from test_service import call, register + + +@pytest.mark.asyncio +async def test_presence_rechecks_current_subject_permissions(installed): + app, _ = installed + key, subject, _ = await register(app, 'private-presence') + created = await call(app, 'communication.presence_set', + {'state': 'busy', 'message': 'private activity'}, + key=key, subject=subject) + assert created.status == 'ok', wire(created) + assert (await call(app, 'communication.presence_get', + {'subject_id': subject})).data['message'] == 'private activity' + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(subject)).generation + changed = await call(app, 'content.chmod', {'id': subject, 'mode': '0700'}, + key=key, subject=subject, expected=((subject, generation),)) + assert changed.status == 'ok', changed.error + hidden = await call(app, 'communication.presence_get', {'subject_id': subject}) + assert hidden.status == 'error', wire(hidden) + assert 'private activity' not in str(wire(hidden)) + own = await call(app, 'communication.presence_get', {'subject_id': subject}, + key=key, subject=subject) + assert own.data['message'] == 'private activity' + + +@pytest.mark.asyncio +async def test_handoff_filters_references_moved_to_forbidden_namespace(installed): + app, _ = installed + key, subject, _ = await register(app, 'handoff-move-owner') + other_key, other, _ = await register(app, 'handoff-move-other') + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'Work'}, + key=key, subject=subject) + rid = post.resources[0].id + created = await call(app, 'communication.handoff_create', + {'to_subject': other, 'resource_refs': [rid]}, + key=key, subject=subject) + hid = created.data['handoff']['id'] + moved = await call(app, 'content.move', + {'id': rid, 'parent': subject, 'name': 'todos'}, + key=key, subject=subject, + expected=((rid, post.data['generation']),)) + assert moved.status == 'ok', moved.error + for actor_key, actor in ((key, subject), (other_key, other)): + got = await call(app, 'communication.handoff_get', {'id': hid}, + key=actor_key, subject=actor) + assert got.status == 'ok', wire(got) + assert not got.data['handoff']['resource_refs'] + listed = await call(app, 'communication.handoff_list', {}, + key=actor_key, subject=actor) + assert not listed.data['items'][0]['resource_refs'] diff --git a/tests/test_collaboration_list_bounds.py b/tests/test_collaboration_list_bounds.py new file mode 100644 index 00000000..28069848 --- /dev/null +++ b/tests/test_collaboration_list_bounds.py @@ -0,0 +1,108 @@ +"""Real signed Resource-backed work lists must never materialize an unbounded query.""" +from contextlib import asynccontextmanager + +import pytest +from read_only_evidence import readonly_evidence +from test_service import call, register + +from msg.core.codec import wire +from msg.plugins.collaboration_resources import KINDS + + +async def create_records(app, key, subject, kind, count): + arguments = { + 'request': {'title': 'Review', 'description': 'Bounded work', 'requirements': 'Read tests'}, + 'offer': {'description': 'Review', 'scope': 'Python', 'availability': 'Today'}, + 'checkpoint': {'summary': 'Resume', 'resource_refs': []}, + } + if kind == 'proposal': + posts = [] + for body in ('Before', 'After'): + post = await call(app, 'content.post_create', {'parent': '/main', 'body': body}, + key=key, subject=subject) + assert post.status == 'ok', str(wire(post)) + posts.append(post.resources[0]) + arguments[kind] = {'target': posts[0].id, 'base_revision': posts[0].revision, + 'content_ref': wire(posts[1]), 'message': 'Review bounded proposal'} + identifiers = [] + for _ in range(count): + made = await call(app, 'communication.' + kind + '_create', arguments[kind], + key=key, subject=subject) + assert made.status == 'ok', str(wire(made)) + identifiers.append(made.data['id']) + return identifiers + + +@asynccontextmanager +async def observe_batches(app, monkeypatch): + transaction = app.metadata.transaction + batches, violations = [], [] + + @asynccontextmanager + async def checked_transaction(*, write): + assert write is False + async with transaction(write=False) as tx: + rows = tx.rows + + def checked_rows(sql, parameters=()): + work_query = sql.startswith('SELECT id FROM resources WHERE owner=? AND type=? ') + if work_query and ' LIMIT ' not in sql.upper(): + violations.append(sql) + raise AssertionError('unbounded collaboration Resource SQL') + result = rows(sql, parameters) + if work_query: + batches.append(len(result)) + if len(result) > 128: + violations.append('batch size=' + str(len(result))) + raise AssertionError('oversized collaboration Resource batch') + return result + + with monkeypatch.context() as patch: + patch.setattr(tx, 'rows', checked_rows) + yield tx + + with monkeypatch.context() as patch: + patch.setattr(app.metadata, 'transaction', checked_transaction) + yield batches, violations + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind,count,limit', [ + ('request', 3, 1), ('offer', 3, 1), ('checkpoint', 3, 1), ('proposal', 3, 1), + ('checkpoint', 129, 64), +]) +async def test_work_lists_use_bounded_sql_and_actual_continuations( + installed, monkeypatch, kind, count, limit): + app, _ = installed + key, subject, _ = await register(app, 'work-list-bounds') + ids = await create_records(app, key, subject, kind, count) + other_key, other, _ = await register(app, 'other-work-owner') + other_ids = await create_records(app, other_key, other, kind, 1) + async with app.metadata.transaction(write=False) as tx: + expected = [row[0] for row in tx.rows( + "SELECT id FROM resources WHERE owner=? AND type=? AND state='active' ORDER BY id", + (subject, KINDS[kind]))] + assert set(expected) == set(ids) + async with readonly_evidence(app, monkeypatch): + async with observe_batches(app, monkeypatch) as (batches, violations): + seen, after = [], None + for offset in range(0, count, limit): + args = {'limit': limit, **({'after': after} if after is not None else {})} + page = await call(app, 'communication.' + kind + '_list', args, + key=key, subject=subject) + assert not violations, 'unbounded work-list SQL: ' + repr(violations) + assert page.status == 'ok', str(wire(page)) + ids_on_page = [item['id'] for item in page.data['items']] + assert ids_on_page == expected[offset:offset + limit] + seen.extend(ids_on_page) + more = offset + limit < count + after = page.data['next_after'] + assert after == (ids_on_page[-1] if more else None) + assert all(identifier not in str(wire(page)) for identifier in other_ids) + assert seen == expected and len(set(seen)) == count + assert batches and max(batches) <= 128 + empty = await call(app, 'communication.' + kind + '_list', + {'limit': limit, 'after': expected[-1]}, key=key, subject=subject) + assert not violations, repr(violations) + assert empty.status == 'ok', str(wire(empty)) + assert not empty.data['items'] and empty.data['next_after'] is None diff --git a/tests/test_collaboration_list_deadline.py b/tests/test_collaboration_list_deadline.py new file mode 100644 index 00000000..0535a8fb --- /dev/null +++ b/tests/test_collaboration_list_deadline.py @@ -0,0 +1,35 @@ +"""Work-list deadlines are enforced before scanning and before projecting rows.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from read_only_evidence import readonly_evidence +from test_collaboration_list_bounds import create_records, observe_batches +from test_service import call, register + +from msg.plugins import collaboration_resources + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['request', 'offer', 'checkpoint', 'proposal']) +@pytest.mark.parametrize('stage', ['before_query', 'after_query']) +async def test_expired_work_list_budget_never_projects_content(installed, monkeypatch, kind, stage): + app, _ = installed + key, subject, _ = await register(app, 'work-list-deadline') + await create_records(app, key, subject, kind, 1) + # Only the work-list handler's clock changes. Authentication, recovery, + # principal selection, database reads and target ownership remain real. + readings = iter([float('inf')] if stage == 'before_query' else [0.0, float('inf')]) + projection = AsyncMock(side_effect=AssertionError('expired request projected work content')) + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + patch.setattr(collaboration_resources, 'time', + SimpleNamespace(monotonic=lambda: next(readings, float('inf')))) + patch.setattr(collaboration_resources, '_project', projection) + async with observe_batches(app, monkeypatch) as (batches, violations): + result = await call(app, 'communication.' + kind + '_list', {'limit': 1}, + key=key, subject=subject) + assert not violations, repr(violations) + assert result.status == 'error' and result.error.code == 'query_cost_exceeded' + assert len(batches) == (0 if stage == 'before_query' else 1) + projection.assert_not_called() diff --git a/tests/test_collaboration_resource_surfaces.py b/tests/test_collaboration_resource_surfaces.py new file mode 100644 index 00000000..dddf2126 --- /dev/null +++ b/tests/test_collaboration_resource_surfaces.py @@ -0,0 +1,89 @@ +"""CLI and canonical subject views execute the same collaboration contracts.""" +import httpx +import pytest +from test_collaboration_surfaces import _headers +from test_service import NOW, call, register + +from msg import cli +from msg.client import ClientState, MsgClient +from msg.core.codec import loads +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_work_record_paths_are_read_only_current_acl_views(installed): + app, _ = installed + key, subject, _ = await register(app, 'work-paths') + commands = { + 'request': {'title': 'Work', 'description': 'Read', 'requirements': 'Explicit'}, + 'offer': {'description': 'Review', 'scope': 'Code', 'availability': 'Today'}, + 'checkpoint': {'summary': 'Resume', 'resource_refs': []}, + } + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + for kind, args in commands.items(): + made = await call(app, 'communication.' + kind + '_create', args, key=key, subject=subject) + rid = made.data['id'] + prefix = '/@work-paths/' + kind + 's' + headers = _headers(app, 'communication.' + kind + '_list', {}, key, subject) + listed = await http.get(prefix, headers=headers) + assert listed.status_code == 200, listed.text + assert listed.json()['items'][0]['id'] == rid + headers = _headers(app, 'communication.' + kind + '_get', {'id': rid}, key, subject) + got = await http.get(prefix + '/' + rid, headers=headers) + assert got.status_code == 200 and got.json()[kind]['id'] == rid + head = await http.head(prefix + '/' + rid, headers=headers) + assert head.headers['etag'] == got.headers['etag'] + cached = await http.get(prefix + '/' + rid, + headers={**headers, 'If-None-Match': got.headers['etag']}) + assert cached.status_code == 304 + assert (await http.post(prefix + '/' + rid, json={})).status_code in {404, 405} + + +@pytest.mark.asyncio +async def test_work_cli_create_transition_and_proposal_accept(installed, tmp_path, monkeypatch, capsys): + app, _ = installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + directory = tmp_path / 'client' + client = MsgClient(ClientState(directory, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), clock=lambda: NOW) + assert (await client.register('work-cli')).status == 'ok' + monkeypatch.setattr(cli, 'MsgClient', lambda state, transport: + MsgClient(state, transport, clock=lambda: NOW)) + + async def invoke(*command): + args = cli.parser().parse_args(['--config-dir', str(directory), + '--server', app.settings.service_url, *command]) + status = await cli.run(args) + result = loads(capsys.readouterr().out.encode().strip()) + assert status == 0, result + return result + + made = await invoke('request', 'create', '--title', 'Work', '--description', 'Review', + '--requirements', 'Explicit claim') + rid = made['data']['id'] + claimed = await invoke('request', 'claim', rid, '--generation', str(made['data']['generation'])) + assert claimed['data']['status'] == 'claimed' + fulfilled = await invoke('request', 'fulfill', rid, '--generation', str(claimed['data']['generation'])) + assert fulfilled['data']['status'] == 'fulfilled' + assert (await invoke('request', 'get', rid))['data']['request']['status'] == 'fulfilled' + assert (await invoke('request', 'list'))['data']['items'][0]['id'] == rid + offer = await invoke('offer', 'create', '--description', 'Review', '--scope', 'Python', + '--availability', 'Now') + assert (await invoke('offer', 'withdraw', offer['data']['id'], '--generation', + str(offer['data']['generation'])))['data']['status'] == 'withdrawn' + checkpoint = await invoke('checkpoint', 'create', '--summary', 'Resume here') + assert (await invoke('checkpoint', 'get', checkpoint['data']['id']))['data']['checkpoint']['summary'] == 'Resume here' + target = await client.call('content.post_create', {'parent': '/main', 'body': 'Before'}) + source = await client.call('content.post_create', {'parent': '/main', 'body': 'After'}) + proposal = await invoke('proposal', 'create', '--target', target.resources[0].id, + '--base-revision', target.resources[0].revision, '--content', source.resources[0].id, + '--content-revision', source.resources[0].revision, '--message', 'Replace text') + accepted = await invoke('proposal', 'accept', proposal['data']['id'], '--generation', + str(proposal['data']['generation']), '--proposal-revision', proposal['resources'][0]['revision'], + '--base-revision', target.resources[0].revision, '--target', target.resources[0].id, + '--target-generation', str(target.data['generation'])) + assert accepted['data']['status'] == 'accepted' diff --git a/tests/test_collaboration_resources.py b/tests/test_collaboration_resources.py new file mode 100644 index 00000000..497e2726 --- /dev/null +++ b/tests/test_collaboration_resources.py @@ -0,0 +1,164 @@ +"""Durable work records use ordinary revisions and explicit transitions.""" +from datetime import timedelta + +import pytest +from test_service import NOW, call, register + +from msg.core.codec import wire + + +@pytest.mark.asyncio +async def test_work_request_lifecycle_replay_and_current_auth(installed): + app, _ = installed + key, owner, _ = await register(app, 'request-owner') + other_key, other, _ = await register(app, 'request-other') + args = {'title': 'Review', 'description': 'Review the change', 'requirements': 'Read tests'} + made = await call(app, 'communication.request_create', args, key=key, subject=owner, + rid='create-work-request') + assert made.status == 'ok', made.error + rid = made.data['id'] + repeated = await call(app, 'communication.request_create', args, key=key, subject=owner, + rid='create-work-request') + assert repeated.replayed and repeated.data == made.data + conflict = await call(app, 'communication.request_create', {**args, 'title': 'Changed'}, + key=key, subject=owner, rid='create-work-request') + assert conflict.status == 'error' + assert (await call(app, 'communication.request_get', {'id': rid}, + key=other_key, subject=other)).status == 'error' + # Sharing supplies read access, while claim itself is a dedicated signed operation. + grant = await call(app, 'sharing.grant', {'resource': rid, 'grantee': other, + 'expires_at': wire(NOW + timedelta(days=1))}, key=key, subject=owner) + assert grant.status == 'ok', grant.error + claimed = await call(app, 'communication.request_claim', {'id': rid}, + key=other_key, subject=other, expected=((rid, made.data['generation']),)) + assert claimed.status == 'ok', claimed.error + denied = await call(app, 'communication.request_fulfill', {'id': rid}, + key=key, subject=owner, expected=((rid, claimed.data['generation']),)) + assert denied.error.code == 'collaboration_actor_forbidden' + fulfilled = await call(app, 'communication.request_fulfill', {'id': rid}, + key=other_key, subject=other, expected=((rid, claimed.data['generation']),)) + assert fulfilled.status == 'ok', fulfilled.error + assert fulfilled.data['status'] == 'fulfilled' + got = await call(app, 'communication.request_get', {'id': rid}, key=key, subject=owner) + assert got.data['request']['assignee'] == other + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM revisions WHERE resource_id=?', (rid,))[0] == 3 + assert tx.one("SELECT COUNT(*) FROM events WHERE body LIKE '%communication.request_%'")[0] >= 3 + + +@pytest.mark.asyncio +async def test_checkpoint_relations_are_private_and_rechecked(installed): + app, _ = installed + key, owner, _ = await register(app, 'checkpoint-owner') + other_key, other, _ = await register(app, 'checkpoint-other') + note = await call(app, 'identity.note_put', {'name': 'work', 'body': 'private'}, + key=key, subject=owner) + target = note.resources[0].id + made = await call(app, 'communication.checkpoint_create', + {'summary': 'Resume here', 'resource_refs': [target], 'state_ref': target}, + key=key, subject=owner) + assert made.status == 'ok', made.error + rid = made.data['id'] + grant = await call(app, 'sharing.grant', {'resource': rid, 'grantee': other, + 'expires_at': wire(NOW + timedelta(days=1))}, key=key, subject=owner) + assert grant.status == 'ok', grant.error + got = await call(app, 'communication.checkpoint_get', {'id': rid}, key=other_key, subject=other) + assert got.status == 'ok', got.error + assert not got.data['checkpoint']['resource_refs'] + assert 'state_ref' not in got.data['checkpoint'] + generic = await call(app, 'discovery.get', {'id': rid}, key=other_key, subject=other) + assert generic.status == 'ok', generic.error + assert target not in str(generic.data) + own = await call(app, 'communication.checkpoint_get', {'id': rid}, key=key, subject=owner) + assert own.data['checkpoint']['resource_refs'] == (target,) + assert own.data['checkpoint']['state_ref'] == target + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM relations WHERE source_id=?', (rid,))[0] == 2 + + +@pytest.mark.asyncio +async def test_offer_expiry_withdraw_and_request_schema_are_explicit(installed): + app, _ = installed + key, owner, _ = await register(app, 'offer-owner') + args = {'description': 'Review code', 'scope': 'Python', 'availability': 'Today', + 'expires_at': wire(NOW + timedelta(seconds=60))} + made = await call(app, 'communication.offer_create', args, key=key, subject=owner) + assert made.status == 'ok', made.error + rid = made.data['id'] + app.executor.clock = lambda: NOW + timedelta(seconds=61) + got = await call(app, 'communication.offer_get', {'id': rid}, key=key, subject=owner) + assert got.data['offer']['effective_status'] == 'expired' + expired = await call(app, 'communication.offer_withdraw', {'id': rid}, key=key, subject=owner, + expected=((rid, made.data['generation']),)) + assert expired.error.code == 'collaboration_expired' + app.executor.clock = lambda: NOW + withdrawn = await call(app, 'communication.offer_withdraw', {'id': rid}, key=key, subject=owner, + expected=((rid, made.data['generation']),)) + assert withdrawn.data['status'] == 'withdrawn' + for args in ({}, {'title': '', 'description': 'x', 'requirements': 'x'}, + {'title': 'x', 'description': 'x', 'requirements': 'x', 'status': 'fulfilled'}): + assert (await call(app, 'communication.request_create', args, key=key, subject=owner)).status == 'error' + + +@pytest.mark.asyncio +async def test_request_claim_race_revocation_and_cancel_preserve_history(installed): + import asyncio + app, _ = installed + key, owner, _ = await register(app, 'claim-race-owner') + alice_key, alice, _ = await register(app, 'claim-race-alice') + bob_key, bob, _ = await register(app, 'claim-race-bob') + made = await call(app, 'communication.request_create', + {'title': 'One task', 'description': 'Only one assignee', 'requirements': 'Review'}, + key=key, subject=owner) + rid = made.data['id'] + grants = {} + for subject in (alice, bob): + grant = await call(app, 'sharing.grant', {'resource': rid, 'grantee': subject, + 'expires_at': wire(NOW + timedelta(days=1))}, key=key, subject=owner) + assert grant.status == 'ok', grant.error + grants[subject] = grant.data['grant']['id'] + results = await asyncio.gather(*(call(app, 'communication.request_claim', {'id': rid}, + key=k, subject=s, expected=((rid, made.data['generation']),)) + for k, s in ((alice_key, alice), (bob_key, bob)))) + assert sum(r.status == 'ok' for r in results) == 1 + winner = next((k, s, r) for (k, s), r in zip(((alice_key, alice), (bob_key, bob)), results) + if r.status == 'ok') + revoked = await call(app, 'sharing.revoke', {'grant_id': grants[winner[1]]}, key=key, subject=owner) + assert revoked.status == 'ok' + forbidden = await call(app, 'communication.request_fulfill', {'id': rid}, + key=winner[0], subject=winner[1], expected=((rid, winner[2].data['generation']),)) + assert forbidden.status == 'error' + cancelled = await call(app, 'communication.request_cancel', {'id': rid}, key=key, subject=owner, + expected=((rid, winner[2].data['generation']),)) + assert cancelled.status == 'ok' and cancelled.data['status'] == 'cancelled' + again = await call(app, 'communication.request_claim', {'id': rid}, key=key, subject=owner, + expected=((rid, cancelled.data['generation']),)) + assert again.error.code == 'request_not_open' + + +@pytest.mark.asyncio +async def test_checkpoint_event_failure_rolls_back_resource_revision_and_retry(installed, monkeypatch): + from msg.core.errors import Failure + app, _ = installed + key, owner, _ = await register(app, 'checkpoint-atomic') + # Inject at the common authoritative Event append point, after handler writes. + async with app.metadata.transaction(write=False) as tx: + tx_type = type(tx) + append = tx_type.append_event + + async def fail_event(self, event): + if event.type == 'communication.checkpoint_create': + raise Failure('injected_event_failure') + return await append(self, event) + + args = {'summary': 'A durable point', 'resource_refs': []} + with monkeypatch.context() as patch: + patch.setattr(tx_type, 'append_event', fail_event) + failed = await call(app, 'communication.checkpoint_create', args, key=key, + subject=owner, rid='checkpoint-atomic-retry') + assert failed.error.code == 'injected_event_failure' + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT COUNT(*) FROM resources WHERE type='checkpoint'")[0] == 0 + retried = await call(app, 'communication.checkpoint_create', args, key=key, + subject=owner, rid='checkpoint-atomic-retry') + assert retried.status == 'ok', retried.error diff --git a/tests/test_complete_recovery_proof.py b/tests/test_complete_recovery_proof.py new file mode 100644 index 00000000..da9482a5 --- /dev/null +++ b/tests/test_complete_recovery_proof.py @@ -0,0 +1,262 @@ +"""Disposable complete-state proofs; never real console approval or external freshness.""" +from copy import deepcopy + +import pytest + +from msg.admin.recovery_proof import capture, promote, IndependentRecoveryPin, verify_proof +from msg.core.codec import canonical, digest +from msg.core.errors import Failure +from msg.security.quarantine import SETTING, active + + +@pytest.fixture +async def complete_state(installed): + app, root = installed + packet = await capture(app, root, source_backup_sha256='a'*64, sequence=1) + pin = IndependentRecoveryPin(service=app.settings.service_url, public_key=root.public_key, + digest=digest(packet['state']), sequence=1, source_backup_sha256='a'*64) + gate = {'format': 'msg-recovery-quarantine-v1', 'source_backup_sha256': 'a'*64, + 'outbound_enabled': False, 'authority': 'health_only'} + async with app.metadata.transaction(write=True) as tx: + tx.set_setting(SETTING, gate) + runtime = dict(tx.setting('runtime_config', {}), accept_writes=False, cleanup_enabled=False) + tx.set_setting('runtime_config', runtime) + (app.settings.config_dir/'recovery-drill.json').write_bytes(canonical(gate)) + (app.settings.config_dir/'recovery-drill.json').chmod(0o600) + app.executor.recovery_quarantined = True + return app, root, packet, pin + + +async def test_complete_proof_accepts_exact_disposable_state(complete_state): + app, root, packet, pin = complete_state + result = await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert result['status'] == 'recovery_promoted' and result['requires_runtime_restart'] + async with app.metadata.transaction(write=False) as tx: + assert not active(tx) + assert tx.setting('recovery_runtime_generation') == result['runtime_generation'] + assert tx.setting('recovery_promotion')['receipt']['manifest_digest'] == pin.digest + assert not (app.settings.config_dir/'recovery-drill.json').exists() + assert app.executor.recovery_drill_active() # Existing runtime stays closed. + + +@pytest.mark.parametrize('change', ['extra_setting', 'extra_table', 'extra_column', 'wrong_pin', 'partial']) +async def test_complete_proof_rejects_unaccounted_state(complete_state, change): + app, root, packet, pin = complete_state + if change in {'extra_setting', 'extra_table', 'extra_column'}: + async with app.metadata.transaction(write=True) as tx: + if change == 'extra_setting': + tx.set_setting('recovery_promotion_attacker', {'authority': True}) + elif change == 'extra_table': + tx.execute('CREATE TABLE hidden_authority (id TEXT)', write=True) + else: + tx.execute('ALTER TABLE resources ADD COLUMN hidden_authority TEXT', write=True) + elif change == 'wrong_pin': + from dataclasses import replace + pin = replace(pin, digest='sha256:'+'0'*64) + else: + packet = deepcopy(packet) + packet['state']['metadata']['tables'].pop('credentials') + with pytest.raises(Failure): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + async with app.metadata.transaction(write=False) as tx: + assert active(tx) and tx.setting('recovery_promotion') is None + assert (app.settings.config_dir/'recovery-drill.json').exists() + + +async def test_marker_failure_requires_full_reverification(complete_state, monkeypatch): + from pathlib import Path + app, root, packet, pin = complete_state + marker = app.settings.config_dir/'recovery-drill.json' + unlink = Path.unlink + def fail_marker(path, *args, **kwargs): + if path == marker: + raise OSError('fixture interrupted finalization') + return unlink(path, *args, **kwargs) + with monkeypatch.context() as patch: + patch.setattr(Path, 'unlink', fail_marker) + with pytest.raises(Failure, match='recovery_promotion_finish_required'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert marker.exists() and app.executor.recovery_drill_active() + async with app.metadata.transaction(write=True) as tx: + assert active(tx) + tx.set_setting('extra_after_promotion', True) + with pytest.raises(Failure, match='recovery_complete_state_mismatch'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert marker.exists() + async with app.metadata.transaction(write=True) as tx: + tx.execute("DELETE FROM settings WHERE key='extra_after_promotion'", write=True) + result = await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert result['status'] == 'recovery_promoted' and not marker.exists() + + +async def test_transaction_failure_does_not_consume_audit_sequence(complete_state, monkeypatch): + from msg.storage.session import RelationalSession + app, root, packet, pin = complete_state + original = RelationalSession.set_setting + def fail_receipt(tx, key, value): + if key == 'recovery_promotion': + raise RuntimeError('fixture transaction failure') + return original(tx, key, value) + with monkeypatch.context() as patch: + patch.setattr(RelationalSession, 'set_setting', fail_receipt) + with pytest.raises(RuntimeError, match='fixture transaction failure'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + async with app.metadata.transaction(write=False) as tx: + assert active(tx) and tx.setting('recovery_promotion') is None + assert list(tx.one('SELECT last_value,is_called FROM audit_seq_seq')) == packet['state']['metadata']['sequences']['audit_seq_seq'] + assert (await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture'))['status'] == 'recovery_promoted' + + +async def test_valid_signature_cannot_omit_authority_inventory(complete_state): + from dataclasses import replace + from msg.admin.recovery_proof import PURPOSE + from msg.core.codec import wire + app, root, packet, pin = complete_state + packet = deepcopy(packet) + packet['state']['metadata']['tables'].pop('credentials') + packet['signature'] = wire(root.sign(canonical(packet['state']), purpose=PURPOSE)) + pin = replace(pin, digest=digest(packet['state'])) + with pytest.raises(Failure, match='recovery_complete_state_mismatch'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + + +async def test_source_changes_after_confirmation_refuse_seal(installed): + from msg.admin.recovery_proof import draft, seal + app, root = installed + body = await draft(app, public_key=root.public_key, source_backup_sha256='a'*64, sequence=1) + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('new_authority', 'changed') + with pytest.raises(Failure, match='recovery_complete_state_mismatch'): + await seal(app, body, root) + + +@pytest.mark.parametrize('action', ['sign', 'promote']) +def test_proof_cli_refuses_nonconsole_before_reading_files(action, tmp_path): + import json + import os + from pathlib import Path + import subprocess + import sys + args = ['a'*64, '1', str(tmp_path/'output.json')] if action == 'sign' else [str(tmp_path/'missing.json'), str(tmp_path/'trust.json')] + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1]/'src')) + for key in ('SSH_CONNECTION', 'SSH_CLIENT', 'SSH_TTY'): + env.pop(key, None) + result = subprocess.run([str(Path(sys.executable).with_name('msgd')), '--config-dir', str(tmp_path), 'root', 'recovery-proof', action, *args], env=env, capture_output=True, text=True) + expected = 'local_os_administrator_required' if os.geteuid() else 'local_console_required' + assert result.returncode == 1 and json.loads(result.stderr) == {'status': 'error', 'error': {'code': expected}} + assert not result.stdout and not (tmp_path/'output.json').exists() + + +async def test_complete_proof_from_real_backup_restores_exact_inventory(installed, tmp_path, pg_dsn): + from msg.admin.backups import backup, restore + from msg.admin.recovery_proof import open_for_proof + from test_service import call, register + app, root = installed + key, subject, _ = await register(app, 'complete-backup') + created = await call(app, 'content.post_create', {'parent': '/main', 'body': 'committed recovery bytes'}, key=key, subject=subject) + assert created.status == 'ok' + archive = tmp_path/'complete.zip' + saved = await backup(app, archive) + packet = await capture(app, root, source_backup_sha256=saved['sha256'], sequence=3) + pin = IndependentRecoveryPin(service=app.settings.service_url, public_key=root.public_key, + digest=digest(packet['state']), sequence=3, source_backup_sha256=saved['sha256']) + restore(archive, tmp_path/'complete-etc', tmp_path/'complete-data', postgres_dsn=pg_dsn) + restored = await open_for_proof(tmp_path/'complete-etc') + restored.clock = app.clock + try: + assert (await promote(restored, packet, pin=pin, signer=root, operator='isolated-fixture'))['status'] == 'recovery_promoted' + finally: + await restored.close() + + +@pytest.mark.parametrize('ddl', [ + 'CREATE SCHEMA pga_hidden', + 'CREATE VIEW hidden_view AS SELECT id FROM resources', + 'CREATE INDEX hidden_idx ON resources(owner)', + 'ALTER TABLE resources ENABLE ROW LEVEL SECURITY', + 'CREATE POLICY hidden_policy ON resources USING (true)', + 'CREATE RULE hidden_rule AS ON DELETE TO resources DO INSTEAD NOTHING', + 'ALTER TABLE resources DISABLE TRIGGER ALL', +]) +async def test_unknown_schema_objects_reject_promotion(complete_state, ddl): + app, root, packet, pin = complete_state + async with app.metadata.transaction(write=True) as tx: + tx.execute(ddl, write=True) + with pytest.raises(Failure, match='recovery_schema_'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert (app.settings.config_dir/'recovery-drill.json').exists() + + +async def test_directory_sync_failure_keeps_database_closed_and_can_resume(complete_state, monkeypatch): + import os + app, root, packet, pin = complete_state + def fail_sync(fd): + raise OSError('fixture directory sync failure') + with monkeypatch.context() as patch: + patch.setattr(os, 'fsync', fail_sync) + with pytest.raises(Failure, match='recovery_promotion_finish_required'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert not (app.settings.config_dir/'recovery-drill.json').exists() + async with app.metadata.transaction(write=False) as tx: + assert active(tx) and tx.setting('recovery_promotion') is not None + result = await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + assert result['status'] == 'recovery_promoted' + async with app.metadata.transaction(write=False) as tx: + assert not active(tx) + + +async def test_configuration_policy_drift_cannot_bypass_full_database_proof(complete_state): + app, root, packet, pin = complete_state + config = app.settings.config_dir/'msgd.toml' + content = config.read_text() + assert '[server]' in content + config.write_text(content.replace('[server]', '[server]\ntransfer_ttl = 12345', 1)) + with pytest.raises(Failure, match='recovery_cached_configuration_mismatch'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + async with app.metadata.transaction(write=False) as tx: + assert active(tx) + + +async def test_resume_requires_committed_generation_fence(complete_state, monkeypatch): + import os + app, root, packet, pin = complete_state + with monkeypatch.context() as patch: + def fail_sync(fd): + raise OSError('fixture interrupted finalization') + patch.setattr(os, 'fsync', fail_sync) + with pytest.raises(Failure, match='recovery_promotion_finish_required'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + async with app.metadata.transaction(write=True) as tx: + tx.execute("DELETE FROM settings WHERE key='recovery_runtime_generation'", write=True) + with pytest.raises(Failure, match='recovery_promotion_receipt_mismatch'): + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + async with app.metadata.transaction(write=False) as tx: + assert active(tx) + + +async def test_subsequent_complete_recovery_does_not_resume_historical_receipt(complete_state): + app, root, packet, pin = complete_state + await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture') + packet = await capture(app, root, source_backup_sha256='b'*64, sequence=2) + pin = IndependentRecoveryPin(service=app.settings.service_url, public_key=root.public_key, + digest=digest(packet['state']), sequence=2, source_backup_sha256='b'*64) + gate = {'format': 'msg-recovery-quarantine-v1', 'source_backup_sha256': 'b'*64, + 'outbound_enabled': False, 'authority': 'health_only'} + async with app.metadata.transaction(write=True) as tx: + tx.set_setting(SETTING, gate) + tx.set_setting('runtime_config', dict(tx.setting('runtime_config', {}), accept_writes=False, cleanup_enabled=False)) + marker = app.settings.config_dir/'recovery-drill.json' + marker.write_bytes(canonical(gate)) + marker.chmod(0o600) + assert (await promote(app, packet, pin=pin, signer=root, operator='isolated-fixture'))['status'] == 'recovery_promoted' + + +def test_schema_vocabulary_preserves_nullability_across_pg_catalog_versions(): + from msg.admin.recovery_state import shape + pg16 = {'tables': {'sample': [['owner', 'text', True, '', None]]}, + 'constraints': [], 'indexes': [], 'triggers': [], 'functions': [], 'sequences': []} + pg18 = deepcopy(pg16) + pg18['constraints'] = [['sample', 'sample_owner_not_null', 'n', 'NOT NULL owner', True]] + assert shape(pg16) == shape(pg18) + pg18['tables']['sample'][0][2] = False + assert shape(pg16) != shape(pg18) diff --git a/tests/test_configuration_field_contracts.py b/tests/test_configuration_field_contracts.py new file mode 100644 index 00000000..e0ef85aa --- /dev/null +++ b/tests/test_configuration_field_contracts.py @@ -0,0 +1,124 @@ +"""Every accepted configuration field has executable loading/rejection evidence.""" +import json + +import pytest + +from msg.config import load_settings +from msg.core.errors import Failure + + +@pytest.mark.parametrize('value', ['"false"', '1', '[]']) +def test_mail_enable_requires_boolean_even_when_truthy(tmp_path, value): + (tmp_path / 'msgd.toml').write_text('[storage]\npostgres_dsn="service=msgd"\n') + (tmp_path / 'mail.toml').write_text('enabled='+value+'\nhost="smtp.example.test"\n' + 'tls="starttls"\nsender="sender@example.test"\n') + with pytest.raises(Failure, match='^invalid_mail_enabled$'): + load_settings(tmp_path) + + +@pytest.mark.parametrize('value', ['0', '65536', 'true', '"587"']) +def test_mail_port_rejects_out_of_range_or_non_integer(tmp_path, value): + (tmp_path / 'msgd.toml').write_text('[storage]\npostgres_dsn="service=msgd"\n') + (tmp_path / 'mail.toml').write_text('enabled=true\nhost="smtp.example.test"\n' + 'tls="starttls"\nsender="sender@example.test"\nport='+value+'\n') + with pytest.raises(Failure, match='^invalid_mail_port$'): + load_settings(tmp_path) + + +@pytest.mark.parametrize('section', ['server', 'storage', 'limits', 'tools']) +def test_configuration_sections_reject_non_tables_with_stable_error(tmp_path, section): + base='[storage]\npostgres_dsn="service=msgd"\n' if section!='storage' else '' + (tmp_path / 'msgd.toml').write_text(section+'=false\n'+base) + with pytest.raises(Failure, match='^invalid_configuration_section$'): + load_settings(tmp_path) + + +def test_field_matrix_runs_normal_default_and_rejection_vectors_without_secrets(): + from msg.admin.config_check import configuration_selftest + from msg.config_contracts import CONFIGURATION_FIELDS + result = configuration_selftest() + assert set(result) == set(CONFIGURATION_FIELDS) + assert all(row['status']=='pass' for row in result.values()), result + assert all(row['default'] in {'pass', 'required', 'disabled'} and + row['accepted']=='pass' and row['rejected']=='pass' for row in result.values()) + assert 'password' not in json.dumps(result) + + +def test_doctor_field_status_never_substitutes_parsing_for_live_checks(): + from msg.admin.config_check import configuration_doctor + observations={'configuration':{'ok':True},'storage':{'ok':False}, + 'valkey':{'ok':True,'status':'disabled'}} + result=configuration_doctor(observations) + assert result['storage.postgres_dsn']['related_check']['status']=='fail' + assert result['storage.valkey_url']['related_check']['status']=='disabled' + assert result['mail.host']['status']=='pass' + assert result['mail.host']['scope']=='configuration_loading' + assert result['mail.host']['related_check']['status']=='not_checked' + assert all(row['status']=='fail' for row in configuration_doctor({}).values()) + + +def test_vector_gap_is_an_error_instead_of_empty_success(monkeypatch): + from msg.admin import config_check + changed=dict(config_check.CONFIGURATION_FIELDS, **{'server.uncovered':{}}) + monkeypatch.setattr(config_check,'CONFIGURATION_FIELDS',changed) + with pytest.raises(Failure,match='^configuration_diagnostic_coverage_missing$'): + config_check.configuration_selftest() + + +def test_effective_default_drift_is_reported_as_failure(monkeypatch): + from dataclasses import replace + import msg.config as config + from msg.admin.config_check import configuration_selftest + real=config.load_settings + monkeypatch.setattr(config,'load_settings',lambda path:replace(real(path),temporary_ttl=42)) + result=configuration_selftest() + assert result['server.temporary_ttl']=={'status':'fail','code':'configuration_default_mismatch'} + + +@pytest.mark.asyncio +async def test_real_doctor_exposes_complete_field_matrix_read_only(installed): + from msg.admin.diagnostics import doctor + from msg.config_contracts import CONFIGURATION_FIELDS + from test_service import NOW + app,_=installed + config_path=app.settings.config_dir/'msgd.toml' + original=config_path.read_bytes() + result=doctor(app.settings.config_dir,clock=lambda:NOW) + assert set(result['configuration_fields'])==set(CONFIGURATION_FIELDS) + assert all(row['status']=='pass' for row in result['configuration_fields'].values()) + assert config_path.read_bytes()==original + assert 'service=' not in json.dumps(result['configuration_fields']) + + +def test_real_doctor_reports_bad_configuration_without_exposing_values(tmp_path): + from msg.admin.diagnostics import doctor + (tmp_path/'msgd.toml').write_text('[storage]\npostgres_dsn="do-not-echo-me"\n') + result=doctor(tmp_path) + assert result['ok'] is False + assert all(row['status']=='fail' for row in result['configuration_fields'].values()) + assert 'do-not-echo-me' not in json.dumps(result) + + +def test_configuration_selftest_opens_no_network_or_secret_file(monkeypatch): + import socket + import psycopg + from pathlib import Path + from msg.admin.config_check import configuration_selftest + def forbidden(*args, **kwargs): + raise AssertionError('configuration acceptance must not access secrets or network') + monkeypatch.setattr(socket,'create_connection',forbidden) + monkeypatch.setattr(psycopg,'connect',forbidden) + monkeypatch.setattr(Path,'read_bytes',forbidden) + assert all(row['status']=='pass' for row in configuration_selftest().values()) + + +@pytest.mark.asyncio +async def test_official_selftest_runs_configuration_field_vectors(): + from msg.admin.diagnostics import selftest + from msg.config_contracts import CONFIGURATION_FIELDS + result=await selftest() + assert result['checks']['configuration_loading'] is True, result + assert set(result['configuration_fields'])==set(CONFIGURATION_FIELDS) + assert all(row['status']=='pass' for row in result['configuration_fields'].values()) + assert result['cleaned_up'] is True + assert result['ok'] is True, result diff --git a/tests/test_content_edit_check.py b/tests/test_content_edit_check.py new file mode 100644 index 00000000..dec23ad2 --- /dev/null +++ b/tests/test_content_edit_check.py @@ -0,0 +1,26 @@ +"""The official content editing check uses authenticated operations and retained history.""" +import pytest +from test_service import call, register + +from msg.admin.content_edit_check import check_content_editing + + +@pytest.mark.asyncio +async def test_content_edit_check_executes_patch_and_denial_paths(installed): + app, _ = installed + observed = [] + async def invoke(name, args, key=None, subject=None, **kwargs): + result = await call(app, name, args, key=key, subject=subject, **kwargs) + observed.append((name, args, result)) + return result + async def enrolled(name): + key, subject, _ = await register(app, name) + return key, subject + assert await check_content_editing(app, invoke, enrolled, '') + patches = [result for name, _, result in observed if name == 'file.patch'] + assert len(patches) == 3 + assert patches[0].status == 'ok' + assert {result.error.code for result in patches[1:]} == {'revision_conflict', 'permission_denied'} + history = [result for name, args, result in observed + if name == 'file.read' and args.get('view') == 'history'] + assert len(history) == 1 and len(history[0].data['revisions']) == 2 diff --git a/tests/test_dm_selftest.py b/tests/test_dm_selftest.py new file mode 100644 index 00000000..87fc80ca --- /dev/null +++ b/tests/test_dm_selftest.py @@ -0,0 +1,30 @@ +"""The production diagnostic uses real signed requests only in an isolated root.""" +from types import SimpleNamespace + +import pytest +from test_service import NOW + +from msg.admin.dm_check import check_private_dm +from msg.admin.root import _approve_csr, _provision +from msg.application import Application +from msg.config import write_example +from msg.core.errors import Failure + + +@pytest.mark.asyncio +async def test_private_dm_selftest_refuses_live_installation(): + with pytest.raises(Failure, match='isolated_selftest_required'): + await check_private_dm(SimpleNamespace(selftest_run_id=None), NOW) + + +@pytest.mark.asyncio +async def test_private_dm_selftest_uses_real_isolated_clients(tmp_path, pg_dsn): + app = Application(write_example(tmp_path / 'etc', tmp_path / 'data', + 'https://selftest.invalid', postgres_dsn=pg_dsn), clock=lambda: NOW, + selftest_run_id='a' * 32) + try: + csr, root = await _provision(app, 'isolated-dm-test-passphrase') + await _approve_csr(app, csr, root, expected_digest=None, operator='isolated-test') + assert await check_private_dm(app, NOW) + finally: + await app.close() diff --git a/tests/test_entitlement_orders.py b/tests/test_entitlement_orders.py new file mode 100644 index 00000000..7c290069 --- /dev/null +++ b/tests/test_entitlement_orders.py @@ -0,0 +1,274 @@ +"""Explicit synchronous redemption shares Order storage without faking buyer acceptance.""" +import pytest + +from msg.admin.market_check import inspect_clearing +from msg.core.codec import digest, wire +from msg.core.errors import Failure +from msg.market.policy import contract +from msg.plugins import offers +from msg.plugins.money import _balance, _supply +from test_market_redemption import setup_offer +from test_service import call + + +async def intent(app, args): + quote = (await call(app, 'money.offers', {})).data['offers'][0] + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(args['offer_id']) + return {**args, 'listing_revision': resource.revision, + 'offer_snapshot_digest': digest(quote), 'total_price_minor': quote['price_minor'] * args['quantity'], + 'settlement_policy': 'deterministic-entitlement-v1'} + + +@pytest.mark.asyncio +async def test_explicit_entitlement_redemption_uses_one_order_and_conserves_supply(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + signed = await intent(app, args) + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3, + rid='order-redemption') + assert result.status == 'ok', wire(result) + order = result.data['order'] + assert order['id'].startswith('ord_') and order['state'] == 'settled' + assert order['contract_version'] == 5 and order['delivered_at'] is None + repeated = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3, + rid='order-redemption') + assert repeated.replayed and repeated.data == result.data + conflict = await call(app, 'money.redeem', {**signed, 'quantity': 6, 'total_price_minor': 12}, + key=key, subject=owner, contract_version=3, rid='order-redemption') + assert conflict.error.code == 'idempotency_conflict' and not conflict.replayed + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM money_purchases')[0] == 0 + assert tx.one('SELECT COUNT(*) FROM store_orders')[0] == 1 + assert tx.one('SELECT COUNT(*) FROM store_deliveries')[0] == 0 + assert tx.one('SELECT COUNT(*) FROM resource_entitlements')[0] == 1 + assert tx.one('SELECT COUNT(*) FROM order_settlements')[0] == 1 + assert tx.one("SELECT COUNT(*) FROM order_transitions WHERE body LIKE '%accepted%'")[0] == 0 + locked = contract(tx, order['id']) + assert _balance(tx, owner) == 90 and _balance(tx, 'u_root') == 10 + assert _balance(tx, locked['escrow_subject']) == 0 and _supply(tx) == 100 + inspect_clearing(app, tx) + read = await call(app, 'orders.get', {'order_id': order['id']}, key=key, subject=owner) + assert read.status == 'ok' and read.data['order'] == result.data['order'] + cancel = await call(app, 'orders.cancel', {'order_id': order['id']}, key=key, subject=owner, + contract_version=2) + assert cancel.error.code == 'order_not_cancellable' + + +@pytest.mark.asyncio +async def test_grant_failure_rolls_back_shared_order_funding_and_release(installed, monkeypatch): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + signed = await intent(app, args) + provider = offers.ENTITLEMENT_FULFILLERS[offers.ENTITLEMENT_KIND] + def fail(*args): + provider[3](*args) + raise Failure('entitlement_order_injected_failure') + monkeypatch.setitem(offers.ENTITLEMENT_FULFILLERS, offers.ENTITLEMENT_KIND, (*provider[:3], fail)) + tables = ('store_orders', 'order_contracts', 'order_settlements', 'order_transitions', + 'money_ledger', 'ledger_accounts', 'resource_entitlements', 'events', 'audit') + async with app.metadata.transaction(write=False) as tx: + before = {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in tables} + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert result.error.code == 'entitlement_order_injected_failure', wire(result) + async with app.metadata.transaction(write=False) as tx: + assert {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in tables} == before + + +@pytest.mark.asyncio +async def test_explicit_intent_and_legacy_pending_are_not_interchangeable(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + pending = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, subject=owner, contract_version=2) + assert pending.status == 'ok' + original = pending.data['purchase'] + signed = await intent(app, args) + for changed in ({'settlement_policy': 'buyer-acceptance'}, {'offer_snapshot_digest': 'sha256:'+'0'*64}, + {'total_price_minor': 11}, {'listing_revision': 'wrong-revision'}): + result = await call(app, 'money.redeem', {**signed, **changed}, key=key, subject=owner, + contract_version=3) + assert result.status == 'error' + missing = {k: v for k, v in signed.items() if k != 'settlement_policy'} + assert (await call(app, 'money.redeem', missing, key=key, subject=owner, contract_version=3)).status == 'error' + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert result.status == 'ok', wire(result) + old = await call(app, 'money.purchase_get', {'purchase_id': original['id']}, key=key, subject=owner) + assert old.data['purchase'] == original + cancelled = await call(app, 'money.purchase_cancel', {'purchase_id': original['id']}, key=key, subject=owner) + assert cancelled.status == 'ok' and cancelled.data['purchase']['state'] == 'refunded' + + +@pytest.mark.asyncio +async def test_concurrent_redemptions_cannot_spend_last_balance_twice(installed): + import asyncio + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + signed = await intent(app, {**args, 'quantity': 40}) + results = await asyncio.gather(*[call(app, 'money.redeem', signed, key=key, subject=owner, + contract_version=3, rid=rid) for rid in ('last-a', 'last-b')]) + assert sorted(r.status for r in results) == ['error', 'ok'] + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM store_orders')[0] == 1 + assert tx.one('SELECT COUNT(*) FROM resource_entitlements')[0] == 1 + assert _balance(tx, owner) == 20 and _balance(tx, 'u_root') == 80 and _supply(tx) == 100 + inspect_clearing(app, tx) + + +@pytest.mark.asyncio +async def test_revoked_key_cannot_replay_but_historical_signed_order_still_verifies(installed): + from test_market_account_scope import restricted_key + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + scoped = await restricted_key(app, key, owner, 'money.redeem', 3, account=True) + signed = await intent(app, args) + original = await call(app, 'money.redeem', signed, key=scoped, subject=owner, + contract_version=3, rid='revocable-redemption') + assert original.status == 'ok', wire(original) + revoked = await call(app, 'identity.key_revoke', {'key_id': scoped.key_id}, key=key, subject=owner) + assert revoked.status == 'ok', wire(revoked) + denied = await call(app, 'money.redeem', signed, key=scoped, subject=owner, + contract_version=3, rid='revocable-redemption') + assert denied.status == 'error' and not denied.replayed + read = await call(app, 'orders.get', {'order_id': original.data['order']['id']}, key=key, subject=owner) + assert read.status == 'ok', wire(read) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM resource_entitlements')[0] == 1 + assert _balance(tx, owner) == 90 + + +@pytest.mark.asyncio +async def test_recomputed_contract_digest_cannot_forge_signed_settlement_consent(installed): + from msg.core.codec import loads + from msg.market.policy import validate_projection + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + result = await call(app, 'money.redeem', await intent(app, args), key=key, subject=owner, + contract_version=3) + assert result.status == 'ok', wire(result) + oid = result.data['order']['id'] + async with app.metadata.transaction(write=True) as tx: + locked = loads(tx.one('SELECT body FROM order_contracts WHERE order_id=?', (oid,))[0]) + locked['redemption_request']['source'] = 'manual' + # Restore validation receives the candidate body before trusting its digest. + with pytest.raises(Failure): + validate_projection(tx, locked) + assert contract(tx, oid)['redemption_request']['source'] != 'manual' + + +@pytest.mark.asyncio +async def test_unavailable_provider_version_rejects_before_funding(installed, monkeypatch): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + signed = await intent(app, args) + provider = offers.ENTITLEMENT_FULFILLERS[offers.ENTITLEMENT_KIND] + monkeypatch.setitem(offers.ENTITLEMENT_FULFILLERS, offers.ENTITLEMENT_KIND, + (provider[0], provider[1], 2, provider[3])) + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert result.error.code == 'offer_provider_unavailable' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM store_orders')[0] == 0 + assert _balance(tx, owner) == 100 and _supply(tx) == 100 + + +@pytest.mark.asyncio +async def test_order_writer_preserves_full_byte_quantity_above_signed_integer32(installed): + from msg.admin.money import apply_money, apply_offer + app, root = installed + key, owner, args, fields = await setup_offer(app, root) + quantity = 2**31 + 1 + quote = await apply_offer(app, root, action='set', operator='test', offer_id=args['offer_id'], + fields={**fields, 'price_minor': 1, 'max_quantity': quantity}) + await apply_money(app, root, action='mint', operator='test', amount_minor=quantity) + await apply_money(app, root, action='transfer', operator='test', amount_minor=quantity, subject_id=owner) + signed = await intent(app, {**args, 'quantity': quantity, 'price_revision': quote['offer']['price_revision']}) + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert result.status == 'ok', wire(result) + assert result.data['order']['quantity'] == quantity + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT quantity FROM resource_entitlements')[0] == quantity + assert _balance(tx, owner) == 100 and _balance(tx, 'u_root') == quantity + assert _supply(tx) == quantity + 100 + + +@pytest.mark.asyncio +async def test_imported_legacy_quote_requires_explicit_adoption_before_order_writer(installed): + from msg.admin.offer_import import preview_import, apply_import + from test_offer_import import legacy + app, root = installed + key, owner, args = await legacy(app, root) + quote = next(o for o in (await call(app, 'money.offers', {})).data['offers'] if o['offer_id'] == 'old-offer') + signed = {**args, 'listing_revision': 'not-a-resource', 'offer_snapshot_digest': digest(quote), + 'total_price_minor': 10, 'settlement_policy': 'deterministic-entitlement-v1'} + denied = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert denied.error.code == 'offer_requires_import' + async with app.metadata.transaction(write=False) as tx: + plan = await preview_import(app, tx, 'old-offer') + await apply_import(app, root, plan, operator='test') + signed['listing_revision'] = plan['revision_id'] + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3) + assert result.status == 'ok', wire(result) + assert result.data['order']['listing_id'] == 'old-offer' + assert result.data['order']['listing_revision'] == plan['revision_id'] + + +@pytest.mark.asyncio +async def test_backup_preserves_order_grant_and_receipts_without_reexecution(installed, tmp_path, pg_dsn): + from msg.admin.backups import backup, restore + from msg.application import Application + from msg.config import load_settings + from test_service import NOW + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + signed = await intent(app, args) + result = await call(app, 'money.redeem', signed, key=key, subject=owner, contract_version=3, + rid='backup-order-redemption') + assert result.status == 'ok', wire(result) + tables = ('store_orders', 'order_contracts', 'order_settlements', 'order_transitions', + 'money_ledger', 'resource_entitlements', 'server_offer_resources') + async with app.metadata.transaction(write=False) as tx: + before = {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in tables} + archive = tmp_path/'order-redemption.zip' + await backup(app, archive) + restore(archive, tmp_path/'restored-etc', tmp_path/'restored-data', postgres_dsn=pg_dsn) + restored = Application(load_settings(tmp_path/'restored-etc'), clock=lambda: NOW) + await restored.load() + try: + async with restored.metadata.transaction(write=False) as tx: + assert {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in tables} == before + assert inspect_clearing(restored, tx)['conserved'] + assert contract(tx, result.data['order']['id'])['version'] == 5 + blocked = await call(restored, 'money.redeem', signed, key=key, subject=owner, contract_version=3, + rid='backup-order-redemption') + assert blocked.error.code == 'writes_paused' + async with restored.metadata.transaction(write=False) as tx: + assert {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in tables} == before + finally: + await restored.close() + + +@pytest.mark.asyncio +async def test_diagnostics_reject_incomplete_synchronous_order_projection(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + result = await call(app, 'money.redeem', await intent(app, args), key=key, subject=owner, + contract_version=3) + assert result.status == 'ok', wire(result) + async with app.metadata.transaction(write=True) as tx: + tx.execute("UPDATE store_orders SET state='created' WHERE id=?", (result.data['order']['id'],), write=True) + with pytest.raises(Failure, match='incomplete_entitlement_commit'): + inspect_clearing(app, tx) + + +@pytest.mark.asyncio +async def test_entitlement_expiry_cannot_drift_from_signed_duration(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root, duration=60) + result = await call(app, 'money.redeem', await intent(app, args), key=key, subject=owner, + contract_version=3) + assert result.status == 'ok', wire(result) + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE resource_entitlements SET expires_at=NULL WHERE id=?', + (result.data['order']['entitlement_id'],), write=True) + async with app.metadata.transaction(write=False) as tx: + with pytest.raises(Failure, match='^order_entitlement_mismatch$'): + contract(tx, result.data['order']['id']) diff --git a/tests/test_error_messages.py b/tests/test_error_messages.py new file mode 100644 index 00000000..cec0b985 --- /dev/null +++ b/tests/test_error_messages.py @@ -0,0 +1,68 @@ +"""New failures have safe messages without changing historical signed results.""" +from datetime import timedelta + +import httpx +import pytest +from test_service import NOW + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure +from msg.core.models import OperationResult +from msg.core.packet import decode_result +from msg.core.requests import receipt_bytes, request_for +from msg.security.crypto import Ed25519Signer, verify +from msg.transports.client import ( + GraphQLTransport, + HTTPTransport, + MCPHTTPTransport, + PathGETTransport, +) +from msg.transports.http import create_app + + +@pytest.mark.parametrize('message', [None, 'An explicitly stored message.']) +def test_error_result_preserves_exact_canonical_bytes_and_receipt(message): + result = OperationResult(request_id='old-result', operation='discovery.get', status='error', + actor=None, subject=None) + legacy = wire(result) + legacy['error'] = {'code': 'permission_denied', 'retryable': False, + 'field_path': None, 'retry_after_seconds': None} + if message is not None: + legacy['error']['message'] = message + signing = {key: value for key, value in legacy.items() + if key not in {'receipt', 'replayed', 'prefer_cli', 'cli_url'}} + original_receipt_payload = canonical(signing) + signer = Ed25519Signer.generate() + legacy['receipt'] = wire(signer.sign(original_receipt_payload, purpose='receipt')) + decoded = decode_result(legacy) + assert decoded.error.message == message + assert canonical(decoded) == canonical(legacy) + assert receipt_bytes(decoded) == original_receipt_payload + verify(signer.public_key, receipt_bytes(decoded), decoded.receipt, purpose='receipt') + + +@pytest.mark.asyncio +async def test_real_operation_errors_keep_safe_message_across_network_adapters(installed, monkeypatch): + app, _ = installed + packet = request_for('discovery.get', {'id': 'missing-private-name'}, app.settings.service_url, + expires_at=NOW + timedelta(seconds=60)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + for kind in (HTTPTransport, PathGETTransport, GraphQLTransport, MCPHTTPTransport): + result = await kind(app.settings.service_url, http=http).call(packet) + assert result.error.code == 'not_found' + assert result.error.message == 'The requested resource was not found.' + assert 'missing-private-name' not in result.error.message + async def unexpected(*args, **kwargs): + raise RuntimeError('token=never-disclose-this') + monkeypatch.setattr(app.authenticator, 'authenticate', unexpected) + result = await HTTPTransport(app.settings.service_url, http=http).call(packet) + assert result.error.code == 'internal_error' + assert result.error.message == 'The service could not complete the operation.' + assert 'never-disclose-this' not in canonical(result).decode() + + +def test_pre_executor_failure_uses_fixed_fallback_without_interpolating_input(): + result = Failure('future_code', details={'unused': 'secret-value'}).as_dict() + assert result['message'] == 'The operation could not be completed.' + assert 'future_code' not in result['message'] and 'secret-value' not in result['message'] diff --git a/tests/test_execution_composition.py b/tests/test_execution_composition.py index fea56ccb..91bb30f4 100644 --- a/tests/test_execution_composition.py +++ b/tests/test_execution_composition.py @@ -18,7 +18,7 @@ async def test_core_executor_runs_with_explicit_services_and_no_application(): active = [] - transaction = object() + transaction = SimpleNamespace(setting=lambda key, default=None: default) principal = Principal(actor='u_test', subject='u_test', credential_id='k_test', method='signature', certificates=(), ceiling=()) diff --git a/tests/test_feature_manifest.py b/tests/test_feature_manifest.py index add08565..c5417a49 100644 --- a/tests/test_feature_manifest.py +++ b/tests/test_feature_manifest.py @@ -14,7 +14,7 @@ def test_feature_inventory_has_unique_real_samples_and_explicit_checks(): 'content','git_content'} <= {row['feature_id'] for row in rows} assert all(set(row)=={'feature_id','enabled_by_default','default_config', 'sample_resource','doctor_check','selftest_case'} for row in rows) - assert next(row for row in rows if row['feature_id']=='git_content')['selftest_case'] is None + assert next(row for row in rows if row['feature_id']=='git_content')['selftest_case']=='git_push_read_cas' mapped={row['feature_id']:(row['doctor_check'],row['selftest_case']) for row in rows} assert {key:mapped[key] for key in ('bootstrap','postgres','root_trust','online_ca', 'authorization','audit','content','git_content')} == { @@ -25,7 +25,7 @@ def test_feature_inventory_has_unique_real_samples_and_explicit_checks(): 'authorization':('authority_snapshot','certgate'), 'audit':('audit','online_delegation'), 'content':('content_layout','idempotency'), - 'git_content':('git',None), + 'git_content':('git','git_push_read_cas'), } assert {key:mapped[key] for key in ('recovery','search','hosting')} == { 'recovery':('recovery_checkpoint','recovery_checkpoint_replay'), @@ -78,7 +78,7 @@ def test_feature_results_do_not_invent_success(): assert doctor_result['valkey_signal']['status']=='disabled' selftest_result=feature_results(rows,{'isolated_namespace':True},'selftest_case') assert selftest_result['bootstrap']['status']=='pass' - assert selftest_result['git_content']=={'status':'skip','check':None} + assert selftest_result['git_content']=={'status':'fail','check':'git_push_read_cas','reason':'check_missing'} assert selftest_result['postgres']['status']=='fail' @@ -88,7 +88,7 @@ async def test_doctor_feature_results_are_grounded_in_read_only_checks(installed result=doctor(app.settings.config_dir,clock=lambda:NOW) for feature_id in ('bootstrap','postgres','root_trust','online_ca','authorization', 'audit','content','git_content','identity_upgrade', - 'recovery','search','hosting'): + 'recovery','search','hosting','honors'): row=result['features'][feature_id] assert row['status']=='pass',result assert result['checks'][row['check']]['ok'] is True @@ -99,10 +99,11 @@ async def test_doctor_feature_results_are_grounded_in_read_only_checks(installed @pytest.mark.asyncio async def test_selftest_feature_results_are_grounded_in_isolated_operations(): result=await selftest() - assert result['ok'] and result['cleaned_up'],result + assert result['ok'] and result['cleaned_up'],result['checks'].get('failure',result) for feature_id in ('bootstrap','postgres','root_trust','online_ca','authorization', - 'audit','content','identity_upgrade','recovery','search','hosting'): + 'audit','content','identity_upgrade','recovery','search','hosting','honors'): row=result['features'][feature_id] assert row['status']=='pass',result assert result['checks'][row['check']] is True - assert result['features']['git_content']['status']=='skip' + assert result['features']['git_content']=={'status':'pass','check':'git_push_read_cas'} + assert result['checks']['git_push_read_cas'] is True diff --git a/tests/test_file_copy_integrity.py b/tests/test_file_copy_integrity.py new file mode 100644 index 00000000..3dbac09c --- /dev/null +++ b/tests/test_file_copy_integrity.py @@ -0,0 +1,65 @@ +"""Copying a retained reference must verify its payload before publication.""" +import pytest +from msg.core.codec import b64, wire +from msg.core.models import ResourceRef +from test_service import call, register + + +@pytest.mark.asyncio +@pytest.mark.parametrize('damage', ['missing', 'corrupt']) +async def test_binary_copy_rejects_unavailable_source_without_publication(installed, damage): + app, _ = installed + key, owner, _ = await register(app, 'copy-integrity') + parent = '/@copy-integrity/files' + created = await call(app, 'content.file_put', + {'parent': parent, 'name': 'source.bin', 'data': b64(b'original')}, + key=key, subject=owner) + assert created.status == 'ok', wire(created) + async with app.metadata.transaction(write=False) as tx: + revision = await tx.revision(ResourceRef(id=created.resources[0].id)) + before = tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] + for table in ('resources','revisions','events')) + path = app.contents.binary / revision.content.digest.removeprefix('sha256:') + if damage == 'missing': + path.unlink() + else: + path.write_bytes(b'tampered') + copied = await call(app, 'content.file_put', + {'parent': parent, 'name': 'copy.bin', 'source': {'id': created.resources[0].id}}, + key=key, subject=owner) + assert copied.status == 'error', wire(copied) + assert copied.error.code == ('content_missing' if damage == 'missing' else 'content_digest_mismatch') + async with app.metadata.transaction(write=False) as tx: + assert tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] + for table in ('resources','revisions','events')) == before + + +@pytest.mark.asyncio +async def test_reupload_over_damaged_binary_is_not_published(installed): + app, _ = installed + key, owner, _ = await register(app, 'reupload-integrity') + parent = '/@reupload-integrity/files' + created = await call(app, 'file.create', + {'parent': parent, 'name': 'first.bin', 'data': b64(b'original')}, + key=key, subject=owner) + assert created.status == 'ok', wire(created) + async with app.metadata.transaction(write=False) as tx: + revision = await tx.revision(ResourceRef(id=created.resources[0].id)) + before = tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] + for table in ('resources','revisions','events')) + # Same size, different bytes: an existing content-addressed path is not + # evidence that the payload still matches its digest. + path = app.contents.binary / revision.content.digest.removeprefix('sha256:') + path.write_bytes(b'tampered') + again = await call(app, 'file.create', + {'parent': parent, 'name': 'second.bin', 'data': b64(b'original')}, + key=key, subject=owner) + assert again.status == 'error' and again.error.code == 'content_digest_mismatch', wire(again) + async with app.metadata.transaction(write=False) as tx: + assert tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] + for table in ('resources','revisions','events')) == before + path.write_bytes(b'original') + repaired = await call(app, 'file.create', + {'parent': parent, 'name': 'second.bin', 'data': b64(b'original')}, + key=key, subject=owner) + assert repaired.status == 'ok', wire(repaired) diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py new file mode 100644 index 00000000..a6034c4b --- /dev/null +++ b/tests/test_file_operations.py @@ -0,0 +1,131 @@ +"""The file vocabulary uses the same resources, revisions and authorization.""" +import pytest +from msg.core.codec import b64, wire +from test_service import call, register + + +@pytest.mark.asyncio +async def test_file_write_read_copy_conflicts_and_directory_permissions(installed): + app, _ = installed + key, owner, _ = await register(app, 'file-contract') + stranger, other, _ = await register(app, 'file-outsider') + parent = '/@file-contract/files' + created = await call(app, 'file.create', {'parent':parent,'name':'a.txt', + 'data':b64(b'old'),'media_type':'text/plain'}, key=key, subject=owner) + assert created.status == 'ok', wire(created) + rid = created.resources[0].id + duplicate = await call(app, 'file.create', {'parent':parent,'name':'a.txt', + 'data':b64(b'duplicate')}, key=key, subject=owner) + assert duplicate.status == 'error' + args = {'id':rid,'base_revision':created.resources[0].revision,'data':b64(b'new')} + denied = await call(app, 'file.write', args, key=stranger, subject=other, + expected=((rid,created.data['generation']),)) + assert denied.status == 'error' and denied.error.code == 'permission_denied', wire(denied) + written = await call(app, 'file.write', args, key=key, subject=owner, + expected=((rid,created.data['generation']),)) + assert written.status == 'ok', wire(written) + stale = await call(app, 'file.write', args, key=key, subject=owner, + expected=((rid,written.data['generation']),)) + assert stale.status == 'error' and stale.error.code == 'revision_conflict', wire(stale) + read = await call(app, 'file.read', {'id':rid}, key=key, subject=owner) + assert read.status == 'ok' and read.data['content'] == 'new', wire(read) + old = await call(app, 'file.read', {'id':rid,'revision':created.resources[0].revision},key=key,subject=owner) + assert old.status == 'ok' and old.data['content'] == 'old', wire(old) + copy = await call(app, 'file.copy', {'parent':parent,'name':'b.txt','source':{'id':rid}},key=key,subject=owner) + assert copy.status == 'ok', wire(copy) + stat = await call(app, 'file.stat', {'id':rid},key=key,subject=owner) + assert stat.status == 'ok' and stat.data['generation'] == written.data['generation'], wire(stat) + folder = await call(app, 'file.mkdir', {'parent':parent,'name':'nested'},key=key,subject=owner) + assert folder.status == 'ok', wire(folder) + moved = await call(app, 'file.move', {'id':rid,'parent':folder.resources[0].id},key=key,subject=owner, + expected=((rid,written.data['generation']),)) + assert moved.status == 'ok', wire(moved) + listing = await call(app, 'file.list', {'parent':folder.resources[0].id},key=key,subject=owner) + assert listing.status == 'ok' and [item['id'] for item in listing.data['items']] == [rid], wire(listing) + deleted = await call(app, 'file.delete', {'id':rid},key=key,subject=owner, + expected=((rid,moved.data['generation']),)) + assert deleted.status == 'ok' and deleted.data['state'] == 'archived', wire(deleted) + + +@pytest.mark.asyncio +async def test_existing_operation_limited_key_does_not_gain_file_alias(installed): + from msg.core.codec import canonical + from msg.core.models import Scope + from msg.security.capabilities import grant_for + from msg.security.crypto import Ed25519Signer + app, _ = installed + owner_key, owner, _ = await register(app, 'file-limited') + key = Ed25519Signer.generate() + grant = grant_for(app.registry.capability('resource.basic'), + scope=Scope(resource_id=owner,descendants=True), operations=('content.file_put@1',)) + proof = key.sign(canonical({'subject_id':owner,'public_key':b64(key.public_key)}),purpose='key-add') + added = await call(app,'identity.key_add',{'public_key':b64(key.public_key), + 'possession_proof':wire(proof),'ceiling':wire((grant,))},key=owner_key,subject=owner) + assert added.status == 'ok', wire(added) + args = {'parent':'/@file-limited/files','name':'old.txt','data':b64(b'bytes')} + allowed = await call(app,'content.file_put',args,key=key,subject=owner) + assert allowed.status == 'ok', wire(allowed) + denied = await call(app,'file.create',{**args,'name':'alias.txt'},key=key,subject=owner) + assert denied.status == 'error' and denied.error.code == 'credential_ceiling', wire(denied) + + +@pytest.mark.asyncio +async def test_file_patch_and_atomic_batch_preserve_single_publication(installed): + from test_batch import packet + app, _ = installed + key, owner, _ = await register(app,'file-batch') + args = {'parent':'/@file-batch/files','name':'edit.txt','data':b64(b'old'),'media_type':'text/plain'} + created = await call(app,'file.create',args,key=key,subject=owner) + rid = created.resources[0].id + patched = await call(app,'file.patch',{'id':rid,'base_revision':created.resources[0].revision, + 'base_generation':created.data['generation'],'patch':{'kind':'exact','exact':'old','replacement':'new'}}, + key=key,subject=owner,expected=((rid,created.data['generation']),)) + assert patched.status == 'ok', wire(patched) + ops = [packet(app,key,owner,'file.create',{**args,'name':'batch.txt'},'file-child-a'), + packet(app,key,owner,'file.create',args,'file-child-b')] + failed = await call(app,'file.batch',{'requests':ops},key=key,subject=owner) + assert failed.status == 'error' and failed.error.code == 'batch_aborted', wire(failed) + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT COUNT(*) FROM resources WHERE name='batch.txt'")[0] == 0 + assert tx.one("SELECT COUNT(*) FROM results WHERE request_id='file-child-a'")[0] == 0 + nested = packet(app,key,owner,'file.batch',{'requests':ops},'nested-file-batch') + denied = await call(app,'file.batch',{'requests':[nested]},key=key,subject=owner) + assert denied.status == 'error' and denied.error.code == 'operation_not_batchable', wire(denied) + succeeded = await call(app,'file.batch',{'requests':ops[:1]},key=key,subject=owner,rid='file-batch-success') + assert succeeded.status == 'ok' and succeeded.data['atomic'] is True, wire(succeeded) + replay = await call(app,'file.batch',{'requests':ops[:1]},key=key,subject=owner,rid='file-batch-success') + assert replay.status == 'ok' and replay.replayed, wire(replay) + + +@pytest.mark.asyncio +async def test_directory_owner_cannot_replace_another_authors_file(installed): + app, _ = installed + key, owner, _ = await register(app,'directory-owner') + writer, author, _ = await register(app,'file-author') + directory = await call(app,'file.mkdir',{'parent':'/main','name':'shared-files'},key=key,subject=owner) + assert directory.status == 'ok', wire(directory) + created = await call(app,'file.create',{'parent':directory.resources[0].id, + 'name':'author.txt','data':b64(b'author bytes')},key=writer,subject=author) + assert created.status == 'ok', wire(created) + rid = created.resources[0].id + denied = await call(app,'file.write',{'id':rid,'base_revision':created.resources[0].revision, + 'data':b64(b'directory owner overwrite')},key=key,subject=owner, + expected=((rid,created.data['generation']),)) + assert denied.status == 'error' and denied.error.code == 'permission_denied', wire(denied) + + +@pytest.mark.asyncio +async def test_binary_replacement_removes_old_search_projection(installed): + app, _ = installed + key, owner, _ = await register(app,'binary-replace') + created = await call(app,'file.create',{'parent':'/@binary-replace/files','name':'document', + 'data':b64(b'old searchable secret'),'media_type':'text/plain'},key=key,subject=owner) + rid = created.resources[0].id + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT text FROM projections WHERE resource_id=?',(rid,))[0] == 'old searchable secret' + result = await call(app,'file.write',{'id':rid,'base_revision':created.resources[0].revision, + 'data':b64(b'\x00\xff'),'media_type':'application/octet-stream'},key=key,subject=owner, + expected=((rid,created.data['generation']),)) + assert result.status == 'ok', wire(result) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT text FROM projections WHERE resource_id=?',(rid,)) is None diff --git a/tests/test_git_selftest.py b/tests/test_git_selftest.py new file mode 100644 index 00000000..da13f980 --- /dev/null +++ b/tests/test_git_selftest.py @@ -0,0 +1,28 @@ +"""The Git diagnostic executes real isolated protocols and refuses live apps.""" +from types import SimpleNamespace + +import pytest + +from msg.admin.git_check import check_git +from msg.core.errors import Failure +from test_service import call, register + + +async def test_git_selftest_requires_disposable_namespace(): + with pytest.raises(Failure, match='selftest_namespace_required'): + await check_git(SimpleNamespace(selftest_run_id=None), None, None) + + +async def test_git_selftest_real_publication_read_and_stale_ref(installed, monkeypatch): + app, _ = installed + # The installed fixture owns a disposable PostgreSQL database and storage. + monkeypatch.setattr(app, 'selftest_run_id', 'isolated-git-fixture') + + async def invoke(operation, args, key=None, subject=None): + return await call(app, operation, args, key=key, subject=subject) + + async def enroll(name): + key, user, _ = await register(app, name) + return key, user + + assert await check_git(app, invoke, enroll) is True diff --git a/tests/test_git_transport_boundaries.py b/tests/test_git_transport_boundaries.py new file mode 100644 index 00000000..d5a9624d --- /dev/null +++ b/tests/test_git_transport_boundaries.py @@ -0,0 +1,140 @@ +"""Git owns files/publication; protocol adapters own requests and responses.""" +from __future__ import annotations + +import ast +import importlib +from pathlib import Path +import subprocess +import sys + +import httpx +import pytest + +from msg.core.errors import Failure +from msg.core.codec import wire +from msg.extensions.repositories import NativeGitStore +from msg.extensions.ssh_git import ReferenceGuard +from msg.transports.http import create_app +from test_service import call, register +from test_native_git_read_only import business_snapshot, refs_snapshot, seed_ref + + +def dependencies(module): + tree=ast.parse(Path(importlib.import_module(module).__file__).read_text()) + return [node.module or '' for node in ast.walk(tree) if isinstance(node,ast.ImportFrom)] + [ + alias.name for node in ast.walk(tree) if isinstance(node,ast.Import) for alias in node.names] + + +def test_shared_repository_module_does_not_import_http_or_ssh_adapters(): + forbidden=('starlette','msg.transports.http','msg.transports.git_http','msg.extensions.ssh_git') + assert not [name for name in dependencies('msg.extensions.repositories') + if any(name==prefix or name.startswith(prefix+'.') for prefix in forbidden)] + + +def test_repository_store_has_no_http_handlers_or_request_conversion_methods(): + assert not {'http','http_push','http_lfs','_http_push_receive','_lfs_packet', + '_lfs_authorize','_lfs_preflight_publish','_repo_read_path','_job'}.intersection(vars(NativeGitStore)) + assert {'path','lfs','lfs_capacity','create','refs','import_bundle','update_refs'}<=set(vars(NativeGitStore)) + + +def test_shared_publication_has_no_protocol_adapter_dependency(): + module=importlib.import_module('msg.extensions.git_publication') + assert not hasattr(module,'receive_pack') + forbidden=('starlette','msg.transports.http','msg.transports.git_http','msg.extensions.ssh_git') + assert not [name for name in dependencies(module.__name__) + if any(name==prefix or name.startswith(prefix+'.') for prefix in forbidden)] + + +def test_both_adapters_use_the_same_publication_implementation(): + owner=importlib.import_module('msg.extensions.git_publication') + ssh=importlib.import_module('msg.extensions.ssh_git') + http=importlib.import_module('msg.transports.git_http') + for name in ('ReferenceGuard','hook_program','relay_bounded_stdin','guarded_command'): + assert getattr(ssh,name) is getattr(owner,name) + assert getattr(owner,name).__module__==owner.__name__ + assert http.guarded_command is owner.guarded_command + assert 'msg.extensions.ssh_git' not in dependencies(http.__name__) + + +def test_http_helpers_have_one_owner_without_a_router_import_cycle(): + common=importlib.import_module('msg.transports.http_common') + routes=importlib.import_module('msg.transports.http_routes') + public=importlib.import_module('msg.transports.http') + git=importlib.import_module('msg.transports.git_http') + for name in ('body_bytes','json_response','error_status'): + assert getattr(common,name) is getattr(routes,name) is getattr(public,name) is getattr(git,name) + assert getattr(common,name).__module__==common.__name__ + assert routes.BASE_HEADERS is common.BASE_HEADERS + assert not {'msg.transports.http','msg.transports.http_routes'}.intersection(dependencies(git.__name__)) + assert not {'msg.transports.http','msg.transports.http_routes','msg.transports.git_http'}.intersection(dependencies(common.__name__)) + + +def test_git_storage_and_publication_import_without_loading_protocol_adapters(): + code=''' +import importlib.abc,sys +class NoAdapters(importlib.abc.MetaPathFinder): + def find_spec(self,fullname,path=None,target=None): + if any(fullname==name or fullname.startswith(name+'.') for name in + ('starlette','msg.transports.http','msg.transports.http_routes', + 'msg.transports.git_http','msg.extensions.ssh_git')): + raise AssertionError('shared Git imported adapter '+fullname) +sys.meta_path.insert(0,NoAdapters()) +from msg.extensions.repositories import NativeGitStore +from msg.extensions.git_publication import ReferenceGuard,guarded_command +''' + result=subprocess.run([sys.executable,'-c',code],capture_output=True,text=True) + assert result.returncode==0,result.stderr + + +async def test_http_uses_explicit_store_composition_not_storage_inheritance(installed): + app,_=installed + adapter=importlib.import_module('msg.transports.git_http').GitHTTPAdapter + store=NativeGitStore(app) + assert not hasattr(store,'app') + assert store.root==app.settings.server.repositories_dir + assert store.blob_root==app.settings.server.blob_dir + http=adapter(app,store=store) + assert http.store is store and http.app is app + assert not isinstance(http,NativeGitStore) + assert '__getattr__' not in vars(adapter) + + +async def test_public_git_read_and_rejected_push_preserve_all_business_state(installed): + app,_=installed + key,uid,_=await register(app,'git-boundary-read') + created=await call(app,'git.create',{'parent':'/@git-boundary-read','name':'code.git'}, + key=key,subject=uid) + assert created.status=='ok',wire(created) + rid=created.resources[0].id + repo=NativeGitStore(app).path(rid) + commit=seed_ref(repo) + before=await business_snapshot(app) + refs=refs_snapshot(repo) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as client: + path='/@git-boundary-read/code.git' + # Dumb HTTP HEAD is intentionally disabled by http.getanyfile=false. + # Exercise the supported smart fetch, not a new endpoint contract. + want=f'want {commit}\n'.encode() + packet=f'{len(want)+4:04x}'.encode()+want+b'00000009done\n' + fetched=await client.post(path+'/git-upload-pack',content=packet, + headers={'Content-Type':'application/x-git-upload-pack-request'}) + assert fetched.status_code==200 and b'PACK' in fetched.content, fetched.text + advertised=await client.get(path+'/info/refs?service=git-upload-pack') + assert advertised.status_code==200 and b'git-upload-pack' in advertised.content + rejected=await client.post(path+'/git-receive-pack',content=b'0000') + assert rejected.status_code in {401,403,405} + unauthenticated=await client.get('/-/git/'+rid+'/info/refs?service=git-receive-pack') + assert unauthenticated.status_code==401 + assert refs_snapshot(repo)==refs + assert await business_snapshot(app)==before + + +def test_reference_batch_validation_preserves_exact_git_change_contract(): + old,new='0'*40,'1'*40 + rows=ReferenceGuard.validate_changes(f'{old} {new} refs/heads/main\n{old} {new} refs/tags/v1\n') + assert rows==[{'ref':'refs/heads/main','old':old,'new':new}, + {'ref':'refs/tags/v1','old':old,'new':new}] + for value in ('',f'{old} {new} refs/heads/main\n{old} {new} refs/heads/main', + f'bad {new} refs/heads/main',f'{old} {new} refs/remotes/private'): + with pytest.raises(Failure):ReferenceGuard.validate_changes(value) diff --git a/tests/test_honor_diagnostics.py b/tests/test_honor_diagnostics.py new file mode 100644 index 00000000..bb679e88 --- /dev/null +++ b/tests/test_honor_diagnostics.py @@ -0,0 +1,46 @@ +import pytest +from test_service import call, register + +from msg.bootstrap import feature_manifest, manifest +from msg.core.codec import wire +from msg.core.errors import Failure + + +def test_honor_feature_has_non_granting_bootstrap_sample(): + row = next(row for row in feature_manifest() if row['feature_id'] == 'honors') + assert row['sample_resource'] == 'r_honor_sample' + assert row['doctor_check'] == 'honors' + assert row['selftest_case'] == 'honor_ceremony_display' + sample = manifest()['honor_sample'] + assert sample['profile']['pinned_grant_ids'] == [] + assert sample['grants'] == [] + assert sample['issuer']['purpose'] == 'achievement-grant' + + +@pytest.mark.asyncio +async def test_honor_diagnostics_use_installed_sample_and_real_requests(installed): + from msg.admin.honor_check import check_honors, inspect_honors + app, _ = installed + async with app.metadata.transaction(write=False) as tx: + assert (await inspect_honors(app, tx))['read_only'] is True + assert tx.one('SELECT COUNT(*) FROM achievement_grants')[0] == 0 + sample = await call(app, 'discovery.get', {'id': 'r_honor_sample'}) + assert sample.status == 'ok' + assert wire(sample.data['content']) == manifest()['honor_sample'] + + async def invoke(name, args, key=None, subject=None): + return await call(app, name, args, key=key, subject=subject) + + async def enrolled(name): + key, subject, _ = await register(app, name) + return key, subject + + assert await check_honors(app, invoke, enrolled) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM achievement_grants')[0] == 1 + assert tx.one('SELECT COUNT(*) FROM achievement_pins')[0] == 0 + async with app.metadata.transaction(write=True) as tx: + tx.execute('ALTER TABLE achievement_pins RENAME COLUMN position TO broken_position', write=True) + async with app.metadata.transaction(write=False) as tx: + with pytest.raises(Failure, match='honor_schema_missing'): + await inspect_honors(app, tx) diff --git a/tests/test_hosting_runtime.py b/tests/test_hosting_runtime.py index 2f432f05..7d9df4cb 100644 --- a/tests/test_hosting_runtime.py +++ b/tests/test_hosting_runtime.py @@ -20,7 +20,7 @@ READ_TABLES = ( 'schema_version', 'resources', 'revisions', 'identities', 'memberships', 'credentials', 'certificates', 'settings', 'share_grants', 'share_grants_v2', - 'dm_conversations', 'system_sources', + 'dm_conversations', 'system_sources', 'resource_path_aliases', ) @@ -213,6 +213,31 @@ async def test_hosting_missing_or_stale_installation_fails_without_repair(instal await reader.close() +@pytest.mark.parametrize('synced', [True, False]) +async def test_hosting_compares_declared_rule_retirement(installed, reader_settings, tmp_path, + monkeypatch, synced): + import msg.bootstrap as bootstrap + from msg.bootstrap import sync_system_sources + from test_system_rule_retirement import retirement + app, _ = installed + source, declaration = await retirement(app, tmp_path) + if synced: + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=app.registry) + monkeypatch.setattr(bootstrap, 'SOURCE_RETIREMENTS', declaration) + monkeypatch.setattr(bootstrap, 'system_source_root', lambda: source) + reader = runtime_class()(reader_settings, clock=lambda: NOW) + try: + if synced: + await reader.load() + else: + with pytest.raises(Failure, match='hosting_installation_stale'): + await reader.load() + finally: + await reader.close() + + async def test_hosting_private_preview_head_range_revocation_and_zero_effects(installed, reader_settings): app, _ = installed key, subject, certificate, site, preview = await website(app) @@ -301,3 +326,53 @@ def forbidden(*args, **kwargs): assert daemon.main(['--config-dir', str(reader_settings.config_dir), 'hosting']) == 0 assert called[0] == reader_settings assert len(called) == 2 and called[1][1]['access_log'] is False + + +async def test_readonly_hosting_role_resolves_old_site_paths_without_authority_leaks(installed,reader_settings): + app,_=installed + key,subject,_,site,_=await website(app) + rid=site.resources[0].id + async with app.metadata.transaction(write=False) as tx: + generation=(await tx.resource(rid)).generation + moved=await call(app,'file.move',{'id':rid,'parent':'/@readonly-host','name':'renamed-web'}, + key=key,subject=subject,expected=((rid,generation),)) + assert moved.status=='ok',wire(moved) + reader=runtime_class()(reader_settings,clock=lambda:NOW) + try: + await reader.load() + async with reader.metadata.transaction(write=False) as tx: + assert await tx.resolve_migrated('/@readonly-host/web')==rid + assert tx.one('SHOW transaction_read_only')==('on',) + from msg.extensions.hosting import hosting_app + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(reader)), + base_url=reader_settings.service_url) as http: + for method in ('GET','HEAD'): + response=await http.request(method,'/@readonly-host/web/',headers={'if-none-match':'"warm"','range':'bytes=0-3'}) + assert response.status_code==308,response.text + assert response.headers['location']=='/@readonly-host/renamed-web/' + isolated(response) + canonical_response=await http.get('/@readonly-host/renamed-web/') + assert canonical_response.status_code==200 and canonical_response.content==b'

published

' + private=await call(app,'content.chmod',{'id':rid,'mode':'0700'},key=key,subject=subject, + expected=((rid,moved.data['generation']),)) + assert private.status=='ok',wire(private) + for method in ('GET','HEAD'): + denied=await http.request(method,'/@readonly-host/web/',headers={'if-none-match':canonical_response.headers['etag']}) + assert denied.status_code==403,denied.text + assert 'location' not in denied.headers and 'etag' not in denied.headers + assert 'renamed-web' not in denied.text + finally: + await reader.close() + + +async def test_hosting_missing_alias_table_grant_fails_closed_at_startup(installed,reader_settings): + app,_=installed + role=conninfo_to_dict(reader_settings.server.postgres_dsn)['user'] + with psycopg.connect(app.settings.server.postgres_dsn,autocommit=True) as conn: + conn.execute(sql.SQL('REVOKE SELECT ON public.resource_path_aliases FROM {}').format(sql.Identifier(role))) + reader=runtime_class()(reader_settings,clock=lambda:NOW) + try: + with pytest.raises(Failure,match='^hosting_installation_stale$'): + await reader.load() + finally: + await reader.close() diff --git a/tests/test_http_effect_order.py b/tests/test_http_effect_order.py new file mode 100644 index 00000000..08bcdcaa --- /dev/null +++ b/tests/test_http_effect_order.py @@ -0,0 +1,107 @@ +"""Ordinary read views reject changed effects before resolving any resource.""" +from contextlib import asynccontextmanager +from dataclasses import replace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from msg.security.quarantine import RUNTIME_GENERATION +from msg.transports.http import create_app +from read_only_evidence import readonly_evidence + + +@asynccontextmanager +async def runtime_check_only(app, patch): + """Keep the real database-backed freshness guard; forbid business queries. + + Blocking every transaction also blocks the outer HTTP recovery guard. This + narrow observer instead delegates only that guard's generation read to the + actual session. Resource lookups, other settings, SQL and writes still fail. + """ + transaction = app.metadata.transaction + settings_read = [] + transactions = [] + attempts = [] + + class RuntimeSession: + def __init__(self, session): + self._session = session + + def setting(self, key, default=None): + settings_read.append(key) + assert key == RUNTIME_GENERATION, 'effect gate read a business setting' + return self._session.setting(key, default) + + def __getattr__(self, name): + attempts.append(name) + raise AssertionError('effect gate attempted a business query: ' + name) + + @asynccontextmanager + async def guarded_transaction(*, write): + transactions.append(write) + assert write is False, 'effect gate opened a write transaction' + async with transaction(write=False) as session: + yield RuntimeSession(session) + + runtime = AsyncMock(wraps=app.executor.require_current_runtime) + execute = AsyncMock(side_effect=AssertionError('effect gate executed an operation')) + patch.setattr(app.metadata, 'transaction', guarded_transaction) + patch.setattr(app.executor, 'require_current_runtime', runtime) + patch.setattr(app.executor, 'execute', execute) + yield runtime + assert transactions == [False] * runtime.await_count + assert settings_read == [RUNTIME_GENERATION] * runtime.await_count + assert attempts == [], 'effect gate attempted a business query' + execute.assert_not_called() + + +@pytest.mark.parametrize('effect', ['transaction', 'external']) +@pytest.mark.parametrize('method', ['GET', 'HEAD']) +async def test_non_read_views_do_not_resolve_or_execute(installed, monkeypatch, effect, method): + app, _ = installed + paths = ('/main', '/missing-resource', '/main/raw', '/main/json', + '/main/meta', '/main/history', '/_id/missing/raw') + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + for operation in ('discovery.get', 'discovery.raw'): + key = (operation, 1) + patch.setitem(app.registry._operations, key, + replace(app.registry._operations[key], effect=effect)) + async with runtime_check_only(app, patch) as runtime: + for path in paths: + response = await http.request(method, path) + assert response.status_code == 405, (method, path, response.text) + if method == 'GET': + assert response.json()['error']['code'] == 'effect_mismatch' + else: + assert response.content == b'' + assert runtime.await_count == len(paths) + + +@pytest.mark.parametrize('effect', ['transaction', 'external']) +async def test_stale_runtime_is_checked_before_the_public_effect_gate( + installed, monkeypatch, effect): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + tx.set_setting(RUNTIME_GENERATION, 'effect-order-new-generation') + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + for operation in ('discovery.get', 'discovery.raw'): + key = (operation, 1) + patch.setitem(app.registry._operations, key, + replace(app.registry._operations[key], effect=effect)) + operation_lookup = Mock(side_effect=AssertionError( + 'stale runtime reached the route effect gate')) + patch.setattr(app.registry, 'operation', operation_lookup) + async with runtime_check_only(app, patch) as runtime: + for path in ('/main', '/_id/missing/raw'): + response = await http.get(path) + assert response.status_code == 503 + assert response.json()['error']['code'] == 'recovery_runtime_stale' + assert runtime.await_count == 2 + operation_lookup.assert_not_called() diff --git a/tests/test_independent_share_after_move.py b/tests/test_independent_share_after_move.py new file mode 100644 index 00000000..bc27f296 --- /dev/null +++ b/tests/test_independent_share_after_move.py @@ -0,0 +1,66 @@ +"""A live independent grant survives stacked changes to another source.""" +import pytest +from test_authorization_sources import grant, private_post +from test_service import register +from test_share_grants_v2 import invoke, ok + +from msg.core.codec import canonical + + +@pytest.mark.asyncio +async def test_existing_direct_share_survives_move_leave_and_parent_revocation(installed): + app, _ = installed + owner = await register(app, 'stacked-owner') + middle = await register(app, 'stacked-middle') + independent = await register(app, 'stacked-independent') + derived_only = await register(app, 'stacked-derived-only') + group = ok(await invoke(app, owner, 'group.create', {'name': 'stacked-sources'})).resources[0].id + ok(await invoke(app, owner, 'group.invite', {'group': group, 'subject': middle[1]})) + ok(await invoke(app, middle, 'group.join', {'group': group})) + rid, revision = await private_post(app, owner) + parent_grant = await grant(app, owner, rid, group, kind='group', reshare=True) + for reader in (independent, derived_only): + await grant(app, middle, rid, reader[1], parent=parent_grant) + direct = await grant(app, owner, rid, independent[1]) + + queries = ({'id': rid}, {'id': rid, 'view': 'history'}, {'id': rid, 'revision': revision}) + + async def check(reader, allowed): + for query in queries: + result = await invoke(app, reader, 'discovery.get', query) + if allowed: + ok(result) + expected = revision if query.get('view') == 'history' else 'source-matrix-private' + assert expected in canonical(result.data).decode() + else: + assert result.status == 'error' and result.error.code == 'permission_denied' + assert not result.resources + assert not result.data + + await check(independent, True) + await check(derived_only, True) + destination = ok(await invoke(app, owner, 'content.topic_create', + {'parent': '/main', 'name': 'private-destination'})) + directory = destination.resources[0].id + ok(await invoke(app, owner, 'content.chmod', {'id': directory, 'mode': '0700'}, + expected=((directory, destination.data['generation']),))) + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(rid)).generation + ok(await invoke(app, owner, 'content.move', {'id': rid, 'parent': directory}, + expected=((rid, generation),))) + await check(independent, True) + await check(derived_only, True) + + ok(await invoke(app, middle, 'group.leave', {'group': group})) + await check(independent, True) + await check(derived_only, False) + ok(await invoke(app, owner, 'sharing.revoke', {'grant_id': parent_grant}, version=2)) + await check(independent, True) + await check(derived_only, False) + # Target-specific sharing must never make its new private parent readable. + for reader in (independent, derived_only): + denied = await invoke(app, reader, 'discovery.get', {'id': directory}) + assert denied.error.code == 'permission_denied' + ok(await invoke(app, owner, 'sharing.revoke', {'grant_id': direct}, version=2)) + await check(independent, False) + await check(derived_only, False) diff --git a/tests/test_lease_list_bounds.py b/tests/test_lease_list_bounds.py new file mode 100644 index 00000000..4a4fcdc3 --- /dev/null +++ b/tests/test_lease_list_bounds.py @@ -0,0 +1,104 @@ +"""Legacy lease pages stay bounded while applying real current permissions.""" +from contextlib import asynccontextmanager +from datetime import timedelta + +import pytest +from read_only_evidence import readonly_evidence +from test_service import NOW, call, register + +from msg.core.codec import canonical, loads, wire + + +@pytest.mark.asyncio +@pytest.mark.parametrize('visible_count', [0, 3]) +async def test_lease_pages_bound_sql_and_cross_hidden_batches(installed, monkeypatch, visible_count): + app, _ = installed + owner_key, owner, _ = await register(app, 'lease-target-owner') + reader_key, reader, _ = await register(app, 'lease-list-reader') + targets, templates = [], [] + for name in ('hidden', 'visible'): + post = await call(app, 'content.post_create', + {'parent': '/main', 'name': name, 'body': name + '-lease-body'}, + key=owner_key, subject=owner) + assert post.status == 'ok', wire(post) + targets.append(post) + acquired = await call(app, 'communication.lease_acquire', + {'target': post.resources[0].id, 'purpose': name + '-lease-purpose', + 'expires_at': wire(NOW + timedelta(hours=1))}, + key=reader_key, subject=reader) + assert acquired.status == 'ok', wire(acquired) + async with app.metadata.transaction(write=False) as tx: + templates.append(loads(tx.one('SELECT body FROM collaboration_leases WHERE id=?', + (acquired.data['lease']['id'],))[0])) + hidden = targets[0] + changed = await call(app, 'content.chmod', + {'id': hidden.resources[0].id, 'mode': '0700'}, key=owner_key, subject=owner, + expected=((hidden.resources[0].id, hidden.data['generation']),)) + assert changed.status == 'ok', wire(changed) + + # Stress fixtures copy records originally created by real signed operations. + # They are test-only authoritative rows, not a production migration claim. + async with app.metadata.transaction(write=True) as tx: + tx.execute('DELETE FROM collaboration_leases WHERE holder=?', (reader,), write=True) + for index in range(129 + visible_count): + record = dict(templates[0 if index < 129 else 1]) + record['id'] = f'lease_page_{index:04d}' + if index == 130: + record.update(acquired_at=wire(NOW - timedelta(hours=2)), + expires_at=wire(NOW - timedelta(hours=1))) + if index == 131: + record.update(status='released', generation=2) + tx.execute('INSERT INTO collaboration_leases VALUES (?,?,?,?,?,?,?)', + (record['id'], reader, record['target'], record['status'], + record['generation'], record['expires_at'], canonical(record).decode()), write=True) + transaction = app.metadata.transaction + batches, unbounded_queries = [], [] + + @asynccontextmanager + async def bounded_transaction(*, write): + assert write is False + async with transaction(write=False) as tx: + query_rows = tx.rows + + def bounded_rows(sql, parameters=()): + lease_query = sql.startswith('SELECT id,target,status,expires_at,body FROM collaboration_leases ') + if lease_query and ' LIMIT ' not in sql.upper(): + unbounded_queries.append(sql) + raise AssertionError('lease_list issued an unbounded SELECT') + rows = query_rows(sql, parameters) + if lease_query: + assert len(rows) <= 128, 'lease_list materialized more than one bounded batch' + batches.append(len(rows)) + return rows + + with monkeypatch.context() as session_patch: + session_patch.setattr(tx, 'rows', bounded_rows) + yield tx + + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + patch.setattr(app.metadata, 'transaction', bounded_transaction) + after = None + seen, statuses = [], [] + for page in range(max(1, visible_count)): + args = {'limit': 1} + if after is not None: + args['after'] = after + result = await call(app, 'communication.lease_list', args, + key=reader_key, subject=reader) + assert not unbounded_queries, 'unbounded lease SQL: ' + repr(unbounded_queries) + assert result.status == 'ok', str(wire(result)) + assert 'hidden-lease-purpose' not in str(wire(result)) + items = result.data['items'] + assert len(items) == (1 if visible_count else 0) + seen.extend(item['id'] for item in items) + statuses.extend(item['effective_status'] for item in items) + if page == 0: + assert len(batches) >= 2, 'must scan past the first fully hidden batch' + after = result.data['next_after'] + if page + 1 < visible_count: + assert after == items[-1]['id'] + else: + assert after is None + assert seen == [f'lease_page_{i:04d}' for i in range(129, 129 + visible_count)] + assert statuses == (['active', 'expired', 'released'] if visible_count else []) diff --git a/tests/test_lease_list_deadline.py b/tests/test_lease_list_deadline.py new file mode 100644 index 00000000..82bc788e --- /dev/null +++ b/tests/test_lease_list_deadline.py @@ -0,0 +1,44 @@ +"""Deadline exhaustion fails closed before legacy lease SQL materialization.""" +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest +from read_only_evidence import readonly_evidence +from test_service import call, register + +from msg.plugins import collaboration + + +@pytest.mark.asyncio +async def test_expired_lease_list_budget_refuses_before_query(installed, monkeypatch): + app, _ = installed + key, subject, _ = await register(app, 'lease-deadline') + transaction = app.metadata.transaction + attempted = [] + + @asynccontextmanager + async def no_lease_scan(*, write): + assert write is False + async with transaction(write=False) as tx: + query_rows = tx.rows + + def guard(sql, parameters=()): + if 'FROM collaboration_leases ' in sql: + attempted.append(sql) + raise AssertionError('expired request issued a lease scan') + return query_rows(sql, parameters) + + with monkeypatch.context() as session_patch: + session_patch.setattr(tx, 'rows', guard) + yield tx + + async with readonly_evidence(app, monkeypatch): + with monkeypatch.context() as patch: + # Only this handler sees an expired clock. Real signing, principal, + # runtime generation and current authorization are not replaced. + patch.setattr(collaboration, 'time', SimpleNamespace(monotonic=lambda: float('inf'))) + patch.setattr(app.metadata, 'transaction', no_lease_scan) + result = await call(app, 'communication.lease_list', {'limit': 1}, + key=key, subject=subject) + assert not attempted, 'lease scan ran after its deadline: ' + repr(attempted) + assert result.status == 'error' and result.error.code == 'query_cost_exceeded' diff --git a/tests/test_legacy_approval.py b/tests/test_legacy_approval.py new file mode 100644 index 00000000..1c506570 --- /dev/null +++ b/tests/test_legacy_approval.py @@ -0,0 +1,169 @@ +from copy import deepcopy + +import pytest +from test_legacy_resource_import import setup + +from msg.admin import legacy_approval as module +from msg.core.errors import Failure + + +@pytest.mark.asyncio +async def test_review_requires_real_holder_and_explicit_mapping(installed, tmp_path): + app, _root, _snapshot, approval, _key, _cert = await setup(installed, tmp_path) + approval["identities"] = {"old-id": None} + review = await module.validate_and_review(app, approval) + assert review["real_holder"]["subject"] == approval["operator"] + assert review["unmapped_count"] == 1 + assert review["explicit_new_subject_associations"] == {} + bad = deepcopy(approval) + bad["identities"] = {"old-id": "u_nonexistent"} + with pytest.raises(Failure): + await module.validate_and_review(app, bad) + bad = deepcopy(approval) + bad["extra"] = True + with pytest.raises(Failure, match="legacy_approval_invalid"): + module.recognized_payload(bad) + with pytest.raises(Failure, match="unsupported_legacy_approval_purpose"): + module.recognized_payload({"format": "certificate"}) + + +def test_console_gate_precedes_files_and_pin(tmp_path, monkeypatch): + def denied(*args): + raise Failure("local_only") + + monkeypatch.setattr(module, "require_local_console", denied) + monkeypatch.setattr(module.getpass, "getpass", lambda *_: pytest.fail("PIN requested")) + with pytest.raises(Failure, match="local_only"): + module.sign_approval(tmp_path, tmp_path / "absent", tmp_path / "out") + assert not (tmp_path / "out").exists() + + +def test_private_approval_output_is_exclusive(tmp_path): + target = tmp_path / "draft" + module.write_new(target, {"format": "example"}) + assert target.stat().st_mode & 0o777 == 0o600 + assert module.read_json(target) == {"format": "example"} + with pytest.raises(Failure, match="approval_destination_exists"): + module.write_new(target, {}) + + +def test_console_signs_only_reviewed_legacy_payload(tmp_path, monkeypatch): + from contextlib import nullcontext + from types import SimpleNamespace + + from msg.core.codec import canonical, decode, digest + from msg.core.models import Signature + from msg.security.crypto import Ed25519Signer, seal_private_key, verify + from msg.storage.legacy_resource_import import PURPOSE, approval_payload + + root = Ed25519Signer.generate() + approval = approval_payload( + sha256="a" * 64, + service="https://candidate.invalid", + parent="r_parent", + parent_generation=1, + operator="u_holder", + identities={"old": None}, + expires_at="2026-09-29T12:00:00Z", + ) + draft, envelope, output = (tmp_path / name for name in ("draft", "key", "signed")) + module.write_new(draft, approval) + module.write_new(envelope, seal_private_key(root.private_bytes(), "synthetic-test-pin")) + + async def inspect(*_): + return {"approval_digest": digest(approval)}, root.public_key + + monkeypatch.setattr(module, "require_local_console", lambda *_: "test-console") + monkeypatch.setattr(module, "inspect_approval", inspect) + monkeypatch.setattr( + module, "load_settings", lambda *_: SimpleNamespace(root_private_dir=tmp_path) + ) + monkeypatch.setattr(module, "rotation_lock", lambda *_: nullcontext()) + monkeypatch.setattr(module, "root_envelope", lambda *_: envelope) + monkeypatch.setattr( + module, "input", lambda *_: "APPROVE LEGACY IMPORT " + digest(approval), raising=False + ) + monkeypatch.setattr(module.getpass, "getpass", lambda *_: "synthetic-test-pin") + module.sign_approval(tmp_path, draft, output) + result = module.read_json(output) + verify( + root.public_key, + canonical(approval), + decode(Signature, result["signature"]), + purpose=PURPOSE, + ) + assert result["approval"] == approval + monkeypatch.setattr(module, "input", lambda *_: "cancel", raising=False) + monkeypatch.setattr( + module.getpass, "getpass", lambda *_: pytest.fail("PIN requested after cancel") + ) + with pytest.raises(Failure, match="approval_cancelled"): + module.sign_approval(tmp_path, draft, tmp_path / "cancelled") + assert not (tmp_path / "cancelled").exists() + + +@pytest.mark.asyncio +async def test_runbook_holder_registers_through_local_http(installed, tmp_path): + import httpx + + from msg.client import ClientState, MsgClient + from msg.transports.client import HTTPTransport + from msg.transports.http import create_app + + app, _ = installed + state = ClientState(tmp_path / "new-real-holder", server=app.settings.service_url) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + client = MsgClient(state, HTTPTransport(state.server, http=http), clock=app.clock) + result = await client.register("migration-holder") + assert result.status == "ok", result.error + for name in ("legacy-content", "legacy-git", "legacy-identities"): + result = await client.call("content.topic_create", {"parent": "/main", "name": name}) + assert result.status == "ok", result.error + ref = result.resources[0] + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(ref.id)).generation + result = await client.call( + "content.chmod", {"id": ref.id, "mode": "0700"}, expected=((ref.id, generation),) + ) + assert result.status == "ok", result.error + async with app.metadata.transaction(write=False) as tx: + parent = await tx.resource(ref.id) + assert parent.owner == state.subject and parent.mode == 0o700 + assert state.key_path.exists() + assert ClientState(state.directory).subject == state.subject + + +@pytest.mark.asyncio +async def test_reviewed_visibility_restores_live_content_but_not_provenance(installed, tmp_path): + from test_service import call + + from msg.core.codec import canonical, wire + from msg.storage.legacy_resource_import import PURPOSE, import_content + + app, root, snapshot, approval, key, cert = await setup(installed, tmp_path) + report = await import_content( + app, snapshot, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + post_id = report["url_map"]["/main/17"] + async with app.metadata.transaction(write=False) as tx: + provenance_id = tx.setting("legacy-provenance:" + post_id)["resource"] + candidates = set(report["url_map"].values()) + candidates.discard(approval["parent"]) + for rid in [*sorted(candidates), approval["parent"]]: + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(rid) + result = await call( + app, + "content.chmod", + {"id": rid, "mode": "0755" if resource.type == "topic" else "0644"}, + key=key, + subject=approval["operator"], + certs=(cert,), + expected=((rid, resource.generation),), + ) + assert result.status == "ok", result.error + assert (await call(app, "discovery.get", {"id": post_id})).status == "ok" + assert (await call(app, "discovery.get", {"id": report["url_map"]["/file/1"]})).status == "ok" + assert (await call(app, "discovery.get", {"id": provenance_id})).status == "error" diff --git a/tests/test_legacy_content_http.py b/tests/test_legacy_content_http.py new file mode 100644 index 00000000..aee11d42 --- /dev/null +++ b/tests/test_legacy_content_http.py @@ -0,0 +1,154 @@ +from datetime import timedelta + +import httpx +import pytest + +from msg.core.codec import b64, canonical, wire +from msg.core.requests import request_for +from msg.storage.legacy_resource_import import PURPOSE, import_content +from msg.transports.http import create_app +from test_legacy_resource_import import setup +from test_service import NOW, register + + +def signed(app, key, subject, rid, view=None): + args = {"id": rid} + if view == "meta": + args["view"] = "meta" + packet = request_for( + "discovery.raw" if view == "raw" else "discovery.get", + args, + app.settings.service_url, + signer=key, + subject=subject, + expires_at=NOW + timedelta(minutes=2), + ) + return {"x-msg-request": b64(canonical(packet))} + + +@pytest.mark.asyncio +async def test_import_http_requires_current_signature_and_preserves_provenance(installed, tmp_path): + app, root, source, approval, key, cert = await setup(installed, tmp_path) + report = await import_content( + app, source, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + prefix = "/_legacy/" + approval["source_sha256"] + rid = report["url_map"]["/main/17"] + async with app.metadata.transaction(write=False) as tx: + provenance = tx.setting("legacy-provenance:" + rid)["resource"] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url, + follow_redirects=False, + ) as http: + for method in ("GET", "HEAD"): + for suffix, view in (("", None), ("/raw", "raw"), ("/meta", "meta")): + denied = await http.request( + method, + prefix + "/main/17" + suffix, + headers={"if-none-match": '"warm"', "range": "bytes=0-1"}, + ) + assert denied.status_code == 403, denied.text + assert "location" not in denied.headers and "link" not in denied.headers + assert "etag" not in denied.headers and "old body" not in denied.text + allowed = await http.request( + method, + prefix + "/main/17" + suffix, + headers=signed(app, key, approval["operator"], rid, view), + ) + assert allowed.status_code == 308, allowed.text + assert allowed.headers["cache-control"] == "no-store" + assert allowed.headers["x-msg-legacy-signature"] == "unverified-historical-claim" + assert "/provenance>" in allowed.headers["link"] + followed = await http.request( + method, + allowed.headers["location"], + headers=signed(app, key, approval["operator"], rid, view), + ) + assert followed.status_code == 200, followed.text + history = await http.get( + prefix + "/main/17/provenance", + headers=signed(app, key, approval["operator"], provenance), + ) + assert history.status_code == 308, history.text + value = await http.get( + history.headers["location"], headers=signed(app, key, approval["operator"], provenance) + ) + assert value.status_code == 200 and "historical-signature" in value.text + file_id = report["url_map"]["/file/1"] + attachment = await http.get( + prefix + "/file/1", headers=signed(app, key, approval["operator"], file_id, "raw") + ) + assert attachment.status_code == 308, attachment.text + raw = await http.get( + attachment.headers["location"], + headers=signed(app, key, approval["operator"], file_id, "raw"), + ) + assert raw.content == b"abc" + # No implicit takeover of current /main routes or write alias. + assert (await http.post(prefix + "/main/17")).status_code == 405 + assert (await http.get("/main/17")).status_code == 404 + assert (await http.get("/_legacy/" + "0" * 64 + "/main/17")).status_code == 404 + outsider, other, _ = await register(app, "legacy-outsider") + denied = await http.get(prefix + "/main/17", headers=signed(app, outsider, other, rid)) + assert denied.status_code == 403 and "location" not in denied.headers + # Authorization is live even after a formerly successful redirect. + async with app.metadata.transaction(write=True) as tx: + from dataclasses import replace + + current = await tx.resource(rid) + await tx.replace( + replace(current, mode=0, generation=current.generation + 1), current.generation + ) + denied = await http.get( + prefix + "/main/17", headers=signed(app, key, approval["operator"], rid) + ) + assert denied.status_code == 403 and "location" not in denied.headers + + +@pytest.mark.asyncio +async def test_legacy_mapping_race_and_namespace_escape_do_not_redirect( + installed, tmp_path, monkeypatch +): + from dataclasses import replace + + app, root, source, approval, key, cert = await setup(installed, tmp_path) + report = await import_content( + app, source, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + sha256 = approval["source_sha256"] + path = "/_legacy/" + sha256 + "/main/17" + rid = report["url_map"]["/main/17"] + execute = app.executor.execute + changed = False + + async def replace_mapping(packet, **kwargs): + nonlocal changed + if not changed and packet.arguments.get("id") == rid: + changed = True + async with app.metadata.transaction(write=True) as tx: + record = tx.setting("legacy-import:" + sha256) + record["url_map"]["/main/17"] = tx.setting("legacy-provenance:" + rid)["resource"] + tx.set_setting("legacy-import:" + sha256, record) + return await execute(packet, **kwargs) + + monkeypatch.setattr(app.executor, "execute", replace_mapping) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + response = await http.get(path, headers=signed(app, key, approval["operator"], rid)) + assert response.status_code == 400, response.text + assert "location" not in response.headers and "link" not in response.headers + async with app.metadata.transaction(write=True) as tx: + record = tx.setting("legacy-import:" + sha256) + record["url_map"]["/main/17"] = rid + tx.set_setting("legacy-import:" + sha256, record) + current = await tx.resource(rid) + await tx.replace( + replace( + current, parent=await tx.resolve("/main"), generation=current.generation + 1 + ), + current.generation, + ) + escaped = await http.get(path, headers=signed(app, key, approval["operator"], rid)) + assert escaped.status_code == 404 and "location" not in escaped.headers diff --git a/tests/test_legacy_git_import.py b/tests/test_legacy_git_import.py new file mode 100644 index 00000000..f82be9e5 --- /dev/null +++ b/tests/test_legacy_git_import.py @@ -0,0 +1,149 @@ +from datetime import timedelta +import subprocess + +import pytest + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure +from msg.extensions.repositories import NativeGitStore +from msg.storage.legacy_git_import import ( + PURPOSE, + git_approval, + git_env, + import_git, + sha256_file, + verify_bundle, +) +from test_legacy_resource_import import setup +from test_service import NOW, call + + +def make_bundle(tmp_path, *, lfs=False): + source = tmp_path / "source-repo" + subprocess.run( + ["git", "init", "--initial-branch=main", str(source)], + check=True, + capture_output=True, + env=git_env(), + ) + (source / "file.txt").write_text( + "version https://git-lfs.github.com/spec/v1\noid sha256:" + "0" * 64 + "\nsize 1\n" + if lfs + else "synthetic content\n" + ) + + def git(*args): + result = subprocess.run( + [ + "git", + "-C", + str(source), + "-c", + "user.name=Synthetic", + "-c", + "user.email=synthetic@example.invalid", + *args, + ], + capture_output=True, + env=git_env(), + ) + assert result.returncode == 0 + return result.stdout + + git("add", "file.txt") + git("commit", "-m", "Synthetic fixture") + git("config", "legacy.shouldNeverCopy", "unsafe-source-config") + (source / ".git/hooks/post-checkout").write_text( + "#!/bin/sh\ntouch " + str(tmp_path / "hook-executed") + "\n" + ) + (source / ".git/hooks/post-checkout").chmod(0o700) + bundle = tmp_path / "source.bundle" + git("bundle", "create", str(bundle), "refs/heads/main") + return bundle + + +async def prepared(installed, tmp_path, *, lfs=False): + app, root, _, base, key, cert = await setup(installed, tmp_path) + bundle = make_bundle(tmp_path, lfs=lfs) + verified = verify_bundle(bundle, sha256_file(bundle), protected_work=tmp_path / "verify") + approval = git_approval( + bundle_sha256=verified["bundle_sha256"], + refs_digest=verified["refs_digest"], + default_ref="refs/heads/main", + service=app.settings.service_url, + parent=base["parent"], + parent_generation=base["parent_generation"], + operator=base["operator"], + name="retained.git", + expires_at=wire(NOW + timedelta(hours=1)), + ) + return app, root, key, bundle, approval, verified + + +@pytest.mark.asyncio +async def test_git_bundle_import_preserves_oids_and_private_authority(installed, tmp_path): + app, root, key, bundle, approval, verified = await prepared(installed, tmp_path) + before = sha256_file(bundle) + report = await import_git( + app, bundle, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + assert report["reachable_object_count"] == 3 + assert report["ref_mapping"] == verified["refs"] + store = NativeGitStore(app) + assert not (store.path(report["resource_id"]) / "git-daemon-export-ok").exists() + assert store._run(report["resource_id"], "symbolic-ref", "HEAD").strip() == b"refs/heads/main" + assert ( + b"legacy.shouldnevercopy" + not in store._run(report["resource_id"], "config", "--list").lower() + ) + assert store._run(report["resource_id"], "config", "core.hooksPath").strip() == b"/dev/null" + assert not (tmp_path / "hook-executed").exists() + own = await call( + app, "git.refs", {"id": report["resource_id"]}, key=key, subject=approval["operator"] + ) + assert own.status == "ok", own + assert {row["name"]: row["oid"] for row in own.data["refs"]} == verified["refs"] + denied = await call(app, "git.refs", {"id": report["resource_id"]}) + assert denied.status == "error" + assert sha256_file(bundle) == before + with pytest.raises(Failure): + await import_git( + app, bundle, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + + +@pytest.mark.asyncio +async def test_git_publication_failure_removes_only_new_repository( + installed, tmp_path, monkeypatch +): + app, root, key, bundle, approval, verified = await prepared(installed, tmp_path) + original = NativeGitStore.update_refs + + async def fail_after_refs(self, id, changes): + await original(self, id, changes) + raise RuntimeError("injected ref publication failure") + + monkeypatch.setattr(NativeGitStore, "update_refs", fail_after_refs) + with pytest.raises(RuntimeError, match="injected"): + await import_git( + app, bundle, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + rid = "legacy_git_" + approval["bundle_sha256"][:32] + assert not NativeGitStore(app).path(rid).exists() + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT id FROM resources WHERE id=?", (rid,)) is None + assert tx.setting("legacy-git-import:" + approval["bundle_sha256"]) is None + + +@pytest.mark.asyncio +async def test_git_ref_approval_and_external_lfs_fail_closed(installed, tmp_path): + app, root, key, bundle, approval, verified = await prepared(installed, tmp_path, lfs=True) + assert verified["lfs_pointer_count"] == 1 + with pytest.raises(Failure, match="legacy_git_external_objects_require_separate_import"): + await import_git( + app, bundle, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + signature = wire(root.sign(canonical(approval), purpose=PURPOSE)) + approval["name"] = "another.git" + with pytest.raises(Failure): + await import_git(app, bundle, approval, signature) diff --git a/tests/test_legacy_identity_plan.py b/tests/test_legacy_identity_plan.py new file mode 100644 index 00000000..2f349560 --- /dev/null +++ b/tests/test_legacy_identity_plan.py @@ -0,0 +1,180 @@ +import base64 +import hashlib +import json +import sqlite3 +from datetime import timedelta + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from test_legacy_resource_import import setup, source +from test_service import NOW, call + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure +from msg.storage.legacy_identity_plan import ( + PURPOSE, + _json, + approval_payload, + identity_plan, + import_identity_records, + summary, +) + + +def keypair(): + key = Ed25519PrivateKey.generate() + raw = key.public_key().public_bytes_raw() + return key, base64.b64encode(raw).decode(), hashlib.sha256(raw).hexdigest() + + +def signed_source(tmp_path): + path = source(tmp_path) + root, root_public, root_id = keypair() + key, public, uid = keypair() + with sqlite3.connect(path) as db: + db.row_factory = sqlite3.Row + db.execute("DELETE FROM attachments") + db.execute( + "UPDATE posts SET author_id=?,author_key=?,actor_id=?,actor_key=?,signature=NULL,sig_version=1,sig_nonce=?,sig_issued=?", + (uid, public, uid, public, "a" * 32, int(NOW.timestamp())), + ) + # Independent fixed wire vector from installed crypto.py request/v1. + payload = ( + "msg.lmm.best/request/v1\n" + "action:11:post.create\n" + f"signer_id:64:{uid}\nversion:1:1\n" + f"nonce:32:{'a' * 32}\nissued:10:{int(NOW.timestamp())}\n" + f"owner_id:64:{uid}\nboard:4:main\nname:9:anonymous\n" + "title:0:\nbody:8:old body\nreply_to:0:\nfiles:2:[]\n" + ).encode() + signature = base64.b64encode(key.sign(payload)).decode() + db.execute("UPDATE posts SET signature=?", (signature,)) + body = { + "v": 1, + "serial": "b" * 32, + "issuer_serial": "root", + "issuer_id": root_id, + "subject_key": public, + "subject_id": uid, + "not_before": int(NOW.timestamp()) - 10, + "not_after": int(NOW.timestamp()) + 100, + "delegate": False, + "grants": [{"topic": "*", "actions": ["post.create"]}], + } + signature = base64.b64encode( + root.sign(b"msg.lmm.best/cert/v1\n" + _json(body).encode()) + ).decode() + db.execute( + "INSERT INTO certificates VALUES (?,?,?,?,?,?,?,?)", + (body["serial"], "root", root_id, uid, public, _json(body), signature, 1), + ) + return path, root_public, uid + + +def plan(path, root=None): + return identity_plan( + path, + hashlib.sha256(path.read_bytes()).hexdigest(), + at=int(NOW.timestamp()), + root_public_key=root, + ) + + +def test_verifies_public_signatures_and_never_implies_authority(tmp_path): + path, root, uid = signed_source(tmp_path) + result = plan(path, root) + assert result["identities"][0]["post_signatures"] == {"verified": 1} + assert result["certificates"][0]["status"] == "signature_chain_verified" + assert result["authority_enabled"] is False + assert result["private_ciphertext_imported"] is False + assert uid not in json.dumps(summary(result)) + assert plan(path)["certificates"][0]["status"] == "root_anchor_unknown" + with sqlite3.connect(path) as db: + db.execute("UPDATE posts SET body=?", ("tampered",)) + db.execute("INSERT INTO revocations VALUES (?,?,?,?)", ("b" * 32, 1, "root", "test")) + result = plan(path, root) + assert result["identities"][0]["post_signatures"] == {"invalid": 1} + assert result["certificates"][0]["status"] == "revoked" + + +def test_snapshot_change_and_expiry(tmp_path): + path, root, _ = signed_source(tmp_path) + sha = hashlib.sha256(path.read_bytes()).hexdigest() + result = identity_plan(path, sha, at=int(NOW.timestamp()) + 101, root_public_key=root) + assert result["certificates"][0]["status"] == "expired" + with pytest.raises(Failure, match="legacy_snapshot_changed"): + identity_plan(path, "0" * 64, at=0) + + +@pytest.mark.asyncio +async def test_root_archive_creates_only_private_nonlogin_records(installed, tmp_path): + app, root, snapshot, old_approval, _key, _cert = await setup(installed, tmp_path) + result = plan(snapshot) + signed = approval_payload( + plan_digest=summary(result)["plan_digest"], + service=app.settings.service_url, + parent=old_approval["parent"], + parent_generation=old_approval["parent_generation"], + operator=old_approval["operator"], + mappings={r["old_id"]: None for r in result["identities"]}, + expires_at=wire(NOW + timedelta(hours=1)), + ) + async with app.metadata.transaction(write=False) as tx: + before = { + t: tx.one("SELECT count(*) FROM " + t)[0] + for t in ("identities", "credentials", "certificates", "identity_keys", "jobs") + } + envelope = wire(root.sign(canonical(signed), purpose=PURPOSE)) + changed = {**signed, "mappings": {}} + with pytest.raises(Failure): + await import_identity_records(app, result, changed, envelope) + report = await import_identity_records(app, result, signed, envelope) + assert report["authority_enabled"] is False + async with app.metadata.transaction(write=False) as tx: + assert before == {t: tx.one("SELECT count(*) FROM " + t)[0] for t in before} + rows = tx.execute("SELECT id FROM resources WHERE parent=?", (signed["parent"],)) + ids = [row[0] for row in rows] + for resource_id in ids: + assert (await tx.resource(resource_id)).mode == 0o600 + for resource_id in ids: + response = await call(app, "discovery.get", {"id": resource_id}) + assert response.status == "error" + with pytest.raises(Failure): + await import_identity_records(app, result, signed, envelope) + + +def test_bad_certificate_signature_and_key_mismatch_fail_closed(tmp_path): + path, root, _ = signed_source(tmp_path) + with sqlite3.connect(path) as db: + db.execute("UPDATE certificates SET signature=?", (base64.b64encode(b"x" * 64).decode(),)) + db.execute("UPDATE posts SET author_id=?", ("0" * 64,)) + result = plan(path, root) + assert result["certificates"][0]["status"] == "invalid_signature" + assert any(r["classification"] == "key_conflict" for r in result["identities"]) + assert result["authority_enabled"] is False + + +def test_cli_exclusive_private_plan_and_summary_only(tmp_path, monkeypatch, capsys): + from msg.storage.legacy_identity_plan import main + + path, _, uid = signed_source(tmp_path) + target = tmp_path / "private-plan.json" + monkeypatch.setattr( + "sys.argv", + [ + "plan", + "--snapshot", + str(path), + "--sha256", + hashlib.sha256(path.read_bytes()).hexdigest(), + "--output", + str(target), + "--at", + str(int(NOW.timestamp())), + ], + ) + main() + assert target.stat().st_mode & 0o777 == 0o600 + assert uid not in capsys.readouterr().out + with pytest.raises(FileExistsError): + main() diff --git a/tests/test_legacy_resource_import.py b/tests/test_legacy_resource_import.py new file mode 100644 index 00000000..65cf4971 --- /dev/null +++ b/tests/test_legacy_resource_import.py @@ -0,0 +1,162 @@ +import hashlib +import sqlite3 +from dataclasses import replace +from datetime import timedelta +from pathlib import Path + +import pytest +from test_service import NOW, call, register + +from msg.core.codec import canonical, wire +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from msg.storage.legacy_resource_import import PURPOSE, approval_payload, import_content + + +def source(tmp_path): + path = tmp_path / "old.db" + with sqlite3.connect(path) as db: + db.executescript((Path(__file__).parent / "fixtures/legacy_sqlite_0221.sql").read_text()) + db.execute("INSERT INTO boards VALUES ('main','old board',0,1.0)") + db.execute("INSERT INTO boards VALUES ('meta','reserved v4 name',0,1.0)") + db.execute( + "INSERT INTO posts(id,board,seq,body,created,updated,author_id,signature,sig_version) VALUES (17,'main',2,'old body',1,1,'old-id','historical-signature',2)" + ) + db.execute( + "INSERT INTO attachments(post_id,slot,name,content_type,data,nbytes,sha256,created,uploader_name,downloads) VALUES (17,0,'old.txt','text/plain',?,3,?,1,'old',0)", + (b"abc", hashlib.sha256(b"abc").hexdigest()), + ) + return path + + +async def setup(installed, tmp_path): + app, root = installed + key, uid, cert = await register(app, "importer") + result = await call( + app, + "content.topic_create", + {"parent": "/main", "name": "migration"}, + key=key, + subject=uid, + certs=(cert,), + ) + assert result.status == "ok", result + parent = result.resources[0].id + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(parent) + await tx.replace( + replace(resource, mode=0o700, generation=resource.generation + 1), resource.generation + ) + generation = resource.generation + 1 + snapshot = source(tmp_path) + approval = approval_payload( + sha256=hashlib.sha256(snapshot.read_bytes()).hexdigest(), + service=app.settings.service_url, + parent=parent, + parent_generation=generation, + operator=uid, + identities={"old-id": uid}, + expires_at=wire(NOW + timedelta(hours=1)), + ) + return app, root, snapshot, approval, key, cert + + +@pytest.mark.asyncio +async def test_real_pg_import_reads_content_and_preserves_signature_reference(installed, tmp_path): + app, root, snapshot, approval, key, cert = await setup(installed, tmp_path) + approval["identities"] = {"old-id": None} + async with app.metadata.transaction(write=False) as tx: + counts = { + t: tx.one("SELECT count(*) FROM " + t)[0] + for t in ("credentials", "certificates", "jobs") + } + report = await import_content( + app, snapshot, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + assert report["posts"] == report["attachments"] == 1 + assert report["url_map"]["/main/17"] == report["url_map"]["/main/2"] + assert "/file/1" in report["url_map"] + owner_read = await call( + app, + "discovery.get", + {"id": report["url_map"]["/main/17"]}, + key=key, + subject=approval["operator"], + certs=(cert,), + ) + assert owner_read.status == "ok", owner_read + public = await call(app, "discovery.get", {"id": report["url_map"]["/main/2"]}) + assert public.status == "error" + async with app.metadata.transaction(write=False) as tx: + assert ( + tx.setting("legacy-import-approval:" + approval["source_sha256"])["approval"] + == approval + ) + resource = await tx.resource(report["url_map"]["/main/2"]) + assert resource.mode == 0o600 + revision = await tx.revision(ResourceRef(id=resource.id, revision=resource.revision)) + assert await app.contents.read_bytes(revision.content) == b"old body" + assert revision.signature is None + assert len(revision.relations) == 1 + attached = await tx.revision(revision.relations[0].target) + assert await app.contents.read_bytes(attached.content) == b"abc" + record = tx.setting("legacy-provenance:" + resource.id) + provenance = await tx.resource(record["resource"]) + history = await tx.revision(ResourceRef(id=provenance.id, revision=provenance.revision)) + saved = await app.contents.read_bytes(history.content) + assert b"historical-signature" in saved + assert b'"historical_author_target":null' in saved + assert b"old-id" in saved + assert {t: tx.one("SELECT count(*) FROM " + t)[0] for t in counts} == counts + with pytest.raises(Failure): + await import_content( + app, snapshot, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + + +@pytest.mark.asyncio +async def test_missing_mapping_and_unsigned_changes_rejected(installed, tmp_path): + app, root, snapshot, approval, key, cert = await setup(installed, tmp_path) + approval["identities"] = {} + with pytest.raises(Failure, match="legacy_identity_mapping_incomplete"): + await import_content( + app, snapshot, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + approval["identities"] = {"old-id": approval["operator"]} + signed = wire(root.sign(canonical(approval), purpose=PURPOSE)) + approval["parent"] = "r_root" + with pytest.raises(Failure): + await import_content(app, snapshot, approval, signed) + + +@pytest.mark.asyncio +async def test_failure_rolls_back_pg_resources_and_manifest(installed, tmp_path, monkeypatch): + import msg.storage.legacy_resource_import as module + + app, root, snapshot, approval, key, cert = await setup(installed, tmp_path) + original = module.revise_resource + + async def fail_after_content(*args, **kwargs): + await original(*args, **kwargs) + raise RuntimeError("injected after CAS write") + + monkeypatch.setattr(module, "revise_resource", fail_after_content) + with pytest.raises(RuntimeError, match="injected after CAS write"): + await import_content( + app, snapshot, approval, wire(root.sign(canonical(approval), purpose=PURPOSE)) + ) + async with app.metadata.transaction(write=False) as tx: + assert tx.one("SELECT id FROM resources WHERE parent=?", (approval["parent"],)) is None + assert tx.setting("legacy-import:" + approval["source_sha256"]) is None + assert tx.one("SELECT id FROM resources WHERE id LIKE 'legacy_%'") is None + + +def test_mapping_plan_exposes_counts_and_digest_only(tmp_path): + from msg.storage.legacy_resource_import import identity_requirements + + snapshot = source(tmp_path) + plan = identity_requirements(snapshot, hashlib.sha256(snapshot.read_bytes()).hexdigest(), {}) + assert plan["required_count"] == plan["missing_count"] == 1 + assert plan["unexpected_count"] == 0 + assert "old-id" not in str(plan) + assert plan["requirements_digest"].startswith("sha256:") diff --git a/tests/test_legacy_routes_removed.py b/tests/test_legacy_routes_removed.py index 9605cb03..19fa8314 100644 --- a/tests/test_legacy_routes_removed.py +++ b/tests/test_legacy_routes_removed.py @@ -113,6 +113,9 @@ async def test_public_views_require_read_effects(installed, monkeypatch): ('/latest/post', 'discovery.list'), ('/main', 'discovery.get'), ('/_id/missing/raw', 'discovery.raw'), + ('/missing/raw', 'discovery.raw'), + ('/_id/missing/json', 'discovery.get'), + ('/missing.md', 'discovery.get'), ('/@missing/repo.git/info/refs?service=git-upload-pack', 'git.refs'), ) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), diff --git a/tests/test_legacy_sqlite_preservation.py b/tests/test_legacy_sqlite_preservation.py new file mode 100644 index 00000000..79cee7ad --- /dev/null +++ b/tests/test_legacy_sqlite_preservation.py @@ -0,0 +1,164 @@ +import hashlib +import json +from pathlib import Path +import sqlite3 + +import pytest + +from msg.storage.legacy_sqlite import preserve + + +@pytest.fixture +def snapshot(tmp_path): + path = tmp_path / "legacy.db" + db = sqlite3.connect(path) + db.executescript((Path(__file__).parent / "fixtures/legacy_sqlite_0221.sql").read_text()) + db.execute("INSERT INTO boards VALUES ('main','legacy',0,1.0)") + db.execute("INSERT INTO revocations VALUES ('serial',1.0,'issuer','revoked')") + db.commit() + db.close() + return path + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_preserve_is_inert_lossless_and_bound(snapshot, tmp_path): + before = digest(snapshot) + dest = tmp_path / "inert.db" + plan = preserve(snapshot, expected_sha256=before, destination=dest) + assert digest(snapshot) == before + assert plan["authority_enabled"] is False + assert plan["external_jobs_enabled"] is False + assert plan["source_provenance_verified"] is False + assert len(plan["tables"]) == 33 + assert plan["tables"]["revocations"]["count"] == 1 + db = sqlite3.connect(dest) + row = json.loads( + db.execute("SELECT envelope FROM legacy_rows WHERE source_table='revocations'").fetchone()[ + 0 + ] + ) + assert row == [ + ["text", "serial"], + ["real", "0x1.0000000000000p+0"], + ["text", "issuer"], + ["text", "revoked"], + ] + assert json.loads(db.execute("SELECT manifest FROM provenance").fetchone()[0]) == plan + db.close() + assert dest.stat().st_mode & 0o777 == 0o600 + with pytest.raises(FileExistsError): + preserve(snapshot, expected_sha256=before, destination=dest) + assert dest.exists() + + +@pytest.mark.parametrize( + "mutation", + [ + "CREATE TABLE surprise(x)", + "ALTER TABLE boards ADD COLUMN surprise TEXT", + "CREATE VIEW surprise AS SELECT * FROM boards", + "PRAGMA user_version=1", + ], +) +def test_unknown_schema(snapshot, mutation): + with sqlite3.connect(snapshot) as db: + db.execute(mutation) + with pytest.raises(ValueError): + preserve(snapshot, expected_sha256=digest(snapshot)) + + +def test_digest_and_sidecar_rejected(snapshot): + with pytest.raises(ValueError, match="digest"): + preserve(snapshot, expected_sha256="0" * 64) + Path(str(snapshot) + "-wal").touch() + with pytest.raises(ValueError, match="sidecars"): + preserve(snapshot, expected_sha256=digest(snapshot)) + + +def test_transaction_failure_removes_partial_output(snapshot, tmp_path, monkeypatch): + import msg.storage.legacy_sqlite as module + + original = module._cell + + def fail_late(value): + if value == "serial": + raise ValueError("injected failure after boards imported") + return original(value) + + monkeypatch.setattr(module, "_cell", fail_late) + dest = tmp_path / "archive.db" + with pytest.raises(ValueError, match="injected failure"): + preserve(snapshot, expected_sha256=digest(snapshot), destination=dest) + assert not dest.exists() + + +def test_posts_and_ciphertext_preserved_without_new_signature(snapshot, tmp_path): + import base64 + + raw_body = "旧帖\n exact spacing\n" + ciphertext = b"\x00\xffopaque\x00" + with sqlite3.connect(snapshot) as db: + db.execute( + "INSERT INTO posts(id,board,seq,body,created,updated,signature,sig_version,sig_nonce,sig_issued,deleted,hidden) VALUES (41,?,?,?,?,?,?,?,?,?,?,?)", + ("main", 3, raw_body, 1.0, 2.0, "old-signature", 2, "nonce", 1, 1, 1), + ) + db.execute( + "INSERT INTO keystore_entries VALUES (?,?,?,?,?,?,?)", + ( + "old-owner", + "backup", + ciphertext, + hashlib.sha256(ciphertext).hexdigest(), + 1, + 1.0, + 1.0, + ), + ) + dest = tmp_path / "archive.db" + manifest = preserve(snapshot, expected_sha256=digest(snapshot), destination=dest) + with sqlite3.connect(dest) as db: + cells = json.loads( + db.execute("SELECT envelope FROM legacy_rows WHERE source_table='posts'").fetchone()[0] + ) + values = dict(zip(manifest["tables"]["posts"]["columns"], cells)) + assert values["id"] == ["integer", "41"] + assert values["body"] == ["text", raw_body] + assert values["signature"] == ["text", "old-signature"] + assert values["deleted"] == values["hidden"] == ["integer", "1"] + cells = json.loads( + db.execute( + "SELECT envelope FROM legacy_rows WHERE source_table='keystore_entries'" + ).fetchone()[0] + ) + assert cells[2] == ["blob", base64.b64encode(ciphertext).decode("ascii")] + assert ( + db.execute( + "SELECT name FROM sqlite_schema WHERE name IN ('jobs','identities','certificates')" + ).fetchall() + == [] + ) + + +def test_bad_attachment_digest_aborts(snapshot, tmp_path): + with sqlite3.connect(snapshot) as db: + db.execute( + "INSERT INTO attachments(post_id,slot,name,content_type,data,nbytes,sha256,created,uploader_name,downloads) VALUES (1,0,?,?,?,?,?,1.0,?,0)", + ("a.txt", "text/plain", b"content", 7, "0" * 64, "legacy"), + ) + destination = tmp_path / "inert.db" + with pytest.raises(ValueError, match="attachment digest"): + preserve(snapshot, expected_sha256=digest(snapshot), destination=destination) + assert not destination.exists() + + +def test_count_bound_aborts(snapshot, tmp_path, monkeypatch): + import msg.storage.legacy_sqlite as module + + monkeypatch.setattr(module, "MAX_ROWS", 0) + destination = tmp_path / "inert.db" + with pytest.raises(ValueError, match="row limit"): + preserve(snapshot, expected_sha256=digest(snapshot), destination=destination) + assert not destination.exists() diff --git a/tests/test_lexical_search_grep.py b/tests/test_lexical_search_grep.py index 4c709c54..6ea7620e 100644 --- a/tests/test_lexical_search_grep.py +++ b/tests/test_lexical_search_grep.py @@ -280,3 +280,27 @@ async def test_sealed_search_query_ref_reads_same_authorized_results(installed): assert {item['ref']['id'] for item in path.json()['items']+continued.json()['items']}=={ result.resources[0].id,second.resources[0].id} assert await business_state(app)==before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('prefix,matched,query', [ + ('ß' * 80, 'Target', 'target'), + ('İ' * 80, 'Straße', 'STRASSE'), + ('x' * 80, 'ß', 's'), +]) +async def test_unicode_casefold_snippet_ranges_address_original_text(installed, prefix, matched, query): + app, _ = installed + key, subject, _ = await register(app, 'unicode-search-owner') + created = await call(app, 'content.post_create', + {'parent': '/main', 'body': prefix + matched + '尾' * 80}, + key=key, subject=subject) + assert created.status == 'ok', wire(created) + result = await call(app, 'discovery.lexical_search', { + 'scope': '/main', 'terms': query, 'field': 'body', 'snippet': True, + }, key=key, subject=subject) + assert result.status == 'ok', wire(result) + item = next(item for item in result.data['items'] if item['id'] == created.resources[0].id) + snippet = item['snippet'] + start, end = snippet['range'] + assert snippet['text'][start:end] == matched + assert snippet['text'] == prefix[-40:] + matched + '尾' * 40 diff --git a/tests/test_manifest_feature_linkage.py b/tests/test_manifest_feature_linkage.py new file mode 100644 index 00000000..56211461 --- /dev/null +++ b/tests/test_manifest_feature_linkage.py @@ -0,0 +1,78 @@ +from copy import deepcopy +from dataclasses import replace + +import pytest + +from msg.bootstrap import feature_manifest, manifest +from msg.core.errors import Failure +from msg.core.models import PluginManifest +from msg.core.registry import Registry + + +def plugin(name='identity', **changes): + return PluginManifest(name=name, version='1', dependencies=(), resource_types=(), + capabilities=(), operations=(), migrations=(), **changes) + + +def test_legacy_manifest_keeps_empty_feature_claims(): + assert plugin().feature_ids == () + + +def test_bootstrap_rejects_unknown_feature_and_wrong_check_owner(): + for changes in ({'feature_id': 'unowned'}, {'doctor_check': 'import:evil'}, + {'selftest_case': 'root_network_rejected'}): + definition = deepcopy(manifest()) + definition['features'][0].update(changes) + with pytest.raises(Failure, match='feature_source_mismatch'): + feature_manifest(definition) + + +def test_feature_claims_reject_other_owner_and_duplicates_atomically(): + registry = Registry() + for claims in (('money',), ('root_trust', 'root_trust'), ('absent',)): + with pytest.raises(Failure, match='feature_source_mismatch'): + registry.add(plugin(feature_ids=claims)) + assert not registry._plugins + registry.add(plugin(feature_ids=('root_trust',))) + registry.freeze() + + +def test_plugin_defaults_resolve_from_bootstrap_and_are_isolated(): + registry = Registry() + registry.add(plugin()) + registry.add(replace(plugin('money', feature_ids=('money',)), dependencies=('identity',))) + registry.freeze() + row, = registry.features('money') + assert row == next(row for row in feature_manifest() if row['feature_id'] == 'money') + row['default_config']['banks'].append('mutated') + assert registry.features('money')[0]['default_config']['banks'] == [] + with pytest.raises(Failure, match='unknown_plugin'): + registry.features('absent') + + +def test_missing_feature_source_is_rejected_at_freeze(monkeypatch): + definition = deepcopy(manifest()) + definition['features'].pop() + monkeypatch.setattr('msg.bootstrap.manifest', lambda: definition) + registry = Registry() + registry.add(plugin()) + with pytest.raises(Failure, match='feature_source_mismatch'): + registry.freeze() + assert not registry.frozen + + +def test_features_do_not_bypass_plugin_dependencies(): + registry = Registry() + dependent = replace(plugin('money', feature_ids=('money',)), dependencies=('identity',)) + with pytest.raises(Failure, match='missing_plugin_dependency'): + registry.add(dependent) + assert not registry._plugins + + +@pytest.mark.asyncio +async def test_installed_manifests_link_entire_inventory(installed): + app, _ = installed + from msg.plugins.features import FEATURE_SOURCES + rows = [row for name in app.registry._plugins for row in app.registry.features(name)] + assert len(rows) == len(FEATURE_SOURCES) + assert {row['feature_id'] for row in rows} == set(FEATURE_SOURCES) diff --git a/tests/test_market_account_scope.py b/tests/test_market_account_scope.py index 31e53b1e..b11e6c63 100644 --- a/tests/test_market_account_scope.py +++ b/tests/test_market_account_scope.py @@ -103,7 +103,7 @@ async def test_account_scoped_read_key_preserves_owner_projection(installed, ope @pytest.mark.asyncio -@pytest.mark.parametrize('version', [1, 2, 3]) +@pytest.mark.parametrize('version', [1, 2, 3, 4]) async def test_catalog_scope_alone_cannot_buy_in_any_published_version(installed, version): app, root = installed _, _, buyer_key, buyer, listing, package = await market(app, root) diff --git a/tests/test_market_boundaries.py b/tests/test_market_boundaries.py new file mode 100644 index 00000000..0955e8ed --- /dev/null +++ b/tests/test_market_boundaries.py @@ -0,0 +1,159 @@ +"""Cross-version market ownership and unchanged real-ledger behavior.""" +from __future__ import annotations + +import ast +import importlib +from pathlib import Path +import subprocess +import sys + +import pytest + +from msg.core.codec import canonical, decode, wire +from msg.core.errors import Failure +from msg.core.models import Signature +from msg.security.crypto import verify +from test_managed_checkout import setup_sale, buy, ok + + +ENTRYPOINTS = {'msg.plugins.orders','msg.plugins.store','msg.plugins.money','msg.plugins.delivery'} +OWNERS = ( + ('msg.plugins.orders','msg.market.order_records',{ + '_subject':'require_signed_subject','_viewer':'require_order_viewer', + '_order_id':'new_order_id','_row':'read_order','_view':'legacy_order_view'}), + ('msg.plugins.store','msg.market.catalog',{ + '_listing':'read_listing','_body':'read_listing_body','_package_row':'read_package_record'}), + ('msg.plugins.money','msg.market.ledger',{ + 'account_requirements':'account_requirements','_owner':'require_money_subject', + '_amount':'checked_amount','_balance':'balance','_supply':'total_supply', + 'clearing_decision':'clearing_decision','_post_entry':'append_entry','_post_transfer':'post_transfer'}), + ('msg.plugins.delivery','msg.market.managed_delivery',{ + '_delivery':'read_delivery','_buyer_order':'read_buyer_order', + '_verified_delivery':'verify_managed_delivery','_package':'read_managed_package', + '_payload':'read_managed_payloads','prepare_managed':'prepare_managed', + 'delivery_summary':'delivery_summary'}), +) + + +def test_market_has_no_business_entrypoint_imports_even_inside_functions(): + import msg.market + violations=[] + for path in Path(msg.market.__file__).parent.rglob('*.py'): + for node in ast.walk(ast.parse(path.read_text())): + names=([node.module] if isinstance(node,ast.ImportFrom) else + [item.name for item in node.names] if isinstance(node,ast.Import) else []) + for name in names: + if name in ENTRYPOINTS: + violations.append((path.name,node.lineno,name)) + assert not violations, violations + + +@pytest.mark.parametrize('entrypoint,owner,names',OWNERS) +def test_compatibility_imports_are_the_same_owned_functions(entrypoint,owner,names): + adapter=importlib.import_module(entrypoint) + shared=importlib.import_module(owner) + assert not hasattr(shared,'install') + for old,new in names.items(): + function=getattr(shared,new) + assert function is getattr(adapter,old), (old,new) + assert function.__module__==owner + records=importlib.import_module('msg.market.order_records') + store=importlib.import_module('msg.plugins.store') + assert store._subject is records.require_signed_subject + + +def test_shared_market_and_versioned_use_cases_import_without_loading_entrypoints(): + code=''' +import importlib, importlib.abc, sys +forbidden={'msg.plugins.orders','msg.plugins.store','msg.plugins.money','msg.plugins.delivery'} +class NoEntrypoints(importlib.abc.MetaPathFinder): + def find_spec(self,fullname,path=None,target=None): + if fullname in forbidden: + raise AssertionError('shared market loaded business entrypoint '+fullname) +sys.meta_path.insert(0,NoEntrypoints()) +for name in ('order_records','catalog','ledger','managed_delivery','orders','delivery', + 'escrow','arbitration','email','policy','rationale','targets','delivery_notifications'): + importlib.import_module('msg.market.'+name) +assert not forbidden.intersection(sys.modules) +''' + result=subprocess.run([sys.executable,'-c',code],capture_output=True,text=True) + assert result.returncode==0,result.stderr + + +def test_ledger_guard_has_one_owner_and_does_not_depend_on_escrow_or_handlers(): + ledger=importlib.import_module('msg.market.ledger') + escrow=importlib.import_module('msg.market.escrow') + assert ledger._ESCROW_WRITE is escrow._ESCROW_WRITE + imports=[n.module for n in ast.walk(ast.parse(Path(ledger.__file__).read_text())) + if isinstance(n,ast.ImportFrom)] + assert 'msg.market.escrow' not in imports + assert not ENTRYPOINTS.intersection(imports) + money=importlib.import_module('msg.plugins.money') + for name in ('CURRENCY_ID','CODE','SCALE','MAX_MINOR','POLICY_VERSION','POLICY_DIGEST'): + assert getattr(money,name)==getattr(ledger,name) + + +async def test_shared_order_views_preserve_buyer_privacy_and_zero_write_semantics(installed): + from msg.plugins.orders import _row,_view + app,seller,buyer,_,args=await setup_sale(installed,policy='escrow-v1') + args['auto_accept']=False + order=ok(await buy(app,buyer,args,rid='shared-order-views')).data['order'] + async with app.metadata.transaction(write=False) as tx: + counts=lambda: tuple(tx.one(f'SELECT COUNT(*) FROM {name}')[0] + for name in ('money_ledger','store_orders','store_deliveries','results','events','jobs')) + before=counts() + record=_row(tx,order['id'],buyer[1]) + buyer_view=_view(record,buyer[1]) + seller_view=_view(_row(tx,order['id'],seller[1]),seller[1]) + assert buyer_view['delivery_target']=={'subject_id':buyer[1],'channel':'site'} + assert {'delivery_target','payment_intent_digest','receipt_refs'}<=set(buyer_view) + assert not {'delivery_target','payment_intent_digest','receipt_refs'}.intersection(seller_view) + assert buyer_view['state']==seller_view['state']=='delivered' + with pytest.raises(Failure,match='order_not_found'): + _row(tx,order['id'],'unrelated-viewer') + with pytest.raises(Failure,match='order_not_found'): + _row(tx,'ord_does_not_exist',buyer[1]) + assert counts()==before + + +async def test_shared_posting_path_rejects_forged_escrow_authority_without_writes(installed): + from msg.plugins.money import _balance,_post_transfer + from msg.plugins.orders import _row + app,seller,buyer,_,args=await setup_sale(installed,policy='escrow-v1') + args['auto_accept']=False + order=ok(await buy(app,buyer,args,rid='shared-posting-guard')).data['order'] + async with app.metadata.transaction(write=True) as tx: + record=_row(tx,order['id'],buyer[1]) + before=tuple(tx.one(f'SELECT COUNT(*) FROM {name}')[0] + for name in ('money_ledger','money_accounts','order_escrow_decisions','order_settlements')) + for authority in (None,object()): + with pytest.raises(Failure,match='escrow_release_forbidden'): + _post_transfer(tx,sender=record['escrow_subject'],recipient=seller[1], + amount=record['total_price_minor'],actor=buyer[1], + request_id='forged-shared-leg',now=app.clock(),receipt_signer=app.receipt_signer, + escrow_authority=authority) + after=tuple(tx.one(f'SELECT COUNT(*) FROM {name}')[0] + for name in ('money_ledger','money_accounts','order_escrow_decisions','order_settlements')) + assert before==after + assert _balance(tx,record['escrow_subject'])==record['total_price_minor'] + assert _balance(tx,seller[1])==0 + + +async def test_alias_reads_and_replay_preserve_signed_receipt_bytes(installed): + from msg.plugins.delivery import _delivery,_verified_delivery + from msg.plugins.orders import _row + app,_,buyer,_,args=await setup_sale(installed) + first=ok(await buy(app,buyer,args,rid='shared-receipt-bytes')) + payment=first.data['payment'] + verify(app.receipt_signer.public_key,canonical(payment['body']), + decode(Signature,payment['signature']),purpose='money-receipt') + async with app.metadata.transaction(write=False) as tx: + before=tuple(tx.rows('SELECT seq,receipt FROM money_ledger ORDER BY seq')) + record=_row(tx,first.data['order']['id'],buyer[1]) + await _verified_delivery(app,tx,record,_delivery(tx,record['id'])) + replay=ok(await buy(app,buyer,args,rid='shared-receipt-bytes')) + assert replay.replayed and replay.data==first.data + assert canonical(replay.data['payment'])==canonical(payment) + async with app.metadata.transaction(write=False) as tx: + assert tuple(tx.rows('SELECT seq,receipt FROM money_ledger ORDER BY seq'))==before + assert canonical(payment).decode() in [row[1] for row in before] diff --git a/tests/test_market_cli_bindings.py b/tests/test_market_cli_bindings.py index 0e0224c6..cf67bfe8 100644 --- a/tests/test_market_cli_bindings.py +++ b/tests/test_market_cli_bindings.py @@ -38,7 +38,7 @@ async def test_every_advertised_shortcut_resolves_an_actual_registered_operation assert (group, action) == ('bounty', 'prove') continue try: - spec = app.registry.operation(operation, 2 if operation == 'money.redeem' else 1) + spec = app.registry.operation(operation, 3 if operation == 'money.redeem' else 1) except Failure: unknown.append((group, action, operation)) continue @@ -160,14 +160,20 @@ async def test_explicit_buy_version_preserves_old_inputs_and_settlement_semantic @pytest.mark.asyncio -async def test_redeem_keeps_version_two_default_and_can_explicitly_select_one(installed): +async def test_redeem_defaults_to_native_order_and_preserves_explicit_legacy_versions(installed): from test_market_redemption import setup_offer app, root = installed key, owner, quote, _fields = await setup_offer(app, root) client = ExecutorClient(app, key, owner) + native = await command(client, 'money', 'redeem', canonical(quote).decode(), + '--request-id', 'cli-native') + assert native.status == 'ok' and native.data['order']['contract_version'] == 5, wire(native) + replay = await command(client, 'money', 'redeem', canonical(quote).decode(), + '--request-id', 'cli-native') + assert replay.replayed and replay.data == native.data pending = await command(client, 'money', 'redeem', canonical({**quote, 'defer': True}).decode(), - '--request-id', 'cli-pending') + '--contract-version', '2', '--request-id', 'cli-pending') assert pending.status == 'ok' and pending.data['purchase']['state'] == 'pending', wire(pending) immediate = await command(client, 'money', 'redeem', canonical(quote).decode(), '--contract-version', '1', '--request-id', 'cli-immediate') @@ -184,3 +190,27 @@ async def test_out_of_range_version_is_rejected_before_client_or_business_writes await command(None, 'money', 'state', '--contract-version', version) assert await business_snapshot(app) == before + + +@pytest.mark.asyncio +async def test_redeem_cli_rejects_unimported_offer_and_changed_pinned_intent(installed): + from msg.admin.offer_import import apply_import, preview_import + from test_offer_import import legacy + + app, root = installed + key, owner, quote = await legacy(app, root) + client = ExecutorClient(app, key, owner) + before = await business_snapshot(app) + with pytest.raises(Failure, match='^offer_resource_required_use_explicit_legacy_contract$'): + await command(client, 'money', 'redeem', canonical(quote).decode()) + assert await business_snapshot(app) == before + async with app.metadata.transaction(write=False) as tx: + plan = await preview_import(app, tx, quote['offer_id']) + await apply_import(app, root, plan, operator='test') + before = await business_snapshot(app) + with pytest.raises(Failure, match='^offer_intent_changed$'): + await command(client, 'money', 'redeem', canonical({**quote, 'total_price_minor': 9}).decode()) + assert await business_snapshot(app) == before + result = await command(client, 'money', 'redeem', canonical(quote).decode()) + assert result.status == 'ok', wire(result) + assert result.data['order']['listing_revision'] == plan['revision_id'] diff --git a/tests/test_market_compatibility_reads.py b/tests/test_market_compatibility_reads.py new file mode 100644 index 00000000..df9c3be4 --- /dev/null +++ b/tests/test_market_compatibility_reads.py @@ -0,0 +1,178 @@ +"""Compatibility projections preserve legacy authority and signed financial facts.""" +import pytest +from msg.core.codec import wire +from msg.core.errors import Failure +from msg.market.compatibility import purchase_order +from msg.market.ledger import post_transfer, post_escrow_release +from msg.plugins.offers import _purchase +from test_market_redemption import setup_offer +from test_service import call, register, NOW + + +async def snapshot(app): + async with app.metadata.transaction(write=False) as tx: + return {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in ( + 'money_purchases', 'server_offers', 'money_ledger', 'ledger_accounts', + 'resource_entitlements', 'store_orders', 'store_deliveries', 'events', 'audit')} + + +@pytest.mark.asyncio +async def test_offer_listing_v2_projection_preserves_v1_and_checks_revision(installed): + app, root = installed + _, _, args, _ = await setup_offer(app, root) + before = await snapshot(app) + old = await call(app, 'money.offers', {}) + new = await call(app, 'money.offers', {}, contract_version=2) + assert new.status == 'ok', wire(new) + assert new.data['offers'] == old.data['offers'] + listing = new.data['listings'][0] + assert listing['listing_id'] == args['offer_id'] + assert listing['source'] == 'server_offer' and listing['escrow_policy'] == 'legacy-entitlement-v1' + get = await call(app, 'store.listing_get', {'id': args['offer_id'], 'source': 'server_offer'}, + contract_version=2) + assert get.data['listing'] == listing + wrong = await call(app, 'store.listing_get', {'id': args['offer_id'], 'source': 'server_offer', + 'revision': 'nonexistent'}, contract_version=2) + assert wrong.error.code == 'listing_not_found' + native = await call(app, 'store.listing_get', {'id': args['offer_id']}, contract_version=2) + assert native.status == 'ok' + assert native.data['listing']['listing_id'] == args['offer_id'] + assert native.data['listing']['server_offer']['price_revision'] == args['price_revision'] + assert await snapshot(app) == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('outcome', ['pending', 'settled', 'refunded']) +async def test_purchase_order_projection_zero_writes_and_owner_only(installed, outcome): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + outsider_key, outsider, _ = await register(app, 'compat-outsider') + created = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, + subject=owner, contract_version=2) + pid = created.data['purchase']['id'] + if outcome != 'pending': + op = 'money.purchase_settle' if outcome == 'settled' else 'money.purchase_cancel' + result = await call(app, op, {'purchase_id': pid}, key=key, subject=owner) + assert result.status == 'ok', wire(result) + before = await snapshot(app) + legacy = await call(app, 'money.purchase_get', {'purchase_id': pid}, key=key, subject=owner) + new = await call(app, 'money.purchase_get', {'purchase_id': pid}, key=key, subject=owner, + contract_version=2) + assert new.status == 'ok', wire(new) + assert new.data['purchase'] == legacy.data['purchase'] + order = new.data['order'] + assert order['id'] == pid and order['source'] == 'legacy_purchase' + assert order['state'] == ('funded' if outcome == 'pending' else outcome) + assert order['accepted_at'] is None and order['delivery_id'] is None + assert order['offer_snapshot'] == legacy.data['purchase']['offer_snapshot'] + unified = await call(app, 'orders.get', {'order_id': pid, 'source': 'legacy_purchase'}, + key=key, subject=owner, contract_version=2) + assert unified.data['order'] == order + for id_ in (pid, 'nonexistent'): + denied = await call(app, 'orders.get', {'order_id': id_, 'source': 'legacy_purchase'}, + key=outsider_key, subject=outsider, contract_version=2) + assert denied.error.code == 'order_not_found' + index = await call(app, 'orders.list', {'role': 'buy'}, key=key, subject=owner, + contract_version=2) + assert index.data['orders'] == (order,) + absent_status = 'completed' if outcome == 'pending' else 'open' + empty = await call(app, 'orders.list', {'status': absent_status}, key=key, subject=owner, + contract_version=2) + assert len(empty.data['orders']) == 0 + seller = await call(app, 'orders.list', {'role': 'sell'}, key=key, subject=owner, + contract_version=2) + assert len(seller.data['orders']) == 0 + assert await snapshot(app) == before + # The read adapter never makes a purchase reachable by store mutation code. + denied = await call(app, 'orders.cancel', {'order_id': pid}, key=key, subject=owner) + assert denied.error.code == 'order_not_found' + + +@pytest.mark.asyncio +async def test_purchase_release_boundary_and_projection_preflight_fail_closed(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + created = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, + subject=owner, contract_version=2) + pid = created.data['purchase']['id'] + before = await snapshot(app) + async with app.metadata.transaction(write=True) as tx: + p = _purchase(tx, pid, owner) + with pytest.raises(Failure, match='legacy_purchase_invalid'): + purchase_order(tx, {**p, 'state': 'unknown'}) + with pytest.raises(Failure, match='legacy_purchase_invalid'): + purchase_order(tx, {**p, 'offer_snapshot': {}}) + with pytest.raises(Failure, match='legacy_purchase_invalid'): + purchase_order(tx, {**p, 'offer_snapshot': {**p['offer_snapshot'], 'provider_version': 999}}) + kwargs = dict(recipient=owner, amount=p['total_minor'], actor=owner, request_id='bad-release', + now=NOW, receipt_signer=app.receipt_signer, reference='invalid', kind='refund') + with pytest.raises(Failure, match='escrow_release_forbidden'): + post_transfer(tx, sender=p['escrow_account'], **kwargs) + with pytest.raises(Failure, match='escrow_account_mismatch'): + post_escrow_release(tx, escrow_account=p['escrow_account'], source_id='wrong', + account_kind='purchase_escrow', buyer=owner, seller='u_root', **kwargs) + with pytest.raises(Failure, match='escrow_recipient_mismatch'): + post_escrow_release(tx, escrow_account=p['escrow_account'], source_id=pid, + account_kind='purchase_escrow', buyer=owner, seller='u_root', + **{**kwargs, 'recipient': 'unrelated'}) + assert await snapshot(app) == before + + +@pytest.mark.asyncio +async def test_v2_native_reads_preserve_current_order_policy_and_authorization(installed): + from test_market_lifecycle import market + from test_orders import _intent + app, root = installed + sk, seller, bk, buyer, listing, _ = await market(app, root) + bought = await call(app, 'orders.buy', _intent(listing), key=bk, subject=buyer, contract_version=4) + assert bought.status == 'ok', wire(bought) + oid = bought.data['order']['id'] + from msg.admin.money import apply_offer + from msg.plugins.hosting_capacity import ENTITLEMENT_KIND + quote = await apply_offer(app, root, action='set', operator='test', offer_id='mixed-offer', + fields={'resource_kind': 'website', 'unit': 'byte', 'price_minor': 2, + 'min_quantity': 1, 'max_quantity': 40, 'entitlement_kind': ENTITLEMENT_KIND, + 'duration_seconds': None}) + purchase = await call(app, 'money.redeem', {'offer_id': 'mixed-offer', 'quantity': 1, + 'currency_id': 'primary', 'price_revision': quote['offer']['price_revision']}, + key=bk, subject=buyer) + assert purchase.status == 'ok', wire(purchase) + pid = purchase.data['purchase']['id'] + before = await snapshot(app) + for key, viewer in ((bk, buyer), (sk, seller)): + old = await call(app, 'orders.get', {'order_id': oid}, key=key, subject=viewer) + new = await call(app, 'orders.get', {'order_id': oid}, key=key, subject=viewer, + contract_version=2) + assert new.status == 'ok' and new.data == old.data + assert new.data['order']['contract_version'] == 4 + native_listing = await call(app, 'store.listing_get', {'id': listing['listing_id']}) + compatible_listing = await call(app, 'store.listing_get', {'id': listing['listing_id']}, + contract_version=2) + assert compatible_listing.data == native_listing.data + index = await call(app, 'orders.list', {}, key=bk, subject=buyer, contract_version=2) + assert index.status == 'ok' + assert {o['id'] for o in index.data['orders']} == {oid, pid} + assert {o['source'] for o in index.data['orders']} == {'store_order', 'legacy_purchase'} + limited = await call(app, 'orders.list', {'limit': 1}, key=bk, subject=buyer, contract_version=2) + assert limited.data['orders'] == index.data['orders'][:1] + assert await snapshot(app) == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['money.purchase_get', 'orders.get', 'orders.list']) +async def test_compatibility_reads_require_current_account_scope(installed, operation): + from test_market_account_scope import restricted_key + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + created = await call(app, 'money.redeem', args, key=key, subject=owner) + pid = created.data['purchase']['id'] + args = ({'purchase_id': pid} if operation == 'money.purchase_get' else + {'order_id': pid, 'source': 'legacy_purchase'} if operation == 'orders.get' else {}) + denied_key = await restricted_key(app, key, owner, operation, 2) + allowed_key = await restricted_key(app, key, owner, operation, 2, account=True) + before = await snapshot(app) + denied = await call(app, operation, args, key=denied_key, subject=owner, contract_version=2) + assert denied.error.code == 'credential_ceiling' + allowed = await call(app, operation, args, key=allowed_key, subject=owner, contract_version=2) + assert allowed.status == 'ok', wire(allowed) + assert await snapshot(app) == before diff --git a/tests/test_market_explicit_acceptance.py b/tests/test_market_explicit_acceptance.py new file mode 100644 index 00000000..145f3bd3 --- /dev/null +++ b/tests/test_market_explicit_acceptance.py @@ -0,0 +1,138 @@ +"""V4 managed delivery keeps payment in escrow until signed buyer acceptance.""" +import pytest + +from msg.core.codec import wire +from msg.plugins.money import _balance +from test_market_lifecycle import market +from test_orders import _intent +from test_service import call + + +@pytest.mark.asyncio +async def test_v4_delivery_requires_current_buyer_and_exact_digest_before_settlement(installed): + app, root = installed + sk, seller, bk, buyer, listing, _ = await market(app, root) + args = _intent(listing) + bought = await call(app, 'orders.buy', args, key=bk, subject=buyer, + contract_version=4, rid='explicit-buy') + assert bought.status == 'ok', wire(bought) + order = bought.data['order'] + assert order['state'] == 'delivered' and order['contract_version'] == 4 + contract = await call(app, 'orders.contract', {'order_id': order['id']}, + key=bk, subject=buyer) + assert contract.status == 'ok', wire(contract) + assert contract.data['contract']['settlement_policy'] == { + 'id': 'explicit-buyer-acceptance', 'version': 1} + replay = await call(app, 'orders.buy', args, key=bk, subject=buyer, + contract_version=4, rid='explicit-buy') + assert replay.replayed and replay.data == bought.data + delivery = await call(app, 'delivery.get', {'order_id': order['id']}, + key=bk, subject=buyer, contract_version=2) + assert delivery.status == 'ok', wire(delivery) + assert delivery.data['delivery']['state'] == 'prepared' + accept = {'order_id': order['id'], + 'delivery_digest': delivery.data['delivery']['delivery_digest']} + wrong_buyer = await call(app, 'delivery.accept', accept, + key=sk, subject=seller, contract_version=2) + assert wrong_buyer.status == 'error' + wrong_digest = await call(app, 'delivery.accept', + {**accept, 'delivery_digest': 'sha256:' + '0' * 64}, + key=bk, subject=buyer, contract_version=2) + assert wrong_digest.error.code == 'delivery_mismatch' + async with app.metadata.transaction(write=False) as tx: + escrow = tx.one('SELECT escrow_subject FROM store_orders WHERE id=?', (order['id'],))[0] + assert (_balance(tx, buyer), _balance(tx, seller), _balance(tx, escrow)) == (15_000_000, 0, 5_000_000) + assert tx.one('SELECT COUNT(*) FROM order_settlements WHERE order_id=?', (order['id'],))[0] == 0 + accepted = await call(app, 'delivery.accept', accept, key=bk, subject=buyer, + contract_version=2, rid='explicit-accept') + assert accepted.status == 'ok' and accepted.data['state'] == 'settled', wire(accepted) + repeated = await call(app, 'delivery.accept', accept, key=bk, subject=buyer, + contract_version=2, rid='explicit-accept') + assert repeated.replayed and repeated.data == accepted.data + again = await call(app, 'delivery.accept', accept, key=bk, subject=buyer, + contract_version=2, rid='explicit-accept-again') + assert again.status == 'ok' and again.data['receipt'] == accepted.data['receipt'] + async with app.metadata.transaction(write=False) as tx: + assert (_balance(tx, buyer), _balance(tx, seller), _balance(tx, escrow)) == (15_000_000, 5_000_000, 0) + assert tx.one('SELECT COUNT(*) FROM order_settlements WHERE order_id=?', (order['id'],))[0] == 1 + + +@pytest.mark.asyncio +async def test_cli_default_checkout_waits_for_signed_acceptance(installed): + from test_market_cli_bindings import ExecutorClient, command + from msg.core.codec import canonical + app, root = installed + _, _, bk, buyer, listing, _ = await market(app, root) + bought = await command(ExecutorClient(app, bk, buyer), 'orders', 'buy', + canonical(_intent(listing)).decode()) + assert bought.status == 'ok', wire(bought) + assert bought.data['order']['contract_version'] == 4 + assert bought.data['order']['state'] == 'delivered' + + +@pytest.mark.asyncio +async def test_revoked_acceptance_key_cannot_replay_settlement(installed): + from test_market_account_scope import restricted_key + app, root = installed + _, _, bk, buyer, listing, _ = await market(app, root) + accept_key = await restricted_key(app, bk, buyer, 'delivery.accept', 2, account=True) + bought = await call(app, 'orders.buy', _intent(listing), key=bk, subject=buyer, + contract_version=4) + order = bought.data['order'] + delivery = await call(app, 'delivery.get', {'order_id': order['id']}, + key=bk, subject=buyer, contract_version=2) + args = {'order_id': order['id'], + 'delivery_digest': delivery.data['delivery']['delivery_digest']} + accepted = await call(app, 'delivery.accept', args, key=accept_key, subject=buyer, + contract_version=2, rid='accept-revocable') + assert accepted.status == 'ok', wire(accepted) + revoked = await call(app, 'identity.key_revoke', {'key_id': accept_key.key_id}, + key=bk, subject=buyer) + assert revoked.status == 'ok', wire(revoked) + replay = await call(app, 'delivery.accept', args, key=accept_key, subject=buyer, + contract_version=2, rid='accept-revocable') + assert replay.status == 'error' and not replay.replayed + + +@pytest.mark.asyncio +async def test_explicit_reservation_policy_survives_separate_funding(installed): + app, root = installed + _, _, bk, buyer, listing, _ = await market(app, root) + created = await call(app, 'orders.create', _intent(listing), key=bk, subject=buyer, + contract_version=2) + assert created.status == 'ok', wire(created) + order = created.data['order'] + assert order['contract_version'] == 4 + funded = await call(app, 'orders.fund', { + 'order_id': order['id'], 'order_digest': order['order_digest'], + 'total_price_minor': order['total_price_minor'], 'currency_id': 'primary'}, + key=bk, subject=buyer) + assert funded.status == 'ok' and funded.data['order']['state'] == 'delivered', wire(funded) + + +@pytest.mark.asyncio +async def test_waiting_acceptance_survives_restart_without_release(installed): + from msg.application import Application + from msg.admin.market_check import inspect_clearing + from test_service import NOW + app, root = installed + _, seller, bk, buyer, listing, _ = await market(app, root) + bought = await call(app, 'orders.buy', _intent(listing), key=bk, subject=buyer, + contract_version=4) + assert bought.status == 'ok', wire(bought) + order = bought.data['order'] + restarted = Application(app.settings, clock=lambda: NOW) + await restarted.load() + try: + delivery = await call(restarted, 'delivery.get', {'order_id': order['id']}, + key=bk, subject=buyer, contract_version=2) + assert delivery.status == 'ok' and delivery.data['delivery']['state'] == 'prepared' + async with restarted.metadata.transaction(write=False) as tx: + assert inspect_clearing(restarted, tx)['conserved'] + assert _balance(tx, seller) == 0 + accepted = await call(restarted, 'delivery.accept', { + 'order_id': order['id'], 'delivery_digest': delivery.data['delivery']['delivery_digest']}, + key=bk, subject=buyer, contract_version=2) + assert accepted.status == 'ok' and accepted.data['state'] == 'settled', wire(accepted) + finally: + await restarted.close() diff --git a/tests/test_offer_compatibility_boundary.py b/tests/test_offer_compatibility_boundary.py new file mode 100644 index 00000000..d6e2db1f --- /dev/null +++ b/tests/test_offer_compatibility_boundary.py @@ -0,0 +1,60 @@ +"""Legacy catalogue rows must satisfy the same issuer limits as new local offers.""" +import pytest + +from msg.plugins.hosting_capacity import MAX_CAPACITY_BYTES +from test_market_redemption import setup_offer +from test_service import call + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('column', 'value'), [ + ('unit', 'megabyte'), + ('max_quantity', MAX_CAPACITY_BYTES + 1), + ('duration_seconds', 315360001), +]) +async def test_invalid_legacy_offer_is_hidden_and_cannot_fund(installed, column, value): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + tx.execute(f'UPDATE server_offers SET {column}=? WHERE offer_id=?', + (value, args['offer_id']), write=True) + tables = ('money_ledger', 'money_purchases', 'resource_entitlements', 'ledger_accounts') + async with app.metadata.transaction(write=False) as tx: + before = {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY id') for table in tables} + original = tx.rows('SELECT * FROM server_offers') + catalog = await call(app, 'money.offers', {}) + assert catalog.status == 'ok' and len(catalog.data['offers']) == 0 + for version in (1, 2): + result = await call(app, 'money.redeem', args, key=key, subject=owner, + contract_version=version) + assert result.status == 'error' and result.error.code == 'offer_not_found' + async with app.metadata.transaction(write=False) as tx: + assert {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY id') for table in tables} == before + assert tx.rows('SELECT * FROM server_offers') == original + + +@pytest.mark.asyncio +async def test_catalog_filter_does_not_rewrite_or_invalidate_locked_purchase(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + result = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, + subject=owner, contract_version=2, rid='legacy-compatible-purchase') + assert result.status == 'ok' + original = result.data['purchase'] + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE server_offers SET unit=? WHERE offer_id=?', + ('megabyte', args['offer_id']), write=True) + signed_funding = tx.one('SELECT receipt FROM money_ledger WHERE id=?', + (original['funding_transaction_id'],)) + assert len((await call(app, 'money.offers', {})).data['offers']) == 0 + read = await call(app, 'money.purchase_get', {'purchase_id': original['id']}, + key=key, subject=owner) + assert read.data['purchase'] == original + settled = await call(app, 'money.purchase_settle', {'purchase_id': original['id']}, + key=key, subject=owner) + assert settled.status == 'ok' + assert settled.data['purchase']['state'] == 'settled' + assert settled.data['purchase']['offer_snapshot'] == original['offer_snapshot'] + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT receipt FROM money_ledger WHERE id=?', + (original['funding_transaction_id'],)) == signed_funding diff --git a/tests/test_offer_import.py b/tests/test_offer_import.py new file mode 100644 index 00000000..1872c12b --- /dev/null +++ b/tests/test_offer_import.py @@ -0,0 +1,139 @@ +"""Explicit local offer adoption preserves signed financial history byte-for-byte.""" +import asyncio +import shutil + +import pytest + +from msg.admin.money import MoneyAdmin +from msg.admin.offer_import import preview_import, apply_import +from msg.admin.root import root_envelope +from msg.core.codec import digest +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from msg.market.offer_resources import authoritative_offer +from test_market_redemption import setup_offer +from test_service import call + + +async def legacy(app, root): + key, owner, args, _ = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + tx.execute('''INSERT INTO server_offers SELECT 'old-offer',resource_kind,unit,price_minor, + min_quantity,max_quantity,entitlement_kind,duration_seconds,enabled,'old-price' + FROM server_offers WHERE offer_id='web-bytes' ''', write=True) + return key, owner, {**args, 'offer_id': 'old-offer', 'price_revision': 'old-price'} + + +async def snapshot(app): + async with app.metadata.transaction(write=False) as tx: + return {table: tx.rows('SELECT * FROM ' + table + ' ORDER BY 1') for table in + ('server_offers', 'money_purchases', 'resource_entitlements', 'money_ledger', 'ledger_accounts')} + + +@pytest.mark.asyncio +async def test_import_preserves_offer_quote_pending_settled_and_receipt_bytes(installed): + app, root = installed + key, owner, args = await legacy(app, root) + pending = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, subject=owner, contract_version=2) + settled = await call(app, 'money.redeem', args, key=key, subject=owner) + assert pending.status == settled.status == 'ok' + before = await snapshot(app) + async with app.metadata.transaction(write=False) as tx: + plan = await preview_import(app, tx, 'old-offer') + assert plan['purchase_count'] == 2 and plan['entitlement_count'] == 1 + assert await snapshot(app) == before + result = await apply_import(app, root, plan, operator='test') + assert result['approved_digest'] == digest(plan) + assert await snapshot(app) == before + async with app.metadata.transaction(write=False) as tx: + assert (await authoritative_offer(app, tx, 'old-offer'))['price_revision'] == 'old-price' + revision = await tx.revision(ResourceRef(id='old-offer')) + assert revision.id == plan['revision_id'] and revision.id != 'old-price' + assert revision.source_digest == digest(plan) + finished = await call(app, 'money.purchase_settle', {'purchase_id': pending.data['purchase']['id']}, + key=key, subject=owner) + assert finished.status == 'ok' and finished.data['purchase']['offer_snapshot']['price_revision'] == 'old-price' + with pytest.raises(Failure, match='offer_already_resource'): + await apply_import(app, root, plan, operator='test') + + +@pytest.mark.asyncio +async def test_import_approval_rechecks_pending_purchase_and_rolls_back_publish_failure(installed, monkeypatch): + app, root = installed + key, owner, args = await legacy(app, root) + pending = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, subject=owner, contract_version=2) + async with app.metadata.transaction(write=False) as tx: + plan = await preview_import(app, tx, 'old-offer') + await call(app, 'money.purchase_cancel', {'purchase_id': pending.data['purchase']['id']}, key=key, subject=owner) + with pytest.raises(Failure, match='offer_import_preview_stale'): + await apply_import(app, root, plan, operator='test') + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT 1 FROM resources WHERE id=?', ('old-offer',)) is None + plan = await preview_import(app, tx, 'old-offer') + before = await snapshot(app) + original = app.contents.commit_revision + async def fail(*args): + await original(*args) + raise Failure('injected_import_failure') + monkeypatch.setattr(app.contents, 'commit_revision', fail) + with pytest.raises(Failure, match='injected_import_failure'): + await apply_import(app, root, plan, operator='test') + assert await snapshot(app) == before + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT 1 FROM server_offer_resources WHERE offer_id=?', ('old-offer',)) is None + assert tx.one('SELECT 1 FROM revisions WHERE id=?', (plan['revision_id'],)) is None + monkeypatch.setattr(app.contents, 'commit_revision', original) + assert (await apply_import(app, root, plan, operator='test'))['offer_id'] == 'old-offer' + + +@pytest.mark.asyncio +async def test_console_import_dry_run_needs_no_pin_and_real_import_requires_full_approval(installed, + installation_seed, monkeypatch): + app, root = installed + await legacy(app, root) + from msg.admin import money + monkeypatch.setattr(money, 'require_local_console', lambda _: 'test-console') + monkeypatch.setattr('builtins.input', lambda _: pytest.fail('dry-run asked approval')) + monkeypatch.setattr(money.getpass, 'getpass', lambda _: pytest.fail('dry-run asked PIN')) + before = await snapshot(app) + dry = await asyncio.to_thread(MoneyAdmin(app.settings.config_dir).execute_offer, + 'import', offer_id='old-offer', dry_run=True) + assert dry['approval_digest'] == digest(dry['plan']) + assert await snapshot(app) == before + monkeypatch.setattr('builtins.input', lambda _: 'CONFIRM MONEY') + with pytest.raises(Failure, match='approval_cancelled'): + await asyncio.to_thread(MoneyAdmin(app.settings.config_dir).execute_offer, + 'import', offer_id='old-offer') + seed, _, _, _ = installation_seed + shutil.copytree(root_envelope(seed/'etc').parent, root_envelope(app.settings.config_dir).parent) + monkeypatch.setattr('builtins.input', lambda prompt: prompt.removeprefix('Type ').removesuffix(' to continue: ')) + monkeypatch.setattr(money.getpass, 'getpass', lambda _: 'correct-horse-test-passphrase') + result = await asyncio.to_thread(MoneyAdmin(app.settings.config_dir).execute_offer, + 'import', offer_id='old-offer') + assert result['offer_id'] == 'old-offer' and await snapshot(app) == before + + +@pytest.mark.asyncio +async def test_import_rejects_inconsistent_entitlement_before_publication(installed): + app, root = installed + key, owner, args = await legacy(app, root) + result = await call(app, 'money.redeem', args, key=key, subject=owner) + assert result.status == 'ok' + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE resource_entitlements SET quantity=quantity+1 WHERE offer_id=?', + ('old-offer',), write=True) + async with app.metadata.transaction(write=False) as tx: + with pytest.raises(Failure, match='offer_import_inconsistent_entitlements'): + await preview_import(app, tx, 'old-offer') + assert tx.one('SELECT 1 FROM resources WHERE id=?', ('old-offer',)) is None + + +def test_daemon_import_parser_dispatches_dry_run(monkeypatch): + from msg.daemon import main + calls = [] + def execute(self, action, **kwargs): + calls.append((action, kwargs)) + return {'ok': True} + monkeypatch.setattr(MoneyAdmin, 'execute_offer', execute) + assert main(['money', 'offer', 'import', 'old-offer', '--dry-run']) == 0 + assert calls == [('import', {'offer_id': 'old-offer', 'fields': None, 'dry_run': True})] diff --git a/tests/test_offer_resource_writer.py b/tests/test_offer_resource_writer.py new file mode 100644 index 00000000..8c69851b --- /dev/null +++ b/tests/test_offer_resource_writer.py @@ -0,0 +1,166 @@ +"""Stage 1: new local offers have one signed Resource/Revision writer.""" +import pytest + +from msg.admin.money import apply_offer +from msg.core.codec import canonical, digest, wire +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from msg.market.offer_resources import authoritative_offer +from msg.security.crypto import verify +from test_market_redemption import setup_offer +from test_service import call + + +@pytest.mark.asyncio +async def test_new_offer_is_root_signed_listing_with_immutable_price_history(installed): + app, root = installed + key, owner, args, fields = await setup_offer(app, root) + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(args['offer_id']) + assert resource.type == 'listing' and resource.parent == 't_store' + assert resource.owner == 'u_root' and resource.mode == 0o444 + revision = await tx.revision(ResourceRef(id=resource.id)) + assert revision.id == args['price_revision'] + manifest = {k: v for k, v in wire(revision).items() if k not in {'manifest_digest', 'signature'}} + verify(root.public_key, canonical(manifest), revision.signature, purpose='revision') + assert digest(manifest) == revision.manifest_digest + original = await app.contents.read_bytes(revision.content) + assert (await authoritative_offer(app, tx, resource.id))['price_minor'] == 2 + ordinary = await call(app, 'store.listing_get', {'id': resource.id}) + assert ordinary.status == 'ok', wire(ordinary) + assert ordinary.data['listing']['server_offer']['offer_id'] == args['offer_id'] + assert ordinary.data['listing']['listing_revision'] == args['price_revision'] + pending = await call(app, 'money.redeem', {**args, 'defer': True}, key=key, subject=owner, + contract_version=2) + assert pending.status == 'ok', wire(pending) + updated = await apply_offer(app, root, action='set', operator='test', + offer_id=args['offer_id'], fields={**fields, 'price_minor': 3}) + assert updated['offer']['price_revision'] != args['price_revision'] + async with app.metadata.transaction(write=False) as tx: + assert await app.contents.read_bytes(revision.content) == original + current = await tx.revision(ResourceRef(id=resource.id)) + assert current.parents == (revision.id,) + assert (await authoritative_offer(app, tx, resource.id)) == updated['offer'] + settled = await call(app, 'money.purchase_settle', {'purchase_id': pending.data['purchase']['id']}, + key=key, subject=owner) + assert settled.status == 'ok' and settled.data['purchase']['total_minor'] == 10 + disabled = await apply_offer(app, root, action='disable', operator='test', offer_id=args['offer_id']) + assert disabled['offer']['price_revision'] == updated['offer']['price_revision'] + ordinary = await call(app, 'store.listing_get', {'id': resource.id}) + assert ordinary.data['listing']['state'] == 'paused' + assert len((await call(app, 'money.offers', {})).data['offers']) == 0 + + +@pytest.mark.asyncio +async def test_projected_price_tampering_cannot_change_signed_offer_or_spend(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + original = await authoritative_offer(app, tx, args['offer_id']) + tx.execute('UPDATE server_offers SET price_minor=1 WHERE offer_id=?', + (args['offer_id'],), write=True) + count = tx.one('SELECT COUNT(*) FROM money_ledger')[0] + denied = await call(app, 'money.redeem', args, key=key, subject=owner) + assert denied.error.code == 'offer_projection_mismatch' + assert (await call(app, 'money.offers', {})).error.code == 'offer_projection_mismatch' + with pytest.raises(Failure, match='offer_projection_mismatch'): + await apply_offer(app, root, action='disable', operator='test', offer_id=args['offer_id']) + async with app.metadata.transaction(write=False) as tx: + assert await authoritative_offer(app, tx, args['offer_id']) == original + assert tx.one('SELECT COUNT(*) FROM money_ledger')[0] == count + assert tx.one('SELECT COUNT(*) FROM money_purchases')[0] == 0 + + +@pytest.mark.asyncio +async def test_legacy_offer_is_not_silently_adopted_on_read_or_edit(installed): + app, root = installed + _, _, _, fields = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + tx.execute('''INSERT INTO server_offers + SELECT 'legacy-offer',resource_kind,unit,price_minor,min_quantity,max_quantity, + entitlement_kind,duration_seconds,enabled,'price_legacy' + FROM server_offers WHERE offer_id='web-bytes' ''', write=True) + assert (await call(app, 'money.offers', {})).status == 'ok' + await apply_offer(app, root, action='set', operator='test', offer_id='legacy-offer', fields=fields) + native = await call(app, 'store.listing_get', {'id': 'legacy-offer'}, contract_version=2) + assert native.error.code == 'listing_not_found' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT 1 FROM server_offer_resources WHERE offer_id=?', ('legacy-offer',)) is None + assert tx.one('SELECT 1 FROM resources WHERE id=?', ('legacy-offer',)) is None + + +@pytest.mark.asyncio +async def test_local_offer_revision_failure_rolls_back_projection_and_resource(installed, monkeypatch): + app, root = installed + _, _, _, fields = await setup_offer(app, root) + async with app.metadata.transaction(write=False) as tx: + before = {table: tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in + ('resources', 'revisions', 'server_offers', 'server_offer_resources', 'events', 'audit')} + commit = app.contents.commit_revision + async def fail_after_commit(*args): + await commit(*args) + raise Failure('injected_offer_revision_failure') + monkeypatch.setattr(app.contents, 'commit_revision', fail_after_commit) + with pytest.raises(Failure, match='injected_offer_revision_failure'): + await apply_offer(app, root, action='set', operator='test', offer_id='rollback-offer', fields=fields) + async with app.metadata.transaction(write=False) as tx: + assert {table: tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in before} == before + monkeypatch.setattr(app.contents, 'commit_revision', commit) + retried = await apply_offer(app, root, action='set', operator='test', offer_id='rollback-offer', fields=fields) + assert retried['offer']['enabled'] + + +@pytest.mark.asyncio +async def test_remote_seller_cannot_reprice_or_purchase_root_offer_via_store(installed): + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(args['offer_id']) + before = await authoritative_offer(app, tx, resource.id) + denied = await call(app, 'store.listing_update', {'id': resource.id, 'price_minor': 1}, + key=key, subject=owner, expected=((resource.id, resource.generation),)) + assert denied.error.code == 'listing_not_found' + denied = await call(app, 'orders.buy', {'listing_id': resource.id, + 'listing_revision': resource.revision, 'quantity': 1, 'currency_id': 'primary', + 'total_price_minor': 2}, key=key, subject=owner, contract_version=4) + assert denied.status == 'error' + async with app.metadata.transaction(write=False) as tx: + assert await authoritative_offer(app, tx, resource.id) == before + assert tx.one('SELECT COUNT(*) FROM store_orders')[0] == 0 + + +@pytest.mark.asyncio +async def test_existing_resource_id_collision_cannot_be_overwritten_by_local_offer(installed): + from dataclasses import replace + app, root = installed + _, owner, args, fields = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + template = await tx.resource(args['offer_id']) + collision = replace(template, id='collision-offer', name='collision-offer', owner=owner, + revision=None, generation=0) + await tx.insert(collision) + with pytest.raises(Failure, match='offer_resource_conflict'): + await apply_offer(app, root, action='set', operator='test', offer_id=collision.id, fields=fields) + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(collision.id) == collision + assert tx.one('SELECT 1 FROM server_offers WHERE offer_id=?', (collision.id,)) is None + assert tx.one('SELECT 1 FROM server_offer_resources WHERE offer_id=?', (collision.id,)) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target', ['web-bytes', 't_store']) +async def test_private_or_inactive_source_cannot_leak_through_legacy_offer_view(installed, target): + from dataclasses import replace + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(target) + await tx.replace(replace(resource, mode=0o700, generation=resource.generation + 1), + resource.generation) + count = tx.one('SELECT COUNT(*) FROM money_ledger')[0] + assert (await call(app, 'money.offers', {})).error.code == 'offer_resource_invalid' + denied = await call(app, 'money.redeem', args, key=key, subject=owner) + assert denied.error.code == 'offer_resource_invalid' + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM money_ledger')[0] == count + assert tx.one('SELECT COUNT(*) FROM money_purchases')[0] == 0 diff --git a/tests/test_order_resources.py b/tests/test_order_resources.py new file mode 100644 index 00000000..d7606088 --- /dev/null +++ b/tests/test_order_resources.py @@ -0,0 +1,222 @@ +"""Ordinary Order revisions are authority, not an unchecked second write model.""" +from datetime import timedelta + +import pytest + +from msg.core.codec import b64, canonical, loads, wire +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from msg.market.order_resources import verify_source +from msg.plugins.money import _balance +from read_only_evidence import business_snapshot +from test_market_lifecycle import market +from test_orders import _intent +from test_service import NOW, call + + +async def buy(app, key, buyer, listing): + result = await call(app, 'orders.buy', _intent(listing), key=key, subject=buyer, + contract_version=4) + assert result.status == 'ok', wire(result) + return result.data['order'] + + +async def source(app, oid): + async with app.metadata.transaction(write=False) as tx: + resource = await verify_source(app, tx, oid) + revision = await tx.revision(ResourceRef(id=oid)) + return resource, revision, loads(await app.contents.read_bytes(revision.content)) + + +@pytest.mark.asyncio +async def test_order_resource_accept_appends_revision_without_rewriting_signed_origin(installed): + app, root = installed + sk, seller, bk, buyer, listing, _ = await market(app, root) + order = await buy(app, bk, buyer, listing) + oid = order['id'] + first, original, body = await source(app, oid) + assert first.type == 'order' and first.mode == 0o400 + assert body['order']['state'] == 'delivered' + assert body['contract']['creation_request']['contract_version'] == 4 + async with app.metadata.transaction(write=False) as tx: + assert await tx.path(oid) == (await tx.path(buyer)) + '/orders/' + oid + # The seller's role-trimmed market view is separate from buyer-only source bytes. + assert (await call(app, 'orders.get', {'order_id': oid}, key=sk, subject=seller)).status == 'ok' + assert (await call(app, 'discovery.get', {'id': oid}, key=sk, subject=seller)).status == 'error' + for op, args in [('content.chmod', {'id': oid, 'mode': '0644'}), + ('file.write', {'id': oid, 'base_revision': original.id, 'data': b64(b'forged')}), + ('content.archive', {'id': oid})]: + rejected = await call(app, op, args, key=bk, subject=buyer, + expected=((oid, first.generation),)) + assert rejected.status == 'error' and rejected.error.code in { + 'permission_denied', 'controlled_resource', 'order_controlled_resource', 'not_editable'}, wire(rejected) + delivery = await call(app, 'delivery.get', {'order_id': oid}, key=bk, subject=buyer, contract_version=2) + accepted = await call(app, 'delivery.accept', {'order_id': oid, + 'delivery_digest': delivery.data['delivery']['delivery_digest']}, key=bk, subject=buyer, + contract_version=2, rid='resource-accept') + assert accepted.status == 'ok', wire(accepted) + second, revision, current = await source(app, oid) + assert second.generation == first.generation + 1 and revision.parents == (original.id,) + assert current['order']['state'] == 'settled' and current['contract'] == body['contract'] + assert loads(await app.contents.read_bytes(original.content)) == body + replay = await call(app, 'delivery.accept', {'order_id': oid, + 'delivery_digest': delivery.data['delivery']['delivery_digest']}, key=bk, subject=buyer, + contract_version=2, rid='resource-accept') + assert replay.replayed and (await source(app, oid))[0] == second + + +@pytest.mark.asyncio +async def test_sql_state_drift_cannot_be_read_republished_or_used_to_release(installed): + app, root = installed + _sk, _seller, bk, buyer, listing, _ = await market(app, root) + order = await buy(app, bk, buyer, listing) + oid = order['id'] + resource, _, _ = await source(app, oid) + async with app.metadata.transaction(write=True) as tx: + tx.execute("UPDATE store_orders SET state='funded' WHERE id=?", (oid,), write=True) + before = await business_snapshot(app) + for operation, args, version in [('orders.get', {'order_id': oid}, 1), + ('orders.cancel', {'order_id': oid}, 2), + ('orders.buy', _intent(listing), 4)]: + result = await call(app, operation, args, key=bk, subject=buyer, contract_version=version) + assert result.error.code == 'order_resource_projection_mismatch', wire(result) + assert await business_snapshot(app) == before + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(oid) == resource + + +@pytest.mark.asyncio +async def test_missing_order_body_blocks_reads_and_money_before_mutation(installed, monkeypatch): + app, root = installed + _sk, _seller, bk, buyer, listing, _ = await market(app, root) + order = await buy(app, bk, buyer, listing) + _, revision, _ = await source(app, order['id']) + read = app.contents.read_bytes + async def missing(ref): + if ref == revision.content: + raise FileNotFoundError('fixture') + return await read(ref) + monkeypatch.setattr(app.contents, 'read_bytes', missing) + before = await business_snapshot(app) + result = await call(app, 'orders.get', {'order_id': order['id']}, key=bk, subject=buyer) + assert result.error.code == 'content_missing' + result = await call(app, 'orders.cancel', {'order_id': order['id']}, key=bk, subject=buyer, contract_version=2) + assert result.error.code == 'content_missing' + assert await business_snapshot(app) == before + + +@pytest.mark.asyncio +async def test_revision_publication_failure_rolls_back_order_delivery_and_ledger(installed, monkeypatch): + app, root = installed + _sk, _seller, bk, buyer, listing, _ = await market(app, root) + before = await business_snapshot(app) + original = app.contents.commit_revision + async def fail(parent, revision): + await original(parent, revision) + if revision.resource_id.startswith('ord_'): + raise Failure('injected_order_revision_failure') + monkeypatch.setattr(app.contents, 'commit_revision', fail) + result = await call(app, 'orders.buy', _intent(listing), key=bk, subject=buyer, contract_version=4) + assert result.error.code == 'injected_order_revision_failure', wire(result) + assert await business_snapshot(app) == before + + +@pytest.mark.asyncio +async def test_timeout_internal_writer_advances_source_and_refunds_once(installed): + app, root = installed + _sk, seller, bk, buyer, listing, _ = await market(app, root, mode='service', kind='service') + order = await buy(app, bk, buyer, listing) + first, original, _ = await source(app, order['id']) + app.clock = lambda: NOW + timedelta(days=2) + from msg.market.escrow import resolve_due + assert await resolve_due(app) == [order['id']] + assert await resolve_due(app) == [] + current, revision, body = await source(app, order['id']) + assert revision.parents == (original.id,) and current.generation == first.generation + 1 + assert body['order']['state'] == 'refunded' + async with app.metadata.transaction(write=False) as tx: + assert (_balance(tx, buyer), _balance(tx, seller)) == (20_000_000, 0) + + +@pytest.mark.asyncio +async def test_signed_arbitration_internal_entrypoint_advances_order_source(installed, monkeypatch): + import test_market_arbitration as arb + async def buy_v4(app, key, buyer, listing): + return await call(app, 'orders.buy', _intent(listing), key=key, subject=buyer, contract_version=4) + monkeypatch.setattr(arb, 'buy', buy_v4) + app, _, members, _, seller, bk, buyer, order, opened = await arb.configured(installed) + first, previous, body = await source(app, order['id']) + assert body['order']['state'] == 'disputed' + panel = opened['panel'] + base = await arb.reasoned_base(app, members[panel[0]], panel[0], opened['case_id']) + for member in panel[:2]: + vote = await arb.vote(app, members[member], member, base) + assert vote.status == 'ok', wire(vote) + from msg.market.arbitration import _read, execute + async with app.metadata.transaction(write=True) as tx: + case = _read(tx, opened['case_id']) + await execute(app, tx, case, vote.data['decision']['id'], NOW, buyer, 'internal-arbitration') + current, revision, body = await source(app, order['id']) + assert current.generation == first.generation + 1 and revision.parents == (previous.id,) + assert body['order']['state'] == 'settled' + async with app.metadata.transaction(write=False) as tx: + assert (_balance(tx, buyer), _balance(tx, seller)) == (17_000_000, 3_000_000) + + +@pytest.mark.asyncio +async def test_account_scoped_entitlement_replay_keeps_original_authority(installed): + from test_market_account_scope import restricted_key + from test_market_redemption import setup_offer + from test_entitlement_orders import intent + app, root = installed + key, owner, args, _ = await setup_offer(app, root) + scoped = await restricted_key(app, key, owner, 'money.redeem', 3, account=True) + signed = await intent(app, args) + first = await call(app, 'money.redeem', signed, key=scoped, subject=owner, + contract_version=3, rid='scoped-resource') + assert first.status == 'ok', wire(first) + repeated = await call(app, 'money.redeem', signed, key=scoped, subject=owner, + contract_version=3, rid='scoped-resource') + assert repeated.replayed and repeated.data == first.data, wire(repeated) + assert first.resources == () and first.data['order_resources'][0]['id'] == first.data['order']['id'] + + +@pytest.mark.asyncio +async def test_accept_revision_failure_rolls_back_release_and_buyer_ack(installed, monkeypatch): + app, root = installed + _sk, _seller, bk, buyer, listing, _ = await market(app, root) + order = await buy(app, bk, buyer, listing) + delivery = await call(app, 'delivery.get', {'order_id': order['id']}, + key=bk, subject=buyer, contract_version=2) + before = await business_snapshot(app) + original = app.contents.commit_revision + async def fail(parent, revision): + await original(parent, revision) + if revision.resource_id == order['id']: + raise Failure('injected_accept_revision_failure') + monkeypatch.setattr(app.contents, 'commit_revision', fail) + result = await call(app, 'delivery.accept', {'order_id': order['id'], + 'delivery_digest': delivery.data['delivery']['delivery_digest']}, + key=bk, subject=buyer, contract_version=2) + assert result.error.code == 'injected_accept_revision_failure', wire(result) + assert await business_snapshot(app) == before + assert (await source(app, order['id']))[2]['order']['state'] == 'delivered' + + +@pytest.mark.asyncio +async def test_invalid_source_signature_cannot_authorize_lifecycle_or_read(installed): + from dataclasses import replace + app, root = installed + _sk, _seller, bk, buyer, listing, _ = await market(app, root, mode='service', kind='service') + order = await buy(app, bk, buyer, listing) + _, revision, _ = await source(app, order['id']) + corrupted = replace(revision, signature=replace(revision.signature, value=b'x' * 64)) + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE revisions SET body=? WHERE id=?', + (canonical(corrupted).decode(), revision.id), write=True) + before = await business_snapshot(app) + for operation, version in [('orders.get', 1), ('orders.cancel', 2)]: + result = await call(app, operation, {'order_id': order['id']}, key=bk, subject=buyer, + contract_version=version) + assert result.error.code == 'invalid_signature', wire(result) + assert await business_snapshot(app) == before diff --git a/tests/test_patch_rebase_identity.py b/tests/test_patch_rebase_identity.py new file mode 100644 index 00000000..cd39b7c0 --- /dev/null +++ b/tests/test_patch_rebase_identity.py @@ -0,0 +1,41 @@ +"""A stale unified patch cannot relocate to a different identical old block.""" +import pytest + +from msg.core.codec import wire +from msg.core.errors import Failure +from msg.core.text_patch import apply_patch +from test_service import call, register +from test_text_patch import state, content + +PATCH = {'kind': 'unified', 'diff': '@@ -1 +1 @@\n-old\n+new\n'} + + +def test_unified_rebase_requires_unique_anchor_in_base_too(): + base = 'old\nseparator\nold\n' + current = 'changed\nseparator\nold\n' + assert apply_patch(base, PATCH) == 'new\nseparator\nold\n' + with pytest.raises(Failure) as exc: + apply_patch(current, PATCH, base_source=base) + assert exc.value.code == 'patch_ambiguous' + + +@pytest.mark.asyncio +async def test_signed_rebase_cannot_edit_surviving_duplicate(installed): + app, _ = installed + key, owner, _ = await register(app, 'rebase-identity') + created = await call(app, 'content.post_create', + {'parent': '/main', 'body': 'old\nseparator\nold\n'}, key=key, subject=owner) + rid = created.resources[0].id + edited = await call(app, 'content.text_patch', + {'id': rid, 'base_revision': created.resources[0].revision, + 'exact': 'old\nseparator', 'replacement': 'changed\nseparator'}, + key=key, subject=owner, expected=((rid, created.data['generation']),)) + assert edited.status == 'ok', wire(edited) + before = await state(app, rid) + result = await call(app, 'content.text_patch', + {'id': rid, 'base_revision': created.resources[0].revision, + 'base_generation': created.data['generation'], 'rebase': True, 'patch': PATCH}, + contract_version=3, key=key, subject=owner, expected=((rid, edited.data['generation']),)) + assert result.status == 'error' and result.error.code == 'patch_ambiguous', wire(result) + assert await state(app, rid) == before + assert (await content(app, rid))[1] == 'changed\nseparator\nold\n' diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py new file mode 100644 index 00000000..c914fa5d --- /dev/null +++ b/tests/test_plugin_lifecycle.py @@ -0,0 +1,167 @@ +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from msg.config import load_settings, write_example +from msg.core.errors import Failure +from msg.core.registry import Registry +from msg.plugins import BUILTINS, install_registry +from test_manifest_feature_linkage import plugin + + +@pytest.mark.parametrize('value,code', [ + ('"identity"', 'invalid_plugin_list'), + ('{}', 'invalid_plugin_list'), + ('["identity", "identity"]', 'invalid_plugin_list'), + ('["identity", "external.py"]', 'unknown_plugin'), + ('[]', 'identity_plugin_required'), +]) +def test_invalid_plugin_configuration_rejected_before_application(tmp_path, value, code): + settings = write_example(tmp_path/'etc', tmp_path/'data') + path = settings.config_dir/'msgd.toml' + with path.open('a') as stream: + stream.write('\n[plugins]\nenabled = '+value+'\n') + with pytest.raises(Failure, match=code): + load_settings(settings.config_dir) + + +def test_configuration_defaults_are_installed_defaults(tmp_path): + settings = write_example(tmp_path/'etc', tmp_path/'data') + assert settings.server.plugins == BUILTINS + assert settings.credential_delivery_recovery_window == 15*60 + assert settings.money.enabled is True + assert settings.money.currency_id == 'primary' + assert settings.money.display_name == settings.money.code == 'MSG' + assert (settings.money.scale, settings.money.transfer_fee, settings.money.allow_overdraft) == (6, 0, False) + + +@pytest.mark.parametrize('plugins,code', [ + ('identity', 'invalid_plugin_list'), + (('identity', 'identity'), 'invalid_plugin_list'), + (('external.py',), 'unknown_plugin'), + ((), 'identity_plugin_required'), +]) +def test_invalid_direct_install_is_atomic(plugins, code): + context = SimpleNamespace(registry=Registry()) + with pytest.raises(Failure, match=code): + install_registry(context, plugins) + assert not context.registry._plugins + assert not context.registry._schemas + + +def test_unsupported_migration_is_rejected_before_contract_registration(): + registry = Registry() + with pytest.raises(Failure, match='unsupported_plugin_migration'): + registry.add(replace(plugin(), migrations=('run:untrusted.module',))) + assert not registry._plugins + assert not registry.operations() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('version', [1, 99, True]) +async def test_unavailable_queued_operation_never_runs_effect(installed, monkeypatch, version): + from msg.workers.effects import EffectWorker + from test_effect_retry_state import job_for + app, _ = installed + job = replace(job_for('webhook', state='pending', attempts=0), lease_until=None, + arguments={'contract_version': version}) + if version == 1: + # A restarted application has omitted the communication plugin. + app.registry._operations.pop(('communication.send', 1)) + async with app.metadata.transaction(write=True) as tx: + await tx.enqueue(job) + worker = EffectWorker(app) + async def forbidden(*args): + raise AssertionError('disabled or unregistered operation reached external dispatch') + monkeypatch.setattr(worker, '_webhook', forbidden) + assert await worker.run_once() is True + async with app.metadata.transaction(write=False) as tx: + current = await tx.job(job.id) + assert current.state == 'failed' + assert current.result is None + assert tx.setting('job_status:' + job.id)['code'] == ( + 'invalid_job_contract_version' if version is True else 'unknown_operation') + assert current.arguments == job.arguments + + +@pytest.mark.asyncio +@pytest.mark.parametrize('installed_version,queued_version', [(1, 1), (2, 2), (1, 2), (2, 1)]) +async def test_worker_uses_exact_queued_contract_version(installed, monkeypatch, + installed_version, queued_version): + from msg.workers.effects import EffectWorker + from test_effect_retry_state import job_for + app, _ = installed + spec = app.registry._operations.pop(('communication.send', 1)) + app.registry._operations[('communication.send', installed_version)] = replace(spec, version=installed_version) + job = replace(job_for('webhook', state='pending', attempts=0), lease_until=None, + arguments={'contract_version': queued_version}) + async with app.metadata.transaction(write=True) as tx: + await tx.enqueue(job) + worker = EffectWorker(app) + dispatched = [] + async def dispatch(current): + dispatched.append(current.id) + await worker._finish(current, 'done', 'test_dispatched') + monkeypatch.setattr(worker, '_webhook', dispatch) + assert await worker.run_once() + async with app.metadata.transaction(write=False) as tx: + current = await tx.job(job.id) + assert current.state == ('done' if installed_version == queued_version else 'failed') + assert dispatched == ([job.id] if installed_version == queued_version else []) + + +@pytest.mark.asyncio +async def test_disabling_plugin_preserves_stored_data_and_pending_job(installed, monkeypatch): + from msg.application import Application + from test_effect_retry_state import job_for + app, _ = installed + job = replace(job_for('webhook', state='pending', attempts=0), lease_until=None) + async with app.metadata.transaction(write=True) as tx: + await tx.enqueue(job) + async with app.metadata.transaction(write=False) as tx: + before = tx.rows('SELECT id,body FROM resources ORDER BY id') + settings = replace(app.settings, server=replace(app.settings.server, + plugins=tuple(name for name in app.settings.server.plugins if name != 'communication'))) + disabled = Application(settings) + async def forbidden_notification(*args): + raise AssertionError('disabled plugin produced a notification') + monkeypatch.setattr('msg.plugins.communication.enqueue_domain_webhooks', forbidden_notification) + await disabled._event_notifications(None, None) + with pytest.raises(Failure, match='unknown_operation'): + disabled.registry.operation('communication.send') + # Constructing the disabled registry neither rewrites facts nor completes jobs. + async with app.metadata.transaction(write=False) as tx: + assert tx.rows('SELECT id,body FROM resources ORDER BY id') == before + assert await tx.job(job.id) == job + + +@pytest.mark.asyncio +async def test_version_two_maintenance_producer_persists_exact_version(installed, monkeypatch): + from test_effect_retry_state import job_for + from test_service import NOW + app, _ = installed + captured = [] + async def allow(*args): + return True + async def enqueue(job): + captured.append(job) + monkeypatch.setattr(app.authorizer, 'has', allow) + request = SimpleNamespace(operation='system.maintenance', contract_version=2, + request_id='versioned-maintenance', arguments={'action': 'deliver_due_todos'}) + context = SimpleNamespace(principal=job_for('maintenance').principal, now=NOW) + await app.registry.operation('system.maintenance', 2).handler( + context, request, SimpleNamespace(enqueue=enqueue)) + assert len(captured) == 1 + assert captured[0].arguments['contract_version'] == 2 + assert captured[0].arguments['action'] == 'deliver_due_todos' + + +def test_disabled_plugin_diagnostics_cannot_claim_feature_success(): + from msg.admin.diagnostics import feature_results + from msg.bootstrap import feature_manifest + result = feature_results(feature_manifest(), {'market_clearing': {'ok': True}}, + 'doctor_check', enabled_plugins=('identity',)) + assert result['money'] == {'status': 'disabled', 'check': 'market_clearing'} + assert result['root_trust'] == {'status': 'fail', 'check': 'root_trust', + 'reason': 'check_missing'} diff --git a/tests/test_proposals.py b/tests/test_proposals.py new file mode 100644 index 00000000..575bcdf5 --- /dev/null +++ b/tests/test_proposals.py @@ -0,0 +1,114 @@ +"""A proposal binds immutable content; only explicit current write authority accepts it.""" +import pytest +from test_service import call, register + +from msg.core.codec import wire + + +async def fixture(app): + key, subject, _ = await register(app, 'proposal-owner') + target = await call(app, 'content.post_create', {'parent': '/main', 'body': 'Old text'}, + key=key, subject=subject) + source = await call(app, 'content.post_create', {'parent': '/main', 'body': 'Proposed text'}, + key=key, subject=subject) + args = {'target': target.resources[0].id, 'base_revision': target.resources[0].revision, + 'content_ref': wire(source.resources[0]), 'message': 'Please apply'} + proposal = await call(app, 'communication.proposal_create', args, key=key, subject=subject) + assert proposal.status == 'ok', proposal.error + return key, subject, target, source, proposal + + +def acceptance(target, proposal): + return {'id': proposal.data['id'], 'proposal_revision': proposal.resources[0].revision, + 'base_revision': target.resources[0].revision} + + +def generations(target, proposal): + return ((target.resources[0].id, target.data['generation']), + (proposal.data['id'], proposal.data['generation'])) + + +@pytest.mark.asyncio +async def test_proposal_accept_uses_pinned_content_and_replays_once(installed): + app, _ = installed + key, subject, target, source, proposal = await fixture(app) + changed_source = await call(app, 'content.post_edit', {'id': source.resources[0].id, + 'expected_revision': source.resources[0].revision, 'body': 'Later unapproved text'}, + key=key, subject=subject, expected=((source.resources[0].id, source.data['generation']),)) + assert changed_source.status == 'ok', changed_source.error + accepted = await call(app, 'communication.proposal_accept', acceptance(target, proposal), + key=key, subject=subject, expected=generations(target, proposal), rid='accept-proposal-once') + assert accepted.status == 'ok', accepted.error + result = await call(app, 'discovery.get', {'id': target.resources[0].id}, key=key, subject=subject) + assert result.data['content'] == 'Proposed text' + again = await call(app, 'communication.proposal_accept', acceptance(target, proposal), + key=key, subject=subject, expected=generations(target, proposal), rid='accept-proposal-once') + assert again.replayed and again.resources == accepted.resources + record = await call(app, 'communication.proposal_get', {'id': proposal.data['id']}, + key=key, subject=subject) + assert record.data['proposal']['status'] == 'accepted' + assert record.data['proposal']['target_ref']['id'] == target.resources[0].id + + +@pytest.mark.asyncio +async def test_proposal_conflict_keeps_open_and_cannot_edit_without_current_rights(installed): + from datetime import timedelta + + from test_service import NOW + app, _ = installed + key, subject, target, source, proposal = await fixture(app) + other_key, other, _ = await register(app, 'proposal-reader') + shared = await call(app, 'sharing.grant', {'resource': proposal.data['id'], 'grantee': other, + 'expires_at': wire(NOW + timedelta(days=1))}, key=key, subject=subject) + assert shared.status == 'ok', shared.error + denied = await call(app, 'communication.proposal_accept', acceptance(target, proposal), + key=other_key, subject=other, expected=generations(target, proposal)) + assert denied.status == 'error' + edit = await call(app, 'content.post_edit', {'id': target.resources[0].id, + 'expected_revision': target.resources[0].revision, 'body': 'Concurrent change'}, + key=key, subject=subject, expected=((target.resources[0].id, target.data['generation']),)) + assert edit.status == 'ok', edit.error + conflict = await call(app, 'communication.proposal_accept', acceptance(target, proposal), + key=key, subject=subject, expected=((target.resources[0].id, edit.data['generation']), + (proposal.data['id'], proposal.data['generation']))) + assert conflict.error.code == 'revision_conflict' + got = await call(app, 'communication.proposal_get', {'id': proposal.data['id']}, key=key, subject=subject) + assert got.data['proposal']['status'] == 'open' + wrong = await call(app, 'communication.proposal_withdraw', + {'id': proposal.data['id'], 'proposal_revision': source.resources[0].revision}, + key=key, subject=subject, expected=((proposal.data['id'], proposal.data['generation']),)) + assert wrong.error.code == 'revision_conflict' + withdrawn = await call(app, 'communication.proposal_withdraw', + {'id': proposal.data['id'], 'proposal_revision': proposal.resources[0].revision}, + key=key, subject=subject, expected=((proposal.data['id'], proposal.data['generation']),)) + assert withdrawn.data['status'] == 'withdrawn' + + +@pytest.mark.asyncio +async def test_proposal_rechecks_content_visibility_and_rejects_other_target_kinds(installed): + app, _ = installed + key, subject, target, _, _ = await fixture(app) + source_key, source_subject, _ = await register(app, 'proposal-source-owner') + source = await call(app, 'content.post_create', {'parent': '/main', 'body': 'Shared draft'}, + key=source_key, subject=source_subject) + args = {'target': target.resources[0].id, 'base_revision': target.resources[0].revision, + 'content_ref': wire(source.resources[0]), 'message': 'Use this draft'} + unsupported = await call(app, 'communication.proposal_create', {**args, 'target': '/main'}, + key=key, subject=subject) + assert unsupported.error.code == 'proposal_target_kind_unsupported' + proposal = await call(app, 'communication.proposal_create', args, key=key, subject=subject) + assert proposal.status == 'ok', proposal.error + hidden = await call(app, 'content.chmod', {'id': source.resources[0].id, 'mode': '0600'}, + key=source_key, subject=source_subject, + expected=((source.resources[0].id, source.data['generation']),)) + assert hidden.status == 'ok', hidden.error + failed = await call(app, 'communication.proposal_accept', acceptance(target, proposal), + key=key, subject=subject, expected=generations(target, proposal)) + assert failed.status == 'error' + got = await call(app, 'communication.proposal_get', {'id': proposal.data['id']}, key=key, subject=subject) + assert got.data['proposal']['status'] == 'open' + assert 'content_ref' not in got.data['proposal'] + generic = await call(app, 'discovery.get', {'id': proposal.data['id']}, key=key, subject=subject) + assert source.resources[0].id not in str(generic.data) + original = await call(app, 'discovery.get', {'id': target.resources[0].id}, key=key, subject=subject) + assert original.data['content'] == 'Old text' diff --git a/tests/test_read_entry_contract_matrix.py b/tests/test_read_entry_contract_matrix.py new file mode 100644 index 00000000..fef10213 --- /dev/null +++ b/tests/test_read_entry_contract_matrix.py @@ -0,0 +1,97 @@ +"""A signed query and its bounded continuation retain meaning across dispatchers.""" +from dataclasses import replace +from datetime import timedelta + +import httpx +import pytest +from test_read_projection_cache_transports import forbid_read_effects +from test_route_effect_matrix import database_snapshot +from test_service import NOW, call, register + +from msg.core.codec import canonical, wire +from msg.core.requests import request_for +from msg.transports.client import TRANSPORTS +from msg.transports.http import create_app + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation,version', [ + ('discovery.read_query', 1), ('discovery.read_query', 3), + ('discovery.lexical_search', 5), ('communication.sync', 1), +]) +async def test_signed_filter_sort_page_and_budget_across_four_entries( + installed, monkeypatch, operation, version): + app, _ = installed + key, subject, _ = await register(app, 'entry-matrix') + ids = [] + bodies = ['matrixneedle alpha', 'matrixneedle beta', 'unmatched'] + if operation == 'discovery.lexical_search': + bodies += ['matrixneedle alpha unmatched', 'alpha without phrase'] + for body in bodies: + result = await call(app, 'content.post_create', {'parent': '/main', 'body': body}, + key=key, subject=subject) + assert result.status == 'ok', wire(result) + ids.append(result.resources[0].id) + if operation == 'discovery.read_query': + arguments = {'parent': '/main', 'type': 'post', 'author': subject, + 'query': 'matrixneedle', 'sort': 'id', 'direction': 'desc', + 'fields': ['id'], 'limit': 1} + if version == 3: + arguments['query_version'] = 3 + elif operation == 'discovery.lexical_search': + arguments = {'scope': {'resource_refs': [{'id': 't_main'}]}, + 'terms': 'alpha beta', 'mode': 'any', 'exact': 'matrixneedle', + 'not_terms': 'unmatched', 'type': 'post', 'author': subject, + 'order': 'created', 'fields': ['id'], 'limit': 1} + else: + # Sync has event order and a limit, not user-defined sort/filter. + arguments = {'limit': 1} + before = await database_snapshot(app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + adapters = [adapter(app.settings.service_url, http=http) + for adapter in TRANSPORTS.values()] + + async def equivalent(args): + packet = request_for(operation, args, app.settings.service_url, + signer=key, subject=subject, contract_version=version, + expires_at=NOW + timedelta(seconds=90)) + signed_bytes = canonical(packet) + results = [await adapter.call(packet) for adapter in adapters] + assert canonical(packet) == signed_bytes # No resigning/argument normalization. + assert all(wire(result) == wire(results[0]) for result in results[1:]) + return results[0] + + with forbid_read_effects(app, monkeypatch): + first = await equivalent(arguments) + assert first.status == 'ok', wire(first) + assert len(first.data['items']) == 1 + cursor = first.data.get('cursor') or first.data.get('sync_cursor') + assert cursor + second = await equivalent({'cursor': cursor}) + assert second.status == 'ok', wire(second) + if operation != 'communication.sync': + assert len(second.data['items']) == 1 + items = first.data['items'] + second.data['items'] + found = [item.get('id') or item['ref']['id'] for item in items] + assert set(found) == set(ids[:2]) + if operation == 'discovery.read_query': + assert found == sorted(ids[:2], reverse=True) + else: + assert first.data['sync_cursor'] != second.data['sync_cursor'] + denied = await equivalent({**arguments, 'limit': 101}) + assert denied.status == 'error', wire(denied) + assert denied.error.code == ('query_cost_exceeded' if + operation == 'discovery.read_query' and version == 1 else 'schema_validation') + if operation == 'discovery.read_query' and version == 3: + monkeypatch.setattr(app, 'settings', replace(app.settings, + server=replace(app.settings.server, limits=replace( + app.settings.server.limits, max_response_bytes=1500)))) + oversized = await equivalent(arguments) + assert oversized.status == 'error', wire(oversized) + assert oversized.error.code == 'response_too_large' + if operation == 'communication.sync': + unsupported = await equivalent({'limit': 1, 'sort': 'id', 'type': 'post'}) + assert unsupported.status == 'error', wire(unsupported) + assert unsupported.error.code == 'schema_validation' + assert await database_snapshot(app) == before diff --git a/tests/test_read_query_keyset_boundary.py b/tests/test_read_query_keyset_boundary.py new file mode 100644 index 00000000..ec6068b7 --- /dev/null +++ b/tests/test_read_query_keyset_boundary.py @@ -0,0 +1,82 @@ +"""Real first pages must not depend on a fabricated Unicode maximum key.""" +import pytest + +from msg.core.codec import wire +from read_only_evidence import readonly_evidence +from test_service import call, register + + +@pytest.mark.parametrize('operation,version', [ + ('discovery.list', 1), ('discovery.search', 1), + ('discovery.read_query', 1), ('discovery.read_query', 2), + ('discovery.read_query', 3), +]) +@pytest.mark.parametrize('sort', ['id', 'time', 'name']) +@pytest.mark.parametrize('direction', ['asc', 'desc']) +async def test_first_page_and_continuations_use_actual_database_keys( + installed, monkeypatch, operation, version, sort, direction): + app, _ = installed + key, subject, _ = await register(app, 'keyset-owner') + # The supplementary-plane name also exceeds U+FFFF in byte/codepoint + # ordering. Equal creation times exercise the existing ID tie breaker. + names = ['Alpha', 'zebra', 'éclair', '𐐀-tail', 'unmatched'] + expected_ids = [] + for index, name in enumerate(names): + result = await call(app, 'content.post_create', { + 'parent': '/main', 'name': name, + 'body': 'keysetneedle' if index < 4 else 'not a query match', + }, key=key, subject=subject) + assert result.status == 'ok', wire(result) + if index < 4: + expected_ids.append(result.resources[0].id) + column = {'id': 'id', 'time': 'created_at', 'name': 'name'}[sort] + ordering = 'ASC' if direction == 'asc' else 'DESC' + async with app.metadata.transaction(write=False) as tx: + # Independent SQL oracle has no synthetic first-page key. Use the + # database's existing collation, not Python's Unicode sort order. + expected = [row[0] for row in tx.rows( + f'SELECT id FROM resources WHERE id IN (?,?,?,?) ' + f'ORDER BY {column} {ordering},id {ordering}', tuple(expected_ids))] + assert len(expected) == 4 + arguments = {'parent': '/main', 'type': 'post', 'author': subject, + 'query': 'keysetneedle', 'sort': sort, 'direction': direction, + 'fields': ['id'], 'limit': 1} + if version == 3: + arguments['query_version'] = 3 + + async def read(args): + result = await call(app, operation, args, key=key, subject=subject, + contract_version=version) + assert result.status == 'ok', wire(result) + return result.data + + def continuation(cursor): + return ({'cursor': cursor} if operation == 'discovery.read_query' + else {**arguments, 'cursor': cursor}) + + async with readonly_evidence(app, monkeypatch): + query = arguments + found = [] + cursors = [] + for index, expected_id in enumerate(expected): + page = await read(query) + assert list(page['items']) == [{'id': expected_id}] + found.append(page['items'][0]['id']) + if index < len(expected) - 1: + assert page.get('next') + cursor = page['cursor'] + assert cursor not in cursors, 'cursor did not advance' + cursors.append(cursor) + query = continuation(cursor) + else: + assert not page.get('next') + if version == 3: + exhausted = await read(continuation(page['cursor'])) + assert list(exhausted['items']) == [] + assert exhausted['pageInfo']['hasNextPage'] is False + assert found == expected + assert len(found) == len(set(found)) + # Replaying a cursor repeats the same page, not an offset-dependent + # or newly invented boundary, and still goes through authorization. + repeated = await read(continuation(cursors[0])) + assert list(repeated['items']) == [{'id': expected[1]}] diff --git a/tests/test_recovery_authority_reconcile.py b/tests/test_recovery_authority_reconcile.py new file mode 100644 index 00000000..289a3110 --- /dev/null +++ b/tests/test_recovery_authority_reconcile.py @@ -0,0 +1,164 @@ +"""Current metadata can be staged only while all old/new derived authority is isolated.""" +from copy import deepcopy + +import pytest + +from msg.core.codec import wire +from msg.core.errors import Failure +from msg.core.models import EffectJob, Principal +from msg.security.quarantine import SETTING, active +from msg.workers.effects import EffectWorker +from test_service import NOW, call, register +from test_recovery_policy_v2 import packet_for, replay + + +@pytest.fixture +async def authority_restore(installed): + app, _ = installed + old = await register(app, 'old-authority') + new = await register(app, 'new-authority') + created = await call(app, 'content.topic_create', {'parent': old[1], 'name': 'staged'}, + key=old[0], subject=old[1]) + assert created.status == 'ok' + target = created.resources[0].id + assert (await call(app, 'discovery.get', {'id': target}, key=old[0], subject=old[1])).status == 'ok' + job = EffectJob(id='authority-mail', event_id='old-event', kind='mail', dedupe_key='authority-mail', + principal=Principal(actor=old[1], subject=old[1], credential_id=None, + method='local', certificates=(), ceiling=()), + operation='mail.send', arguments={}, state='pending', attempts=0, next_attempt_at=NOW, lease_until=None) + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(target) + await tx.enqueue(job) + tx.set_setting(SETTING, {'format': 'msg-recovery-quarantine-v1', + 'source_backup_sha256': 'a' * 64, 'outbound_enabled': False, 'authority': 'health_only'}) + body, signed, pin = packet_for(resource) + previous = {key: getattr(resource, key) for key in ('owner', 'group', 'parent')} + body['entries'][0].update(kind='resource.authority.reconcile', + value={'previous': previous, 'current': dict(previous, owner=new[1], parent=new[1])}) + body['coverage']['domains'] = ['resource_authority'] + return app, old, new, resource, job, body, signed, pin + + +async def assert_isolated(app, users, resource, job): + for key, subject, cert in users: + for operation, args in [('discovery.get', {'id': resource.id}), + ('content.chmod', {'id': resource.id, 'mode': '0777'})]: + result = await call(app, operation, args, key=key, subject=subject, certs=(cert,)) + assert result.error.code == ('recovery_quarantined' if operation == 'discovery.get' else 'writes_paused') + worker = EffectWorker(app) + assert await worker._claim() == (None, False) + assert await worker.run_once() is False + async with app.metadata.transaction(write=False) as tx: + assert await tx.job(job.id) == job + assert active(tx) + + +async def test_stage_new_owner_parent_without_exposing_old_or_new_scope(authority_restore): + app, old, new, resource, job, body, signed, pin = authority_restore + await assert_isolated(app, (old, new), resource, job) + async with app.metadata.transaction(write=False) as tx: + revisions = tx.rows('SELECT body FROM revisions ORDER BY id') + certificates = tx.rows('SELECT body,revoked FROM certificates ORDER BY id') + result = await replay(app, body, signed, pin) + assert result['changed'] == 1 and result['promotion'] == 'blocked' + assert result['authority_rebuild_required'] == [resource.id] + assert 'derived_authority_inventory_incomplete' in result['promotion_blocked_reasons'] + assert (await replay(app, body, signed, pin))['changed'] == 0 + await assert_isolated(app, (old, new), resource, job) + async with app.metadata.transaction(write=False) as tx: + updated = await tx.resource(resource.id) + assert (updated.owner, updated.parent, updated.mode) == (new[1], new[1], resource.mode) + assert tx.rows('SELECT body FROM revisions ORDER BY id') == revisions + assert tx.rows('SELECT body,revoked FROM certificates ORDER BY id') == certificates + + +@pytest.mark.parametrize('invalid', ['state', 'cycle', 'missing_parent', 'missing_owner']) +async def test_bad_reconciliation_rolls_back_earlier_certificate_revocation(authority_restore, invalid): + app, old, new, resource, job, body, signed, pin = authority_restore + fact = body['entries'][0] + if invalid == 'state': + fact['value']['previous']['parent'] = 'unrelated' + elif invalid == 'cycle': + fact['value']['current']['parent'] = resource.id + elif invalid == 'missing_parent': + fact['value']['current']['parent'] = 'missing' + else: + fact['value']['current']['owner'] = 'missing' + fact['sequence'] = 2 + body['entries'] = [{'sequence': 1, 'kind': 'certificate.current', 'subject': old[1], + 'target': old[2], 'at': wire(NOW), 'value': {'body_digest': None}}, fact] + body['sequence'] = 2 + body['coverage']['domains'] = ['certificates', 'resource_authority'] + with pytest.raises(Failure): + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(resource.id) == resource + assert not await tx.certificate_revoked(old[2]) + assert tx.setting('recovery_replay') is None and active(tx) + await assert_isolated(app, (old, new), resource, job) + + +async def test_contiguous_owner_chain_converges_once_and_rejects_gap(authority_restore): + app, old, new, resource, job, body, signed, pin = authority_restore + second = deepcopy(body['entries'][0]) + second['sequence'] = 2 + second['subject'] = new[1] + second['value'] = {'previous': second['value']['current'], + 'current': dict(second['value']['current'], parent=old[1])} + body['entries'].append(second) + body['sequence'] = 2 + assert (await replay(app, body, signed, pin))['changed'] == 1 + assert (await replay(app, body, signed, pin))['changed'] == 0 + await assert_isolated(app, (old, new), resource, job) + async with app.metadata.transaction(write=False) as tx: + actual = await tx.resource(resource.id) + assert actual.owner == new[1] and actual.parent == old[1] + second['value']['previous']['group'] = 'unrelated' + with pytest.raises(Failure, match='recovery_checkpoint_invalid'): + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(resource.id) == actual + + +async def test_acl_then_transfer_replays_twice(authority_restore): + app, old, new, resource, job, body, signed, pin = authority_restore + transfer = body['entries'][0] + transfer['sequence'] = 2 + acl = dict(transfer, sequence=1, kind='resource.acl.restrict', + value={'owner': resource.owner, 'group': resource.group, 'mode': 0o700}) + body.update(entries=[acl, transfer], sequence=2) + body['coverage']['domains'] = ['resource_acl', 'resource_authority'] + await replay(app, body, signed, pin) + assert (await replay(app, body, signed, pin))['changed'] == 0 + await assert_isolated(app, (old, new), resource, job) + + +@pytest.mark.parametrize('bad_binding', [None, 'owner', 'group', 'past_owner']) +async def test_intermediate_acl_binds_to_signed_authority_chain(authority_restore, bad_binding): + app, old, new, resource, job, body, signed, pin = authority_restore + first = body['entries'][0] + middle = dict(first, sequence=2, subject=new[1], kind='resource.acl.restrict', + value={'owner': new[1], 'group': resource.group, 'mode': 0o400}) + final = dict(first, sequence=3, subject=new[1], + value={'previous': deepcopy(first['value']['current']), + 'current': dict(first['value']['previous'])}) + body.update(entries=[first, middle, final], sequence=3) + body['coverage']['domains'] = ['resource_acl', 'resource_authority'] + if bad_binding: + if bad_binding == 'past_owner': + middle['value']['owner'] = old[1] + middle['subject'] = old[1] + else: + middle['value'][bad_binding] = 'unrelated' + with pytest.raises(Failure, match='recovery_fact_subject_mismatch'): + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(resource.id) == resource + assert tx.setting('recovery_replay') is None + else: + await replay(app, body, signed, pin) + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + actual = await tx.resource(resource.id) + assert actual.owner == old[1] and actual.mode == ((resource.mode & 0o7000) | (resource.mode & 0o400)) + await assert_isolated(app, (old, new), resource, job) diff --git a/tests/test_recovery_current_authority.py b/tests/test_recovery_current_authority.py new file mode 100644 index 00000000..d52e5c7a --- /dev/null +++ b/tests/test_recovery_current_authority.py @@ -0,0 +1,153 @@ +"""Real PostgreSQL regressions for recovery replay and current Root authority.""" +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +import pytest + +from msg.admin.backup_retirement import import_record +from msg.admin.recovery_replay import FORMAT, TrustedCheckpointPin, _replay +from msg.constants import ROOT_SUBJECT +from msg.core.codec import canonical, digest, wire +from msg.core.errors import Failure +from msg.core.models import Resource +from msg.security.backup_retirement import check, setting_key, verified +from msg.security.certificates import sign_certificate +from msg.security.crypto import Ed25519Signer +from msg.security.quarantine import SETTING, active +from msg.storage.postgres import PostgresMetadataStore +from test_backup_retirement_attestation import SUBJECT, credential, root_certificate, signed +from test_service import NOW + + +@pytest.fixture +async def retirement_store(pg_dsn): + store = PostgresMetadataStore(pg_dsn) + root = Ed25519Signer.generate() + details = {'old_identity_key_id': 'k_old', 'old_encryption_key_id': 'e_old'} + record = signed(details, root) + async with store.transaction(write=True) as tx: + await tx.register_certificate(root_certificate(root), None, 0) + tx.execute('INSERT INTO credentials VALUES(?,?,?)', + (root.key_id, ROOT_SUBJECT, canonical(credential(root, ROOT_SUBJECT)).decode()), write=True) + tx.execute('''INSERT INTO custodial_upgrades(id,subject,credential_id,status,expires_at, + challenge,body) VALUES(?,?,?,?,?,?,?)''', ('cupg_current', SUBJECT, 't_old', + 'completed', wire(NOW), '{}', canonical(details).decode()), write=True) + tx.set_setting('active_root_certificate', 'cert_root') + tx.set_setting(setting_key(SUBJECT, details['old_identity_key_id']), record) + try: + yield store, root, details, record + finally: + await store.close() + + +@pytest.mark.parametrize('change', [ + 'certificate_expired', 'certificate_boundary', 'certificate_future', + 'credential_expired', 'credential_boundary', 'credential_future', + 'leaf', 'parent', 'issuer', 'tampered', +]) +async def test_retirement_rechecks_current_root_and_import_rejects(retirement_store, change): + store, root, details, record = retirement_store + async with store.transaction(write=True) as tx: + cert = await tx.certificate('cert_root') + key = await tx.credential(root.key_id) + if change.startswith('credential_'): + values = {'credential_expired': {'expires_at': NOW - timedelta(seconds=1)}, + 'credential_boundary': {'expires_at': NOW}, + 'credential_future': {'not_before': NOW + timedelta(seconds=1)}} + key = replace(key, **values[change]) + tx.execute('UPDATE credentials SET body=? WHERE id=?', + (canonical(key).decode(), key.id), write=True) + else: + values = {'certificate_expired': {'expires_at': NOW - timedelta(seconds=1)}, + 'certificate_boundary': {'expires_at': NOW}, + 'certificate_future': {'not_before': NOW + timedelta(seconds=1)}, + 'leaf': {'kind': 'leaf', 'issuance': None}, + 'parent': {'parent_certificate_id': 'cert_other'}, + 'issuer': {'issuer_id': 'u_other'}, + 'tampered': {'delegation_depth': cert.delegation_depth - 1}} + cert = replace(cert, **values[change]) + if change != 'tampered': + cert = sign_certificate(cert, root) + tx.execute('UPDATE certificates SET body=? WHERE id=?', + (canonical(cert).decode(), cert.resource_id), write=True) + async with store.transaction(write=False) as tx: + before = tx.rows('SELECT key,value FROM settings ORDER BY key') + assert await verified(tx, SUBJECT, details, now=NOW) == ('invalid', None) + assert tx.rows('SELECT key,value FROM settings ORDER BY key') == before + assert tx.one('SELECT count(*) FROM audit')[0] == 0 + with pytest.raises(Failure, match='^backup_retirement_root_unavailable$'): + async with store.transaction(write=True) as tx: + await import_record(tx, record, now=NOW, operator='isolated-test') + async with store.transaction(write=False) as tx: + assert tx.one('SELECT count(*) FROM audit')[0] == 0 + assert tx.setting(setting_key(SUBJECT, details['old_identity_key_id'])) == record + + +async def test_retirement_accepts_valid_root_and_stops_at_credential_expiry(retirement_store): + store, root, details, _ = retirement_store + async with store.transaction(write=True) as tx: + key = replace(await tx.credential(root.key_id), not_before=NOW, + expires_at=NOW + timedelta(seconds=1)) + tx.execute('UPDATE credentials SET body=? WHERE id=?', + (canonical(key).decode(), key.id), write=True) + async with store.transaction(write=False) as tx: + status, proof = await verified(tx, SUBJECT, details, now=NOW) + assert status == 'verified' and proof.binds(SUBJECT, details) + assert await verified(tx, SUBJECT, details, now=NOW + timedelta(seconds=1)) == ('invalid', None) + assert await verified(tx, SUBJECT, details, now=None) == ('clock_unavailable', None) + + +def test_far_future_retirement_is_rejected_without_datetime_overflow(): + root = Ed25519Signer.generate() + details = {'old_identity_key_id': 'k_old', 'old_encryption_key_id': 'e_old'} + record = signed(details, root, attested=datetime(9999, 1, 1, tzinfo=UTC), + not_after=datetime(9999, 12, 31, tzinfo=UTC)) + with pytest.raises(Failure, match='^backup_retirement_expired$'): + check(record, root.public_key, SUBJECT, details, now=NOW) + + +@pytest.mark.parametrize('status,offset', [ + (None, None), ('active', None), ('lifted', None), + ('active', -1), ('lifted', -1), ('active', 60), ('lifted', 60), +]) +async def test_checkpoint_ban_cannot_inherit_stale_expiry(pg_dsn, status, offset): + store = PostgresMetadataStore(pg_dsn) + signer = Ed25519Signer.generate() + subject, target = 'u_replay', 't_replay' + resource = Resource(id=target, type='topic', type_version=1, name='replay', parent=None, + owner=subject, group='g_public', mode=0o700, generation=1, revision=None, state='active', + created_at=NOW, created_by=subject, modified_at=NOW, modified_by=subject) + body = {'format': FORMAT, 'service': 'http://testserver', 'source_backup_sha256': 'a' * 64, + 'sequence': 1, 'entries': [{'sequence': 1, 'kind': 'topic_ban.apply', + 'subject': subject, 'target': target, 'at': wire(NOW)}]} + pin = TrustedCheckpointPin(service=body['service'], public_key=signer.public_key, + digest=digest(body), sequence=1) + packet = {'checkpoint': body, 'signature': wire(signer.sign(canonical(body), + purpose='recovery-checkpoint-v1'))} + try: + async with store.transaction(write=True) as tx: + await tx.insert(resource) + tx.set_setting(SETTING, {'format': 'msg-recovery-quarantine-v1', + 'source_backup_sha256': 'a' * 64, 'outbound_enabled': False, + 'authority': 'health_only', 'revocation_replay': 'required'}) + if status is not None: + expires = None if offset is None else wire(NOW + timedelta(seconds=offset)) + tx.execute('INSERT INTO topic_bans VALUES(?,?,?,?,?,?,?)', + (target, subject, subject, wire(NOW), expires, 'original', status), write=True) + results = await asyncio.gather(*(_replay(store, packet, pin=pin, operator='isolated-test') + for _ in range(3))) + assert sum(result['changed'] for result in results) == int(status != 'active' or offset is not None) + assert all(result['promotion'] == 'blocked' and result['backup_retired'] is False + for result in results) + async with store.transaction(write=False) as tx: + assert tx.one('SELECT status,expires_at FROM topic_bans WHERE topic=? AND subject=?', + (target, subject)) == ('active', None) + assert active(tx) and tx.one('SELECT count(*) FROM jobs')[0] == 0 + assert await tx.resource(target) == resource + if status is not None: + assert tx.one('SELECT actor,reason FROM topic_bans WHERE topic=? AND subject=?', + (target, subject)) == (subject, 'original') + assert (await _replay(store, packet, pin=pin, operator='isolated-test'))['changed'] == 0 + finally: + await store.close() diff --git a/tests/test_recovery_policy_v2.py b/tests/test_recovery_policy_v2.py new file mode 100644 index 00000000..2400c24f --- /dev/null +++ b/tests/test_recovery_policy_v2.py @@ -0,0 +1,249 @@ +"""Pinned typed ceilings shrink old authorization without claiming complete recovery.""" +from copy import deepcopy +from datetime import timedelta + +import pytest + +from msg.admin.recovery_replay import TrustedCheckpointPin, _replay, verify_checkpoint, POLICY_FORMAT +from msg.core.codec import canonical, digest, wire +from msg.core.errors import Failure +from msg.security.crypto import Ed25519Signer +from msg.security.quarantine import SETTING, active +from test_service import NOW + + +@pytest.fixture +async def policy_target(installed): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource('u_root') + tx.set_setting(SETTING, {'format': 'msg-recovery-quarantine-v1', + 'source_backup_sha256': 'a' * 64, 'outbound_enabled': False, 'authority': 'health_only'}) + return app, resource + + +def packet_for(resource, *, mode=0, complete=False): + signer = Ed25519Signer.generate() + body = {'format': POLICY_FORMAT, 'service': 'http://testserver', + 'source_backup_sha256': 'a' * 64, 'sequence': 1, + 'coverage': {'complete': complete, 'domains': ['resource_acl']}, + 'entries': [{'sequence': 1, 'kind': 'resource.acl.restrict', 'subject': resource.owner, + 'target': resource.id, 'at': wire(NOW), + 'value': {'owner': resource.owner, 'group': resource.group, 'mode': mode}}]} + def signed(body, purpose='recovery-checkpoint-v2'): + return {'checkpoint': body, 'signature': wire(signer.sign(canonical(body), purpose=purpose))} + def pin(body): + return TrustedCheckpointPin(service=body['service'], public_key=signer.public_key, + digest=digest(body), sequence=body['sequence']) + return body, signed, pin + + +async def replay(app, body, signed, pin): + return await _replay(app.metadata, signed(body), pin=pin(body), operator='isolated-test') + + +async def test_v2_acl_is_monotonic_idempotent_and_preserves_history(policy_target): + app, resource = policy_target + body, signed, pin = packet_for(resource) + async with app.metadata.transaction(write=False) as tx: + history = tx.rows('SELECT body FROM revisions ORDER BY id') + result = await replay(app, body, signed, pin) + assert result['changed'] == 1 and result['promotion'] == 'blocked' + assert result['coverage'] == {'complete': False, 'domains': ['resource_acl']} + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + updated = await tx.resource(resource.id) + assert updated.mode == 0 and updated.generation == resource.generation + 1 + assert updated.revision == resource.revision and active(tx) + assert tx.rows('SELECT body FROM revisions ORDER BY id') == history + # A newly pinned broader ceiling cannot restore rights already removed. + broader, signed2, pin2 = packet_for(resource, mode=0o777) + broader['entries'] = body['entries'] + [dict(broader['entries'][0], sequence=2)] + broader['sequence'] = 2 + assert (await replay(app, broader, signed2, pin2))['changed'] == 0 + + +@pytest.mark.parametrize('change', ['complete', 'coverage', 'owner', 'bool', 'extra', 'purpose', 'v1']) +async def test_v2_rejects_unsupported_authority_and_contracts(policy_target, change): + app, resource = policy_target + body, signed, pin = packet_for(resource) + if change == 'complete': + body['coverage']['complete'] = True + elif change == 'coverage': + body['coverage']['domains'] = [] + elif change == 'owner': + body['entries'][0]['value']['owner'] = 'u_other' + elif change == 'bool': + body['entries'][0]['value']['mode'] = True + elif change == 'extra': + body['entries'][0]['value']['grant'] = 'root' + elif change == 'v1': + body['format'] = 'msg-revocation-checkpoint-v1' + body.pop('coverage') + with pytest.raises(Failure): + if change == 'purpose': + verify_checkpoint(signed(body, 'recovery-checkpoint-v1'), pin=pin(body)) + else: + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(resource.id) == resource + assert tx.setting('recovery_replay') is None and active(tx) + + +async def test_v2_policy_ban_expiry_and_rollback(policy_target): + from msg.core.models import Resource + app, owner = policy_target + topic = Resource(id='t_recovery_policy', type='topic', type_version=1, name='policy', + parent=owner.id, owner=owner.id, group='g_public', mode=0o777, generation=1, + revision=None, state='active', created_at=NOW, created_by=owner.id, + modified_at=NOW, modified_by=owner.id) + async with app.metadata.transaction(write=True) as tx: + await tx.insert(topic) + tx.execute('INSERT INTO topic_memberships VALUES (?,?,?,?,?,NULL)', + (topic.id, owner.id, 'admin', 'active', wire(NOW)), write=True) + body, signed, pin = packet_for(topic) + fact = {'sequence': 2, 'kind': 'topic.policy.restrict', 'subject': owner.id, + 'target': topic.id, 'at': wire(NOW), 'value': {'membership_policy': 'invite'}} + ban = dict(fact, sequence=3, kind='topic_ban.set', + value={'expires_at': wire(NOW + timedelta(days=1))}) + body.update(sequence=3, entries=body['entries'] + [fact, ban], + coverage={'complete': False, 'domains': ['resource_acl', 'topic_ban', 'topic_policy']}) + bad = deepcopy(body) + bad['entries'][2]['target'] = 'missing' + with pytest.raises(Failure): + await replay(app, bad, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(topic.id)).mode == 0o777 + assert tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?', (topic.id,)) is None + assert (await replay(app, body, signed, pin))['changed'] == 3 + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?', (topic.id,)) == ('invite',) + assert tx.one('SELECT expires_at FROM topic_bans WHERE topic=?', (topic.id,)) == (ban['value']['expires_at'],) + assert tx.one('SELECT role,status FROM topic_memberships WHERE topic=?', (topic.id,)) == ('member', 'removed') + assert active(tx) + + # Approval and invite cannot be ordered; their safe intersection stays closed. + body['entries'].append(dict(fact, sequence=4, value={'membership_policy': 'approval'})) + body['sequence'] = 4 + assert (await replay(app, body, signed, pin))['changed'] == 1 + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?', (topic.id,)) == ('closed',) + # The historical explicit lift is a relaxation, not a deny-only fact. + body['entries'].append({'sequence': 5, 'kind': 'topic_ban.lift', 'subject': owner.id, + 'target': topic.id, 'at': wire(NOW)}) + body['sequence'] = 5 + body['coverage']['domains'] = ['resource_acl', 'revocations', 'topic_ban', 'topic_policy'] + receipt = await replay(app, body, signed, pin) + assert receipt['scope'] == 'supported_policy_reconciliation_only' + assert receipt['promotion'] == 'blocked' and receipt['coverage']['complete'] is False + assert 'supported_fact_inventory_incomplete' in receipt['promotion_blocked_reasons'] + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT status FROM topic_bans WHERE topic=?', (topic.id,)) == ('lifted',) + assert tx.one('SELECT role,status FROM topic_memberships WHERE topic=?', (topic.id,)) == ('member', 'removed') + assert active(tx) + + +def test_packaged_policy_schema_matches_typed_contract(): + import json + from importlib.resources import files + import jsonschema + from types import SimpleNamespace + resource = SimpleNamespace(id='t_test', owner='u_owner', group='g_public') + body, signed, pin = packet_for(resource) + schema = json.loads(files('msg.data').joinpath('recovery-policy-checkpoint.schema.json').read_text()) + jsonschema.validate(signed(body), schema) + assert verify_checkpoint(signed(body), pin=pin(body)) == body + body['coverage']['complete'] = True + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(signed(body), schema) + + +async def test_v1_prefix_can_continue_in_v2_but_not_downgrade(policy_target): + app, resource = policy_target + body, signed, pin = packet_for(resource) + old = dict(body, format='msg-revocation-checkpoint-v1', sequence=0, entries=[]) + old.pop('coverage') + await _replay(app.metadata, signed(old, 'recovery-checkpoint-v1'), pin=pin(old), operator='isolated-test') + await replay(app, body, signed, pin) + with pytest.raises(Failure, match='recovery_checkpoint_regression'): + await _replay(app.metadata, signed(old, 'recovery-checkpoint-v1'), pin=pin(old), operator='isolated-test') + async with app.metadata.transaction(write=False) as tx: + assert tx.setting('recovery_replay')['format'] == POLICY_FORMAT and active(tx) + + +@pytest.mark.parametrize('existing_days', [None, 1, 3]) +async def test_v2_ban_never_shortens_an_active_restriction(policy_target, existing_days): + from dataclasses import replace + app, owner = policy_target + topic = replace(owner, id='t_existing_ban', type='topic', name='existing-ban', + parent=owner.id, revision=None, generation=1) + previous = wire(NOW + timedelta(days=existing_days)) if existing_days else None + desired = wire(NOW + timedelta(days=2)) + async with app.metadata.transaction(write=True) as tx: + await tx.insert(topic) + tx.execute('INSERT INTO topic_bans VALUES (?,?,?,?,?,?,?)', + (topic.id, owner.id, owner.id, wire(NOW), previous, 'prior', 'active'), write=True) + body, signed, pin = packet_for(topic) + body['entries'][0].update(kind='topic_ban.set', value={'expires_at': desired}) + body['coverage']['domains'] = ['topic_ban'] + await replay(app, body, signed, pin) + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + expected = None if existing_days is None else max(previous, desired) + assert tx.one('SELECT expires_at FROM topic_bans WHERE topic=?', (topic.id,)) == (expected,) + assert active(tx) + + +@pytest.mark.parametrize('special', [0o4000, 0o2000, 0o1000, 0o7000]) +async def test_acl_restriction_preserves_existing_special_security_bits(policy_target, special): + from dataclasses import replace + app, resource = policy_target + async with app.metadata.transaction(write=True) as tx: + resource = replace(resource, mode=special | 0o777, generation=resource.generation + 1) + await tx.replace(resource, resource.generation - 1) + body, signed, pin = packet_for(resource, mode=0o400) + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(resource.id)).mode == special | 0o400 + assert active(tx) + + +@pytest.mark.parametrize('current', ['same', 'different_grants', 'different_constraints', + 'different_issuance', 'different_parent', 'different_expiry', 'absent']) +async def test_current_certificate_digest_revokes_stale_authority_without_resigning(policy_target, current): + from dataclasses import replace + app, resource = policy_target + async with app.metadata.transaction(write=False) as tx: + certificate_id = tx.setting('active_root_certificate') + certificate = await tx.certificate(certificate_id) + original = tx.one('SELECT body FROM certificates WHERE id=?', (certificate_id,))[0] + wanted = certificate if current == 'same' else replace(certificate, grants=()) + if current == 'different_constraints': + wanted = replace(certificate, grants=(replace(certificate.grants[0], + constraints={'max_response_bytes': 1}),)) + elif current == 'different_issuance': + wanted = replace(certificate, issuance=replace(certificate.issuance, max_cert_ttl_seconds=1)) + elif current == 'different_parent': + wanted = replace(certificate, parent_certificate_id='different-parent') + elif current == 'different_expiry': + wanted = replace(certificate, expires_at=NOW + timedelta(days=1)) + body, signed, pin = packet_for(resource) + body['entries'][0].update(kind='certificate.current', target=certificate_id, + subject=certificate.subject_id, value={'body_digest': None if current == 'absent' else digest(wanted)}) + body['coverage']['domains'] = ['certificates'] + result = await replay(app, body, signed, pin) + assert result['changed'] == (current != 'same') + assert (await replay(app, body, signed, pin))['changed'] == 0 + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT body,revoked FROM certificates WHERE id=?', (certificate_id,)) == (original, int(current != 'same')) + assert active(tx) + # Even a fresh exact fingerprint cannot un-revoke a previously retired cert. + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE certificates SET revoked=1 WHERE id=?', (certificate_id,), write=True) + body['entries'].append(dict(body['entries'][0], sequence=2, value={'body_digest': digest(certificate)})) + body['sequence'] = 2 + await replay(app, body, signed, pin) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT revoked FROM certificates WHERE id=?', (certificate_id,)) == (1,) diff --git a/tests/test_recovery_runtime_generation.py b/tests/test_recovery_runtime_generation.py new file mode 100644 index 00000000..9792657a --- /dev/null +++ b/tests/test_recovery_runtime_generation.py @@ -0,0 +1,153 @@ +"""Promotion invalidates idle runtimes even if they never observed quarantine.""" +import httpx +import pytest +from test_hosting_runtime import reader_settings, runtime_class, website # noqa: F401 +from test_service import NOW, call + +from msg.application import Application +from msg.core.errors import Failure +from msg.transports.http import create_app +from msg.workers.effects import EffectWorker +from msg.workers.maintenance import run_maintenance + + +async def promote_generation(app): + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('recovery_quarantine', {'restore': 'test'}) + # Deliberately no calls on the already loaded peer during quarantine. + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('recovery_runtime_generation', 'new-verified-promotion') + tx.execute("DELETE FROM settings WHERE key='recovery_quarantine'", write=True) + + +@pytest.mark.asyncio +async def test_idle_executor_and_http_require_restart_after_promotion(installed): + app, _ = installed + peer = await Application(app.settings, clock=lambda: NOW).load() + try: + await promote_generation(app) + result = await call(peer, 'discovery.get', {'id': '/main'}) + assert result.status == 'error' and result.error.code == 'recovery_runtime_stale' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(peer)), + base_url=app.settings.service_url) as http: + for path in ('/', '/healthz', '/_read/t_main/json'): + response = await http.get(path) + assert response.status_code == 503 + assert response.json()['error']['code'] == 'recovery_runtime_stale' + fresh = await Application(app.settings, clock=lambda: NOW).load() + try: + assert (await call(fresh, 'discovery.get', {'id': '/main'})).status == 'ok' + finally: + await fresh.close() + finally: + await peer.close() + + +@pytest.mark.asyncio +async def test_idle_workers_claim_and_timers_cannot_mutate_promoted_database(installed): + app, _ = installed + peer = await Application(app.settings, clock=lambda: NOW).load() + worker = EffectWorker(peer) + try: + await promote_generation(app) + from msg.market.arbitration import resolve_cases + from msg.market.escrow import resolve_due + from msg.workers.leases import current_attempt + for timer in (resolve_due, resolve_cases): + with pytest.raises(Failure, match='recovery_runtime_stale'): + await timer(peer) + async with peer.metadata.transaction(write=True) as tx: + with pytest.raises(Failure, match='recovery_runtime_stale'): + await current_attempt(peer, tx, None) + for work in (worker.run_once, worker._claim): + with pytest.raises(Failure, match='recovery_runtime_stale'): + await work() + with pytest.raises(Failure, match='recovery_runtime_stale'): + await run_maintenance(peer, 'cleanup_expired', scheduled=True) + finally: + await peer.close() + + +@pytest.mark.asyncio +async def test_idle_readonly_hosting_requires_restart_without_expanding_role(installed, reader_settings): # noqa: F811 + app, _ = installed + await website(app) + reader = await runtime_class()(reader_settings, clock=lambda: NOW).load() + from msg.extensions.hosting import hosting_app + try: + await promote_generation(app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(reader)), + base_url=app.settings.service_url) as http: + response = await http.get('/@readonly-host/web/') + assert response.status_code != 200 + assert b'recovery_runtime_stale' in response.content + assert b'

published

' not in response.content + fresh = await runtime_class()(reader_settings, clock=lambda: NOW).load() + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=hosting_app(fresh)), + base_url=app.settings.service_url) as http: + response = await http.get('/@readonly-host/web/') + assert response.status_code == 200 + assert b'

published

' in response.content + finally: + await fresh.close() + finally: + await reader.close() + + +@pytest.mark.asyncio +async def test_generation_mismatch_remains_latched_even_if_setting_reverted(installed): + app, _ = installed + await promote_generation(app) + with pytest.raises(Failure, match='recovery_runtime_stale'): + await app.executor.require_current_runtime() + async with app.metadata.transaction(write=True) as tx: + tx.execute("DELETE FROM settings WHERE key='recovery_runtime_generation'", write=True) + with pytest.raises(Failure, match='recovery_runtime_stale'): + await app.executor.require_current_runtime() + with pytest.raises(Failure, match='recovery_runtime_stale'): + await app.load() + + +@pytest.mark.asyncio +async def test_promotion_during_load_cannot_pin_new_generation_to_old_runtime(installed, monkeypatch): + from contextlib import asynccontextmanager + + app, _ = installed + peer = Application(app.settings, clock=lambda: NOW) + await peer.open_storage() + original = peer.metadata.transaction + entered = 0 + + @asynccontextmanager + async def promote_between_startup_transactions(*, write): + nonlocal entered + entered += 1 + if entered == 2: + await promote_generation(app) + async with original(write=write) as tx: + yield tx + + monkeypatch.setattr(peer.metadata, 'transaction', promote_between_startup_transactions) + try: + with pytest.raises(Failure, match='recovery_runtime_stale'): + await peer.load() + assert not peer._loaded + finally: + await peer.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('value', [None, False, '', {}]) +async def test_present_invalid_generation_is_not_treated_as_an_old_installation(installed, value): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('recovery_runtime_generation', value) + result = await call(app, 'discovery.get', {'id': '/main'}) + assert result.status == 'error' and result.error.code == 'recovery_runtime_stale' + peer = Application(app.settings, clock=lambda: NOW) + try: + with pytest.raises(Failure, match='recovery_runtime_stale'): + await peer.load() + finally: + await peer.close() diff --git a/tests/test_registry_contract_references.py b/tests/test_registry_contract_references.py new file mode 100644 index 00000000..6a1aafb7 --- /dev/null +++ b/tests/test_registry_contract_references.py @@ -0,0 +1,79 @@ +"""Registry freeze rejects dangling references before serving any contract.""" +from dataclasses import replace + +import pytest + +from msg.core.errors import Failure +from msg.core.models import ( + CapabilitySpec, OperationSpec, PluginManifest, ResourceRef, ResourceTypeSpec, +) +from msg.core.registry import Registry + + +REF = ResourceRef(id='schema_contract_reference') + + +async def unused(*args): + raise AssertionError('Registry validation must not invoke application code') + + +def declarations(): + resource = ResourceTypeSpec(name='document', version=1, container=False, + content_schema=None, operations=frozenset(), relations=frozenset()) + capability = CapabilitySpec(name='document.read', version=1, + scope_types=frozenset({'document'}), operations=frozenset({'document.read@1'}), + replaces_checks=frozenset(), delegatable=False, ca_only=False, + constraints_schema=None) + operation = OperationSpec(name='document.read', version=1, input_schema=REF, + output_schema=REF, effect='read', entries=frozenset({'network'}), + require_signature=False, requirements=unused, handler=unused) + return resource, capability, operation + + +def registry(resource, capability, operation): + result = Registry() + result.add_schema(REF, {'type': 'object'}) + result.add(PluginManifest(name='identity', version='1', dependencies=(), + resource_types=(resource,), capabilities=(capability,), + operations=(operation,), migrations=())) + return result + + +@pytest.mark.parametrize('owner', ['resource', 'capability']) +def test_freeze_rejects_unregistered_optional_schema_and_can_retry(owner): + resource, capability, operation = declarations() + missing = ResourceRef(id='schema_missing') + if owner == 'resource': + resource = replace(resource, content_schema=missing) + else: + capability = replace(capability, constraints_schema=missing) + installed = registry(resource, capability, operation) + with pytest.raises(Failure, match='^missing_schema$'): + installed.freeze() + assert installed.frozen is False + # Forward references are permitted while assembling the complete registry. + installed.add_schema(missing, {'type': 'object'}) + installed.freeze() + assert installed.frozen is True + + +@pytest.mark.parametrize('reference', ['document.unknown@1', 'document.read@2', 'document.read']) +def test_freeze_rejects_unknown_resource_operation_including_wrong_version(reference): + resource, capability, operation = declarations() + resource = replace(resource, operations=frozenset({reference})) + installed = registry(resource, capability, operation) + with pytest.raises(Failure, match='^unknown_resource_operation$'): + installed.freeze() + assert installed.frozen is False + + +def test_freeze_accepts_registered_content_constraints_and_operation_references(): + resource, capability, operation = declarations() + resource = replace(resource, content_schema=REF, + operations=frozenset({'document.read@1'})) + capability = replace(capability, constraints_schema=REF) + installed = registry(resource, capability, operation) + installed.freeze() + assert installed.resource_type('document') == resource + assert installed.capability('document.read') == capability + assert installed.operation('document.read') == operation diff --git a/tests/test_registry_operation_rules.py b/tests/test_registry_operation_rules.py new file mode 100644 index 00000000..a4c138a7 --- /dev/null +++ b/tests/test_registry_operation_rules.py @@ -0,0 +1,62 @@ +"""Operations own rule dependencies; publication does not infer them anew.""" +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from msg.bootstrap import RULE_PATHS +from msg.core.errors import Failure +from msg.core.registry import Registry +from msg.plugins.common import registration + + +async def unused(*args): + raise AssertionError('contract assembly must not execute handlers') + + +def assemble(name, **options): + registry = Registry() + op, finish = registration(SimpleNamespace(registry=registry), 'identity') + op(name, {'type': 'object'}, **options)(unused) + finish() + return registry + + +@pytest.mark.parametrize(('name', 'ids'), [ + ('identity.register', ('msg.identity', 'msg.auth')), + ('content.topic_create', ('msg.topics', 'msg.read-write')), + ('discussion.reply', ('msg.topics', 'msg.read-write')), + ('content.post_create', ('msg.read-write',)), + ('transfer.begin', ('msg.files', 'msg.protocol')), + ('keystore.write', ('msg.files', 'msg.protocol')), + ('git.push', ('msg.files', 'msg.protocol')), + ('cert.issue', ('msg.auth',)), + ('group.join', ('msg.auth',)), + ('system.get', ('msg.security', 'msg.protocol')), + ('tool.invoke', ('msg.security', 'msg.protocol')), + ('discovery.get', ('msg.read-write', 'msg.protocol')), + ('orders.buy', ('msg.protocol',)), +]) +def test_existing_default_rule_descriptions_remain_identical(name, ids): + registry = assemble(name) + registry.freeze() + assert registry.describe(registry.operation(name))['requires_rules'] == [ + {'rule_id': rid, 'path': RULE_PATHS[rid]} for rid in ids] + + +def test_explicit_rule_dependencies_override_name_and_are_owned_by_spec(): + registry = assemble('identity.custom', requires_rules=('msg.recovery', 'msg.security')) + registry.freeze() + spec = registry.operation('identity.custom') + assert spec.requires_rules == ('msg.recovery', 'msg.security') + # Renaming an already assembled declaration must not change its rules. + assert registry.describe(replace(spec, name='content.custom'))['requires_rules'] == [ + {'rule_id': rid, 'path': RULE_PATHS[rid]} for rid in spec.requires_rules] + + +@pytest.mark.parametrize('ids', [('msg.unknown',), ('msg.auth', 'msg.auth'), ()]) +def test_freeze_rejects_missing_duplicate_or_empty_operation_rules(ids): + registry = assemble('identity.custom', requires_rules=ids) + with pytest.raises(Failure, match='^dangling_requires_rules$'): + registry.freeze() + assert not registry.frozen diff --git a/tests/test_registry_template_tools.py b/tests/test_registry_template_tools.py new file mode 100644 index 00000000..29b11606 --- /dev/null +++ b/tests/test_registry_template_tools.py @@ -0,0 +1,169 @@ +"""Installed contract metadata stays separate from resource revisions and values.""" +from dataclasses import replace +from hashlib import sha256 + +import pytest + +from msg.bootstrap import manifest +from msg.core.codec import decode, wire +from msg.core.errors import Failure +from msg.core.models import FieldSpec, PluginManifest, ResourceRef, TemplateSpec +from msg.core.registry import Registry +from msg.core.template_dsl import normalize_values, parse_template +from msg.extensions.tools import read_tool + + +def template(): + return TemplateSpec(resource=ResourceRef(id='tpl_example'),digest='sha256:'+'0'*64, + fields=(FieldSpec(name='title',type='str',required=False,default_json=b'"hello"'),), + renderer_version=1) + + +def test_templates_are_versioned_owned_specs_with_defaults_and_strict_lookup(): + registry=Registry() + first=template() + registry.add_template(first) + second=replace(first,fields=(replace(first.fields[0],default_json=b'"new"'),)) + registry.add_template(second,version=2) + assert registry.template('tpl_example') == first + assert registry.template('tpl_example',2) == second + assert registry.templates() == (first,second) + assert normalize_values(registry.template('tpl_example'),{}) == {'title':'hello'} + with pytest.raises(Failure,match='^unknown_template$'): + registry.template('unregistered') + with pytest.raises(Failure,match='^duplicate_template$'): + registry.add_template(second) + with pytest.raises(Failure,match='^invalid_registry_name$'): + registry.add_template(first,version=0) + + +def test_template_registration_snapshots_nested_fields_and_validates_defaults(): + registry=Registry() + constraints={'label':['one']} + spec=replace(template(),fields=(replace(template().fields[0],constraints=constraints),)) + registry.add_template(spec) + constraints['label'].append('two') + assert registry.template('tpl_example').fields[0].constraints['label']==('one',) + with pytest.raises(Failure,match='^template_type_error'): + registry.add_template(replace(spec,fields=(replace(spec.fields[0],default_json=b'42'),)),2) + with pytest.raises(Failure,match='^unknown_template_renderer$'): + registry.add_template(replace(spec,renderer_version=2),2) + + +async def test_installed_template_and_tool_contracts_match_published_resources(installed): + app,_=installed + for name,source in manifest()['templates'].items(): + parsed=parse_template(source) + registered=app.registry.template('tpl_'+name,parsed.version) + assert registered.fields==parsed.fields + assert registered.digest=='sha256:'+sha256(source.encode()).hexdigest() + assert registered.resource.revision is None + assert {spec.resource.id for spec in app.registry.tools()}=={'tool_dns','tool_curl'} + async with app.metadata.transaction(write=False) as tx: + for registered in app.registry.tools(): + actual=await read_tool(app,tx,registered.resource.id) + assert actual.resource.revision is not None + assert replace(actual,resource=registered.resource)==registered + with pytest.raises(Failure,match='^registry_frozen$'): + app.registry.add_template(template()) + with pytest.raises(Failure,match='^registry_frozen$'): + app.registry.add_tool(app.registry.tool('tool_dns'),2) + with pytest.raises(Failure,match='^unknown_tool$'): + app.registry.tool('tool_dns',2) + + +async def test_tool_registry_rejects_conflicts_missing_schemas_and_operations(installed): + app,_=installed + original=app.registry.tool('tool_dns') + # Build a fresh registration graph from the installed public contracts. + registry=Registry() + registry.add(PluginManifest(name='identity',version='1',dependencies=(), + resource_types=(),capabilities=(),operations=(),migrations=())) + registry.add_tool(original) + with pytest.raises(Failure,match='^duplicate_tool$'): + registry.add_tool(original) + with pytest.raises(Failure,match='^unknown_tool_operation$'): + registry.freeze() + operation=app.registry.operation('tool.run') + registry.add_operation(operation) + for ref in (operation.input_schema,operation.output_schema): + registry.add_schema(ref,app.registry.schema(ref)) + with pytest.raises(Failure,match='^missing_schema$'): + registry.freeze() + for ref in (original.input_schema,original.output_schema): + registry.add_schema(ref,app.registry.schema(ref)) + registry.freeze() + assert registry.tool('tool_dns')==decode(type(original),wire(original)) + + +async def test_shipped_template_post_keeps_signed_content_contract(installed): + from test_service import register,call + app,_=installed + key,uid,_=await register(app,'registry-template-user') + result=await call(app,'content.post_create', + {'parent':'/tmp','template':'message','values':{'body':'hello'}},key=key,subject=uid) + assert result.status=='ok',wire(result) + async with app.metadata.transaction(write=False) as tx: + revision=await tx.revision(result.resources[0]) + from msg.core.codec import loads + content=loads(await app.contents.read_bytes(revision.content)) + assert content=={'template_id':'tpl_message','template_version':1, + 'template_digest':app.registry.template('tpl_message').digest,'values':{'body':'hello'}} + + +@pytest.mark.parametrize('mutation,error',[ + ({'executor_key':'uninstalled'},'untrusted_tool_executor'), + ({'input_schema':{'id':'schema:tool.curl:input'}},'tool_schema_mismatch'), + ({'version':99},'unknown_tool'), + ({},None), +]) +async def test_tool_resource_policy_is_preserved_but_cannot_replace_registered_code(installed,mutation,error): + from types import SimpleNamespace + from msg.constants import TOOLS_SPACE + from msg.core.codec import canonical + from msg.extensions.tools import descriptor + app,_=installed + value=descriptor('dns') + value['network']['timeout_ms']=1234 + value.update(mutation) + async def resource(rid): + return SimpleNamespace(type='tool',parent=TOOLS_SPACE) + async def revision(ref): + assert ref.revision=='pinned' + return SimpleNamespace(id='pinned',content=None) + async def read_bytes(content,limit): + return canonical(value) + context=SimpleNamespace(registry=app.registry,contents=SimpleNamespace(read_bytes=read_bytes)) + tx=SimpleNamespace(resource=resource,revision=revision) + if error: + with pytest.raises(Failure,match='^'+error+'$'): + await read_tool(context,tx,'tool_dns','pinned') + else: + actual=await read_tool(context,tx,'tool_dns','pinned') + assert actual.resource.revision=='pinned' + assert actual.network.timeout_ms==1234 + assert app.registry.tool('tool_dns').network.timeout_ms==30000 + + +async def test_user_template_versions_remain_resources_outside_frozen_registry(installed): + from test_service import register,call + app,_=installed + key,uid,_=await register(app,'registry-user-template') + before=app.registry.templates() + created=await call(app,'content.template_put', + {'parent':'/templates','source':'user-notice@7\nbody:text!\npriority:int=2\n'}, + key=key,subject=uid) + assert created.status=='ok',wire(created) + rid=created.resources[0].id + result=await call(app,'content.post_create',{'parent':'/tmp', + 'template':{'id':rid,'version':7},'values':{'body':'user data'}},key=key,subject=uid) + assert result.status=='ok',wire(result) + async with app.metadata.transaction(write=False) as tx: + revision=await tx.revision(result.resources[0]) + from msg.core.codec import loads + content=loads(await app.contents.read_bytes(revision.content)) + assert content['template_id']==rid and content['template_version']==7 + assert content['values']=={'body':'user data','priority':2} + assert app.registry.templates()==before + with pytest.raises(Failure,match='^unknown_template$'): + app.registry.template(rid,7) diff --git a/tests/test_resource_path_migration.py b/tests/test_resource_path_migration.py new file mode 100644 index 00000000..4b88e19a --- /dev/null +++ b/tests/test_resource_path_migration.py @@ -0,0 +1,148 @@ +"""Old paths locate stable resources only through current read authorization.""" +from datetime import timedelta +import httpx +from msg.core.codec import b64, canonical, wire +from msg.core.requests import request_for +from msg.transports.http import create_app +from test_service import NOW, call, register +from test_route_effect_matrix import database_snapshot + + +def signed(app,key,subject,rid,view=None,revision=None): + args={'id':rid} + if view in {'meta','history'}: args['view']=view + if revision: args['revision']=revision + packet=request_for('discovery.raw' if view=='raw' else 'discovery.get',args, + app.settings.service_url,signer=key,subject=subject,expires_at=NOW+timedelta(seconds=90)) + return {'x-msg-request':b64(canonical(packet))} + + +async def test_moved_paths_preserve_views_and_current_authority(installed): + app,_=installed + key,owner,_=await register(app,'path-migration') + made=await call(app,'file.create',{'parent':'/main','name':'old.txt','data':b64(b'private payload'), + 'media_type':'text/plain'},key=key,subject=owner) + rid=made.resources[0].id + moved=await call(app,'file.move',{'id':rid,'parent':'/main','name':'hidden-new.txt'}, + key=key,subject=owner,expected=((rid,made.data['generation']),)) + assert moved.status=='ok',wire(moved) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url='http://testserver',follow_redirects=False) as http: + for suffix in ('','/json','/meta','/history','/raw','/revisions/'+made.resources[0].revision+'/raw'): + response=await http.get('/main/old.txt'+suffix) + assert response.status_code==308,response.text + assert response.headers['location']=='/main/hidden-new.txt'+suffix + assert response.headers['cache-control']=='no-store' + private=await call(app,'content.chmod',{'id':rid,'mode':'0600'},key=key,subject=owner, + expected=((rid,moved.data['generation']),)) + assert private.status=='ok' + before=await database_snapshot(app) + for method in ('GET','HEAD'): + for view in ('json','meta','history','raw'): + response=await http.request(method,'/main/old.txt/'+view, + headers={'if-none-match':'"warm"','range':'bytes=0-2'}) + assert response.status_code==403,response.text + assert 'location' not in response.headers and 'etag' not in response.headers + assert 'hidden-new' not in response.text and 'private payload' not in response.text + allowed=await http.request(method,'/main/old.txt/'+view, + headers=signed(app,key,owner,rid,view)) + assert allowed.status_code==308,allowed.text + assert allowed.headers['location']=='/main/hidden-new.txt/'+view + old_signed=await http.get('/main/old.txt/json',headers=signed(app,key,owner,'/main/old.txt','json')) + assert old_signed.status_code==308,old_signed.text + assert await database_snapshot(app)==before + denied_write=await call(app,'file.write',{'id':'/main/old.txt','base_revision':made.resources[0].revision, + 'data':b64(b'wrong')},key=key,subject=owner,expected=((rid,private.data['generation']),)) + assert denied_write.status=='error' and denied_write.error.code=='not_found',wire(denied_write) + + +async def test_parent_and_child_moves_and_path_reuse(installed): + app,_=installed + key,owner,_=await register(app,'path-tree') + directory=await call(app,'file.mkdir',{'parent':'/main','name':'old-dir'},key=key,subject=owner) + did=directory.resources[0].id + made=await call(app,'file.create',{'parent':did,'name':'leaf.txt','data':b64(b'original')},key=key,subject=owner) + rid=made.resources[0].id + renamed=await call(app,'file.move',{'id':did,'parent':'/main','name':'new-dir'},key=key,subject=owner, + expected=((did,directory.data['generation']),)) + assert renamed.status=='ok',wire(renamed) + moved=await call(app,'file.move',{'id':rid,'parent':did,'name':'moved.txt'},key=key,subject=owner, + expected=((rid,made.data['generation']),)) + assert moved.status=='ok',wire(moved) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)),base_url='http://testserver') as http: + response=await http.get('/main/old-dir/leaf.txt/raw') + assert response.status_code==308,response.text + assert response.headers['location']=='/main/new-dir/moved.txt/raw' + fresh=await call(app,'file.mkdir',{'parent':'/main','name':'old-dir'},key=key,subject=owner) + assert fresh.status=='ok',wire(fresh) + # A recreated current namespace shadows its historic namespace completely. + assert (await http.get('/main/old-dir/leaf.txt/raw')).status_code==404 + assert (await http.get('/main/new-dir/leaf.txt/raw')).status_code==308 + + +async def test_migrated_extensionless_post_and_failed_move_rollback(installed): + app,_=installed + key,owner,_=await register(app,'migration-atomic') + first=await call(app,'content.post_create',{'parent':'/main','name':'former','body':'retained'},key=key,subject=owner) + second=await call(app,'content.post_create',{'parent':'/main','name':'occupied','body':'other'},key=key,subject=owner) + rid=first.resources[0].id + assert second.status=='ok' + failed=await call(app,'file.move',{'id':rid,'parent':'/main','name':'occupied.md'},key=key,subject=owner, + expected=((rid,first.data['generation']),)) + assert failed.status=='error',wire(failed) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT 1 FROM resource_path_aliases WHERE resource_id=?',(rid,)) is None + assert await tx.path(rid)=='/main/former.md' + moved=await call(app,'file.move',{'id':rid,'parent':'/main','name':'latest.md'},key=key,subject=owner, + expected=((rid,first.data['generation']),)) + assert moved.status=='ok',wire(moved) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)),base_url=app.settings.service_url) as http: + response=await http.get('/main/former') + assert response.status_code==308,response.text + assert response.headers['location']=='/main/latest.md' + + +async def test_alias_reuse_between_route_resolution_and_signed_read_never_leaks_location(installed,monkeypatch): + app,_=installed + key,owner,_=await register(app,'alias-race-owner') + other,reader,_=await register(app,'alias-race-reader') + made=await call(app,'file.create',{'parent':'/main','name':'old-race.txt','data':b64(b'secret')},key=key,subject=owner) + rid=made.resources[0].id + moved=await call(app,'file.move',{'id':rid,'parent':'/main','name':'private-target-name.txt'}, + key=key,subject=owner,expected=((rid,made.data['generation']),)) + private=await call(app,'content.chmod',{'id':rid,'mode':'0600'},key=key,subject=owner, + expected=((rid,moved.data['generation']),)) + assert private.status=='ok' + execute=app.executor.execute + replaced=False + async def concurrent_reuse(packet,**kwargs): + nonlocal replaced + if not replaced and packet.operation=='discovery.get' and packet.arguments.get('id')=='/main/old-race.txt': + replaced=True + new=await call(app,'file.create',{'parent':'/main','name':'old-race.txt','data':b64(b'public replacement')}, + key=key,subject=owner) + assert new.status=='ok',wire(new) + return await execute(packet,**kwargs) + monkeypatch.setattr(app.executor,'execute',concurrent_reuse) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)),base_url=app.settings.service_url) as http: + response=await http.get('/main/old-race.txt/json',headers=signed(app,other,reader,'/main/old-race.txt','json')) + assert response.status_code==400,response.text + assert response.json()['error']['code']=='resource_mismatch' + assert 'location' not in response.headers and 'private-target-name' not in response.text + + +async def test_migration_location_preserves_subject_namespace_sigil(installed): + app,_=installed + key,owner,_=await register(app,'path-sigil') + parent='/@path-sigil/files' + created=await call(app,'file.create',{'parent':parent,'name':'old.txt','data':b64(b'bytes')},key=key,subject=owner) + rid=created.resources[0].id + moved=await call(app,'file.move',{'id':rid,'parent':parent,'name':'new.txt'},key=key,subject=owner, + expected=((rid,created.data['generation']),)) + assert moved.status=='ok',wire(moved) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)),base_url=app.settings.service_url) as http: + response=await http.get(parent+'/old.txt/raw',headers=signed(app,key,owner,rid,'raw')) + assert response.status_code==308,response.text + assert response.headers['location']==parent+'/new.txt/raw' + followed=await http.get(response.headers['location'],headers=signed(app,key,owner,rid,'raw')) + assert followed.status_code==200 and followed.content==b'bytes' diff --git a/tests/test_restore_physical_failure.py b/tests/test_restore_physical_failure.py new file mode 100644 index 00000000..ad2f431c --- /dev/null +++ b/tests/test_restore_physical_failure.py @@ -0,0 +1,46 @@ +"""A post-import filesystem failure cannot expose restored snapshot authority.""" +from pathlib import Path + +import psycopg +import pytest + +from msg.admin import backups +from msg.core.codec import loads + + +@pytest.mark.asyncio +async def test_restore_move_failure_keeps_committed_database_quarantined(installed, tmp_path, pg_dsn, monkeypatch): + app, _ = installed + archive = tmp_path / 'physical-failure.zip' + await backups.backup(app, archive) + config = tmp_path / 'restore-etc' + data = tmp_path / 'restore-data' + original_move = backups.shutil.move + moves = [] + def interrupted_move(source, destination, *args, **kwargs): + moves.append(Path(source).name) + if Path(source).name == 'blobs': + raise OSError('injected restore blob move failure') + return original_move(source, destination, *args, **kwargs) + monkeypatch.setattr(backups.shutil, 'move', interrupted_move) + with pytest.raises(OSError, match='injected restore blob move failure'): + backups.restore(archive, config, data, postgres_dsn=pg_dsn) + assert moves == ['content', 'repositories', 'blobs'] + assert (data / 'git' / 'content' / 'private.git').is_dir() + assert not (data / 'blobs' / 'sha256').exists() + marker = loads((config / 'recovery-drill.json').read_bytes()) + assert marker['outbound_enabled'] is False + assert marker['revocation_replay'] == 'required' + # The import has genuinely committed, yet its independent database gate is + # already active. Losing the config marker cannot promote this partial restore. + with psycopg.connect(pg_dsn) as connection: + assert connection.execute('SELECT COUNT(*) FROM resources').fetchone()[0] > 0 + values = dict(connection.execute( + "SELECT key,value FROM settings WHERE key IN ('recovery_quarantine','runtime_config')")) + quarantine = loads(values['recovery_quarantine']) + runtime = loads(values['runtime_config']) + assert quarantine['authority'] == 'health_only' + assert quarantine['outbound_enabled'] is False + assert quarantine['revocation_replay'] == 'required' + assert runtime['accept_writes'] is False and runtime['cleanup_enabled'] is False + assert not (config / 'root').exists() diff --git a/tests/test_saved_queries.py b/tests/test_saved_queries.py new file mode 100644 index 00000000..5ac2b320 --- /dev/null +++ b/tests/test_saved_queries.py @@ -0,0 +1,131 @@ +"""Explicit saved descriptors outlive read tickets, never their live authority.""" +from datetime import timedelta + +import pytest +from test_service import NOW, call, register + +from msg.core.codec import b64, canonical, digest, wire +from msg.core.requests import request_for + + +async def make_query_ref(app, key, subject, arguments, kind='read'): + payload = canonical({'version': 1, 'kind': kind, 'arguments': arguments}) + opened = await call(app, 'transfer.open', {'direction': 'upload', 'size': len(payload), + 'digest': digest(payload), 'media_type': 'application/vnd.msg.read-query+json'}, + key=key, subject=subject) + assert opened.status == 'ok', opened.error + transfer = opened.data['transfer_id'] + part = await call(app, 'transfer.part_put', {'transfer_id': transfer, 'offset': 0, + 'data': b64(payload), 'digest': digest(payload)}, key=key, subject=subject) + assert part.status == 'ok', part.error + sealed = await call(app, 'transfer.seal', {'transfer_id': transfer, 'final_size': len(payload), + 'final_digest': digest(payload)}, key=key, subject=subject) + assert sealed.status == 'ok', sealed.error + ticket = await call(app, 'transfer.query_seal', {'transfer_id': transfer}, key=key, subject=subject) + assert ticket.status == 'ok', ticket.error + return ticket.data['query_ref'] + + +async def current_call(app, op, args, key, subject, **kwargs): + return await app.executor.execute(request_for(op, args, app.settings.service_url, + signer=key, subject=subject, expires_at=app.clock() + timedelta(seconds=120), **kwargs)) + + +@pytest.mark.asyncio +async def test_save_pins_descriptor_replays_and_outlives_only_ticket(installed): + app, _ = installed + key, subject, _ = await register(app, 'saved-query-owner') + args = {'parent': '/main', 'type': 'post', 'tag': 'work'} + token = await make_query_ref(app, key, subject, args) + saved = await call(app, 'query.save', {'query_ref': token}, key=key, subject=subject, + rid='save-descriptor-once') + assert saved.status == 'ok', saved.error + replay = await call(app, 'query.save', {'query_ref': token}, key=key, subject=subject, + rid='save-descriptor-once') + assert replay.replayed and replay.resources == saved.resources + ref = wire(saved.resources[0]) + got = await call(app, 'query.saved_get', {'ref': ref}, key=key, subject=subject) + assert got.status == 'ok', got.error + assert dict(got.data['arguments']) == {**args, 'parent': 't_main'} + assert got.data['operation'] == 'discovery.read_query' + assert got.data['digest'] == saved.data['descriptor_digest'] + assert 'principal' not in got.data + assert (await call(app, 'discovery.get', {'id': ref['id']}, key=key, subject=subject)).status == 'error' + app.executor.clock = lambda: NOW + timedelta(minutes=16) + app.clock = app.executor.clock + app.executor.authenticator.clock = app.clock + expired = await current_call(app, 'query.save', {'query_ref': token}, key, subject) + assert expired.error.code == 'query_ref_expired' + durable = await current_call(app, 'query.saved_get', {'ref': ref}, key, subject) + assert durable.status == 'ok', durable.error + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(ref['id'])).generation + archived = await current_call(app, 'query.saved_archive', {'ref': ref}, key, subject, + expected=((ref['id'], generation),)) + assert archived.status == 'ok', archived.error + assert (await current_call(app, 'query.saved_get', {'ref': ref}, key, subject)).error.code == 'saved_query_not_found' + + +@pytest.mark.asyncio +async def test_saved_query_rechecks_scope_and_never_shares_captured_authority(installed): + app, _ = installed + key, subject, _ = await register(app, 'saved-scope-owner') + other_key, other, _ = await register(app, 'saved-scope-reader') + topic = await call(app, 'content.topic_create', {'parent': '/main', 'name': 'saved-scope'}, + key=key, subject=subject) + rid = topic.resources[0].id + token = await make_query_ref(app, other_key, other, {'parent': rid, 'type': 'post'}) + saved = await call(app, 'query.save', {'query_ref': token}, key=other_key, subject=other) + assert saved.status == 'ok', saved.error + ref = wire(saved.resources[0]) + wrong = await call(app, 'query.save', {'query_ref': token}, key=key, subject=subject) + assert wrong.error.code == 'query_ref_principal_mismatch' + assert (await call(app, 'query.saved_get', {'ref': ref}, key=key, subject=subject)).status == 'error' + grant = await call(app, 'sharing.grant', {'resource': ref['id'], 'grantee': subject, + 'expires_at': wire(NOW + timedelta(hours=1))}, key=other_key, subject=other) + assert grant.status == 'error' + hidden = await call(app, 'content.chmod', {'id': rid, 'mode': '0700'}, key=key, subject=subject, + expected=((rid, topic.data['generation']),)) + assert hidden.status == 'ok', hidden.error + denied = await call(app, 'query.saved_get', {'ref': ref}, key=other_key, subject=other) + assert denied.status == 'error' + + +@pytest.mark.asyncio +async def test_saved_query_requires_pinned_revision_and_unchanged_digest(installed): + app, _ = installed + key, subject, _ = await register(app, 'saved-pin-owner') + token = await make_query_ref(app, key, subject, {'parent': '/main'}) + saved = await call(app, 'query.save', {'query_ref': token}, key=key, subject=subject) + assert saved.status == 'ok', saved.error + ref = wire(saved.resources[0]) + for bad_ref in ({'id': ref['id']}, {**ref, 'revision': 'nonexistent_revision'}): + assert (await call(app, 'query.saved_get', {'ref': bad_ref}, key=key, subject=subject)).status == 'error' + tampered = token[:-1] + ('a' if token[-1] != 'a' else 'b') + assert (await call(app, 'query.save', {'query_ref': tampered}, key=key, subject=subject)).status == 'error' + + +@pytest.mark.asyncio +async def test_saved_authority_is_current_and_internal_body_cannot_be_projected(installed): + from dataclasses import replace + app, _ = installed + key, subject, _ = await register(app, 'saved-authority-owner') + token = await make_query_ref(app, key, subject, {'parent': '/main'}) + forbidden_projection = await current_call(app, 'query.save', {'query_ref': token}, key, subject, + return_fields=('content',)) + assert forbidden_projection.error.code == 'projection_unavailable' + saved = await call(app, 'query.save', {'query_ref': token}, key=key, subject=subject) + assert saved.status == 'ok', saved.error + ref = wire(saved.resources[0]) + async with app.metadata.transaction(write=True) as tx: + credential = await tx.credential(key.key_id) + ceiling = tuple(replace(grant, operations=grant.operations - {'query.save@1'}) + for grant in credential.ceiling) + await tx.save_credential(replace(credential, ceiling=ceiling), (await tx.subject(subject)).auth_version) + generation = (await tx.resource(ref['id'])).generation + denied = await call(app, 'query.saved_get', {'ref': ref}, key=key, subject=subject) + assert denied.error.code == 'credential_ceiling' + # Revocation remains possible without the removed creation authority. + archived = await current_call(app, 'query.saved_archive', {'ref': ref}, key, subject, + expected=((ref['id'], generation),)) + assert archived.status == 'ok', archived.error diff --git a/tests/test_saved_query_typed_scopes.py b/tests/test_saved_query_typed_scopes.py new file mode 100644 index 00000000..7ddf8bd5 --- /dev/null +++ b/tests/test_saved_query_typed_scopes.py @@ -0,0 +1,66 @@ +"""Saved search descriptors preserve the typed scope and its live authority.""" +import pytest +from test_saved_queries import make_query_ref +from test_service import call, register + +from msg.core.codec import digest, wire + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['subject', 'org', 'resource_refs']) +async def test_signed_typed_search_save_read_and_current_revocation(installed, kind): + app, _ = installed + key, owner, _ = await register(app, 'typed-query-owner') + reader_key, reader, _ = await register(app, 'typed-query-reader') + if kind == 'subject': + target = owner + supplied = {'subject': '/@typed-query-owner'} + normalized = {'subject': owner} + elif kind == 'org': + group = await call(app, 'group.create', {'name': 'typed-query-org'}, key=key, subject=owner) + assert group.status == 'ok', group.error + target = group.resources[0].id + async with app.metadata.transaction(write=False) as tx: + supplied = {'org': await tx.path(target)} + normalized = {'org': target} + else: + topic = await call(app, 'content.topic_create', {'parent': '/main', 'name': 'typed-query-topic'}, + key=key, subject=owner) + assert topic.status == 'ok', topic.error + target = topic.resources[0].id + supplied = {'resource_refs': [{'id': '/main/typed-query-topic'}, {'id': '/main'}]} + normalized = {'resource_refs': [{'id': item} for item in sorted([target, 't_main'])]} + args = {'scope': supplied, 'terms': 'typed-query-test'} + direct = await call(app, 'discovery.lexical_search', args, key=reader_key, subject=reader, + contract_version=5) + assert direct.status == 'ok', direct.error + token = await make_query_ref(app, reader_key, reader, args, kind='search') + saved = await call(app, 'query.save', {'query_ref': token}, key=reader_key, subject=reader) + assert saved.status == 'ok', saved.error + ref = wire(saved.resources[0]) + got = await call(app, 'query.saved_get', {'ref': ref}, key=reader_key, subject=reader) + assert got.status == 'ok', got.error + expected = {'operation': 'discovery.lexical_search', 'contract_version': 5, + 'arguments': {**args, 'scope': normalized}} + assert wire(got.data) == {**expected, 'digest': digest(expected)} + assert reader_key.key_id not in str(got.data) + assert 'principal' not in got.data + assert saved.data['descriptor_digest'] == digest(expected) + raw = await call(app, 'discovery.get', {'id': ref['id']}, key=reader_key, subject=reader) + assert raw.status == 'error' + # Saving a legal search does not silently broaden the ReadQuery-only watch. + watch = await call(app, 'communication.watch_create', + {'query_ref': ref, 'event_types': ['content.post_create'], 'delivery': 'inbox'}, + key=reader_key, subject=reader, contract_version=2) + assert watch.error.code == 'watch_query_unsupported' + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(target)).generation + revoked = await call(app, 'content.chmod', {'id': target, 'mode': '0700'}, + key=key, subject=owner, expected=((target, generation),)) + assert revoked.status == 'ok', revoked.error + denied = await call(app, 'query.saved_get', {'ref': ref}, key=reader_key, subject=reader) + assert denied.status == 'error' + assert 'principal' not in str(wire(denied)) + # A new save must recheck every typed member; no silently narrowed union. + resave = await call(app, 'query.save', {'query_ref': token}, key=reader_key, subject=reader) + assert resave.status == 'error' diff --git a/tests/test_saved_query_watch.py b/tests/test_saved_query_watch.py new file mode 100644 index 00000000..62147cbe --- /dev/null +++ b/tests/test_saved_query_watch.py @@ -0,0 +1,154 @@ +"""Pinned saved-query watches match only current authorized Event references.""" +from datetime import timedelta + +import pytest + +from msg.core.codec import b64, canonical, digest, wire +from test_service import NOW, call, register + + +async def save_query(app, key, subject, arguments): + payload = canonical({'version': 1, 'kind': 'read', 'arguments': arguments}) + opened = await call(app, 'transfer.open', {'direction': 'upload', 'size': len(payload), + 'digest': digest(payload), 'media_type': 'application/vnd.msg.read-query+json'}, key=key, subject=subject) + assert opened.status == 'ok', wire(opened) + tid = opened.data['transfer_id'] + part = await call(app, 'transfer.part_put', {'transfer_id': tid, 'offset': 0, 'data': b64(payload), 'digest': digest(payload)}, key=key, subject=subject) + assert part.status == 'ok', wire(part) + sealed = await call(app, 'transfer.seal', {'transfer_id': tid, 'final_size': len(payload), 'final_digest': digest(payload)}, key=key, subject=subject) + assert sealed.status == 'ok', wire(sealed) + token = await call(app, 'transfer.query_seal', {'transfer_id': tid}, key=key, subject=subject) + assert token.status == 'ok', wire(token) + saved = await call(app, 'query.save', {'query_ref': token.data['query_ref']}, key=key, subject=subject) + assert saved.status == 'ok', wire(saved) + return saved + + +async def messages(app, recipient): + async with app.metadata.transaction(write=False) as tx: + return [row[0] for row in tx.rows('SELECT resource FROM messages WHERE recipient=? ORDER BY resource', (recipient,))] + + +@pytest.mark.asyncio +async def test_saved_watch_matches_shared_read_predicates_and_source_revoke(installed): + app, _ = installed + ak, author, _ = await register(app, 'saved-watch-author') + rk, reader, _ = await register(app, 'saved-watch-reader') + saved = await save_query(app, rk, reader, {'parent': '/main', 'type': 'post', 'author': author}) + ref = wire(saved.resources[0]) + args = {'query_ref': ref, 'event_types': ['content.post_create', 'content.post_edit'], 'delivery': 'inbox'} + created = await call(app, 'communication.watch_create', args, key=rk, subject=reader, contract_version=2) + assert created.status == 'ok', wire(created) + chosen = await call(app, 'content.post_create', {'parent': '/main', 'body': 'match'}, key=ak, subject=author) + skipped = await call(app, 'content.post_create', {'parent': '/main', 'body': 'different author'}, key=rk, subject=reader) + assert chosen.status == skipped.status == 'ok' + assert await messages(app, reader) == [chosen.resources[0].id] + hidden = await call(app, 'content.chmod', {'id': chosen.resources[0].id, 'mode': '0600'}, + key=ak, subject=author, expected=((chosen.resources[0].id, chosen.data['generation']),)) + assert hidden.status == 'ok', wire(hidden) + edited = await call(app, 'content.post_edit', {'id': chosen.resources[0].id, + 'expected_revision': chosen.resources[0].revision, 'body': 'private change'}, + key=ak, subject=author, expected=((chosen.resources[0].id, hidden.data['generation']),)) + assert edited.status == 'ok', wire(edited) + assert await messages(app, reader) == [chosen.resources[0].id] + async with app.metadata.transaction(write=False) as tx: + source = await tx.resource(ref['id']) + archived = await call(app, 'query.saved_archive', {'ref': ref}, key=rk, subject=reader, + expected=((source.id, source.generation),)) + assert archived.status == 'ok', wire(archived) + after = await call(app, 'content.post_create', {'parent': '/main', 'body': 'source revoked'}, key=ak, subject=author) + assert after.status == 'ok', wire(after) + assert await messages(app, reader) == [chosen.resources[0].id] + + +@pytest.mark.asyncio +async def test_saved_watch_rejects_unsupported_predicates_and_missing_revision(installed): + app, _ = installed + key, user, _ = await register(app, 'saved-watch-reject') + saved = await save_query(app, key, user, {'parent': '/main', 'limit': 1}) + args = {'query_ref': wire(saved.resources[0]), 'event_types': ['content.post_create'], 'delivery': 'inbox'} + rejected = await call(app, 'communication.watch_create', args, key=key, subject=user, contract_version=2) + assert rejected.error.code == 'watch_query_unsupported', wire(rejected) + args['query_ref'] = {'id': saved.resources[0].id} + rejected = await call(app, 'communication.watch_create', args, key=key, subject=user, contract_version=2) + assert rejected.error.code == 'watch_query_revision_required', wire(rejected) + + +@pytest.mark.asyncio +async def test_saved_watch_cli_signs_pinned_v2_and_expiry_stops_delivery(installed, tmp_path, monkeypatch, capsys): + import httpx + from msg import cli + from msg.client import ClientState, MsgClient + from msg.core.codec import loads + from msg.transports.client import HTTPTransport + from msg.transports.http import create_app + app, _ = installed + ak, author, _ = await register(app, 'saved-watch-cli-author') + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url) as http: + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + directory = tmp_path/'client' + client = MsgClient(ClientState(directory, server=app.settings.service_url), HTTPTransport(app.settings.service_url, http=http), clock=lambda: NOW) + assert (await client.register('saved-watch-cli')).status == 'ok' + saved = await save_query(app, client.state.signer, client.state.subject, {'parent': '/main', 'type': 'post'}) + ref = saved.resources[0] + monkeypatch.setattr(cli, 'MsgClient', lambda state, transport: MsgClient(state, transport, clock=lambda: NOW)) + args = cli.parser().parse_args(['--config-dir', str(directory), '--server', app.settings.service_url, + 'watch', 'create', '--query-ref', ref.id, '--query-revision', ref.revision, + '--event', 'content.post_create', '--expires-at', wire(NOW+timedelta(seconds=1))]) + status = await cli.run(args) + output = loads(capsys.readouterr().out.encode().strip()) + assert status == 0 and output['status'] == 'ok', output + before = await call(app, 'content.post_create', {'parent': '/main', 'body': 'live'}, key=ak, subject=author) + assert before.status == 'ok' + assert await messages(app, client.state.subject) == [before.resources[0].id] + app.clock = lambda: NOW+timedelta(seconds=2) + after = await call(app, 'content.post_create', {'parent': '/main', 'body': 'expired'}, key=ak, subject=author) + assert after.status == 'ok', wire(after) + assert await messages(app, client.state.subject) == [before.resources[0].id] + + +@pytest.mark.asyncio +async def test_saved_watch_delivers_new_tag_match_without_query_execution(installed): + app, _ = installed + ak, author, _ = await register(app, 'saved-watch-tag-author') + rk, reader, _ = await register(app, 'saved-watch-tag-reader') + saved = await save_query(app, rk, reader, {'parent': '/main', 'tag': 'research'}) + watched = await call(app, 'communication.watch_create', { + 'query_ref': wire(saved.resources[0]), 'event_types': ['content.post_create', 'content.tags_set'], + 'delivery': 'inbox'}, key=rk, subject=reader, contract_version=2) + assert watched.status == 'ok', wire(watched) + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'untagged'}, key=ak, subject=author) + assert post.status == 'ok' + assert await messages(app, reader) == [] + tagged = await call(app, 'content.tags_set', {'id': post.resources[0].id, 'tags': ['research']}, + key=ak, subject=author, expected=((post.resources[0].id, post.data['generation']),)) + assert tagged.status == 'ok', wire(tagged) + assert await messages(app, reader) == [post.resources[0].id] + + +@pytest.mark.asyncio +async def test_saved_watch_stops_when_source_key_revoked_but_watch_key_remains_valid(installed): + from msg.security.crypto import Ed25519Signer + app, _ = installed + ak, author, _ = await register(app, 'saved-watch-dual-author') + first, reader, _ = await register(app, 'saved-watch-dual-reader') + saved = await save_query(app, first, reader, {'parent': '/main', 'type': 'post'}) + second = Ed25519Signer.generate() + public = b64(second.public_key) + async with app.metadata.transaction(write=False) as tx: + ceiling = wire((await tx.credential(first.key_id)).ceiling) + proof = second.sign(canonical({'subject_id': reader, 'public_key': public}), purpose='key-add') + added = await call(app, 'identity.key_add', {'public_key': public, 'possession_proof': wire(proof), + 'ceiling': ceiling}, key=first, subject=reader) + assert added.status == 'ok', wire(added) + watched = await call(app, 'communication.watch_create', { + 'query_ref': wire(saved.resources[0]), 'event_types': ['content.post_create'], 'delivery': 'inbox'}, + key=second, subject=reader, contract_version=2) + assert watched.status == 'ok', wire(watched) + revoked = await call(app, 'identity.key_revoke', {'key_id': first.key_id}, key=second, subject=reader) + assert revoked.status == 'ok', wire(revoked) + alive = await call(app, 'communication.watch_list', {}, key=second, subject=reader) + assert alive.status == 'ok', wire(alive) + posted = await call(app, 'content.post_create', {'parent': '/main', 'body': 'source key gone'}, key=ak, subject=author) + assert posted.status == 'ok', wire(posted) + assert await messages(app, reader) == [] diff --git a/tests/test_search_source_relation_filters.py b/tests/test_search_source_relation_filters.py index 7e895812..c1755e97 100644 --- a/tests/test_search_source_relation_filters.py +++ b/tests/test_search_source_relation_filters.py @@ -11,7 +11,8 @@ @pytest.mark.asyncio -async def test_v3_query_string_path_and_sealed_ref_share_filters(installed): +@pytest.mark.parametrize('version', [3, 5]) +async def test_v3_query_string_path_and_sealed_ref_share_filters(installed, version): app,_=installed key,user,_=await register(app,'search-v3-http') root=await call(app,'content.post_create',{'parent':'/main','body':'v3needle root'}, @@ -26,8 +27,12 @@ async def test_v3_query_string_path_and_sealed_ref_share_filters(installed): assert query.status_code==path.status_code==200,(query.text,path.text) assert query.content==path.content assert [row['id'] for row in query.json()['items']]==[reply.resources[0].id] - descriptor=canonical({'version':1,'kind':'search','arguments':{ - 'scope':'/main','terms':'v3needle','relation_type':'reply_to'}}) + descriptor_args={'scope':'/main','terms':'v3needle','relation_type':'reply_to'} + if version==5: + descriptor_args['revision']=reply.resources[0].revision + descriptor_args['relation_to']=root.resources[0].id + descriptor_args['scope']={'resource_refs':[{'id':reply.resources[0].id}]} + descriptor=canonical({'version':1,'kind':'search','arguments':descriptor_args}) opened=await call(app,'transfer.open',{'direction':'upload','size':len(descriptor), 'digest':digest(descriptor),'media_type':'application/vnd.msg.read-query+json'}, key=key,subject=user) @@ -122,3 +127,220 @@ async def test_v3_old_cursor_rechecks_grants_before_items_facets_and_rank(instal assert wire(continued)['data']['facets'] == {'type': [{'value': 'post', 'count': 1}]} fresh = await call(app, 'discovery.lexical_search', args, contract_version=3) assert [row['id'] for row in fresh.data['items']] == [visible_first] + + +@pytest.mark.asyncio +async def test_v5_revision_and_source_version_filters_use_current_readable_revision(installed): + app, _ = installed + key, subject, _ = await register(app, 'search-v5-owner') + created = await call(app, 'content.post_create', {'parent': '/main', 'body': 'v5revisionneedle'}, + key=key, subject=subject) + assert created.status == 'ok', wire(created) + rid, revision = created.resources[0].id, created.resources[0].revision + args = {'scope': '/main', 'terms': 'v5revisionneedle', 'revision': revision} + found = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert found.status == 'ok', wire(found) + assert [item['id'] for item in found.data['items']] == [rid] + old = await call(app, 'discovery.lexical_search', args, contract_version=4) + assert old.status == 'error' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + query = await http.get('/_search', params=args) + path = await http.get('/_s/q/5/s/%2Fmain/t/v5revisionneedle/rv/' + revision) + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + edited = await call(app, 'content.post_edit', {'id': rid, 'body': 'v5revisionneedle updated', + 'expected_revision': revision}, key=key, subject=subject, + expected=((rid, created.data['generation']),)) + assert edited.status == 'ok', wire(edited) + gone = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert gone.status == 'ok' and not gone.data['items'] + release = await call(app, 'discovery.lexical_search', + {'scope': '/_rules', 'terms': 'identity', 'source_kind': 'release'}, + contract_version=3) + ref = release.data['items'][0]['id'] + from msg.core.models import ResourceRef + async with app.metadata.transaction(write=False) as tx: + source = await tx.revision(ResourceRef(id=ref)) + filtered = await call(app, 'discovery.lexical_search', { + 'scope': '/_rules', 'terms': 'identity', 'revision': source.id, + 'source_kind': 'release', 'source_version': source.source_version}, contract_version=5) + assert filtered.status == 'ok', wire(filtered) + assert [item['id'] for item in filtered.data['items']] == [ref] + missed = await call(app, 'discovery.lexical_search', { + 'scope': '/_rules', 'terms': 'identity', 'revision': source.id, + 'source_version': source.source_version + 1}, contract_version=5) + assert missed.status == 'ok' and not missed.data['items'] + + +@pytest.mark.asyncio +async def test_v5_source_version_cursor_preserves_contract_and_rejects_downgrade(installed): + app, _ = installed + first = await call(app, 'discovery.lexical_search', { + 'scope': '/_rules', 'terms': 'identity', 'source_kind': 'release', + 'source_version': 1, 'limit': 1}, contract_version=5) + assert first.status == 'ok' and first.data['cursor'], wire(first) + downgraded = await call(app, 'discovery.lexical_search', + {'cursor': first.data['cursor']}, contract_version=4) + assert downgraded.status == 'error' and downgraded.error.code == 'cursor_query_mismatch' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + next_page = await http.get(first.data['next']) + assert next_page.status_code == 200, next_page.text + query = await http.get('/_search?scope=%2F_rules&terms=identity&source_version=1') + path = await http.get('/_s/q/5/s/%2F_rules/t/identity/sv/1') + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + + +@pytest.mark.asyncio +async def test_v5_revision_filter_does_not_reveal_private_resource(installed): + app, _ = installed + key, subject, _ = await register(app, 'private-revision-owner') + created = await call(app, 'content.post_create', + {'parent': '/main', 'body': 'privatev5needle'}, key=key, subject=subject) + rid = created.resources[0].id + changed = await call(app, 'content.chmod', {'id': rid, 'mode': '0600'}, + key=key, subject=subject, expected=((rid, created.data['generation']),)) + assert changed.status == 'ok', wire(changed) + args = {'scope': '/main', 'terms': 'privatev5needle', + 'revision': created.resources[0].revision, 'facets': ['type'], 'snippet': True} + denied = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert denied.status == 'ok' and not denied.data['items'] + assert wire(denied.data['facets']) == {'type': []} + allowed = await call(app, 'discovery.lexical_search', args, key=key, subject=subject, contract_version=5) + assert allowed.status == 'ok' and [item['id'] for item in allowed.data['items']] == [rid] + + +@pytest.mark.asyncio +async def test_v5_directional_relations_and_presence_recheck_both_endpoints(installed): + app, _ = installed + key, subject, _ = await register(app, 'directional-search-owner') + root = await call(app, 'content.post_create', {'parent': '/main', 'body': 'directionneedle root'}, + key=key, subject=subject) + reply = await call(app, 'discussion.reply', {'target': {'id': root.resources[0].id}, + 'body': 'directionneedle reply'}, key=key, subject=subject) + assert root.status == reply.status == 'ok' + root_id, reply_id = root.resources[0].id, reply.resources[0].id + base = {'scope': '/main', 'terms': 'directionneedle', 'facets': ['type']} + async def find(extra, *, signed=False): + result = await call(app, 'discovery.lexical_search', {**base, **extra}, contract_version=5, + key=key if signed else None, subject=subject if signed else None) + assert result.status == 'ok', wire(result) + return [item['id'] for item in result.data['items']] + assert await find({'relation_to': root_id, 'relation_type': 'reply_to'}) == [reply_id] + assert await find({'relation_from': reply_id, 'relation_type': 'reply_to'}) == [root_id] + assert await find({'has_replies': True}) == [root_id] + assert await find({'has_references': True}) == [root_id] + assert await find({'has_replies': False}) == [reply_id] + private = await call(app, 'content.chmod', {'id': reply_id, 'mode': '0600'}, + key=key, subject=subject, expected=((reply_id, reply.data['generation']),)) + assert private.status == 'ok', wire(private) + assert await find({'relation_from': reply_id}) == [] + assert await find({'has_replies': True}) == [] + assert await find({'has_references': True}) == [] + assert await find({'has_replies': True}, signed=True) == [root_id] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + query = await http.get('/_search', params={**base, 'facets': 'type', 'has_replies': '1'}) + path = await http.get('/_s/q/5/s/%2Fmain/t/directionneedle/fc/type/hr/1') + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + # Readable source cannot reveal its now-private target through a predicate. + hidden = await call(app, 'content.chmod', {'id': root_id, 'mode': '0600'}, + key=key, subject=subject, expected=((root_id, root.data['generation']),)) + assert hidden.status == 'ok', wire(hidden) + public_reply = await call(app, 'content.chmod', {'id': reply_id, 'mode': '0644'}, + key=key, subject=subject, expected=((reply_id, private.data['generation']),)) + assert public_reply.status == 'ok', wire(public_reply) + assert await find({'relation_to': root_id}) == [] + assert await find({'relation_to': root_id}, signed=True) == [reply_id] + + +@pytest.mark.asyncio +async def test_v5_title_is_explicit_resource_display_name_alias(installed): + app, _ = installed + key, subject, _ = await register(app, 'title-search-owner') + created = await call(app, 'content.post_create', { + 'parent': '/main', 'name': 'NeedleTitle', 'body': 'unrelated content'}, key=key, subject=subject) + assert created.status == 'ok', wire(created) + args = {'scope': '/main', 'terms': 'needletitle', 'field': 'title', + 'fields': ['id', 'title', 'snippet'], 'snippet': True} + found = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert found.status == 'ok', wire(found) + assert wire(found.data['items']) == [{'id': created.resources[0].id, 'title': 'NeedleTitle.md', + 'snippet': {'field': 'title', 'text': 'NeedleTitle.md', 'range': [0, 11]}}] + old = await call(app, 'discovery.lexical_search', args, contract_version=4) + assert old.status == 'error' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + query = await http.get('/_search', params={**args, 'fields': 'id,title,snippet', 'snippet': '1'}) + path = await http.get('/_s/q/5/s/%2Fmain/t/needletitle/f/t/fi/id,title,snippet/x/1') + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + old_path = await http.get('/_s/q/4/s/%2Fmain/t/needletitle/f/t') + assert old_path.status_code >= 400 + + +@pytest.mark.asyncio +async def test_v5_typed_scopes_union_resources_and_subject_with_current_access(installed): + from urllib.parse import quote + app, _ = installed + key, subject, _ = await register(app, 'scope-search-owner') + posts = [] + for name in ('scope-one', 'scope-two', 'scope-outside'): + post = await call(app, 'content.post_create', {'parent': '/main', 'name': name, 'body': 'scopev5needle'}, + key=key, subject=subject) + assert post.status == 'ok', wire(post) + posts.append(post) + ids = [post.resources[0].id for post in posts] + scope = {'resource_refs': [{'id': rid} for rid in ids[:2]]} + args = {'scope': scope, 'terms': 'scopev5needle', 'limit': 1, 'order': 'name'} + result = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert result.status == 'ok', wire(result) + assert [item['id'] for item in result.data['items']] == [ids[0]] + continued = await call(app, 'discovery.lexical_search', {'cursor': result.data['cursor']}, contract_version=5) + assert continued.status == 'ok' and [item['id'] for item in continued.data['items']] == [ids[1]] + owned = await call(app, 'discovery.lexical_search', {**args, 'scope': {'subject': subject}, 'limit': 10}, + key=key, subject=subject, contract_version=5) + assert owned.status == 'ok' and {item['id'] for item in owned.data['items']} == set(ids), wire(owned) + bad_type = await call(app, 'discovery.lexical_search', {**args, 'scope': {'org': subject}}, contract_version=5) + assert bad_type.status == 'error' and bad_type.error.code == 'invalid_search_scope' + group = await call(app, 'group.create', {'name': 'typed-search-group'}, key=key, subject=subject) + assert group.status == 'ok', wire(group) + group_id = group.resources[0].id + grouped = await call(app, 'content.chgrp', {'id': ids[0], 'group': group_id}, + key=key, subject=subject, expected=((ids[0], posts[0].data['generation']),)) + assert grouped.status == 'ok', wire(grouped) + group_search = await call(app, 'discovery.lexical_search', + {**args, 'scope': {'org': group_id}, 'limit': 10}, key=key, subject=subject, contract_version=5) + assert group_search.status == 'ok' and [item['id'] for item in group_search.data['items']] == [ids[0]], wire(group_search) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + query = await http.get('/_search', params={'scope': canonical(scope).decode(), 'terms': 'scopev5needle'}) + path = await http.get('/_s/q/5/s/' + quote(canonical(scope).decode(), safe=',') + '/t/scopev5needle') + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + descriptor=canonical({'version':1,'kind':'search','arguments':args}) + opened=await call(app,'transfer.open',{'direction':'upload','size':len(descriptor), + 'digest':digest(descriptor),'media_type':'application/vnd.msg.read-query+json'}, + key=key,subject=subject) + tid=opened.data['transfer_id'] + await call(app,'transfer.part_put',{'transfer_id':tid,'offset':0, + 'data':b64(descriptor),'digest':digest(descriptor)},key=key,subject=subject) + await call(app,'transfer.seal',{'transfer_id':tid,'final_size':len(descriptor), + 'final_digest':digest(descriptor)},key=key,subject=subject) + sealed=await call(app,'transfer.query_seal',{'transfer_id':tid},key=key,subject=subject) + assert sealed.status=='ok',wire(sealed) + first=await call(app,'transfer.query_get',{'query_ref':sealed.data['query_ref']},key=key,subject=subject) + assert first.status=='ok' and first.data['cursor'],wire(first) + second=await call(app,'transfer.query_get',{'query_ref':sealed.data['query_ref'], + 'cursor':first.data['cursor']},key=key,subject=subject) + assert second.status=='ok' and [item['id'] for item in second.data['items']]==[ids[1]],wire(second) + changed = await call(app, 'content.chmod', {'id': ids[1], 'mode': '0600'}, key=key, subject=subject, + expected=((ids[1], posts[1].data['generation']),)) + assert changed.status == 'ok', wire(changed) + revoked = await call(app, 'discovery.lexical_search', {'cursor': result.data['cursor']}, contract_version=5) + assert revoked.status == 'error' + old = await call(app, 'discovery.lexical_search', args, contract_version=4) + assert old.status == 'error' diff --git a/tests/test_search_spelling.py b/tests/test_search_spelling.py new file mode 100644 index 00000000..5fb2ad19 --- /dev/null +++ b/tests/test_search_spelling.py @@ -0,0 +1,64 @@ +"""Spelling hints are explicit, deterministic and limited to visible names.""" +import httpx +import pytest +from test_lexical_search_grep import business_state +from test_service import call, register + +from msg.core.codec import wire +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_spelling_is_opt_in_and_rechecks_visible_name_dictionary(installed): + app, _ = installed + key, subject, _ = await register(app, 'spelling-owner') + posts = [] + for name in ('gadget', 'gadger'): + created = await call(app, 'content.post_create', + {'parent': '/main', 'name': name, 'body': 'a product'}, key=key, subject=subject) + assert created.status == 'ok', wire(created) + posts.append(created) + private = await call(app, 'content.chmod', {'id': posts[1].resources[0].id, 'mode': '0600'}, + key=key, subject=subject, expected=((posts[1].resources[0].id, posts[1].data['generation']),)) + assert private.status == 'ok', wire(private) + args = {'scope': '/main', 'terms': 'gadge', 'field': 'name'} + before = await business_state(app) + ordinary = await call(app, 'discovery.lexical_search', args, contract_version=5) + assert ordinary.status == 'ok' and 'spelling' not in ordinary.data + hinted = await call(app, 'discovery.lexical_search', {**args, 'spell': True}, contract_version=5) + assert hinted.status == 'ok', wire(hinted) + assert wire(hinted.data['spelling']) == {'source': 'name', 'terms': [ + {'term': 'gadge', 'suggestions': [{'value': 'gadget', 'distance': 1, 'count': 1}]}]} + assert [row['id'] for row in hinted.data['items']] == [row['id'] for row in ordinary.data['items']] + old = await call(app, 'discovery.lexical_search', {**args, 'spell': True}, contract_version=4) + assert old.status == 'error' + short = await call(app, 'discovery.lexical_search', {**args, 'terms': 'g', 'spell': True}, contract_version=5) + assert short.status == 'error' and short.error.code == 'query_cost_exceeded' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + query = await http.get('/_search', params={**args, 'spell': '1'}) + path = await http.get('/_s/q/5/s/%2Fmain/t/gadge/f/n/sp/1') + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + assert await business_state(app) == before + revoked = await call(app, 'content.chmod', {'id': posts[0].resources[0].id, 'mode': '0600'}, + key=key, subject=subject, expected=((posts[0].resources[0].id, posts[0].data['generation']),)) + assert revoked.status == 'ok', wire(revoked) + hidden = await call(app, 'discovery.lexical_search', {**args, 'spell': True}, contract_version=5) + assert hidden.status == 'ok' and not hidden.data['items'] + assert wire(hidden.data['spelling']) == {'source': 'name', 'terms': [{'term': 'gadge', 'suggestions': []}]} + + +@pytest.mark.asyncio +async def test_v5_registered_collaboration_relation_names_select_same_http_contract(installed): + app, _ = installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + for kind in ('reference', 'state', 'target', 'content'): + query = await http.get('/_search', params={ + 'scope': '/main', 'terms': 'relationneedle', 'relation_type': kind}) + path = await http.get('/_s/q/5/s/%2Fmain/t/relationneedle/rt/' + kind) + assert query.status_code == path.status_code == 200, (query.text, path.text) + assert query.content == path.content + old = await http.get('/_s/q/4/s/%2Fmain/t/relationneedle/rt/' + kind) + assert old.status_code >= 400 diff --git a/tests/test_single_mailbox_delivery.py b/tests/test_single_mailbox_delivery.py index 5e872063..5274187d 100644 --- a/tests/test_single_mailbox_delivery.py +++ b/tests/test_single_mailbox_delivery.py @@ -37,7 +37,8 @@ def test_market_rejects_non_exact_single_mailbox_without_leaking_it(address): with pytest.raises(Failure) as caught: validate_address(address) - assert caught.value.as_dict() == {'code': 'invalid_email', 'retryable': False} + assert caught.value.as_dict() == {'code': 'invalid_email', 'retryable': False, + 'message': 'Enter one valid email address.'} assert address not in str(caught.value) diff --git a/tests/test_smtp_socket_acceptance.py b/tests/test_smtp_socket_acceptance.py new file mode 100644 index 00000000..4c1a2e47 --- /dev/null +++ b/tests/test_smtp_socket_acceptance.py @@ -0,0 +1,125 @@ +"""Exercise SMTP over a real, certificate-verified loopback TLS socket.""" +import socket +import ssl +import subprocess +from contextlib import contextmanager +from email import policy +from email.parser import BytesParser +from threading import Thread +from types import SimpleNamespace + +import pytest + +from msg.core.errors import Failure +from msg.workers.mail import SmtpSender + + +@pytest.fixture +def tls_contexts(tmp_path): + cert, key = tmp_path / 'cert.pem', tmp_path / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', str(key), '-out', str(cert), '-days', '1', + '-subj', '/CN=localhost', '-addext', 'subjectAltName=IP:127.0.0.1'], + check=True, capture_output=True) + server = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server.load_cert_chain(cert, key) + client = ssl.create_default_context(cafile=str(cert)) + return server, client + + +@contextmanager +def smtp_sink(context, outcome): + commands, messages, errors = [], [], [] + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + listener.settimeout(5) + + def serve(): + try: + conn, _ = listener.accept() + with context.wrap_socket(conn, server_side=True) as conn: + conn.settimeout(5) + with conn.makefile('rwb') as wire: + def reply(value): + wire.write(value + b'\r\n') + wire.flush() + reply(b'220 localhost isolated test sink') + while line := wire.readline(): + commands.append(line.strip()) + verb = line.split(b' ', 1)[0].strip().upper() + if verb in (b'EHLO', b'HELO'): + reply(b'250 localhost') + elif verb == b'MAIL': + reply(b'250 sender accepted') + elif verb == b'RCPT': + reply(b'550 recipient refused' if outcome == 'refused' else b'250 recipient accepted') + elif verb == b'RSET': + reply(b'250 reset') + elif verb == b'DATA': + reply(b'354 send message') + body = [] + while (line := wire.readline()) != b'.\r\n': + if not line: + raise AssertionError('EOF during DATA') + body.append(line[1:] if line.startswith(b'..') else line) + messages.append(b''.join(body)) + if outcome == 'disconnect': + return + reply(b'250 accepted') + else: + raise AssertionError(f'unexpected command {verb!r}') + except (OSError, AssertionError) as exc: + errors.append(exc) + + thread = Thread(target=serve, daemon=True) + thread.start() + try: + yield listener.getsockname()[1], commands, messages + finally: + thread.join(6) + assert not thread.is_alive(), 'SMTP sink did not finish' + assert not errors, (errors, commands) + + +def sender(port): + return SmtpSender(SimpleNamespace(enabled=True, host='127.0.0.1', port=port, + sender='sender@example.test', tls='tls', credential_file=None)) + + +def job(): + return SimpleNamespace(id='isolated-smtp-acceptance', arguments={ + 'recipient': 'reader@example.test', 'subject': 'minimal notice', + 'text': 'Open your inbox.\n.dot-stuffed line'}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('outcome', 'expected'), [ + ('accepted', 'sent'), ('disconnect', 'uncertain'), ('refused', 'uncertain')]) +async def test_real_tls_smtp_success_refusal_and_lost_ack(tls_contexts, monkeypatch, outcome, expected): + server, client = tls_contexts + monkeypatch.setattr(ssl, 'create_default_context', lambda: client) + # Keep EHLO hostname discovery independent of the host machine's DNS. + monkeypatch.setattr(socket, 'getfqdn', lambda: 'sink.example.test') + with smtp_sink(server, outcome) as (port, commands, messages): + assert await sender(port).send(job()) == expected + assert sum(command.upper().startswith(b'MAIL ') for command in commands) == 1 + assert [command.lower() for command in commands if command.upper().startswith(b'RCPT ')] == [ + b'rcpt to:'] + assert len(messages) == (0 if outcome == 'refused' else 1) + if messages: + message = BytesParser(policy=policy.default).parsebytes(messages[0]) + assert message['To'] == 'reader@example.test' + assert message['Message-ID'] == '' + assert '.dot-stuffed line' in message.get_content() + + +@pytest.mark.asyncio +async def test_real_smtp_connection_failure_is_retryable_before_data(): + # Bind without listening to reserve a port which refuses TCP connections. + with socket.socket() as reserved: + reserved.bind(('127.0.0.1', 0)) + with pytest.raises(Failure) as caught: + await sender(reserved.getsockname()[1]).send(job()) + assert caught.value.code == 'mail_connection_failed' + assert caught.value.retryable diff --git a/tests/test_ssh_rss.py b/tests/test_ssh_rss.py index 7f236a8d..b124bc78 100644 --- a/tests/test_ssh_rss.py +++ b/tests/test_ssh_rss.py @@ -86,6 +86,20 @@ async def job(): assert (await tx.job(initial.id)).state=='done' assert (await tx.resource(rid)).generation==repo.data['generation']+1 assert tx.one('SELECT COUNT(*) FROM audit')[0]>0 + # A real Git ref transaction that loses stdin after prepare must abort + # every staged ref. This covers EOF, not host power loss or disk durability. + interrupted=await job() + before_refs,_=await store.refs(rid) + async with app.metadata.transaction(write=False) as tx: + before_generation=(await tx.resource(rid)).generation + commands=(f'start\ncreate refs/heads/second {commit}\n' + f'create refs/heads/third {commit}\nprepare\n').encode() + code,output,changed=await guarded_command(app,interrupted, + lambda _:['update-ref','--stdin'],input_data=commands,capture_output=True) + assert code==0 and b'prepare: ok' in output and not changed + assert (await store.refs(rid))[0]==before_refs + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(rid)).generation==before_generation pending=await job() await call(app,'identity.ssh_key_revoke',{'key_id':ssh.key_id},key=key,subject=uid) denied=await guarded_command(app,pending,lambda _:['update-ref','refs/heads/other',commit,'0'*40]) diff --git a/tests/test_storage_commit_faults.py b/tests/test_storage_commit_faults.py new file mode 100644 index 00000000..a21ae46d --- /dev/null +++ b/tests/test_storage_commit_faults.py @@ -0,0 +1,75 @@ +"""Real PostgreSQL COMMIT rejection after durable Git/CAS publication.""" +import pytest +from test_service import call, register + +from msg.core.codec import b64 +from msg.core.models import ResourceRef +from msg.workers.maintenance import _collect + + +@pytest.mark.asyncio +@pytest.mark.parametrize('media', ['text/plain', 'application/octet-stream']) +async def test_deferred_commit_failure_leaves_collectable_orphan_and_retry(installed, monkeypatch, media): + app, _ = installed + key, subject, _ = await register(app, 'commit-fault') + initial = await call(app, 'file.create', {'parent': '/@commit-fault/files', + 'name': 'retained.dat', 'data': b64(b'retained bytes'), 'media_type': media}, + key=key, subject=subject) + assert initial.status == 'ok', initial.error + rid = initial.resources[0].id + tables = ('resources', 'revisions', 'events', 'results', 'audit', 'jobs') + async with app.metadata.transaction(write=False) as tx: + old = await tx.revision(ResourceRef(id=rid)) + before = tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in tables) + captured = [] + commit_revision = app.contents.commit_revision + async def observe(topic, revision): + result = await commit_revision(topic, revision) + captured.append(revision) + return result + monkeypatch.setattr(app.contents, 'commit_revision', observe) + # A real deferred PostgreSQL trigger fails inside connection.commit(), after + # handler, Event and idempotency rows have been prepared. No mocked principal + # or fabricated connection failure can short-circuit that boundary. + async with app.metadata.transaction(write=True) as tx: + tx.execute("""CREATE FUNCTION reject_test_revision_commit() RETURNS trigger AS $$ + BEGIN RAISE EXCEPTION 'injected_deferred_commit_failure'; END; + $$ LANGUAGE plpgsql""", write=True) + tx.execute('''CREATE CONSTRAINT TRIGGER reject_test_revision_commit + AFTER INSERT ON revisions DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION reject_test_revision_commit()''', write=True) + args = {'id': rid, 'base_revision': old.id, 'data': b64(b'uncommitted bytes')} + failed = await call(app, 'file.write', args, key=key, subject=subject, + expected=((rid, initial.data['generation']),), rid='commit-fault-request') + assert failed.status == 'error', failed + assert len(captured) == 1 # durable publication ran before COMMIT rejected it + orphan = captured[0] + assert await app.contents.read_bytes(orphan.content) == b'uncommitted bytes' + assert not await app.contents.pinned(orphan.content, orphan.id) + async with app.metadata.transaction(write=False) as tx: + assert tuple(tx.one('SELECT COUNT(*) FROM ' + table)[0] for table in tables) == before + assert (await tx.resource(rid)).revision == old.id + assert tx.one('SELECT 1 FROM results WHERE request_id=?', ('commit-fault-request',)) is None + async with app.metadata.transaction(write=True) as tx: + tx.execute('DROP TRIGGER reject_test_revision_commit ON revisions', write=True) + tx.execute('DROP FUNCTION reject_test_revision_commit()', write=True) + result = await _collect(app, tx, grace_seconds=0) + assert result['unreferenced_contents_removed'] == 1 + assert not (app.contents.index / orphan.content.digest[7:]).exists() + assert not (app.contents.path / 'revisions' / orphan.id).exists() + if media == 'application/octet-stream': + assert not (app.contents.binary / orphan.content.digest[7:]).exists() + assert await app.contents.read_bytes(old.content) == b'retained bytes' + assert await app.contents.pinned(old.content, old.id) + retry = await call(app, 'file.write', args, key=key, subject=subject, + expected=((rid, initial.data['generation']),), rid='commit-fault-request') + assert retry.status == 'ok' and not retry.replayed, retry.error + # Old immutable bytes remain retained after a new revision and ordinary archive. + archived = await call(app, 'file.delete', {'id': rid}, key=key, subject=subject, + expected=((rid, retry.data['generation']),)) + assert archived.status == 'ok', archived.error + async with app.metadata.transaction(write=True) as tx: + assert (await _collect(app, tx, grace_seconds=0))['unreferenced_contents_removed'] == 0 + assert (await tx.revision(ResourceRef(id=rid, revision=old.id))) == old + assert await app.contents.read_bytes(old.content) == b'retained bytes' + assert await app.contents.read_bytes(captured[-1].content) == b'uncommitted bytes' diff --git a/tests/test_subject_receipts.py b/tests/test_subject_receipts.py new file mode 100644 index 00000000..7d28c0fe --- /dev/null +++ b/tests/test_subject_receipts.py @@ -0,0 +1,193 @@ +"""/@user/receipts projects the caller's persisted OperationResults read-only.""" +from datetime import timedelta + +import httpx +import pytest + +from msg.core.codec import b64, canonical, digest, wire +from msg.core.requests import request_for +from msg.transports.http import create_app +from test_service import NOW, call, register + +PROJECTION = {'request_id', 'operation', 'status', 'committed_at', 'resources', 'signed'} + + +def _headers(app, operation, args, key, subject): + packet=request_for(operation,args,app.settings.service_url,signer=key, + subject=subject,expires_at=NOW+timedelta(seconds=120)) + return {'X-Msg-Request':b64(canonical(packet))} + + +def _state(tx): + counts=tuple(tx.one(f'SELECT COUNT(*) FROM {table}')[0] for table in + ('results','audit','jobs','events','messages','revisions')) + results=digest([list(row) for row in tx.rows( + 'SELECT subject,request_id,digest,body FROM results ORDER BY subject,request_id')]) + generations=tx.one('SELECT SUM(generation) FROM resources')[0] + return counts,results,generations + + +@pytest.mark.asyncio +async def test_subject_receipts_are_owner_only_read_only_projection(installed): + app,_=installed + alice_key,alice,_=await register(app,'receipt-alice') + bob_key,bob,_=await register(app,'receipt-bob') + written=[] + for index in range(3): + post=await call(app,'content.post_create',{'parent':'/main','body':f'receipt {index}'}, + key=alice_key,subject=alice) + assert post.status=='ok',wire(post) + written.append(post) + bob_post=await call(app,'content.post_create',{'parent':'/main','body':'bob receipt'}, + key=bob_key,subject=bob) + assert bob_post.status=='ok',wire(bob_post) + async with app.metadata.transaction(write=False) as tx: + expected=[row[0] for row in tx.rows( + 'SELECT request_id FROM results WHERE subject=? ORDER BY request_id',(alice,))] + before=_state(tx) + assert {post.request_id for post in written}<=set(expected) and len(expected)>=4 + assert bob_post.request_id not in expected + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + list_headers=_headers(app,'communication.receipt_list',{},alice_key,alice) + listed=await http.get('/@receipt-alice/receipts',headers=list_headers) + assert listed.status_code==200,listed.text + body=listed.json() + assert body['path']=='/@receipt-alice/receipts' + assert [item['request_id'] for item in body['items']]==expected + assert 'cursor' not in body + for item in body['items']: + assert set(item)==PROJECTION + assert item['signed'] is True and item['status']=='ok' + slashed=await http.get('/@receipt-alice/receipts/',headers=list_headers) + suffixed=await http.get('/@receipt-alice/receipts/json',headers=list_headers) + assert slashed.content==suffixed.content==listed.content + cached=await http.get('/@receipt-alice/receipts', + headers={**list_headers,'If-None-Match':listed.headers['etag']}) + assert cached.status_code==304 + + target=written[1] + item_headers=_headers(app,'communication.receipt_get', + {'request_id':target.request_id},alice_key,alice) + single=await http.get(f'/@receipt-alice/receipts/{target.request_id}',headers=item_headers) + assert single.status_code==200,single.text + receipt=single.json()['receipt'] + assert single.json()['path']==f'/@receipt-alice/receipts/{target.request_id}' + assert receipt==next(item for item in body['items'] + if item['request_id']==target.request_id) + assert receipt['operation']=='content.post_create' + assert receipt['resources']==[wire(ref) for ref in target.resources] + assert receipt['committed_at']==wire(target.committed_at) + for secret in ('data','output','receipt','cli_url','prefer_cli','actor','subject'): + assert secret not in receipt + suffixed=await http.get(f'/@receipt-alice/receipts/{target.request_id}/json', + headers=item_headers) + assert suffixed.content==single.content + head=await http.head(f'/@receipt-alice/receipts/{target.request_id}',headers=item_headers) + assert head.status_code==200 and head.content==b'' + assert head.headers['etag']==single.headers['etag'] + + missing_headers=_headers(app,'communication.receipt_get', + {'request_id':'r_missing_receipt'},alice_key,alice) + missing=await http.get('/@receipt-alice/receipts/r_missing_receipt',headers=missing_headers) + assert missing.status_code==404 and missing.json()['error']['code']=='not_found' + + bob_list=_headers(app,'communication.receipt_list',{},bob_key,bob) + cross=await http.get('/@receipt-alice/receipts',headers=bob_list) + assert cross.status_code==403 and cross.json()['error']['code']=='permission_denied' + assert target.request_id not in cross.text + for request_id in (target.request_id,bob_post.request_id,'r_missing_receipt'): + bob_get=_headers(app,'communication.receipt_get',{'request_id':request_id},bob_key,bob) + denied=await http.get(f'/@receipt-alice/receipts/{request_id}',headers=bob_get) + assert denied.status_code==403,denied.text + assert denied.json()['error']['code']=='permission_denied' + own=await http.get('/@receipt-bob/receipts',headers=bob_list) + assert own.status_code==200 + assert bob_post.request_id in [item['request_id'] for item in own.json()['items']] + assert not set(expected)&{item['request_id'] for item in own.json()['items']} + + for path in ('/@receipt-alice/receipts',f'/@receipt-alice/receipts/{target.request_id}'): + anonymous=await http.get(path) + assert anonymous.status_code==401 + assert anonymous.json()['error']['code']=='authentication_required' + assert 'items' not in anonymous.json() and 'receipt' not in anonymous.json() + + unknown=await http.get('/@receipt-alice/receipts?after=x',headers=list_headers) + assert unknown.status_code==400 + assert unknown.json()['error']['code']=='unknown_query_parameter' + detail_query=await http.get(f'/@receipt-alice/receipts/{target.request_id}?limit=1', + headers=item_headers) + assert detail_query.json()['error']['code']=='unknown_query_parameter' + for bad in (0,201): + bad_headers=_headers(app,'communication.receipt_list',{'limit':bad},alice_key,alice) + rejected=await http.get(f'/@receipt-alice/receipts?limit={bad}',headers=bad_headers) + assert rejected.status_code==400,rejected.text + for path in ('/@receipt-alice/receipts',f'/@receipt-alice/receipts/{target.request_id}'): + written_path=await http.post(path,json={}) + assert written_path.status_code in {404,405} + + pages=[] + cursor=None + while True: + args={'limit':1} if cursor is None else {'limit':1,'cursor':cursor} + page=await http.get('/@receipt-alice/receipts',params=args, + headers=_headers(app,'communication.receipt_list',args, + alice_key,alice)) + assert page.status_code==200,page.text + data=page.json() + assert len(data['items'])==1 + pages.append(data['items'][0]['request_id']) + cursor=data.get('cursor') + if cursor is None: + break + assert data['next_requires_auth'] is True + assert data['next'].startswith('/-/g/communication.receipt_list/j/') + stolen_args={'limit':1,'cursor':cursor} + stolen=await http.get('/@receipt-bob/receipts',params=stolen_args, + headers=_headers(app,'communication.receipt_list', + stolen_args,bob_key,bob)) + assert stolen.status_code==400 + assert stolen.json()['error']['code']=='cursor_query_mismatch' + assert pages==expected + + async with app.metadata.transaction(write=False) as tx: + assert _state(tx)==before + + +@pytest.mark.asyncio +async def test_receipt_projection_hides_resources_no_longer_readable(installed): + app,_=installed + alice_key,alice,_=await register(app,'receipt-share-alice') + bob_key,bob,_=await register(app,'receipt-share-bob') + note=await call(app,'identity.note_put',{'name':'receipt-note','body':'Private'}, + key=alice_key,subject=alice) + assert note.status=='ok',wire(note) + rid=note.resources[0].id + grant=await call(app,'sharing.grant',{'resource':rid,'grantee':bob, + 'expires_at':wire(NOW+timedelta(days=1))}, + key=alice_key,subject=alice) + assert grant.status=='ok',wire(grant) + sent=await call(app,'communication.send', + {'recipient':alice,'resource':wire(note.resources[0])}, + key=bob_key,subject=bob) + assert sent.status=='ok',wire(sent) + assert rid in {ref.id for ref in sent.resources} + shown=await call(app,'communication.receipt_get',{'request_id':sent.request_id}, + key=bob_key,subject=bob) + assert shown.status=='ok',wire(shown) + assert rid in {ref['id'] for ref in shown.data['receipt']['resources']} + revoked=await call(app,'sharing.revoke',{'grant_id':grant.data['grant']['id']}, + key=alice_key,subject=alice) + assert revoked.status=='ok',wire(revoked) + async with app.metadata.transaction(write=False) as tx: + stored=tx.one('SELECT body FROM results WHERE subject=? AND request_id=?', + (bob,sent.request_id))[0] + hidden=await call(app,'communication.receipt_get',{'request_id':sent.request_id}, + key=bob_key,subject=bob) + assert hidden.status=='ok',wire(hidden) + assert rid not in {ref['id'] for ref in hidden.data['receipt']['resources']} + assert hidden.data['receipt']['signed'] is True + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT body FROM results WHERE subject=? AND request_id=?', + (bob,sent.request_id))[0]==stored diff --git a/tests/test_system_rule_migration.py b/tests/test_system_rule_migration.py index 10bb62b0..0923f8c3 100644 --- a/tests/test_system_rule_migration.py +++ b/tests/test_system_rule_migration.py @@ -9,7 +9,8 @@ @pytest.mark.asyncio -async def test_source_move_preserves_public_resource_and_revision(installed,tmp_path): +@pytest.mark.parametrize("container", [tuple, list]) +async def test_source_move_preserves_public_resource_and_revision(installed,tmp_path,container): app,_=installed source=tmp_path/'system' shutil.copytree(system_source_root(),source) @@ -18,7 +19,7 @@ async def test_source_move_preserves_public_resource_and_revision(installed,tmp_ moved.parent.mkdir() original.rename(moved) paths={'msg.identity':'rules/moved/identity.md'} - declaration={'msg.identity':('rules/identity.md','rules/moved/identity.md')} + declaration={'msg.identity':container(('rules/identity.md','rules/moved/identity.md'))} async with app.metadata.transaction(write=False) as tx: before=await tx.resource('r_rule_identity') with pytest.raises(Failure,match='system_source_migration_required'): diff --git a/tests/test_system_rule_retirement.py b/tests/test_system_rule_retirement.py new file mode 100644 index 00000000..b067fb44 --- /dev/null +++ b/tests/test_system_rule_retirement.py @@ -0,0 +1,190 @@ +"""Release-only retirement preserves history and refuses dangling dependencies.""" +import shutil +import re +from dataclasses import replace + +import pytest + +from msg.bootstrap import sync_system_sources, system_source_root +from msg.core.codec import wire +from msg.core.errors import Failure +from msg.core.models import ResourceRef +from test_service import NOW, call, register + + +async def retirement(app, tmp_path): + source = tmp_path / 'retirement-source' + shutil.copytree(system_source_root(), source) + (source / 'rules/recovery.md').unlink() + for path in source.rglob('*.md'): + text = path.read_text() + if '/_rules/recovery' in text: + text = re.sub(r'version: (\d+)', lambda match: 'version: ' + str(int(match[1])+1), + text, count=1) + path.write_text('\n'.join(line for line in text.splitlines() + if '/_rules/recovery' not in line) + '\n') + async with app.metadata.transaction(write=False) as tx: + row = tx.one('SELECT source_path,source_version,source_digest FROM system_sources ' + 'WHERE rule_id=?', ('msg.recovery',)) + return source, {'msg.recovery': {'source_path': row[0].removeprefix('docs/system/'), + 'version': row[1], 'digest': row[2]}} + + +@pytest.mark.asyncio +async def test_declared_retirement_preserves_history_is_idempotent_and_read_only(installed, tmp_path, monkeypatch): + app, _ = installed + source, declaration = await retirement(app, tmp_path) + async with app.metadata.transaction(write=False) as tx: + before = await tx.resource('r_rule_recovery') + revision = await tx.revision(ResourceRef(id=before.id, revision=before.revision)) + original = await app.contents.read_bytes(revision.content) + for _ in range(2): + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=app.registry) + async with app.metadata.transaction(write=False) as tx: + after = await tx.resource(before.id) + assert after.state == 'archived' + assert (after.revision, after.generation) == (before.revision, before.generation + 1) + assert wire(await tx.revision(ResourceRef(id=before.id, revision=before.revision))) == wire(revision) + assert await app.contents.read_bytes(revision.content) == original + import msg.bootstrap as bootstrap + from msg.application import Application + monkeypatch.setattr(bootstrap, 'SOURCE_RETIREMENTS', declaration) + monkeypatch.setattr(bootstrap, 'system_source_root', lambda: source) + reloaded = Application(app.settings, clock=lambda: NOW) + try: + await reloaded.load() + async with reloaded.metadata.transaction(write=False) as tx: + assert (await tx.resource(before.id)).state == 'archived' + finally: + await reloaded.close() + read = await call(app, 'discovery.get', {'id': before.id, 'revision': before.revision}) + assert read.status == 'ok', wire(read) + assert read.data['content'].encode() == original + key, subject, _ = await register(app, 'retirement-writer') + for operation, args in ( + ('content.chmod', {'id': before.id, 'mode': '0777'}), + ('content.post_edit', {'id': before.id, 'expected_revision': before.revision, 'body': 'replace'}), + ): + result = await call(app, operation, args, key=key, subject=subject) + assert result.error.code == 'system_managed_resource', wire(result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mismatch', ['version', 'digest', 'source_path']) +async def test_retirement_must_pin_current_release_metadata(installed, tmp_path, mismatch): + app, _ = installed + source, declaration = await retirement(app, tmp_path) + declaration['msg.recovery'][mismatch] = {'version': 999, 'digest': 'sha256:' + '0' * 64, + 'source_path': 'rules/wrong.md'}[mismatch] + with pytest.raises(Failure, match='^system_source_retirement_mismatch$'): + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=app.registry) + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource('r_rule_recovery')).state == 'active' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dependency', ['operation', 'index', 'source', 'registry_missing']) +async def test_retirement_rejects_active_dependencies(installed, tmp_path, dependency): + app, _ = installed + source, declaration = await retirement(app, tmp_path) + registry = app.registry + if dependency == 'operation': + key = ('discovery.get', 1) + registry._operations[key] = replace(registry.operation(*key), requires_rules=('msg.recovery',)) + elif dependency == 'index': + with (source / 'rules/_index.md').open('a') as handle: + handle.write('\n[Recovery](/_rules/recovery)\n') + elif dependency == 'source': + with (source / 'rules/security.md').open('a') as handle: + handle.write('\n\n') + else: + registry = None + with pytest.raises(Failure, match='^system_source_retirement_referenced$'): + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=registry) + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource('r_rule_recovery')).state == 'active' + + +@pytest.mark.asyncio +async def test_fresh_install_does_not_invent_retired_history(installed, tmp_path, pg_dsn, monkeypatch): + import msg.bootstrap as bootstrap + from msg.admin.root import _provision + from msg.application import Application + from msg.config import write_example + + source, declaration = await retirement(installed[0], tmp_path) + monkeypatch.setattr(bootstrap, 'SOURCE_RETIREMENTS', declaration) + monkeypatch.setattr(bootstrap, 'system_source_root', lambda: source) + app = Application(write_example(tmp_path / 'fresh-config', tmp_path / 'fresh-data', + postgres_dsn=pg_dsn), clock=lambda: NOW) + try: + await _provision(app, 'test-retirement-passphrase') + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT id FROM resources WHERE id=?', ('r_rule_recovery',)) is None + assert tx.one('SELECT resource_id FROM system_sources WHERE rule_id=?', ('msg.recovery',)) is None + assert tx.one('SELECT id FROM resources WHERE id=?', ('r_rule_security',)) is not None + finally: + await app.close() + + +def test_registry_freeze_rejects_dependency_on_release_retired_rule(monkeypatch): + import msg.bootstrap as bootstrap + from test_registry_operation_rules import assemble + + monkeypatch.setattr(bootstrap, 'SOURCE_RETIREMENTS', {'msg.recovery': {}}) + registry = assemble('recovery.custom', requires_rules=('msg.recovery',)) + with pytest.raises(Failure, match='^dangling_requires_rules$'): + registry.freeze() + assert not registry.frozen + + +@pytest.mark.asyncio +async def test_retirement_cannot_implicitly_reactivate_archived_source(installed, tmp_path): + app, _ = installed + source, declaration = await retirement(app, tmp_path) + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=app.registry) + shutil.copyfile(system_source_root() / 'rules/recovery.md', source / 'rules/recovery.md') + with pytest.raises(Failure, match='^system_source_retirement_required$'): + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + registry=app.registry) + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource('r_rule_recovery')).state == 'archived' + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('change', 'code'), [ + ('unknown', 'system_source_invalid_retirement'), + ('mandatory', 'system_source_invalid_retirement'), + ('malformed', 'system_source_invalid_retirement'), + ('source_present', 'system_source_retirement_source_present'), + ('pointer_drift', 'system_source_pointer_drift'), +]) +async def test_retirement_refuses_invalid_declarations_or_source_drift(installed, tmp_path, change, code): + app, _ = installed + source, declaration = await retirement(app, tmp_path) + if change == 'unknown': + declaration['msg.unknown'] = declaration.pop('msg.recovery') + elif change == 'mandatory': + declaration['msg.rules.index'] = declaration.pop('msg.recovery') + elif change == 'malformed': + declaration['msg.recovery']['version'] = True + elif change == 'source_present': + shutil.copyfile(system_source_root() / 'rules/recovery.md', source / 'rules/recovery.md') + elif change == 'pointer_drift': + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource('r_rule_recovery') + await tx.replace(replace(resource, revision=None, generation=resource.generation+1), + resource.generation) + with pytest.raises(Failure, match='^' + code + '$'): + async with app.metadata.transaction(write=True) as tx: + await sync_system_sources(tx, app.contents, NOW, source_root=source, + retirements=declaration, registry=app.registry) diff --git a/tests/test_task_a_recovery_checkpoint.py b/tests/test_task_a_recovery_checkpoint.py index 4fe1c0c4..77e3fa32 100644 --- a/tests/test_task_a_recovery_checkpoint.py +++ b/tests/test_task_a_recovery_checkpoint.py @@ -1,4 +1,4 @@ -"""Independent checkpoint pins, not database flags, authorize deny-only replay.""" +"""Independent checkpoint pins, not database flags, authorize partial authority replay.""" import asyncio from copy import deepcopy from dataclasses import replace @@ -89,7 +89,10 @@ async def test_replay_is_atomic_monotonic_idempotent_and_never_promotes(restored body, pin, signed = checkpoint results = await asyncio.gather(*(replay(restored, signed(body), pin=pin) for _ in range(3))) assert sum(result['changed'] for result in results) == 1 - assert all(result['promotion'] == 'blocked' and result['backup_retired'] is False for result in results) + assert all(result['promotion'] == 'blocked' and result['backup_retired'] is False and + result['promotion_blocked_reasons'] == [ + 'quarantine_remains', 'supported_fact_inventory_incomplete', + 'backup_retirement_not_attested'] for result in results) async with restored.transaction(write=False) as tx: assert active(tx) credential = await tx.credential('token_one') @@ -286,10 +289,13 @@ async def test_all_supported_owned_revocations_preserve_signed_bytes_and_never_i tx.execute('''INSERT INTO topic_bans (topic,subject,actor,created_at,expires_at,reason,status) VALUES (?,?,?,?,NULL,?,?)''', (resource.id, owner, owner, wire(NOW), 'restored-ban', 'active'), write=True) + banned = replace(resource, id='t_ban', name='ban', parent=resource.id) + await tx.insert(banned) targets = [('credential.revoke', 'token_one'), ('certificate.revoke', 'cert_one'), ('share_grant.revoke', 'sg_one'), ('share_grant_v2.revoke', 'sg_two'), ('share_link.revoke', 'link_one'), ('membership.remove', 'g_test'), - ('topic_membership.remove', 't_test'), ('topic_ban.lift', resource.id), + ('topic_membership.remove', 't_test'), ('topic_ban.apply', 't_ban'), + ('topic_ban.lift', resource.id), ('identity_key.retire', signer.key_id), ('encryption_key.retire', age_id), ('vault.destroy', age_id)] assert {kind for kind, _ in targets} == SUPPORTED_FACTS @@ -299,6 +305,8 @@ async def test_all_supported_owned_revocations_preserve_signed_bytes_and_never_i pin = replace(pin, sequence=len(facts), digest=digest(body)) result = await replay(restored, signed(body), pin=pin) assert result['changed'] == len(facts) and result['backup_retired'] is False + assert result['promotion_blocked_reasons'] == [ + 'quarantine_remains', 'supported_fact_inventory_incomplete', 'backup_retirement_not_attested'] async with restored.transaction(write=False) as tx: assert tx.one('SELECT body,revoked FROM certificates WHERE id=?', ('cert_one',)) == (original, 1) assert (await tx.credential(signer.key_id)).revoked_at == NOW @@ -314,6 +322,8 @@ async def test_all_supported_owned_revocations_preserve_signed_bytes_and_never_i ('t_test', owner)) == ('removed',) assert tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', ('t_test', owner)) == ('lifted',) + assert tx.one('SELECT status,actor,reason FROM topic_bans WHERE topic=? AND subject=?', + ('t_ban', owner)) == ('active', 'u_root', 'recovery-replay') assert tx.one('SELECT status,signing_nonce,signing_ciphertext,age_nonce,age_ciphertext ' 'FROM custodial_vault WHERE subject=?', (owner,)) == ('destroyed', None, None, None, None) assert tx.one('SELECT count(*) FROM jobs')[0] == 0 and active(tx) @@ -344,3 +354,33 @@ def test_packaged_schema_and_example_match_the_replay_contract(): with pytest.raises(Failure, match='^recovery_checkpoint_pin_required$'): verify_checkpoint(example['packet'], pin=None) assert list(validator.iter_errors({})) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('lift', [False, True]) +async def test_replayed_ban_removes_restored_admin_even_after_lift(restored, checkpoint, lift): + from msg.core.models import Resource + body, pin, signed = checkpoint + resource = Resource(id='t_old_admin', type='topic', type_version=1, name='old-admin', + parent=None, owner='u_owner', group='g_public', mode=0o700, generation=1, + revision=None, state='active', created_at=NOW, created_by='u_owner', + modified_at=NOW, modified_by='u_owner') + async with restored.transaction(write=True) as tx: + await tx.insert(resource) + tx.execute('INSERT INTO topic_memberships VALUES (?,?,?,?,?,NULL)', + (resource.id, 'u_owner', 'admin', 'active', wire(NOW)), write=True) + kinds = ['topic_ban.apply'] + (['topic_ban.lift'] if lift else []) + facts = [{'sequence': i, 'kind': kind, 'subject': 'u_owner', + 'target': resource.id, 'at': wire(NOW)} for i, kind in enumerate(kinds, 1)] + body = dict(body, entries=facts, sequence=len(facts)) + pin = replace(pin, digest=digest(body), sequence=len(facts)) + for _ in range(2): + receipt = await replay(restored, signed(body), pin=pin) + assert receipt['scope'] == 'supported_authority_reconciliation_facts' + assert 'supported_fact_inventory_incomplete' in receipt['promotion_blocked_reasons'] + async with restored.transaction(write=False) as tx: + assert tx.one('SELECT role,status FROM topic_memberships WHERE topic=? AND subject=?', + (resource.id, 'u_owner')) == ('member', 'removed') + assert tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', + (resource.id, 'u_owner')) == ('lifted' if lift else 'active',) + assert active(tx) diff --git a/tests/test_tool_feature_selftest.py b/tests/test_tool_feature_selftest.py new file mode 100644 index 00000000..0bfdf162 --- /dev/null +++ b/tests/test_tool_feature_selftest.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace + +import pytest + +from msg.admin.tool_check import inspect_tool_sandbox +from msg.application import Application +from msg.config import write_example +from msg.core.errors import Failure + + +@pytest.mark.asyncio +async def test_doctor_exercises_real_bubblewrap_deny_path(tmp_path): + app = Application(write_example(tmp_path/'etc', tmp_path/'data')) + assert await inspect_tool_sandbox(app) == { + 'runner': 'bubblewrap', 'policy_denial': True, 'external_requests': 0} + + +@pytest.mark.asyncio +async def test_missing_sandbox_is_not_reported_ready(tmp_path, monkeypatch): + app = Application(write_example(tmp_path/'etc', tmp_path/'data')) + monkeypatch.setattr('msg.workers.sandbox.shutil.which', lambda _: None) + with pytest.raises(Failure, match='tool_isolation_unavailable'): + await inspect_tool_sandbox(app) + + +@pytest.mark.asyncio +async def test_tool_selftest_rejects_live_application(): + from msg.admin.tool_check import check_tool_sandbox + with pytest.raises(Failure, match='selftest_namespace_required'): + await check_tool_sandbox(SimpleNamespace(selftest_run_id=None), None, None, None, None) + + +@pytest.mark.asyncio +async def test_tool_selftest_distinguishes_runner_output_from_operation_denial(installed, monkeypatch): + from test_authorization import approve + from test_service import call, register + + from msg.admin.tool_check import check_tool_sandbox + app, root = installed + monkeypatch.setattr(app, 'selftest_run_id', 'isolated-tool-fixture') + key, subject, _ = await register(app, 'tool-selftest') + async def invoke(name, arguments, signer, uid, certificates): + return await call(app, name, arguments, key=signer, subject=uid, certs=certificates) + async def certify(signer, uid, grants): + return await approve(app, root, uid, signer, grants) + result = await check_tool_sandbox(app, invoke, certify, key, subject) + assert result == {'operation': 'private_target_denied', + 'runner': 'loopback_output_and_policy_denial', + 'normal_operation_pipeline': 'not_exercised', 'external_requests': 0} + + +def test_on_demand_feature_samples_are_explicitly_empty(): + from msg.bootstrap import feature_manifest + rows = {row['feature_id']: row for row in feature_manifest()} + for name in ('content_editing', 'private_dm', 'transfer', 'following', 'collaboration'): + assert rows[name]['sample_resource'] is None + assert rows[name]['default_config'] == { + 'sample_state': 'empty', 'sample_creation': 'isolated_selftest'} diff --git a/tests/test_tool_sandbox_acceptance.py b/tests/test_tool_sandbox_acceptance.py new file mode 100644 index 00000000..adf4e622 --- /dev/null +++ b/tests/test_tool_sandbox_acceptance.py @@ -0,0 +1,62 @@ +"""Real bubblewrap child enforces network policy without contacting a peer.""" +from types import SimpleNamespace + +import pytest + +from msg.core.errors import Failure +from msg.core.models import NetworkPolicy +from msg.workers.sandbox import BubblewrapRunner + + +@pytest.mark.asyncio +@pytest.mark.parametrize('url', ['http://127.0.0.1:9/', 'http://169.254.169.254/', + 'file:///etc/passwd', 'http://[::1]:9/']) +async def test_real_sandbox_denies_private_or_non_http_target(tmp_path, url): + app = SimpleNamespace(settings=SimpleNamespace(server=SimpleNamespace( + limits=SimpleNamespace(max_request_bytes=1024)))) + tool = SimpleNamespace(executor_key='curl') + with pytest.raises(Failure) as caught: + await BubblewrapRunner(app)(tool, {'url': url}, (NetworkPolicy(schemes=frozenset({'http', 'https'}), hosts=(), + ports=frozenset({9, 80, 443}), methods=frozenset({'GET'}), allow_private=False, + timeout_ms=1000, max_response_bytes=1024, max_redirects=0),), tmp_path) + assert caught.value.code == 'network_policy_denied' + assert not (tmp_path / 'output.bin').exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('body', [b'test', b'oversized']) +async def test_real_sandbox_explicit_local_grant_returns_bounded_bytes(tmp_path, body): + import asyncio + + requests = [] + + async def receive(reader, writer): + requests.append(await reader.readuntil(b'\r\n\r\n')) + writer.write(f'HTTP/1.1 200 OK\r\nContent-Length: {len(body)}\r\n'.encode() + + b'Content-Type: text/plain\r\nConnection: close\r\n\r\n' + body) + await writer.drain() + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(receive, '127.0.0.1', 0) + async with server: + port = server.sockets[0].getsockname()[1] + app = SimpleNamespace(settings=SimpleNamespace(server=SimpleNamespace( + limits=SimpleNamespace(max_request_bytes=1024)))) + policy = NetworkPolicy(schemes=frozenset({'http'}), hosts=('127.0.0.1',), + ports=frozenset({port}), methods=frozenset({'GET'}), allow_private=True, + timeout_ms=3000, max_response_bytes=4, max_redirects=0) + invocation = BubblewrapRunner(app)(SimpleNamespace(executor_key='curl'), + {'url': f'http://127.0.0.1:{port}/fixture'}, (policy,), tmp_path) + if len(body) > policy.max_response_bytes: + with pytest.raises(Failure, match='tool_response_too_large'): + await invocation + assert not (tmp_path / 'output.bin').exists() + assert len(requests) == 1 + return + result = await invocation + assert result.path == tmp_path / 'output.bin' + assert result.path.read_bytes() == b'test' + assert result.metadata['status'] == 200 + assert len(requests) == 1 + assert requests[0].startswith(b'GET /fixture HTTP/1.1\r\n') diff --git a/tests/test_tools.py b/tests/test_tools.py index 2a3a6100..25ea8ec7 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -4,9 +4,11 @@ import pytest import httpx -from msg.core.codec import wire,canonical,digest +from msg.application import Application +from msg.core.codec import wire,canonical,digest,loads from msg.core.errors import Failure -from msg.core.models import Scope,NetworkPolicy,ResourceRef +from msg.core.models import Scope,NetworkPolicy,ResourceRef,Revision +from msg.plugins.common import new_id from msg.security.network import validate_url,validate_addresses,intersect_policy from msg.workers.effects import EffectWorker,ToolResult from msg.extensions.tools import descriptor, read_tool @@ -121,6 +123,152 @@ async def runner(tool,args,policies,outdir): assert (await tx.job(result.data['job_id'])).state=='uncertain' +async def _claimed_dns_job(app,root,handle): + key,uid,_=await register(app,handle) + cert=await approve(app,root,uid,key,(scoped(app,'tool.use','tool_dns',app.registry.capability('tool.use').operations),)) + result=await call(app,'tool.run',{'id':'/tools/dns','arguments':{'name':'example.org','type':'A'}}, + key=key,subject=uid,certs=(cert.resource_id,)) + assert result.status=='accepted',wire(result) + async with app.metadata.transaction(write=False) as tx: + return await tx.job(result.data['job_id']) + + +async def _enqueue_like(app,template,seconds_ago,**changes): + job_id=new_id('job') + job=replace(template,id=job_id,dedupe_key='test:'+job_id,state='pending',attempts=0,lease_until=None, + next_attempt_at=NOW-timedelta(seconds=seconds_ago),**changes) + async with app.metadata.transaction(write=True) as tx: + await tx.enqueue(job) + return job + + +async def _tool_ref(app,rid): + async with app.metadata.transaction(write=False) as tx: + return wire(ResourceRef(id=rid,revision=(await tx.resource(rid)).revision)) + + +async def _dns_revision_declaring(app,concurrency): + async with app.metadata.transaction(write=True) as tx: + resource=await tx.resource('tool_dns') + blob=await app.contents.put_bytes(canonical({**descriptor('dns'),'concurrency':concurrency}),'application/json') + revision=Revision(format_version=1,id=new_id('v'),resource_id=resource.id,parents=(resource.revision,), + content=blob,relations=(),actor=resource.owner,subject=resource.owner,author=resource.owner, + created_at=app.clock(),manifest_digest='') + revision=replace(revision,manifest_digest=digest({k:v for k,v in wire(revision).items() + if k not in {'manifest_digest','signature'}})) + await app.contents.pin(blob,revision.id) + await app.contents.commit_revision(resource.parent,revision) + await tx.append_revision(revision) + return wire(ResourceRef(id=resource.id,revision=revision.id)) + + +async def _state(app,job_id): + async with app.metadata.transaction(write=False) as tx: + return (await tx.job(job_id)).state + + +@pytest.mark.asyncio +async def test_historical_tool_revision_bytes_have_no_concurrency_and_mean_one(installed): + app,_=installed + assert 'concurrency' not in descriptor('dns') + async with app.metadata.transaction(write=False) as tx: + rev=await tx.revision(ResourceRef(id='tool_dns')) + stored=await app.contents.read_bytes(rev.content,limit=65536) + assert stored==canonical(descriptor('dns')) + assert 'concurrency' not in loads(stored) + + +@pytest.mark.asyncio +async def test_same_tool_is_not_claimed_by_a_second_worker_while_other_jobs_proceed(installed): + app,root=installed + held=await _claimed_dns_job(app,root,'concurrency-holder') + first=EffectWorker(app,tool_runner=lambda *a:None) + claimed,execute=await first._claim() + assert execute and claimed.id==held.id + same_tool=await _enqueue_like(app,held,3) + other_tool=await _enqueue_like(app,held,2,arguments={**held.arguments,'tool':await _tool_ref(app,'tool_curl')}) + non_tool=await _enqueue_like(app,held,1,kind='mail',operation='communication.send', + arguments={'recipient_subject':held.principal.subject}) + peer=await Application(app.settings,clock=lambda:NOW).load() + try: + second=EffectWorker(peer,tool_runner=lambda *a:None) + order=[] + for _ in range(2): + job,execute=await second._claim() + assert execute + order.append(job.id) + assert order==[other_tool.id,non_tool.id] + assert await second._claim()==(None,False) + assert await _state(app,same_tool.id)=='pending' + await first._finish(claimed,'done','ok') + job,execute=await second._claim() + assert execute and job.id==same_tool.id + finally: + await peer.close() + + +@pytest.mark.asyncio +async def test_explicit_tool_concurrency_admits_up_to_the_declared_limit(installed): + app,root=installed + template=await _claimed_dns_job(app,root,'concurrency-two') + ref=await _dns_revision_declaring(app,2) + async with app.metadata.transaction(write=False) as tx: + assert (await read_tool(app,tx,'tool_dns',ref['revision'])).resource.revision==ref['revision'] + async with app.metadata.transaction(write=True) as tx: + await tx.save_job(replace(template,state='done')) + jobs=[await _enqueue_like(app,template,3-i,arguments={**template.arguments,'tool':ref}) for i in range(3)] + peer=await Application(app.settings,clock=lambda:NOW).load() + try: + workers=(EffectWorker(app,tool_runner=lambda *a:None),EffectWorker(peer,tool_runner=lambda *a:None)) + for worker,job in zip(workers,jobs): + claimed,execute=await worker._claim() + assert execute and claimed.id==job.id + for worker in workers: + assert await worker._claim()==(None,False) + assert await _state(app,jobs[2].id)=='pending' + finally: + await peer.close() + + +@pytest.mark.asyncio +async def test_historical_limit_counts_running_jobs_across_revisions_of_one_tool(installed): + app,root=installed + template=await _claimed_dns_job(app,root,'concurrency-mixed') + ref=await _dns_revision_declaring(app,2) + worker=EffectWorker(app,tool_runner=lambda *a:None) + claimed,_=await worker._claim() + assert claimed.id==template.id + historical=await _enqueue_like(app,template,2) + declared=await _enqueue_like(app,template,1,arguments={**template.arguments,'tool':ref}) + job,execute=await worker._claim() + assert execute and job.id==declared.id + assert await worker._claim()==(None,False) + assert await _state(app,historical.id)=='pending' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('declared',[0,33,-1,True,'2',1.5,None]) +async def test_invalid_declared_tool_concurrency_is_rejected_not_unbounded(installed,declared): + app,root=installed + template=await _claimed_dns_job(app,root,'concurrency-invalid') + ref=await _dns_revision_declaring(app,declared) + async with app.metadata.transaction(write=False) as tx: + with pytest.raises(Failure,match='invalid_tool_concurrency'): + await read_tool(app,tx,'tool_dns',ref['revision']) + async with app.metadata.transaction(write=True) as tx: + await tx.save_job(replace(template,state='done')) + bad=await _enqueue_like(app,template,1,arguments={**template.arguments,'tool':ref}) + ran=[] + async def runner(*args):ran.append(args) + worker=EffectWorker(app,tool_runner=runner) + job,execute=await worker._claim() + assert job.id==bad.id and not execute + assert await _state(app,bad.id)=='failed' and not ran + async with app.metadata.transaction(write=False) as tx: + assert tx.setting('job_status:'+bad.id)=={'code':'invalid_tool_concurrency'} + assert await worker._claim()==(None,False) + + @pytest.mark.asyncio async def test_tool_run_is_the_only_public_operation_and_old_short_code_is_retired(installed): app, _ = installed diff --git a/tests/test_transfer_selftest.py b/tests/test_transfer_selftest.py new file mode 100644 index 00000000..d3f8f668 --- /dev/null +++ b/tests/test_transfer_selftest.py @@ -0,0 +1,38 @@ +"""The transfer selftest checks real storage and refuses an ordinary app.""" +from types import SimpleNamespace + +import pytest +from test_service import call, register + +from msg.admin.transfer_check import check_transfer +from msg.core.errors import Failure + + +async def test_transfer_selftest_refuses_nonisolated_application(): + with pytest.raises(Failure, match='selftest_namespace_required'): + await check_transfer(SimpleNamespace(selftest_run_id=None), None, None) + + +@pytest.mark.parametrize('read_mutation', [False, True]) +async def test_transfer_selftest_real_upload_download_and_readonly_boundaries( + installed, monkeypatch, read_mutation): + app, _ = installed + # installed creates a disposable PostgreSQL database and content directory. + monkeypatch.setattr(app, 'selftest_run_id', 'isolated-transfer-fixture') + + async def invoke(operation, arguments, key=None, subject=None): + result = await call(app, operation, arguments, key=key, subject=subject) + if read_mutation and operation == 'transfer.part_get' and result.status == 'ok': + async with app.metadata.transaction(write=True) as tx: + tx.set_setting('selftest-read-mutation', True) + return result + + async def enroll(name): + key, subject, _ = await register(app, name) + return key, subject + + if read_mutation: + with pytest.raises(Failure, match='selftest_transfer_read_mutated'): + await check_transfer(app, invoke, enroll) + else: + assert await check_transfer(app, invoke, enroll) is True diff --git a/tests/test_transfer_status_cursors.py b/tests/test_transfer_status_cursors.py new file mode 100644 index 00000000..3c433269 --- /dev/null +++ b/tests/test_transfer_status_cursors.py @@ -0,0 +1,168 @@ +"""Transfer continuation errors stay bounded, authenticated and read-only.""" +from dataclasses import replace +from datetime import timedelta + +import httpx +import pytest + +from msg.core.codec import b64, canonical, digest, wire +from msg.core.requests import request_for +from msg.transports.client import ( + GraphQLTransport, HTTPTransport, MCPHTTPTransport, PathGETTransport, +) +from msg.transports.http import create_app +from read_only_evidence import readonly_evidence +from test_service import NOW, call, register + + +INVALID_CURSORS = ('-1', str(2**63), 'offset-sentinel', '', '0.5', + '1e3', ' 0', '0 ', '+0', '\u0661', '9' * 64) +TRANSPORTS = (HTTPTransport, PathGETTransport, GraphQLTransport, MCPHTTPTransport) + + +async def prepared_transfer(app, kind): + key, subject, cert = await register(app, 'cursor-owner') + + async def invoke(operation, arguments): + return await call(app, operation, arguments, key=key, subject=subject, certs=(cert,)) + + args = {'direction': 'upload'} + if kind != 'unknown': + args['size'] = 7 + opened = await invoke('transfer.open', args) + assert opened.status == 'ok', wire(opened) + transfer_id = opened.data['transfer_id'] + if kind in {'sealed', 'download'}: + data = b'1234567' + put = await invoke('transfer.part_put', { + 'transfer_id': transfer_id, 'offset': 0, 'data': b64(data), 'digest': digest(data)}) + assert put.status == 'ok', wire(put) + sealed = await invoke('transfer.seal', { + 'transfer_id': transfer_id, 'final_size': len(data), 'final_digest': digest(data)}) + assert sealed.status == 'ok', wire(sealed) + if kind == 'download': + opened = await invoke('transfer.open', { + 'direction': 'download', 'target': wire(sealed.resources[0])}) + assert opened.status == 'ok', wire(opened) + transfer_id = opened.data['transfer_id'] + elif kind == 'cancelled': + cancelled = await invoke('transfer.cancel', {'transfer_id': transfer_id}) + assert cancelled.status == 'ok', wire(cancelled) + return key, subject, cert, transfer_id, invoke + + +def status_packet(app, key, subject, cert, arguments): + return request_for('transfer.status', arguments, app.settings.service_url, + signer=key, subject=subject, certificates=(cert,), + expires_at=NOW + timedelta(seconds=120)) + + +@pytest.mark.parametrize('kind', ['known', 'unknown', 'sealed', 'download', 'cancelled']) +async def test_status_rejects_invalid_cursors_before_any_numeric_backend_use(installed, monkeypatch, kind): + app, _ = installed + _, _, _, tid, invoke = await prepared_transfer(app, kind) + async with readonly_evidence(app, monkeypatch): + for cursor in INVALID_CURSORS: + result = await invoke('transfer.status', {'transfer_id': tid, 'cursor': cursor}) + assert result.status == 'error', (kind, cursor, wire(result)) + assert result.error.code == 'invalid_cursor', (kind, cursor, wire(result)) + assert 'offset-sentinel' not in canonical(result).decode() + + +@pytest.mark.parametrize('transport_class', TRANSPORTS) +async def test_status_cursor_errors_match_real_protocol_adapters(installed, monkeypatch, transport_class): + app, _ = installed + key, subject, cert, tid, _ = await prepared_transfer(app, 'unknown') + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + transport = transport_class(app.settings.service_url, http=http) + async with readonly_evidence(app, monkeypatch): + initial = await transport.call(status_packet(app, key, subject, cert, {'transfer_id': tid})) + assert initial.status == 'ok', wire(initial) + for cursor in INVALID_CURSORS: + result = await transport.call(status_packet(app, key, subject, cert, + {'transfer_id': tid, 'cursor': cursor})) + assert result.status == 'error', (cursor, wire(result)) + assert result.error.code == 'invalid_cursor', (cursor, wire(result)) + + +@pytest.mark.parametrize('known_size', [True, False]) +async def test_status_pagination_preserves_all_ranges_across_protocols(installed, monkeypatch, known_size): + app, _ = installed + key, subject, cert, tid, invoke = await prepared_transfer(app, 'known' if known_size else 'unknown') + for offset in (5, 1, 3): + data = bytes([offset]) + put = await invoke('transfer.part_put', { + 'transfer_id': tid, 'offset': offset, 'data': b64(data), 'digest': digest(data)}) + assert put.status == 'ok', wire(put) + expected = [(0, 1), (2, 3), (4, 5), (6, 7)] if known_size else [(1, 2), (3, 4), (5, 6)] + field = 'missing' if known_size else 'received' + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + async with readonly_evidence(app, monkeypatch): + for transport_class in TRANSPORTS: + transport = transport_class(app.settings.service_url, http=http) + args = {'transfer_id': tid, 'limit': 1} + seen, cursors = [], set() + for index in range(len(expected)): + result = await transport.call(status_packet(app, key, subject, cert, args)) + assert result.status == 'ok', wire(result) + page = [tuple(item) for item in result.data[field]] + assert page == [expected[index]] + seen.extend(page) + # Compact wire responses omit null values on the last page. + cursor = result.data.get('next_cursor') + expected_cursor = str(expected[index + 1][0]) if index + 1 < len(expected) else None + assert cursor == expected_cursor + if cursor is None: + break + assert cursor not in cursors + cursors.add(cursor) + args = {**args, 'cursor': cursor} + assert seen == expected, (transport_class.name, seen) + assert cursor is None + # Preserve the largest supported byte offset and leading-zero + # numeric spelling without allowing larger-than-int64 values. + end = '7' if known_size else str(2**63 - 1) + result = await invoke('transfer.status', {'transfer_id': tid, 'cursor': end}) + assert result.status == 'ok' and not result.data[field], wire(result) + zero = await invoke('transfer.status', {'transfer_id': tid, 'cursor': '000'}) + assert zero.status == 'ok', wire(zero) + assert [tuple(item) for item in zero.data[field]] == expected + + +@pytest.mark.parametrize('kind', ['known', 'sealed', 'download']) +async def test_fixed_status_endpoint_rejects_cursor_beyond_known_size(installed, monkeypatch, kind): + app, _ = installed + key, subject, cert, tid, _ = await prepared_transfer(app, kind) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url=app.settings.service_url) as http: + async with readonly_evidence(app, monkeypatch): + for cursor in ('0', '7'): + response = await http.post('/-/transfer', content=canonical(status_packet( + app, key, subject, cert, {'transfer_id': tid, 'cursor': cursor}))) + assert response.status_code == 200, response.text + assert response.json()['status'] == 'ok', response.text + rejected = await http.post('/-/transfer', content=canonical(status_packet( + app, key, subject, cert, {'transfer_id': tid, 'cursor': '8'}))) + assert rejected.status_code == 400, rejected.text + assert rejected.json()['error']['code'] == 'invalid_cursor', rejected.text + + +async def test_status_cursor_validation_does_not_replace_current_identity_checks(installed, monkeypatch): + app, _ = installed + key, subject, cert, tid, invoke = await prepared_transfer(app, 'unknown') + other, other_subject, other_cert = await register(app, 'cursor-other') + args = {'transfer_id': tid, 'cursor': 'offset-sentinel'} + async with readonly_evidence(app, monkeypatch): + denied = await call(app, 'transfer.status', args, key=other, + subject=other_subject, certs=(other_cert,)) + assert denied.error.code == 'transfer_owner_required', wire(denied) + async with app.metadata.transaction(write=True) as tx: + credential = await tx.credential(key.key_id) + tx.execute('UPDATE credentials SET body=? WHERE id=?', + (canonical(replace(credential, revoked_at=NOW)).decode(), credential.id), write=True) + async with readonly_evidence(app, monkeypatch): + for cursor in ('0', 'offset-sentinel'): + revoked = await invoke('transfer.status', {'transfer_id': tid, 'cursor': cursor}) + assert revoked.error.code == 'credential_revoked', wire(revoked) diff --git a/tests/test_trust_path_compatibility.py b/tests/test_trust_path_compatibility.py new file mode 100644 index 00000000..537eae29 --- /dev/null +++ b/tests/test_trust_path_compatibility.py @@ -0,0 +1,136 @@ +import pytest +from msg.config import load_settings, write_example +from msg.core.codec import canonical, loads +from msg.core.errors import Failure +from msg.application import Application + + +def test_new_layout_and_legacy_configuration(tmp_path): + write_example(tmp_path, tmp_path/'data') + settings=load_settings(tmp_path) + assert settings.trust_file == tmp_path/'trust'/'root.crt' + assert (tmp_path/'plugins.d').is_dir() + # Reserved contents are inert, including Python source; no fragment syntax exists. + (tmp_path/'plugins.d'/'untrusted.py').write_text("raise RuntimeError('must not execute')\n") + load_settings(tmp_path) + (tmp_path/'plugins.d'/'untrusted.py').unlink() + (tmp_path/'plugins.d').rmdir() + assert load_settings(tmp_path).config_dir==tmp_path + assert not (tmp_path/'plugins.d').exists() + settings.trust_file.parent.mkdir() + legacy=tmp_path/'trust'/'root.json' + legacy.write_bytes(b'{"version":1}') + assert load_settings(tmp_path).trust_file == legacy + modern=legacy.with_name('root.crt') + modern.write_bytes(b'{ "version" : 1 }\n') + assert settings.trust_file == modern + modern.write_bytes(b'{"version":2}') + with pytest.raises(Failure,match='root_trust_alias_mismatch'): + settings.trust_file + assert legacy.read_bytes()==b'{"version":1}' + + +@pytest.mark.parametrize('unsafe',['symlink','writable']) +def test_reserved_plugin_directory_rejects_unsafe_paths(tmp_path,unsafe): + write_example(tmp_path,tmp_path/'data') + directory=tmp_path/'plugins.d' + directory.rmdir() + if unsafe=='symlink': + destination=tmp_path/'elsewhere'; destination.mkdir() + directory.symlink_to(destination,target_is_directory=True) + else: + directory.mkdir(); directory.chmod(0o777) + with pytest.raises(Failure,match='unsafe_plugins_directory'): + load_settings(tmp_path) + + +@pytest.mark.asyncio +async def test_legacy_root_json_really_loads_and_conflict_refuses(installed): + app,root=installed + modern=app.settings.trust_file + assert modern.name=='root.crt' + legacy=modern.with_name('root.json') + modern.rename(legacy) + await app.load() + assert app.certificates.root_public_key==root.public_key + modern.write_bytes(legacy.read_bytes()) + await app.load() + changed=loads(modern.read_bytes()); changed['public_key']='changed' + modern.write_bytes(canonical(changed)) + with pytest.raises(Failure,match='root_trust_alias_mismatch'): + await app.load() + + +@pytest.mark.asyncio +async def test_dual_alias_rotation_interruption_is_closed_and_resumable(installed,monkeypatch): + from msg.admin import rotation + from msg.security.crypto import Ed25519Signer + from test_root_rotation_resume import old_envelope, NEW_PIN + app,root=installed + old_envelope(app,root) + modern=app.settings.trust_file + legacy=modern.with_name('root.json'); legacy.write_bytes(modern.read_bytes()) + successor=Ed25519Signer.generate() + journal=rotation.prepare(app,successor,NEW_PIN,old_signer=root,operator='test') + real_write=rotation.durable_write + def interrupt(path,data,**kwargs): + if path==legacy: raise OSError('alias interruption') + return real_write(path,data,**kwargs) + with monkeypatch.context() as patch: + patch.setattr(rotation,'durable_write',interrupt) + with pytest.raises(OSError,match='alias interruption'): + await rotation.complete(app,journal,pin=NEW_PIN) + assert rotation.journal_path(app).exists() + with pytest.raises(Failure,match='root_trust_alias_mismatch'): + await app.load() + fresh=Application(app.settings,clock=app.clock) + try: + await rotation.complete(fresh,journal,pin=NEW_PIN) + assert modern.read_bytes()==legacy.read_bytes() + await fresh.load() + assert fresh.certificates.root_public_key==successor.public_key + finally: + await fresh.close() + + +@pytest.mark.parametrize('alias',['root.crt','root.json']) +def test_trust_alias_symlinks_fail_closed(tmp_path,alias): + write_example(tmp_path,tmp_path/'data') + directory=tmp_path/'trust'; directory.mkdir() + (directory/alias).symlink_to(tmp_path/'missing') + with pytest.raises(Failure,match='unsafe_root_trust_path'): + load_settings(tmp_path).trust_file + + +@pytest.mark.asyncio +async def test_rotation_refuses_unpinned_alias_before_database_commit(installed): + from msg.admin import rotation + from msg.security.crypto import Ed25519Signer + from test_root_rotation_resume import old_envelope, NEW_PIN + app,root=installed + old_envelope(app,root) + journal=rotation.prepare(app,Ed25519Signer.generate(),NEW_PIN,old_signer=root,operator='test') + modern=app.settings.trust_file + legacy=modern.with_name('root.json') + legacy.write_bytes(b'{"version":1,"public_key":"substituted"}') + with pytest.raises(Failure,match='rotation_trust_changed'): + await rotation.complete(app,journal,pin=NEW_PIN) + async with app.metadata.transaction(write=False) as tx: + assert tx.setting('active_root_certificate','cert_root')==journal['old_certificate']['resource_id'] + assert rotation.journal_path(app).exists() + + +@pytest.mark.asyncio +async def test_legacy_rotation_preserves_legacy_path(installed): + from msg.admin import rotation + from msg.security.crypto import Ed25519Signer + from test_root_rotation_resume import old_envelope, NEW_PIN + app,root=installed + old_envelope(app,root) + modern=app.settings.trust_file + legacy=modern.with_name('root.json'); modern.rename(legacy) + successor=Ed25519Signer.generate() + await rotation.complete(app,rotation.prepare(app,successor,NEW_PIN,old_signer=root,operator='test'),pin=NEW_PIN) + assert legacy.is_file() and not modern.exists() + await app.load() + assert app.certificates.root_public_key==successor.public_key diff --git a/tests/test_tui.py b/tests/test_tui.py index 708f923b..13288295 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -86,7 +86,10 @@ async def test_tui_real_pg_read_and_revocation_no_business_effect(installed, tmp ('resources', 'revisions', 'messages', 'events', 'reactions')) await ui.home() await ui.read(rid) + output.seek(0) + output.truncate(0) await ui.search('/main', 'TUI public') + assert 'TUI public text' in output.getvalue() await ui.thread(rid) async with app.metadata.transaction(write=False) as tx: after = tuple(tx.one(f'SELECT COUNT(*) FROM {table}')[0] for table in diff --git a/tests/test_tui_transport_boundaries.py b/tests/test_tui_transport_boundaries.py index bf1471cb..f359ae94 100644 --- a/tests/test_tui_transport_boundaries.py +++ b/tests/test_tui_transport_boundaries.py @@ -179,3 +179,11 @@ async def switch_during_read(operation, arguments): assert ui._last_read is None assert 'NEVER DISPLAY' not in ui.stdout.getvalue() assert len(client.calls) == 1 + + +@pytest.mark.parametrize('text', [7, {'nested': 'text'}, ['text'], None]) +def test_malformed_snippet_text_does_not_crash_or_display(text): + output = StringIO() + ui = TerminalUI(Client([]), stdout=output) + ui._show_items([{'id': 'r_item', 'snippet': {'text': text}}]) + assert output.getvalue().strip() == '1. r_item r_item' diff --git a/tests/test_watch_contracts.py b/tests/test_watch_contracts.py new file mode 100644 index 00000000..a1244937 --- /dev/null +++ b/tests/test_watch_contracts.py @@ -0,0 +1,146 @@ +from dataclasses import replace + +import pytest + +from msg.core.codec import wire +from test_service import call, register, NOW + + +@pytest.mark.asyncio +async def test_watch_events_current_acl_cancel_and_closed_query(installed): + app, _ = installed + ak, author, _ = await register(app, 'watch-author') + rk, reader, _ = await register(app, 'watch-reader') + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'one'}, key=ak, subject=author) + target = post.resources[0].id + args = {'target': target, 'event_types': ['content.post_edit'], 'delivery': 'inbox'} + watched = await call(app, 'communication.watch_create', args, key=rk, subject=reader) + assert watched.status == 'ok', wire(watched) + wid = watched.data['id'] + denied = await call(app, 'discovery.get', {'id': wid}, key=rk, subject=reader) + assert denied.status != 'ok' + changed = await call(app, 'content.post_edit', {'id': target, 'body': 'two', 'expected_revision': post.resources[0].revision}, key=ak, subject=author, expected=((target, post.data['generation']),)) + assert changed.status == 'ok', wire(changed) + inbox = await call(app, 'communication.inbox', {}, key=rk, subject=reader) + assert len(inbox.data['items']) == 1, wire(inbox) + assert set(inbox.data['items'][0]['resource']) == {'id'} + followed = await call(app, 'communication.watch', {'id': target}, key=rk, subject=reader) + assert followed.status == 'ok', wire(followed) + hidden = await call(app, 'content.chmod', {'id': target, 'mode': '0600'}, key=ak, subject=author, expected=((target, changed.data['generation']),)) + assert hidden.status == 'ok', wire(hidden) + unfollowed = await call(app, 'communication.unwatch', {'id': target}, key=rk, subject=reader) + assert unfollowed.status == 'ok', wire(unfollowed) + clipped = await call(app, 'communication.watch_get', {'id': wid}, key=rk, subject=reader) + assert clipped.status == 'ok' and 'target' not in clipped.data, wire(clipped) + inbox = await call(app, 'communication.inbox', {}, key=rk, subject=reader) + assert not inbox.data['items'] + cancelled = await call(app, 'communication.watch_cancel', {'id': wid}, key=rk, subject=reader) + assert cancelled.status == 'ok', wire(cancelled) + unsupported = await call(app, 'communication.watch_create', {'query_ref': 'anything', 'event_types': ['content.post_edit'], 'delivery': 'inbox'}, key=rk, subject=reader) + assert unsupported.error.code == 'watch_query_unsupported', wire(unsupported) + + +@pytest.mark.asyncio +async def test_follow_delivery_dedup_and_revoked_credential(installed): + app, _ = installed + ak, author, _ = await register(app, 'watch-owner2') + rk, reader, _ = await register(app, 'watch-reader2') + for _ in range(2): + followed = await call(app, 'communication.watch', {'id': '/main'}, key=rk, subject=reader) + assert followed.status == 'ok', wire(followed) + watches = await call(app, 'communication.watch_list', {}, key=rk, subject=reader) + assert len(watches.data['items']) == 1, wire(watches) + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'visible'}, key=ak, subject=author) + assert post.status == 'ok', wire(post) + async with app.metadata.transaction(write=True) as tx: + count = tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] + assert count == 1 + credential = await tx.credential(rk.key_id) + await tx.save_credential(replace(credential, revoked_at=NOW), (await tx.subject(reader)).auth_version) + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'after revoke'}, key=ak, subject=author) + assert post.status == 'ok', wire(post) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] == count + + +@pytest.mark.asyncio +async def test_cancelled_watches_leave_the_active_set(installed): + app, _ = installed + ak, author, _ = await register(app, 'watch-churn-author') + rk, reader, _ = await register(app, 'watch-churn-reader') + # Owners cannot archive watch resources through generic content operations, + # so cancellation is the only way to shrink the set watch_list must return. + for _ in range(3): + followed = await call(app, 'communication.watch', {'id': '/main'}, key=rk, subject=reader) + assert followed.status == 'ok', wire(followed) + unfollowed = await call(app, 'communication.unwatch', {'id': '/main'}, key=rk, subject=reader) + assert unfollowed.status == 'ok', wire(unfollowed) + created = await call(app, 'communication.watch_create', { + 'target': '/main', 'event_types': ['content.post_create'], 'delivery': 'inbox'}, + key=rk, subject=reader) + assert created.status == 'ok', wire(created) + cancelled = await call(app, 'communication.watch_cancel', {'id': created.data['id']}, + key=rk, subject=reader) + assert cancelled.status == 'ok', wire(cancelled) + watches = await call(app, 'communication.watch_list', {}, key=rk, subject=reader) + assert watches.status == 'ok' and not watches.data['items'], wire(watches) + got = await call(app, 'communication.watch_get', {'id': created.data['id']}, key=rk, subject=reader) + assert got.status == 'ok' and got.data['status'] == 'cancelled', wire(got) + archived = await call(app, 'content.archive', {'id': created.data['id']}, key=rk, subject=reader) + assert archived.status == 'error', wire(archived) + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'after churn'}, + key=ak, subject=author) + assert post.status == 'ok', wire(post) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] == 0 + + +@pytest.mark.asyncio +async def test_watch_quarantine_and_duplicate_event_projection(installed): + from msg.core.codec import decode, loads + from msg.core.models import Event + from msg.plugins.watches import enqueue + app, _ = installed + ak, author, _ = await register(app, 'watch-owner3') + rk, reader, _ = await register(app, 'watch-reader3') + args = {'target': '/main', 'event_types': ['content.post_create'], 'delivery': 'inbox'} + created = await call(app, 'communication.watch_create', args, key=rk, subject=reader, rid='watch-idempotent') + replay = await call(app, 'communication.watch_create', args, key=rk, subject=reader, rid='watch-idempotent') + assert created.status == replay.status == 'ok' + assert created.data == replay.data + invalid = await call(app, 'communication.watch_create', {**args, 'delivery': 'webhook'}, key=rk, subject=reader) + assert invalid.status == 'error' + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'event'}, key=ak, subject=author) + assert post.status == 'ok' + async with app.metadata.transaction(write=True) as tx: + raw = tx.one("SELECT body FROM events ORDER BY seq DESC LIMIT 1")[0] + event = decode(Event, loads(raw)) + await enqueue(app, tx, event) + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] == 1 + tx.execute('DELETE FROM messages WHERE recipient=?', (reader,), write=True) + tx.set_setting('recovery_quarantine', {'active': True}) + await enqueue(app, tx, event) + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] == 0 + + +@pytest.mark.asyncio +async def test_watch_removed_creating_operation_version_stops_delivery(installed): + app, _ = installed + ak, author, _ = await register(app, 'watch-version-author') + rk, reader, _ = await register(app, 'watch-version-reader') + watched = await call(app, 'communication.watch_create', { + 'target': '/main', 'event_types': ['content.post_create'], 'delivery': 'inbox'}, + key=rk, subject=reader) + assert watched.status == 'ok', wire(watched) + # Historical v1 disappears while a newer version exists. A name-only lookup + # or fall back to the newest contract would incorrectly authorize delivery. + original = app.registry._operations.pop(('communication.watch_create', 1)) + app.registry._operations[('communication.watch_create', 2)] = replace(original, version=2) + try: + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'new'}, key=ak, subject=author) + assert post.status == 'ok', wire(post) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (reader,))[0] == 0 + finally: + app.registry._operations[('communication.watch_create', 1)] = original + del app.registry._operations[('communication.watch_create', 2)] diff --git a/tests/test_watch_corruption.py b/tests/test_watch_corruption.py new file mode 100644 index 00000000..ec564367 --- /dev/null +++ b/tests/test_watch_corruption.py @@ -0,0 +1,59 @@ +"""A broken subscription cannot poison another publisher's Event transaction.""" +import pytest + +from msg.core.codec import digest, wire +from msg.core.models import ResourceRef +from test_service import call, register + + +@pytest.mark.asyncio +@pytest.mark.parametrize('damage,code', [('missing', 'content_missing'), ('invalid_index', 'watch_content_invalid')]) +async def test_missing_watch_blob_is_isolated_and_diagnosed(installed, caplog, damage, code): + app, _ = installed + ak, author, _ = await register(app, 'watch-corrupt-author') + bk, broken, _ = await register(app, 'watch-corrupt-broken') + gk, good, _ = await register(app, 'watch-corrupt-good') + args = {'target': '/main', 'event_types': ['content.post_create'], 'delivery': 'inbox'} + bad = await call(app, 'communication.watch_create', args, key=bk, subject=broken) + healthy = await call(app, 'communication.watch_create', args, key=gk, subject=good) + assert bad.status == healthy.status == 'ok' + async with app.metadata.transaction(write=False) as tx: + revision = await tx.revision(ResourceRef(id=bad.data['id'])) + index = app.contents.index / app.contents._key(revision.content) + if damage == 'missing': + index.unlink() + else: + index.write_text('{}') + with caplog.at_level('WARNING', logger='msg.plugins.watches'): + post = await call(app, 'content.post_create', {'parent': '/main', 'body': 'publisher survives'}, key=ak, subject=author) + assert post.status == 'ok', wire(post) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=?', (broken,))[0] == 0 + assert tx.one('SELECT COUNT(*) FROM messages WHERE recipient=? AND resource=?', (good, post.resources[0].id))[0] == 1 + report = next(r for r in caplog.records if r.message.startswith('watch_projection_corrupt ')) + assert report.watch_digest == digest(bad.data['id']) + assert report.error_code == code + assert broken not in caplog.text and bad.data['id'] not in caplog.text + + +@pytest.mark.asyncio +async def test_watch_database_failure_is_not_swallowed_and_rolls_back(installed, monkeypatch): + from msg.plugins import watches + app, _ = installed + key, subject, _ = await register(app, 'watch-database-failure') + watched = await call(app, 'communication.watch_create', {'target': '/main', + 'event_types': ['content.post_create'], 'delivery': 'inbox'}, key=key, subject=subject) + assert watched.status == 'ok' + async with app.metadata.transaction(write=False) as tx: + before = tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] for table in ('resources', 'events', 'messages')) + + async def broken_database(app, tx, rid): + # A real PostgreSQL statement error aborts this business transaction. + tx.one('SELECT * FROM nonexistent_watch_regression_table') + + monkeypatch.setattr(watches, 'record', broken_database) + posted = await call(app, 'content.post_create', {'parent': '/main', 'body': 'must roll back'}, key=key, subject=subject) + assert posted.status == 'error', wire(posted) + async with app.metadata.transaction(write=False) as tx: + after = tuple(tx.one('SELECT COUNT(*) FROM '+table)[0] for table in ('resources', 'events', 'messages')) + assert after == before diff --git a/tests/test_watch_surfaces.py b/tests/test_watch_surfaces.py new file mode 100644 index 00000000..22e90e0c --- /dev/null +++ b/tests/test_watch_surfaces.py @@ -0,0 +1,40 @@ +"""Real CLI and subject-path watch contract parity.""" +import httpx +import pytest + +from msg import cli +from msg.client import ClientState, MsgClient +from msg.core.codec import loads +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app +from test_service import NOW + + +@pytest.mark.asyncio +async def test_watch_cli_uses_current_public_contract(installed, tmp_path, monkeypatch, capsys): + app, _ = installed + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url) as http: + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + directory = tmp_path/'client' + client = MsgClient(ClientState(directory, server=app.settings.service_url), HTTPTransport(app.settings.service_url, http=http), clock=lambda: NOW) + assert (await client.register('watch-cli')).status == 'ok' + monkeypatch.setattr(cli, 'MsgClient', lambda state, transport: MsgClient(state, transport, clock=lambda: NOW)) + + async def invoke(*command): + args = cli.parser().parse_args(['--config-dir', str(directory), '--server', app.settings.service_url, 'watch', *command]) + status = await cli.run(args) + output = loads(capsys.readouterr().out.encode().strip()) + assert status == 0, output + return output['data'] + + made = await invoke('create', '/main', '--event', 'content.post_create') + wid = made['id'] + listing = await invoke('list') + assert [item['id'] for item in listing['items']] == [wid] + got = await invoke('get', wid) + assert got['status'] == 'active' and got['delivery'] == 'inbox' + assert got['event_types'] == ['content.post_create'] + assert 'principal' not in got and 'operation' not in got + cancelled = await invoke('cancel', wid) + assert cancelled == {'id': wid, 'status': 'cancelled'} + assert (await invoke('get', wid))['status'] == 'cancelled' diff --git a/tests/test_webhook_overlap.py b/tests/test_webhook_overlap.py new file mode 100644 index 00000000..d2e1f2ab --- /dev/null +++ b/tests/test_webhook_overlap.py @@ -0,0 +1,145 @@ +"""Overlapping subscriptions select one currently valid source, then pin it.""" +import secrets +from dataclasses import replace + +import pytest +from test_authorization import approve, scoped +from test_service import call, register +from test_webhook_domain_events import Sink, setup_subscription + +from msg.core.codec import b64, decode, loads +from msg.core.models import Event +from msg.plugins.communication import enqueue_domain_webhooks +from msg.workers.effects import EffectWorker + + +async def setup_overlap(app, root): + key, owner, cert, topic, parent_cert = await setup_subscription(app, root) + post = await call(app, 'content.post_create', {'parent': topic, 'body': 'Base'}, + key=key, subject=owner) + rid = post.resources[0].id + # The creation notification predates overlapping update subscriptions. + await EffectWorker(app, webhook_sender=Sink()).run_once() + child_cert = await approve(app, root, owner, key, ( + scoped(app, 'webhook.domain', rid, ('communication.webhook_subscribe@1',)),)) + for scope, capability in ((topic, parent_cert), (rid, child_cert.resource_id)): + result = await call(app, 'communication.webhook_subscribe', + {'resource_id': scope, 'events': ['resource.updated']}, key=key, subject=owner, + certs=(cert, capability)) + assert result.status == 'ok', result.error + return key, owner, cert, topic, post, child_cert.resource_id + + +async def edit(app, key, owner, post, text='New'): + result = await call(app, 'content.post_edit', {'id': post.resources[0].id, + 'expected_revision': post.resources[0].revision, 'body': text}, key=key, subject=owner, + expected=((post.resources[0].id, post.data['generation']),)) + assert result.status == 'ok', result.error + return result + + +async def update_jobs(app): + async with app.metadata.transaction(write=False) as tx: + return [await tx.job(row[0]) for row in tx.rows("SELECT id FROM jobs WHERE kind='webhook'") + if (await tx.job(row[0])).arguments.get('category') == 'resource.updated'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['unsubscribe', 'endpoint']) +async def test_overlap_one_job_per_event_recipient_channel_and_pinned_revocation(installed, change): + app, root = installed + key, owner, cert, topic, post, _ = await setup_overlap(app, root) + changed = await edit(app, key, owner, post) + jobs = await update_jobs(app) + assert len(jobs) == 1 + chosen = jobs[0] + assert chosen.arguments['scope_id'] == post.resources[0].id + # An upgrade must also recognize jobs using the published scope-bearing key. + async with app.metadata.transaction(write=True) as tx: + old_key = f'{chosen.event_id}:{owner}:{chosen.arguments["scope_id"]}:{post.resources[0].id}:resource.updated:webhook' + legacy = replace(chosen, dedupe_key=old_key) + from msg.core.codec import canonical + tx.execute('UPDATE jobs SET dedupe=?,body=? WHERE id=?', + (old_key, canonical(legacy).decode(), chosen.id), write=True) + # Reprojecting the same committed event cannot create another delivery. + async with app.metadata.transaction(write=True) as tx: + event = decode(Event, loads(tx.one('SELECT body FROM events WHERE id=?', (chosen.event_id,))[0])) + await enqueue_domain_webhooks(app, tx, event) + assert len(await update_jobs(app)) == 1 + if change == 'unsubscribe': + stopped = await call(app, 'communication.webhook_unsubscribe', + {'resource_id': post.resources[0].id}, key=key, subject=owner) + else: + stopped = await call(app, 'communication.webhook_set', + {'url': 'https://replacement.example.org/events', 'secret': b64(secrets.token_bytes(32))}, + key=key, subject=owner) + parent_cert = await approve(app, root, owner, key, ( + scoped(app, 'webhook.domain', topic, ('communication.webhook_subscribe@1',)),)) + subscribed = await call(app, 'communication.webhook_subscribe', + {'resource_id': topic, 'events': ['resource.updated']}, key=key, subject=owner, + certs=(cert, parent_cert.resource_id)) + assert subscribed.status == 'ok', subscribed.error + assert stopped.status == 'ok' + async with app.metadata.transaction(write=True) as tx: + await enqueue_domain_webhooks(app, tx, event) + assert len(await update_jobs(app)) == 1 + sink = Sink() + await EffectWorker(app, webhook_sender=sink).run_once() + assert sink.calls == [] + # A later event is independent and may select the still-enabled parent. + await edit(app, key, owner, changed, 'Next event') + jobs = await update_jobs(app) + assert len(jobs) == 2 and len({j.event_id for j in jobs}) == 2 + assert next(j for j in jobs if j.id != chosen.id).arguments['scope_id'] == topic + await EffectWorker(app, webhook_sender=sink).run_once() + assert len(sink.calls) == 1 + + +@pytest.mark.asyncio +async def test_revoked_child_authority_does_not_suppress_valid_parent(installed): + app, root = installed + key, owner, _, topic, post, child_cert = await setup_overlap(app, root) + async with app.metadata.transaction(write=True) as tx: + tx.execute('UPDATE certificates SET revoked=1 WHERE id=?', (child_cert,), write=True) + await edit(app, key, owner, post) + jobs = await update_jobs(app) + assert len(jobs) == 1 and jobs[0].arguments['scope_id'] == topic + sink = Sink() + await EffectWorker(app, webhook_sender=sink).run_once() + assert len(sink.calls) == 1 + + +@pytest.mark.asyncio +async def test_different_recipients_and_channels_remain_distinct(installed): + app, root = installed + _key, owner, _cert, topic, _, _ = await setup_overlap(app, root) + other_key, other, other_cert = await register(app, 'overlap-second-owner') + endpoint = await call(app, 'communication.webhook_set', + {'url': 'https://other.example.org/events', 'secret': b64(secrets.token_bytes(32))}, + key=other_key, subject=other) + assert endpoint.status == 'ok', endpoint.error + post = await call(app, 'content.post_create', {'parent': topic, 'body': 'Other owner'}, + key=other_key, subject=other) + assert post.status == 'ok', post.error + target = post.resources[0].id + capability = await approve(app, root, other, other_key, ( + scoped(app, 'webhook.domain', target, ('communication.webhook_subscribe@1',)),)) + result = await call(app, 'communication.webhook_subscribe', + {'resource_id': target, 'events': ['resource.updated']}, key=other_key, subject=other, + certs=(other_cert, capability.resource_id)) + assert result.status == 'ok', result.error + await edit(app, other_key, other, post) + jobs = await update_jobs(app) + assert len(jobs) == 2 + assert {job.arguments['recipient_subject'] for job in jobs} == {owner, other} + # A durable job on another channel cannot suppress this event's webhook. + chosen = next(job for job in jobs if job.arguments['recipient_subject'] == owner) + async with app.metadata.transaction(write=True) as tx: + tx.execute('DELETE FROM jobs WHERE id=?', (chosen.id,), write=True) + mail = replace(chosen, id='job_other_channel', kind='mail', + dedupe_key=chosen.dedupe_key.removesuffix(':webhook') + ':mail') + await tx.enqueue(mail) + event = decode(Event, loads(tx.one('SELECT body FROM events WHERE id=?', (chosen.event_id,))[0])) + await enqueue_domain_webhooks(app, tx, event) + assert tx.one('SELECT COUNT(*) FROM jobs WHERE dedupe=?', (mail.dedupe_key,))[0] == 1 + assert len(await update_jobs(app)) == 2 diff --git a/tests/test_webhook_overlap_collation.py b/tests/test_webhook_overlap_collation.py new file mode 100644 index 00000000..5e46517c --- /dev/null +++ b/tests/test_webhook_overlap_collation.py @@ -0,0 +1,29 @@ +"""Repeat delivery invariants with linguistic PostgreSQL ordering, as in CI.""" +import pytest +from test_webhook_overlap import ( + test_different_recipients_and_channels_remain_distinct as exercise_channels, +) +from test_webhook_overlap import ( + test_overlap_one_job_per_event_recipient_channel_and_pinned_revocation as exercise_overlap, +) + + +@pytest.fixture +async def english_jobs(installed): + app, _ = installed + async with app.metadata.transaction(write=True) as tx: + tx.execute('ALTER TABLE jobs ALTER COLUMN dedupe TYPE TEXT COLLATE "en-US-x-icu"', write=True) + # Unlike C ordering, punctuation is not an event-prefix boundary here. + assert tx.one("SELECT 'e_abc:recipient' COLLATE \"en-US-x-icu\" < 'e_abc;'")[0] is False + return installed + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['unsubscribe', 'endpoint']) +async def test_overlap_dedupe_survives_english_collation(english_jobs, change): + await exercise_overlap(english_jobs, change) + + +@pytest.mark.asyncio +async def test_channel_isolation_survives_english_collation(english_jobs): + await exercise_channels(english_jobs)