diff --git a/README.md b/README.md index b660671..4c6dca7 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,7 @@ a sample cannot admit its own key or renew trust. Deployment and credentials remain with their existing owners. [Registration and handoff evidence](docs/registration.md). + +[Release verification](docs/release-verification.md) and +[acceptance coverage](docs/acceptance-coverage.md) describe repeatable package +checks and the limits of local test evidence. diff --git a/docs/acceptance-coverage.md b/docs/acceptance-coverage.md new file mode 100644 index 0000000..93ccc06 --- /dev/null +++ b/docs/acceptance-coverage.md @@ -0,0 +1,47 @@ +# Acceptance coverage — 2026-09-28 + +This maps the current repository tests to the obligations in +[acceptance v0.1](../specs/acceptance-v0.1.md). A listed test is evidence for its +specific assertion, not completion of the whole criterion. The local suite uses +disposable keys, fake clocks and loopback HTTP; it does not prove independent UTC +accuracy, production admission or native consumer correctness. + +Test names below are in `tests/test_clock.py`, `tests/test_authority.py`, and +`tests/test_health.py` unless otherwise noted. Parameterized cases count as tests, +not additional acceptance criteria. The release smoke script is separate from +pytest and runs against an installed wheel. + +| ID | Existing local evidence | Uncovered or native obligation; existing owner record | +| --- | --- | --- | +| C01 | `test_interval_contains_send_with_asymmetric_delay` changes `time.time` and exercises the injected timer | No complete ±5-minute/zone/wall-step platform matrix; native Windows is unsupported by LinuxTimer. Supported Linux/WSL evidence: 0002-T03, 0004-T02 | +| C02 | `test_interval_contains_send_with_asymmetric_delay`; `test_lifecycle_during_exchange_refuses[delay]` | One deterministic asymmetric-delay case, not property-based interval coverage or measured network guarantees: 0002-T03, 0004-T02 | +| C03 | `test_signed_wrong_binding_refuses`, `test_bad_signature`, `test_rotation_requires_fresh_client_and_matching_signer`; wheel smoke refuses wrong key | No exhaustive version/header/modified-uncertainty matrix; accepted signing/trust profile: 0002-T02 | +| C04 | `test_replay_refused`, delayed-exchange refusal, `test_restart_preserves_floor`, `test_new_client_cannot_reset_expired_trust` | Exact replay across process restart and every nonce-timeout interleaving are not separately tested; native lifecycle evidence: 0004-T02 | +| C05 | `test_authority_refuses_unusable_health`, `test_restart_changes_epoch`, `test_step_at_final_timestamp_latches_refusal`, `test_health_expires_during_sample_preparation`, `test_health_model_refusals`, collector replacement test | Disposable host upstream outage/reboot/recovery and accepted error model: 0005-T02/T05, RAIL-HO-WP-0013 | +| C06 | `test_lifecycle_during_exchange_refuses[suspend/domain/backward]`; boot-bound admission behavior | Fake timer disruption does not establish real VM suspend/reboot detection or oscillator bounds: 0002-T03, 0004-T02 | +| C07 | `test_validity_uses_entire_interval` checks entirely valid, not-before overlap and exact expiry | Native signed-artifact semantics and protected-side-effect recheck remain consumer-owned: 0002-T04, 0004-T03 | +| C08 | Expired-session, delayed-sample and lifecycle refusals; health expiry during preparation | No general offline holdover offered; native loss/age/uncertainty policy evidence: 0004-T02 | +| C09 | Wrong signer and changed/revoked trust refusal; wheel smoke requires explicit disposable loopback admission | No real invalid-date TLS bootstrap or redirect-server integration test in this suite. Transport trust acceptance: 0002-T02, 0005-T03 | +| C10 | No approval/CAS concurrency fixture in this repository | Clock interval checks do not establish atomic consume or check-to-use safety. Consumer native proof: 0002-T04, 0004-T03 | +| C11 | `attribution.py` emits recording-time evidence; no direct attribution test | No historical occurrence translation or calibration-domain proof is implemented here. Audit/consumer contract and native evidence: 0002-T04, 0004-T03 | +| C12 | No occurrence-ordering test; application clock returns intervals | Overlap/order handling belongs to the consuming event system; audit acceptance: 0002-T04, 0004-T03 | +| C13 | `test_strict_json`, `test_http_framing_refused`, `test_malformed_health_is_stable_http_refusal`, `test_bad_policy_refuses`, `test_helper_failures_are_sanitized` | Coverage is not exhaustive numeric/schema fuzzing. Profile acceptance remains 0002-T02/T03 | +| C14 | `test_rotation_requires_fresh_client_and_matching_signer`, changed-trust refusal, expired admission and persisted rollback tests | Local file replacement is not distributed revocation; custody visibility deadlines and compromise recovery: 0002-T02, 0005-T03/T05 | +| C15 | `test_connection_limit_and_capacity_recovery`, HTTP liveness/readiness separation, framing and delayed-exchange tests | Eight held connections and recovery are not sustained-load/SLO proof. Host measurements and alerts: 0004-T02, 0005-T06 | +| C16 | `tools/wheel_smoke.py` checks installed CLI and signed authority/client HTTP exchange, wrong-key and unhealthy refusal | This is reference integration only. Named native consumer identity/tenant/expiry/CAS migration: 0004-T03 | + +## Release and dependency evidence + +`tools/verify_release.py` separately checks byte equality of two builds from +separate source roots with different file mtimes, packaged/spec schema equality, +wheel installation into a fresh runtime environment, CLI startup and a signed +loopback exchange. Build tools are hash-pinned in `tools/build-requirements.txt`; +runtime dependencies are installed from a hash-bearing export of `uv.lock`. +The SBOM lists installed runtime distributions and versions, not all platform +libraries, transitive native-library internals, licenses or vulnerability findings. + +See [release verification](release-verification.md) for the exact receipt and +reproduction procedure. No test or artifact publication makes the proposed +contract adopted. The 0003 closeout is limited to the reference implementation; +its original task prose is broader than the evidence in several rows above. +Those unproven native claims remain in existing 0002/0004/0005 records. diff --git a/docs/evidence/2026-09-28-release/runtime.cdx.json b/docs/evidence/2026-09-28-release/runtime.cdx.json new file mode 100644 index 0000000..cfb559b --- /dev/null +++ b/docs/evidence/2026-09-28-release/runtime.cdx.json @@ -0,0 +1,73 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "name": "PyJWT", + "purl": "pkg:pypi/pyjwt@2.14.0", + "type": "library", + "version": "2.14.0" + }, + { + "name": "attrs", + "purl": "pkg:pypi/attrs@26.1.0", + "type": "library", + "version": "26.1.0" + }, + { + "name": "cffi", + "purl": "pkg:pypi/cffi@2.1.1", + "type": "library", + "version": "2.1.1" + }, + { + "name": "cryptography", + "purl": "pkg:pypi/cryptography@50.0.1", + "type": "library", + "version": "50.0.1" + }, + { + "name": "jsonschema", + "purl": "pkg:pypi/jsonschema@4.26.0", + "type": "library", + "version": "4.26.0" + }, + { + "name": "jsonschema-specifications", + "purl": "pkg:pypi/jsonschema-specifications@2025.9.1", + "type": "library", + "version": "2025.9.1" + }, + { + "name": "pycparser", + "purl": "pkg:pypi/pycparser@3.0", + "type": "library", + "version": "3.0" + }, + { + "name": "railiance-clock", + "purl": "pkg:pypi/railiance-clock@0.1.0", + "type": "library", + "version": "0.1.0" + }, + { + "name": "referencing", + "purl": "pkg:pypi/referencing@0.37.0", + "type": "library", + "version": "0.37.0" + }, + { + "name": "rpds-py", + "purl": "pkg:pypi/rpds-py@2026.6.3", + "type": "library", + "version": "2026.6.3" + }, + { + "name": "typing_extensions", + "purl": "pkg:pypi/typing-extensions@4.16.0", + "type": "library", + "version": "4.16.0" + } + ], + "specVersion": "1.5", + "version": 1 +} diff --git a/docs/evidence/2026-09-28-release/verification.json b/docs/evidence/2026-09-28-release/verification.json new file mode 100644 index 0000000..d716721 --- /dev/null +++ b/docs/evidence/2026-09-28-release/verification.json @@ -0,0 +1,127 @@ +{ + "build_requirements_sha256": "ff153e73ea3a33c7d33858be50eda03943c5ef5e2706477a90ca76d250878adb", + "build_tools": [ + [ + "hatchling", + "1.32.4" + ], + [ + "packaging", + "26.3" + ], + [ + "pathspec", + "1.1.1" + ], + [ + "pluggy", + "1.6.0" + ], + [ + "tomlkit", + "0.15.1" + ], + [ + "trove-classifiers", + "2026.9.21.13" + ] + ], + "byte_identical": true, + "checkout_build": { + "railiance_clock-0.1.0-py3-none-any.whl": "7a4790efc97f19b69e942ae1f91a09b1b2a3b22ceca68e41ea4e45655848e420", + "railiance_clock-0.1.0.tar.gz": "cc23505334fbe7a29c5892d52db50883f62082c3885b1e61b559b6b0f5f0d12f" + }, + "independent_builds": [ + { + "railiance_clock-0.1.0-py3-none-any.whl": "7a4790efc97f19b69e942ae1f91a09b1b2a3b22ceca68e41ea4e45655848e420", + "railiance_clock-0.1.0.tar.gz": "cc23505334fbe7a29c5892d52db50883f62082c3885b1e61b559b6b0f5f0d12f" + }, + { + "railiance_clock-0.1.0-py3-none-any.whl": "7a4790efc97f19b69e942ae1f91a09b1b2a3b22ceca68e41ea4e45655848e420", + "railiance_clock-0.1.0.tar.gz": "cc23505334fbe7a29c5892d52db50883f62082c3885b1e61b559b6b0f5f0d12f" + } + ], + "limits": "Local Linux build/install evidence; no native UTC, security admission, vulnerability audit or cross-platform reproducibility claim.", + "platform": "Linux/x86_64", + "python": "3.12.3", + "runtime_lock_sha256": "fe4b4ab7fb1740648a13ff87d028914d94e4c793bb5957830e776a878c9adbb9", + "sbom_sha256": "69b45bf19e94e485d08e7af238f2aa3e3fa4f3969d8a552fcf874a12aa1eb07d", + "smoke": { + "checks": [ + "installed_import", + "three_packaged_schemas", + "cli_help", + "signed_http_cli_read", + "wrong_key_refusal", + "unhealthy_cli_refusal" + ], + "distributions": [ + [ + "PyJWT", + "2.14.0" + ], + [ + "attrs", + "26.1.0" + ], + [ + "cffi", + "2.1.1" + ], + [ + "cryptography", + "50.0.1" + ], + [ + "jsonschema", + "4.26.0" + ], + [ + "jsonschema-specifications", + "2025.9.1" + ], + [ + "pycparser", + "3.0" + ], + [ + "railiance-clock", + "0.1.0" + ], + [ + "referencing", + "0.37.0" + ], + [ + "rpds-py", + "2026.6.3" + ], + [ + "typing_extensions", + "4.16.0" + ] + ] + }, + "smoke_script_sha256": "cdb3adb57a68f95d8fd6878195d0ee7dd7a0a00060bacc2a3f95073e6c0b27ec", + "source_base_commit": "c976c9c277033e9fca384b9e9fcd5d79ff776d43", + "source_date_epoch": "315532800", + "source_inputs_sha256": { + ".gitignore": "74e4a2bdd348ce27bd64fbafbc09bef61433a821df2f23c726b062ead4896ebf", + "README.md": "c544450bc755e5743bac961fd3832f20ec4d9f86ec3562ca4a2d734a2d694a3e", + "pyproject.toml": "11a3e79fcd7b2929137ebad31647c839edc40572958b08b95f08b0605482416d", + "src/railiance_clock/__init__.py": "7c9c93a7d6bee6c68da5c19055c899d84040ecd5186169441f3a4d24d12c45c4", + "src/railiance_clock/admission.py": "afbea12a31e8ac6bea002a4d0477564d76698b633e88edeb0d2f60c582b5fc47", + "src/railiance_clock/attribution.py": "6cc6a5603295bda8fed47c5b144bdec1c6faff8b27acc440a65b81eb376415bd", + "src/railiance_clock/authority.py": "9a7ba85d17d70aeaad86cbb850b36515b7a82aef085799591bdfdf410cd023ee", + "src/railiance_clock/cli.py": "8ed77836cbdd823b86de849f76f228355bf47fcbfbbc572e22e6e48662929b59", + "src/railiance_clock/client.py": "34401db8cecf76a791013b383f9dc6bd56c0478472fe222f86b2be93505ac00f", + "src/railiance_clock/core.py": "32ac078f8729533dbbee15166cd6e450cd8638d178294304c1c60665d536b9a1", + "src/railiance_clock/health.py": "366e725eaf68d88df3a236d1f71bde5e5b7f24846932041fbaa46182e88d0f07", + "src/railiance_clock/protocol.py": "d076bfd0482d553e1c57133e7ab532c8360db3c100f164404a7c44211c3290a2", + "src/railiance_clock/schemas/protected-header-v0.1.schema.json": "15de9f38e61b49677479b431c3ef7e37a674e4b817ef8f78a19bc8fc47bbf4f8", + "src/railiance_clock/schemas/time-request-v0.1.schema.json": "c5a122b1f1f48872a25fffa5950e60dddd585e87a4da502fc4e873a44ae60f4c", + "src/railiance_clock/schemas/time-sample-v0.1.schema.json": "97e026520fff98c7140b8a91b9332ffcba22fd5d509fa983057c7cac6e6aa2d2" + }, + "uv": "uv 0.5.9", + "verifier_sha256": "f3eb2a489ddf32bca164a78c0b3c22358bf45703bcd6d0572413353c1875fb81" +} diff --git a/docs/release-verification.md b/docs/release-verification.md new file mode 100644 index 0000000..308c37a --- /dev/null +++ b/docs/release-verification.md @@ -0,0 +1,62 @@ +# Local release verification + +Run from this repository on Linux with Python 3.11+ and `uv`: + +```sh +python3 tools/verify_release.py --output /tmp/rclk-release-UNIQUE +``` + +The output directory must not already exist. Temporary keys, virtual environments +and build trees are removed at exit. The output retains `verification.json` and +`runtime.cdx.json`; it does not publish or deploy a package. Package downloads may +require registry access. No production credentials or host clock changes are used. + +The verifier performs: + +1. Byte comparison of the three packaged JSON schemas against `specs/`. +2. A fresh build environment installed from hash-pinned + `tools/build-requirements.txt`, independently of runtime dependencies. +3. Two sdist/wheel builds from separate source roots with deliberately different + mtimes and fixed `SOURCE_DATE_EPOCH=315532800`. The sdist explicitly includes + source, project metadata and README; Hatchling also includes `.gitignore`. + All four are represented in the input manifest; generated evidence is excluded. +4. A build from the actual checkout, which must match both copied-source builds. +5. A fresh runtime environment installed from the hash-bearing locked runtime + requirements plus the built wheel, followed by `uv pip check`. +6. An isolated (`python -I`) smoke check outside the checkout: installed-package + import, schema presence, CLI startup, a signed HTTP exchange with a disposable + authority, wrong-key refusal, and sanitized CLI failure on unusable health. +7. Installed distribution/version comparison against `uv.lock` and a CycloneDX + runtime component inventory. Build dependencies are listed separately in the + receipt; neither development dependencies nor build tools enter the runtime. + +Build constraints can be deliberately refreshed with: + +```sh +uv pip compile tools/build-requirements.in --generate-hashes -o tools/build-requirements.txt +``` + +Review that diff and rerun verification after a refresh. Normal verification +uses the committed pins and does not resolve newer build versions. Runtime +installation similarly uses `uv export --locked` and verifies archive hashes. + +## Retained evidence + +The September 28 run is in +[evidence/2026-09-28-release/verification.json](evidence/2026-09-28-release/verification.json) +and its [runtime SBOM](evidence/2026-09-28-release/runtime.cdx.json). +The receipt records source-input hashes, lock/build-requirement hashes, verifier +hashes, Python/uv/build-tool versions, artifact digests and smoke checks. Its base +commit identifies the checkout before the verification changes were committed; +the input manifest identifies the exact built contents, including pending edits. + +This demonstrates repeatability on the recorded Linux/Python toolchain. It does +not establish reproducibility on other platforms/interpreters, native UTC accuracy, +consumer admission, deployment, license clearance or absence of vulnerabilities. +The component inventory covers installed Python distributions, not system libraries +or native libraries embedded inside third-party wheels. Build timestamps are +normalized metadata and are never used as trusted time. + +The [acceptance coverage map](acceptance-coverage.md) distinguishes local checks +from unproven native obligations. RCLK-WP-0003-T03 owns this package evidence; +existing 0002/0004/0005 records retain owner acceptance and deployment gates. diff --git a/pyproject.toml b/pyproject.toml index 1e00f13..840a2de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,5 +13,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/railiance_clock"] +[tool.hatch.build.targets.sdist] +include = ["/src", "/pyproject.toml", "/README.md"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/specs/acceptance-v0.1.md b/specs/acceptance-v0.1.md index f35f3ce..ed8e7c2 100644 --- a/specs/acceptance-v0.1.md +++ b/specs/acceptance-v0.1.md @@ -1,7 +1,8 @@ # Acceptance and threat model — draft v0.1 -No tests below are claimed executed by the specification seed. IDs are future -behavioral acceptance obligations, not a count of implementation coverage. +IDs below are behavioral acceptance obligations, not a count of implementation +coverage. The [current coverage map](../docs/acceptance-coverage.md) identifies +local test evidence, gaps and the existing owner records for native acceptance. | ID | Scenario | Required evidence | | --- | --- | --- | diff --git a/tools/README.md b/tools/README.md index 94e84ed..156c036 100644 --- a/tools/README.md +++ b/tools/README.md @@ -22,3 +22,11 @@ later workplan tasks. The 2026-09-14 live JSON receipt accompanies the human-readable host inventory. Six unit tests cover transport failure, host argument validation, fixed command transport and unknown-versus-synchronized semantics. + +# Release verification + +`python3 tools/verify_release.py --output /tmp/rclk-release-UNIQUE` builds and +compares source archives and wheels, installs the wheel into a fresh environment, +and runs `wheel_smoke.py` with isolated imports. It retains artifact/input hashes +and a runtime component SBOM. See [the procedure](../docs/release-verification.md) +for build pins, evidence and platform limits. diff --git a/tools/build-requirements.in b/tools/build-requirements.in new file mode 100644 index 0000000..55e24a0 --- /dev/null +++ b/tools/build-requirements.in @@ -0,0 +1,2 @@ +# Build-only dependencies; runtime dependencies remain in uv.lock. +hatchling>=1.27,<2 diff --git a/tools/build-requirements.txt b/tools/build-requirements.txt new file mode 100644 index 0000000..b32d0d5 --- /dev/null +++ b/tools/build-requirements.txt @@ -0,0 +1,26 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile tools/build-requirements.in --generate-hashes -o tools/build-requirements.txt +hatchling==1.32.4 \ + --hash=sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c \ + --hash=sha256:c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07 + # via -r tools/build-requirements.in +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via hatchling +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via hatchling +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via hatchling +tomlkit==0.15.1 \ + --hash=sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 \ + --hash=sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97 + # via hatchling +trove-classifiers==2026.9.21.13 \ + --hash=sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71 \ + --hash=sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17 + # via hatchling diff --git a/tools/verify_release.py b/tools/verify_release.py new file mode 100644 index 0000000..f0199b5 --- /dev/null +++ b/tools/verify_release.py @@ -0,0 +1,118 @@ +"""Build twice, smoke-test an isolated wheel, and emit release evidence and SBOM.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import tempfile +import tomllib +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +EPOCH = '315532800' # Fixed ZIP-compatible build timestamp, not trusted time. + + +def run(*args, cwd=ROOT, env=None): + try: + return subprocess.run([str(a) for a in args], cwd=cwd, env=env, + check=True, capture_output=True, text=True).stdout + except subprocess.CalledProcessError as exc: + print(exc.stderr, file=sys.stderr) + raise + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write(path, value): + path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True, help='New evidence directory') + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ, SOURCE_DATE_EPOCH=EPOCH, PYTHONHASHSEED='0') + env.pop('PYTHONPATH', None) + inputs = [ROOT / 'pyproject.toml', ROOT / 'README.md', ROOT / '.gitignore', *sorted((ROOT / 'src').rglob('*.py')), + *sorted((ROOT / 'src').rglob('*.json'))] + manifest = {str(p.relative_to(ROOT)): sha(p) for p in inputs} + # Verify the separately reviewed spec and packaged schema copies agree. + for p in (ROOT / 'src/railiance_clock/schemas').glob('*.json'): + assert p.read_bytes() == (ROOT / 'specs' / p.name).read_bytes(), p.name + with tempfile.TemporaryDirectory(prefix='rclk-release-') as tmp: + temp = Path(tmp) + build_env = temp / 'build-env' + run('uv', 'venv', '--python', sys.executable, build_env) + build_python = build_env / 'bin/python' + run('uv', 'pip', 'install', '--python', build_python, '--require-hashes', + '-r', ROOT / 'tools/build-requirements.txt') + artifacts = [] + for number in (1, 2): + source = temp / f'source-{number}' + source.mkdir() + for original in inputs: + target = source / original.relative_to(ROOT) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(original, target) + # Deliberately vary source mtimes between the independent roots. + os.utime(target, (int(EPOCH) + number * 100, int(EPOCH) + number * 100)) + dest = temp / f'dist-{number}' + run('uv', 'build', source, '--python', build_python, '--no-build-isolation', + '--out-dir', dest, env=env) + artifacts.append({p.name: sha(p) for p in dest.iterdir() + if p.name.endswith(('.whl', '.tar.gz'))}) + assert artifacts[0] == artifacts[1], 'build outputs differ' + checkout_dist = temp / 'checkout-dist' + run('uv', 'build', ROOT, '--python', build_python, '--no-build-isolation', + '--out-dir', checkout_dist, env=env) + checkout_artifacts = {p.name: sha(p) for p in checkout_dist.iterdir() + if p.name.endswith(('.whl', '.tar.gz'))} + assert artifacts[0] == checkout_artifacts, 'checkout build differs from copied inputs' + wheel = next((temp / 'dist-1').glob('*.whl')) + with zipfile.ZipFile(wheel) as archive: + members = archive.namelist() + assert len([m for m in members if m.endswith('.schema.json')]) == 3 + requirements = temp / 'runtime.txt' + run('uv', 'export', '--locked', '--no-dev', '--no-emit-project', '-o', requirements) + install_env = temp / 'installed-env' + run('uv', 'venv', '--python', sys.executable, install_env) + python = install_env / 'bin/python' + run('uv', 'pip', 'install', '--python', python, '--require-hashes', '-r', requirements) + run('uv', 'pip', 'install', '--python', python, '--no-deps', wheel) + run('uv', 'pip', 'check', '--python', python) + smoke = json.loads(run(python, '-I', ROOT / 'tools/wheel_smoke.py', cwd=temp, env=env)) + locked = {p['name']: p['version'] for p in tomllib.loads((ROOT / 'uv.lock').read_text())['package']} + for name, version in smoke['distributions']: + assert locked[name.lower().replace('_', '-')] == version, (name, version) + components = [{'type': 'library', 'name': name, 'version': version, + 'purl': f'pkg:pypi/{name.lower().replace("_", "-")}@{version}'} + for name, version in smoke['distributions']] + write(output / 'runtime.cdx.json', {'bomFormat': 'CycloneDX', 'specVersion': '1.5', + 'version': 1, 'components': components}) + build_tools = json.loads(run(build_python, '-I', '-c', + 'import importlib.metadata as m,json; print(json.dumps(sorted((d.metadata["Name"],d.version) for d in m.distributions())))')) + write(output / 'verification.json', { + 'source_base_commit': run('git', 'rev-parse', 'HEAD').strip(), + 'source_inputs_sha256': manifest, 'runtime_lock_sha256': sha(ROOT / 'uv.lock'), + 'build_requirements_sha256': sha(ROOT / 'tools/build-requirements.txt'), + 'verifier_sha256': sha(Path(__file__)), 'smoke_script_sha256': sha(ROOT / 'tools/wheel_smoke.py'), + 'python': platform.python_version(), 'platform': platform.system() + '/' + platform.machine(), + 'uv': run('uv', '--version').strip(), 'build_tools': build_tools, + 'source_date_epoch': EPOCH, 'independent_builds': artifacts, + 'checkout_build': checkout_artifacts, + 'byte_identical': True, 'smoke': smoke, + 'sbom_sha256': sha(output / 'runtime.cdx.json'), + 'limits': 'Local Linux build/install evidence; no native UTC, security admission, vulnerability audit or cross-platform reproducibility claim.'}) + print(f'Verified two identical builds and isolated wheel smoke test: {output}') + + +if __name__ == '__main__': + main() diff --git a/tools/wheel_smoke.py b/tools/wheel_smoke.py new file mode 100644 index 0000000..07fad44 --- /dev/null +++ b/tools/wheel_smoke.py @@ -0,0 +1,91 @@ +"""Run with an isolated interpreter containing only the installed wheel and deps.""" +import importlib.metadata +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +import railiance_clock +from railiance_clock.admission import admit +from railiance_clock.authority import Authority, Server +from railiance_clock.client import Clock, FileTrust +from railiance_clock.core import ClockError + + +def main(): + installed = Path(railiance_clock.__file__).resolve() + assert installed.is_relative_to(Path(sys.prefix).resolve()), installed + schemas = installed.parent / 'schemas' + for name in ('time-request', 'time-sample', 'protected-header'): + assert json.loads((schemas / (name + '-v0.1.schema.json')).read_text())['type'] == 'object' + cli = Path(sys.executable).parent / 'railiance-clock' + help_result = subprocess.run([str(cli), '--help'], capture_output=True, text=True, check=True) + assert all(command in help_result.stdout for command in ('read', 'serve', 'health-export')) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + key = ec.generate_private_key(ec.SECP256R1()) + private = root / 'key.pem' + private.write_bytes(key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + private.chmod(0o600) + health = root / 'health.json' + health.write_text(json.dumps({'quality': 'bounded', + 'boot_id': Path('/proc/sys/kernel/random/boot_id').read_text().strip(), + 'policy_id': 'wheel-test', 'observed_monotonic_ns': time.monotonic_ns(), 'error_ns': 1000})) + config = {'private_key_file': str(private), 'health_file': str(health), + 'health_owner_uid': os.getuid(), 'policy_id': 'wheel-test', + 'authority_id': 'disposable', 'environment': 'test', 'kid': 'test-key', + 'max_health_age_ns': 30_000_000_000, 'host_drift_ppm': 1000, + 'max_error_ns': 100_000_000, 'max_continuity_error_ns': 50_000_000, + 'max_sample_age_ns': 5_000_000_000} + authority = Authority(config) + server = Server(('127.0.0.1', 0), authority) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + trust = admit(directory=root, authority_id='disposable', environment='test', + kid='test-key', epoch=authority.epoch, policy_id='wheel-test', + public_key_pem=key.public_key().public_bytes(serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo).decode(), + endpoint=f'http://127.0.0.1:{server.server_port}/v1/time-samples', + admission_ref='disposable-wheel-test-only', transport='admitted-loopback', + limits={'max_age_ns': 5_000_000_000, 'max_rtt_ns': 2_000_000_000, + 'max_width_ns': 3_000_000_000, 'max_server_error_ns': 100_000_000, + 'timer_ppm': 1000, 'timer_resolution_ns': 5_000_000, + 'suspend_tolerance_ns': 5_000_000, 'trust_session_ns': 60_000_000_000}) + result = subprocess.run([str(cli), 'read', '--trust', str(trust)], + capture_output=True, text=True, check=True) + window = json.loads(result.stdout) + assert window['authority_id'] == 'disposable' + assert window['epoch'] == authority.epoch + assert window['lower_ns'] <= window['upper_ns'] + bad = json.loads(trust.read_text()); bad['kid'] = 'wrong-key' + trust.write_text(json.dumps(bad)) + try: + Clock(FileTrust(trust)).read() + except ClockError: + pass + else: + raise AssertionError('wrong key accepted') + bad['kid'] = 'test-key'; trust.write_text(json.dumps(bad)) + health.write_text('{"quality":"unusable"}') + refused = subprocess.run([str(cli), 'read', '--trust', str(trust)], + capture_output=True, text=True) + assert refused.returncode == 1 and not refused.stdout + assert refused.stderr == 'Railiance time unavailable or untrusted.\n' + finally: + server.shutdown(); server.server_close(); thread.join() + distributions = sorted((d.metadata['Name'], d.version) for d in importlib.metadata.distributions()) + print(json.dumps({'checks': ['installed_import', 'three_packaged_schemas', 'cli_help', + 'signed_http_cli_read', 'wrong_key_refusal', 'unhealthy_cli_refusal'], + 'distributions': distributions}, sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/workplans/RCLK-WP-0003-reference-implementation.md b/workplans/RCLK-WP-0003-reference-implementation.md index 4b2151d..b713fda 100644 --- a/workplans/RCLK-WP-0003-reference-implementation.md +++ b/workplans/RCLK-WP-0003-reference-implementation.md @@ -11,7 +11,7 @@ depends_on: owner: codex topic_slug: custodian created: "2026-09-14" -updated: "2026-09-24" +updated: "2026-09-28" depends_on_workplans: - RCLK-WP-0002 state_hub_workstream_id: "904e08b5-978f-5bfc-b543-ca5347a56ac5" @@ -63,3 +63,16 @@ records and are not leftovers of this plan. `RCLK-WP-0005` owns infrastructure-as-code delivery. `RCLK-WP-0004` owns the railiance01 pilot and the first consumer migration, and it waits on `RCLK-WP-0005` as well as this plan. + + +### Release-evidence follow-up — 2026-09-28 + +T03 now has a repeatable [release verifier](../docs/release-verification.md), +hash-pinned build tools, independent byte-identical wheel/sdist build evidence, +an isolated installed-wheel signed HTTP/CLI smoke check, and a runtime SBOM. +The [acceptance coverage map](../docs/acceptance-coverage.md) explicitly records +that the original task prose exceeds current evidence: no local approval/CAS +concurrency fixture or historical event-translation test, and no complete native +platform or consumer proof. Finished status retains only the previously stated +reference boundary; those broader claims are not asserted. Existing +0002-T04/0004-T02/T03 and 0005 retain their owner/native gates.