From a1ae50943ae71a94c356123a1c8bf260036cfd46 Mon Sep 17 00:00:00 2001 From: Antonios Voulvoulis Date: Sun, 20 Sep 2026 12:51:59 +0300 Subject: [PATCH 1/2] Record what Linux exposes about storage, and stop inferring the rest D-114 retires the block storage `type` field. Its values - ROTATIONAL, SOLID_STATE, OPTICAL, REMOVABLE, VIRTUAL, NVME - were a physical claim assembled from things that do not establish one. `queue/rotational` is a scheduler hint, not a medium; a subsystem is a driver family, not a transport; a block device is not necessarily one physical disk. Four fields replace it, each named after the kernel attribute it came from: kernel_subsystem basename of device/subsystem, read lexically queue_rotational queue/rotational, recorded, interpreted as nothing kernel_removable the removable flag, under its own name scsi_peripheral_type device/type, only within the SCSI family Keys are stable and hold null when the source is absent, so a missing attribute is visible rather than silently dropped. There is no physical_medium, no transport and no is_aggregate: nothing in /sys supports them. Report columns name their source, and say that neither a subsystem nor a rotational flag establishes the physical storage medium. Defect A, in the same area of collection truth. Machine identity reported COLLECTED with vendor and product both null whenever DMI was absent but a hypervisor was detected, because status was decided by counting non-null values across the subdomain and virtualization facts counted toward the threshold. An incomplete collection reported as complete is the one thing an evidence engine cannot be wrong about. Status is now decided per source: both identity fields are required for COLLECTED, and anything less is PARTIAL with a reason naming the fields actually missing and stating that virtualization detection is reported separately and does not establish machine identity. The published sample report is withdrawn rather than corrected. It described a device as ROTATIONAL and another as OPTICAL, under a column headed "Class", as the example of what this tool claims. It was real output from a disposable lab VM that no longer exists, and the four replacement fields were never collected from that host, so it cannot be re-rendered. Writing plausible values would be inventing evidence for a machine nobody can re-observe. IQ-011 records the gap and requires the replacement to come from reproducible bytes or a new reproducible collection source. check_storage_vocabulary reads the whole published surface, not just the source: a device record carrying a retired value, a device table whose column header hides which kernel attribute it came from, and the retired enum constants. Ten files may write the words in order to forbid them; the four that actually produce storage output are not among them. The sample had been wrong for three schema versions because every gate read the source and none read the documentation. Implements: D-114, STORAGE-SEMANTICS-001, STORAGE-SEMANTICS-002, SCOPE-045 Assisted-by: Claude (implementation, gate authoring, consumer audit) --- Makefile | 9 +- docs/CURRENT_STATE.md | 5 +- docs/IMPLEMENTATION_QUESTIONS.md | 1 + docs/reference/CONTROL_EVIDENCE_MAP.md | 9 +- docs/reference/samples/SAMPLE_REPORT.json | 466 ---------------------- docs/reference/samples/SAMPLE_REPORT.md | 186 --------- lib/isedraf/cli.py | 9 +- lib/isedraf/inventory/collectors.py | 102 +++-- lib/isedraf/inventory/model.py | 21 +- lib/isedraf/report/render.py | 26 +- scripts/ci/check_storage_vocabulary.py | 179 +++++++++ scripts/ci/falsifiable.sh | 117 +++++- scripts/ci/gate_coverage.json | 10 + scripts/ci/project_status.json | 8 +- tests/test_inventory.py | 180 +++++++-- tests/test_report.py | 21 +- 16 files changed, 617 insertions(+), 732 deletions(-) delete mode 100644 docs/reference/samples/SAMPLE_REPORT.json delete mode 100644 docs/reference/samples/SAMPLE_REPORT.md create mode 100644 scripts/ci/check_storage_vocabulary.py diff --git a/Makefile b/Makefile index a7237a8..2896938 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ # CI invokes these same targets rather than re-implementing them in YAML, which is # what prevents a gate silently degrading into a warning. There is no warning tier. -.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help +.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help -check: check-scope check-headers check-python-floor check-packaging check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs +check: check-scope check-headers check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs @echo "make check: all gates passed" ## check-scope D-96: no product implementation before architecture freeze @@ -85,6 +85,11 @@ check-python-floor: check-packaging: @python3 scripts/ci/check_packaging.py +## check-storage-vocabulary D-114: a retired inference does not return as vocabulary +check-storage-vocabulary: + @python3 scripts/ci/check_storage_vocabulary.py --self-test + @python3 scripts/ci/check_storage_vocabulary.py + ## check-native-catalog D-111: the control catalog is ISEDRAF's own, and stays that way check-native-catalog: @python3 scripts/ci/check_native_catalog.py diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 919112e..511f0b9 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -32,6 +32,7 @@ What exists and runs today. Nothing else on this page does. |---|---|---| | `w1a_evidence_contract` | `docs/architecture/freeze/W1A_CORE_PUBLIC.sha256` | — | | `artifact_attestation` | `.github/workflows/release-candidate.yml` | — | +| `block_storage_observation_model` | `lib/isedraf/inventory/collectors.py` | — | | `clean_public_export` | `scripts/ci/release_export.sh` | — | | `code_scanning` | `.github/workflows/codeql.yml` | — | | `consolidated_product_model` | `docs/architecture/ISEDRAF_PRODUCT_HLD.md` | `make check-public-claims` | @@ -110,8 +111,8 @@ Not asserted. Each number is counted at generation time. | | | |---|---| -| Gates | 18 | -| Falsification injections | 100 | +| Gates | 19 | +| Falsification injections | 109 | | Golden vector cases | 15 | | Frozen artifacts | 7 | | Test files | 3 | diff --git a/docs/IMPLEMENTATION_QUESTIONS.md b/docs/IMPLEMENTATION_QUESTIONS.md index 7465570..8275a7d 100644 --- a/docs/IMPLEMENTATION_QUESTIONS.md +++ b/docs/IMPLEMENTATION_QUESTIONS.md @@ -22,3 +22,4 @@ Assumptions only — **never authority**. The owner resolves; resolutions become | IQ-008 | CONFLICT | STORE-001 | `/run/isedraf/` | Implemented the lock at `/var/lib/isedraf/.lock` | The sketch placed the lock at `/run/isedraf/isedraf.lock`. `STORE-001` puts `.lock` inside the state root, which also makes the lock and the store it protects share a filesystem — a lock on a different filesystem cannot guarantee exclusion if the store is mounted elsewhere. `/run/isedraf/` remains available for future ephemeral state. | `STORE-001` | OPEN | | IQ-009 | GAP | SCOPE-077, SNAP-015 | `lib/isedraf/cli.py`, `lib/isedraf/verify.py` | Verification runs in-process at the end of `identity`; a failure exits `64` (engine error) | `SNAP-015` assigns `exit 5` to `integrity_failure`, and `SCOPE-077` states `5` is not reachable in W1-A. A standalone `isedraf verify` therefore has **no frozen exit code for "verification failed"**. No exit code was invented: verification stays in-process, and the standalone command is deferred until the W1-A exit set is extended or `verify` is scoped to a later set. | discovered implementing W1-B | OPEN | | IQ-010 | CONFLICT | SCOPE-070, SCOPE-071, SCOPE-072, STORE-001, PRIV-005 | `lib/isedraf/stateroot.py` | `resolve()` refuses to fall back to a production root this slice cannot reach, and names the requirements | **Not a contradiction between frozen rules — an implementation defect.** `SCOPE-070` already states W1 *"runs under `ISEDRAF_STATE_ROOT`"*, and `PRIV-005`'s Mode A — `sudo isedraf`, root supervisor inside a sandbox, state-root writability preflight — is the only execution topology that ever owns `/var/lib/isedraf`. Mode A is `DEFERRED_TO_FREEZE_SET_2` by `SCOPE-072`, so **W1 has no production mode by design**. The implementation was offering one and failing with a bare permission error. Answers for Freeze Set 2, when Mode A lands: the **root supervisor** creates and owns the root at mode `0700`; packaging creates it at install time; no group-readable relaxation, since `STORE-001` freezes `0700`; `sudo -u` is not a path, because `SCOPE-071` refuses anything reached through sudo and `PRIV-004` refuses when `SUDO_USER` is set | `env -u ISEDRAF_STATE_ROOT isedraf identity` now explains rather than failing on `EACCES` | **CLOSED_IMPLEMENTATION_FIX** | +| IQ-011 | GAP | D-114, STORAGE-SEMANTICS-001 | `docs/reference/samples/` (withdrawn), `docs/reference/CONTROL_EVIDENCE_MAP.md` | The rendered sample report was **withdrawn** rather than edited. It was real output from disposable lab VM `isd-a9` (AlmaLinux 9.7, KVM) and reported a storage device `type` of `ROTATIONAL` / `OPTICAL` — the exact inference D-114 retired, published as an example of what the tool claims. | It could not be regenerated: `isd-a9` no longer exists, the surviving lab corpus belongs to another project and is read-only, and `kernel_subsystem`, `queue_rotational`, `kernel_removable` and `scsi_peripheral_type` were never collected from that host, so re-rendering the old collection cannot produce them. Writing plausible values would be inventing evidence for a machine nobody can re-observe, which is the one failure this project exists to prevent. **Proposal:** regenerate after the RC on a disposable VM created for the purpose, and add a documentation generator so the sample is reproducible from committed bytes rather than from a VM that only one person ever had. A `check_storage_vocabulary` gate now rejects the retired vocabulary if it returns. | `git log 6ca913e`; storage semantics proofs, this milestone | OPEN | diff --git a/docs/reference/CONTROL_EVIDENCE_MAP.md b/docs/reference/CONTROL_EVIDENCE_MAP.md index 156f2a7..6710b6b 100644 --- a/docs/reference/CONTROL_EVIDENCE_MAP.md +++ b/docs/reference/CONTROL_EVIDENCE_MAP.md @@ -202,8 +202,13 @@ Report | Collection completeness | both | per-subdomain status | | Limitations | both | what the evidence does not support | -A sample of the real output is in [`samples/SAMPLE_REPORT.md`](samples/SAMPLE_REPORT.md), generated on a -lab VM and not edited. +A rendered sample of the real output is **not published with this preview**. The previous sample was +generated on a disposable lab VM that no longer exists, and it describes the retired storage schema: +it reports a device `type` of `ROTATIONAL` or `OPTICAL`, which is exactly the inference the storage +observation model now refuses to make. It could not be migrated, because the fields that replaced +that one - `kernel_subsystem`, `queue_rotational`, `kernel_removable`, `scsi_peripheral_type` - were +never collected from that host, and writing plausible values for them would be inventing evidence. +A sample returns when it can be produced by running the tool, not by editing a document. ## Domains not yet mapped diff --git a/docs/reference/samples/SAMPLE_REPORT.json b/docs/reference/samples/SAMPLE_REPORT.json deleted file mode 100644 index 84e47ec..0000000 --- a/docs/reference/samples/SAMPLE_REPORT.json +++ /dev/null @@ -1,466 +0,0 @@ -{ - "assessment": { - "customer": "Example Company S.A.", - "organization": "ITCMS", - "prepared_by": "A. Reviewer", - "reference": "AUDIT-2026-017" - }, - "collection_summary": [ - { - "collection_status": "COLLECTED", - "method": "/proc/sys/kernel/hostname + /etc/hosts", - "reason": null, - "subdomain": "host" - }, - { - "collection_status": "COLLECTED", - "method": "/etc/os-release + uname(2) + /proc/1/comm", - "reason": null, - "subdomain": "platform" - }, - { - "collection_status": "COLLECTED", - "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "reason": null, - "subdomain": "machine" - }, - { - "collection_status": "COLLECTED", - "method": "/proc/cpuinfo", - "reason": null, - "subdomain": "compute" - }, - { - "collection_status": "COLLECTED", - "method": "/proc/meminfo", - "reason": null, - "subdomain": "memory" - }, - { - "collection_status": "COLLECTED", - "method": "/sys/block + /proc/self/mounts + statvfs(2)", - "reason": null, - "subdomain": "storage" - }, - { - "collection_status": "COLLECTED", - "method": "ip -json addr/route", - "reason": null, - "subdomain": "network" - }, - { - "collection_status": "COLLECTED", - "method": "/etc/resolv.conf", - "reason": null, - "subdomain": "dns" - }, - { - "collection_status": "COLLECTED", - "method": "timedatectl show + chronyc tracking", - "reason": null, - "subdomain": "time" - } - ], - "identity_evidence": { - "available": true, - "collection_status": "COLLECTED", - "created_at": "2026-09-18T17:38:25Z", - "evidence_root": "/tmp/tmpiv5d2cqc/state", - "host_id": "sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746", - "ledger_record_hash": "sha256:ccb6f10f5214a4c36dd39032ff31f197eb3091a6adc819429d8378e37868a66e", - "ledger_sequence": 1, - "manifest_hash": "sha256:053313f08b9f173212225d855937b01f43dfa2f4e336421f89e8718aad2d6f1a", - "maturity": "W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained", - "reason": null, - "run_id": "RUN-20260918T173825Z-4f88e5488b22c90e", - "snapshot_id": "SDS-20260918T173825Z-e4d0e8596510180c", - "state_hash": "sha256:fb74919253ca54f43a12b3260cc13e1651b9d8065e68fcad3161d5c071efe63d", - "state_root_class": "DEV", - "verification": { - "problems": [], - "verified": true - } - }, - "inventory": { - "artifact_digest": "sha256:528c0c991f4f59b12463767aaef828d0f7cab70c7b2a0aa5980d42b26b984560", - "collected_at": "2026-09-18T00:00:00Z", - "collection_id": "INV-20260918T000000Z-0000000000000000", - "collection_status": "COLLECTED", - "maturity": "W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021)", - "schema_version": 1, - "subdomains": { - "compute": { - "collection_status": "COLLECTED", - "data": { - "cores_per_socket": 1, - "cpu_model": "Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz", - "cpu_vendor": "GenuineIntel", - "logical_cpus": 2, - "sockets": 2 - }, - "method": "/proc/cpuinfo", - "reason": null, - "state_dimension": "ACTIVE" - }, - "dns": { - "collection_status": "COLLECTED", - "data": { - "method": "/etc/resolv.conf", - "note": null, - "servers": [ - "192.168.122.1" - ] - }, - "method": "/etc/resolv.conf", - "reason": null, - "state_dimension": "RESOLVED" - }, - "host": { - "collection_status": "COLLECTED", - "data": { - "fqdn": null, - "fqdn_source": "NOT_AVAILABLE_LOCALLY", - "hostname": "isd-a9" - }, - "method": "/proc/sys/kernel/hostname + /etc/hosts", - "reason": null, - "state_dimension": "ACTIVE" - }, - "machine": { - "collection_status": "COLLECTED", - "data": { - "hypervisor": "kvm", - "product": "Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996)", - "vendor": "QEMU", - "virtualized": true - }, - "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "reason": null, - "state_dimension": "ACTIVE" - }, - "memory": { - "collection_status": "COLLECTED", - "data": { - "swap_total_bytes": 0, - "total_bytes": 2058444800 - }, - "method": "/proc/meminfo", - "reason": null, - "state_dimension": "ACTIVE" - }, - "network": { - "collection_status": "COLLECTED", - "data": { - "default_routes": [ - { - "family": "inet", - "gateway": "192.168.122.1", - "interface": "eth0" - } - ], - "interfaces": [ - { - "loopback": true, - "mtu": 65536, - "name": "lo", - "state": "UNKNOWN" - }, - { - "loopback": false, - "mtu": 1500, - "name": "eth0", - "state": "UP" - } - ], - "ipv4": [ - { - "address": "127.0.0.1", - "interface": "lo", - "loopback": true, - "prefix_length": 8, - "scope": "host" - }, - { - "address": "192.168.122.54", - "interface": "eth0", - "loopback": false, - "prefix_length": 24, - "scope": "global" - } - ], - "ipv6_stable": [ - { - "address": "::1", - "classification": "LOOPBACK", - "interface": "lo", - "prefix_length": 128, - "scope": "host" - }, - { - "address": "fe80::5054:ff:fed8:b374", - "classification": "LINK_LOCAL", - "interface": "eth0", - "prefix_length": 64, - "scope": "link" - } - ], - "ipv6_volatile": [] - }, - "method": "ip -json addr/route", - "reason": null, - "state_dimension": "ACTIVE" - }, - "platform": { - "collection_status": "COLLECTED", - "data": { - "architecture": "x86_64", - "family": "rhel centos fedora", - "id": "almalinux", - "init_system": "systemd", - "kernel_release": "5.14.0-611.45.1.el9_7.x86_64", - "pretty_name": "AlmaLinux 9.7 (Moss Jungle Cat)", - "version_id": "9.7" - }, - "method": "/etc/os-release + uname(2) + /proc/1/comm", - "reason": null, - "state_dimension": "ACTIVE" - }, - "storage": { - "collection_status": "COLLECTED", - "data": { - "devices": [ - { - "model": "QEMU HARDDISK", - "name": "sda", - "removable": false, - "size_bytes": 12884901888, - "type": "ROTATIONAL" - }, - { - "model": "QEMU DVD-ROM", - "name": "sr0", - "removable": true, - "size_bytes": 374784, - "type": "OPTICAL" - } - ], - "filesystems": [ - { - "fstype": "tmpfs", - "mount_point": "/dev/shm", - "options": [ - "inode64", - "nodev", - "nosuid", - "rw", - "seclabel" - ], - "read_only": false, - "source": "tmpfs" - }, - { - "fstype": "tmpfs", - "mount_point": "/run", - "options": [ - "inode64", - "mode=755", - "nodev", - "nosuid", - "nr_inodes=819200", - "rw", - "seclabel", - "size=402040k" - ], - "read_only": false, - "source": "tmpfs" - }, - { - "fstype": "xfs", - "mount_point": "/", - "options": [ - "attr2", - "inode64", - "logbsize=32k", - "logbufs=8", - "noquota", - "relatime", - "rw", - "seclabel" - ], - "read_only": false, - "source": "/dev/sda4" - }, - { - "fstype": "selinuxfs", - "mount_point": "/sys/fs/selinux", - "options": [ - "noexec", - "nosuid", - "relatime", - "rw" - ], - "read_only": false, - "source": "selinuxfs" - }, - { - "fstype": "xfs", - "mount_point": "/boot", - "options": [ - "attr2", - "inode64", - "logbsize=32k", - "logbufs=8", - "noquota", - "relatime", - "rw", - "seclabel" - ], - "read_only": false, - "source": "/dev/sda3" - }, - { - "fstype": "vfat", - "mount_point": "/boot/efi", - "options": [ - "codepage=437", - "dmask=0077", - "errors=remount-ro", - "fmask=0077", - "iocharset=ascii", - "relatime", - "rw", - "shortname=winnt" - ], - "read_only": false, - "source": "/dev/sda2" - }, - { - "fstype": "rpc_pipefs", - "mount_point": "/var/lib/nfs/rpc_pipefs", - "options": [ - "relatime", - "rw" - ], - "read_only": false, - "source": "sunrpc" - }, - { - "fstype": "tmpfs", - "mount_point": "/run/user/1000", - "options": [ - "gid=1000", - "inode64", - "mode=700", - "nodev", - "nosuid", - "nr_inodes=50255", - "relatime", - "rw", - "seclabel", - "size=201020k", - "uid=1000" - ], - "read_only": false, - "source": "tmpfs" - } - ], - "utilisation": [ - { - "available_bytes": 1029222400, - "mount_point": "/dev/shm", - "total_bytes": 1029222400, - "used_permille": 0 - }, - { - "available_bytes": 405987328, - "mount_point": "/run", - "total_bytes": 411688960, - "used_permille": 14 - }, - { - "available_bytes": 10647334912, - "mount_point": "/", - "total_bytes": 11532218368, - "used_permille": 77 - }, - { - "available_bytes": 857051136, - "mount_point": "/boot", - "total_bytes": 1006632960, - "used_permille": 149 - }, - { - "available_bytes": 201715712, - "mount_point": "/boot/efi", - "total_bytes": 209489920, - "used_permille": 37 - }, - { - "available_bytes": 205844480, - "mount_point": "/run/user/1000", - "total_bytes": 205844480, - "used_permille": 0 - } - ] - }, - "method": "/sys/block + /proc/self/mounts + statvfs(2)", - "reason": null, - "state_dimension": "ACTIVE" - }, - "time": { - "collection_status": "COLLECTED", - "data": { - "ntp_enabled": true, - "offset_nanoseconds": 131300, - "provider": "chrony", - "source": "A29FC801", - "stratum": 4, - "synchronized": true, - "timezone": "UTC", - "uptime_seconds": 31 - }, - "method": "timedatectl show + chronyc tracking", - "reason": null, - "state_dimension": "RESOLVED" - } - } - }, - "limitations": [ - "Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from.", - "Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id.", - "Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift.", - "Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it.", - "Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour.", - "Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash.", - "Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed." - ], - "provenance": { - "collection_methods": { - "compute": "/proc/cpuinfo", - "dns": "/etc/resolv.conf", - "host": "/proc/sys/kernel/hostname + /etc/hosts", - "machine": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "memory": "/proc/meminfo", - "network": "ip -json addr/route", - "platform": "/etc/os-release + uname(2) + /proc/1/comm", - "storage": "/sys/block + /proc/self/mounts + statvfs(2)", - "time": "timedatectl show + chronyc tracking" - }, - "engine_version": "0.0.0-pre", - "inventory_schema_version": 1, - "report_schema_version": 1 - }, - "report": { - "engine_version": "0.0.0-pre", - "generated_at": "2026-09-18T00:00:00Z", - "report_id": "RPT-FIXED", - "status": "COMPLETE" - }, - "report_schema_version": 1, - "target": { - "fqdn": null, - "fqdn_source": "NOT_AVAILABLE_LOCALLY", - "host_id": "sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746", - "hostname": "isd-a9" - } -} diff --git a/docs/reference/samples/SAMPLE_REPORT.md b/docs/reference/samples/SAMPLE_REPORT.md deleted file mode 100644 index 853c672..0000000 --- a/docs/reference/samples/SAMPLE_REPORT.md +++ /dev/null @@ -1,186 +0,0 @@ - - - -# ISEDRAF System Assurance Report - -| | | -|---|---| -| Report ID | `RPT-FIXED` | -| Generated (UTC) | 2026-09-18T00:00:00Z | -| ISEDRAF version | 0.0.0-pre | -| Report status | **COMPLETE** | - -> This report describes what was **observed**. It contains no secure/insecure verdict, because host inventory is evidence and a score would be a judgement the data does not support. - -## Assessment - -| | | -|---|---| -| Prepared by | A. Reviewer | -| Organization | ITCMS | -| Customer | Example Company S.A. | -| Reference | AUDIT-2026-017 | - -## Target - -| | | -|---|---| -| Hostname | isd-a9 | -| FQDN | *not resolvable locally — no resolver was consulted* | -| Host ID | `sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746` | - -## Evidence - -### Host identity — W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained - -| | | -|---|---| -| Snapshot ID | `SDS-20260918T173825Z-e4d0e8596510180c` | -| Manifest hash | `sha256:053313f08b9f173212225d855937b01f43dfa2f4e336421f89e8718aad2d6f1a` | -| State hash | `sha256:fb74919253ca54f43a12b3260cc13e1651b9d8065e68fcad3161d5c071efe63d` | -| Ledger record | sequence 1, `sha256:ccb6f10f5214a4c36dd39032ff31f197eb3091a6adc819429d8378e37868a66e` | -| Collection status | COLLECTED | -| Evidence root | `/tmp/tmpiv5d2cqc/state` (DEV) | -| Independent verification | **PASS** — every hash recomputed from the stored preimages | - -### Host inventory — W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021) - -| | | -|---|---| -| Collection ID | `INV-20260918T000000Z-0000000000000000` | -| Artifact digest | `sha256:528c0c991f4f59b12463767aaef828d0f7cab70c7b2a0aa5980d42b26b984560` | -| Collected (UTC) | 2026-09-18T00:00:00Z | -| Inventory schema | 1 | -| Collection status | COLLECTED | - -The artifact digest is a content SHA-256 over the deterministic inventory artifact. It is **not** a snapshot hash and the inventory is **not** inside the frozen snapshot. - -## Platform - -| | | -|---|---| -| Operating system | AlmaLinux 9.7 (Moss Jungle Cat) | -| Version | 9.7 | -| Family | rhel centos fedora | -| Kernel | `5.14.0-611.45.1.el9_7.x86_64` | -| Architecture | x86_64 | -| Init system | systemd | -| Machine | virtual | -| Hypervisor | kvm | -| Vendor / product | QEMU / Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996) | - -## Compute and memory - -*Hardware observations. They change legitimately on virtualized hosts and do not by themselves indicate configuration drift.* - -| | | -|---|---| -| CPU | Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz | -| Vendor | GenuineIntel | -| Sockets / cores per socket | 2 / 1 | -| Logical CPUs | 2 | -| Memory | 1.9 GiB | -| Swap | — | - -## Storage - -| Device | Size | Class | Removable | Model | -|---|---|---|---|---| -| `sda` | 12.9 GB | ROTATIONAL | no | QEMU HARDDISK | -| `sr0` | 0.0 GB | OPTICAL | yes | QEMU DVD-ROM | - -| Mount point | Source | Type | Mode | Options | -|---|---|---|---|---| -| `/dev/shm` | `tmpfs` | tmpfs | read-write | `inode64,nodev,nosuid,rw,seclabel` | -| `/run` | `tmpfs` | tmpfs | read-write | `inode64,mode=755,nodev,nosuid,nr_inodes=819200,rw` | -| `/` | `/dev/sda4` | xfs | read-write | `attr2,inode64,logbsize=32k,logbufs=8,noquota,relatime` | -| `/sys/fs/selinux` | `selinuxfs` | selinuxfs | read-write | `noexec,nosuid,relatime,rw` | -| `/boot` | `/dev/sda3` | xfs | read-write | `attr2,inode64,logbsize=32k,logbufs=8,noquota,relatime` | -| `/boot/efi` | `/dev/sda2` | vfat | read-write | `codepage=437,dmask=0077,errors=remount-ro,fmask=0077,iocharset=ascii,relatime` | -| `/var/lib/nfs/rpc_pipefs` | `sunrpc` | rpc_pipefs | read-write | `relatime,rw` | -| `/run/user/1000` | `tmpfs` | tmpfs | read-write | `gid=1000,inode64,mode=700,nodev,nosuid,nr_inodes=50255` | - -**Utilisation** — a volatile observation, not configuration: - -| Mount point | Used | -|---|---| -| `/dev/shm` | 0.0% | -| `/run` | 1.4% | -| `/` | 7.7% | -| `/boot` | 14.9% | -| `/boot/efi` | 3.7% | -| `/run/user/1000` | 0.0% | - -## Network - -| Interface | State | MTU | -|---|---|---| -| `eth0` | UP | 1500 | - -**IPv4** - -| Address | Interface | Scope | -|---|---|---| -| `192.168.122.54/24` | `eth0` | global | - -**IPv6** - -| Address | Interface | Classification | -|---|---|---| -| `fe80::5054:ff:fed8:b374/64` | `eth0` | LINK_LOCAL | - -| Default route | Gateway | Interface | -|---|---|---| -| inet | `192.168.122.1` | `eth0` | - -**DNS** — `192.168.122.1` - -Source: `/etc/resolv.conf` - -Addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. - -## Time - -| | | -|---|---| -| Timezone | UTC | -| NTP provider | chrony | -| NTP enabled | yes | -| **Clock synchronized** | yes | -| Current source | `A29FC801` (stratum 4) | -| Offset | 0.131 ms | - -*NTP enabled and clock synchronized are different facts.* Configuration says what was intended; synchronization says what is true now. - -## Collection completeness - -| Subdomain | Status | Method | Reason | -|---|---|---|---| -| host | COLLECTED | `/proc/sys/kernel/hostname + /etc/hosts` | — | -| platform | COLLECTED | `/etc/os-release + uname(2) + /proc/1/comm` | — | -| machine | COLLECTED | `systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id` | — | -| compute | COLLECTED | `/proc/cpuinfo` | — | -| memory | COLLECTED | `/proc/meminfo` | — | -| storage | COLLECTED | `/sys/block + /proc/self/mounts + statvfs(2)` | — | -| network | COLLECTED | `ip -json addr/route` | — | -| dns | COLLECTED | `/etc/resolv.conf` | — | -| time | COLLECTED | `timedatectl show + chronyc tracking` | — | - -An incomplete observation is reported as incomplete. A missing tool is `NOT_TESTED`, a present tool that failed is `ERROR`, and neither is rendered as an empty success. - -## Limitations - -- Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from. -- Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id. -- Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift. -- Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. -- Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour. -- Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash. -- Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed. - diff --git a/lib/isedraf/cli.py b/lib/isedraf/cli.py index 4d89821..2711a68 100644 --- a/lib/isedraf/cli.py +++ b/lib/isedraf/cli.py @@ -161,9 +161,14 @@ def field(block, name): out.write("\nSTORAGE\n") for dev in field("storage", "devices") or []: - out.write(" %-17s : %s, %s\n" % ( + # D-114: the observations, not an invented class. `queue_rotational` is + # printed as a queue property because that is what it is. + rot = dev.get("queue_rotational") + rot_text = "rotational" if rot is True else ( + "non-rotational" if rot is False else "rotational unknown") + out.write(" %-17s : %s, %s, %s\n" % ( dev["name"], "%.1f GB" % (dev["size_bytes"] / 1e9) if dev["size_bytes"] else "?", - dev["type"])) + dev.get("kernel_subsystem") or "subsystem unknown", rot_text)) for fs in (field("storage", "filesystems") or [])[:8]: out.write(" %-17s : %s%s\n" % (fs["mount_point"], fs["fstype"], " (read-only)" if fs["read_only"] else "")) diff --git a/lib/isedraf/inventory/collectors.py b/lib/isedraf/inventory/collectors.py index 4d469e2..5f9983a 100644 --- a/lib/isedraf/inventory/collectors.py +++ b/lib/isedraf/inventory/collectors.py @@ -152,16 +152,37 @@ def dmi(name): if cpuinfo.ok: virtualized = bool(re.search(r"^flags\s*:.*\bhypervisor\b", cpuinfo.value, re.M)) + vendor, product = dmi("sys_vendor"), dmi("product_name") data = {"virtualized": virtualized, "hypervisor": hypervisor, - "vendor": dmi("sys_vendor"), "product": dmi("product_name")} - known = [v for v in data.values() if v is not None] - status = model.COLLECTED if len(known) >= 2 else model.PARTIAL + "vendor": vendor, "product": product} + + # Status is decided PER SOURCE, not by counting non-null values across the whole + # subdomain. The previous rule was `len(known) >= 2` over all four fields, and + # `virtualized` and `hypervisor` counted toward it - so on any virtualized host + # without SMBIOS (a cloud image, a container, a minimal VM) DMI could be entirely + # absent and the subdomain still reported COLLECTED, with vendor and product null + # and no reason, because a reason is only attached to PARTIAL. + # + # That is an incomplete collection reported as complete, which is the one thing an + # evidence engine cannot be wrong about. The Raspberry Pi case was handled correctly + # by accident: no DMI AND no hypervisor happened to fall below the threshold. + # BOTH identity fields, not either. One present and one absent is still an + # incomplete collection, and reporting COLLECTED with a null field is the same + # defect one size smaller. + missing = [n for n, v in (("vendor", vendor), ("product", product)) if v is None] + if not missing: + status, reason = model.COLLECTED, None + else: + status = model.PARTIAL + reason = ("SOURCE_ABSENT: /sys/class/dmi/id exposes no %s on this platform, so " + "machine identity is incomplete. Common on ARM single-board computers " + "and on virtualized or containerized hosts without SMBIOS. " + "Virtualization detection is reported separately and does not " + "establish machine identity." % " or ".join(missing)) return model.subdomain( status, data, method="systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - reason=None if status == model.COLLECTED - else "SOURCE_ABSENT: DMI is not exposed on this platform (common on ARM " - "single-board computers) and no hypervisor could be identified") + reason=reason) # --- compute and memory -------------------------------------------------------------------- @@ -229,30 +250,59 @@ def collect_storage(root="/"): devmodel = read_file(os.path.join(block, name, "device/model")) removable = read_file(os.path.join(block, name, "removable")) scsi_type = read_file(os.path.join(block, name, "device/type")) - is_removable = removable.ok and removable.value.strip() == "1" - - # Order matters. `rotational == 0` is true of an optical drive as well as an - # SSD, so the optical checks come FIRST - otherwise a DVD-ROM is reported as - # solid-state storage, which is what the first published sample did. - kind = model.DEVICE_UNKNOWN - if name.startswith("nvme"): - kind = model.DEVICE_NVME - elif name.startswith(("sr", "scd")) or ( - scsi_type.ok and scsi_type.value.strip() == "5"): # SCSI TYPE_ROM - kind = model.DEVICE_OPTICAL - elif name.startswith("vd"): - # virtio-blk. What backs it is not observable from the guest, so naming - # the transport is the honest answer rather than guessing the medium. - kind = model.DEVICE_VIRTUAL - elif rotational.ok: - kind = (model.DEVICE_ROTATIONAL if rotational.value.strip() == "1" - else model.DEVICE_SOLID_STATE) + devvendor = read_file(os.path.join(block, name, "device/vendor")) + + # D-114. Each dimension is recorded as itself. Nothing here infers a + # physical medium, a transport, or that this block device corresponds to + # one physical disk - an HP RAID logical volume presents as a single + # device over four of them. + # + # kernel_subsystem comes from the kernel's own device/subsystem link, not + # from a name prefix. FIXTURE-CONFINEMENT-001: the link is read LEXICALLY. + # os.path.realpath() would resolve it against the REAL filesystem and read + # the host's /sys/bus while believing it was reading a fixture root - the + # same escape that once let timedatectl run against the live host. + subsystem = None + link = os.path.join(block, name, "device", "subsystem") + try: + if os.path.islink(link): + subsystem = os.path.basename(os.readlink(link).rstrip("/")) or None + except OSError: + subsystem = None + + rot = None + if rotational.ok: + v = rotational.value.strip() + rot = True if v == "1" else (False if v == "0" else None) + + removable_flag = None + if removable.ok: + v = removable.value.strip() + removable_flag = True if v == "1" else (False if v == "0" else None) + + # Meaningful only within the SCSI family. A global `device/type == 5` test + # would be an assumption buried in a branch; scoping it is part of the + # field's definition. + peripheral = None + if subsystem == "scsi" and scsi_type.ok: + try: + peripheral = int(scsi_type.value.strip()) + except ValueError: + peripheral = None + + # Stable keys with null, never a shape that varies by subsystem: the same + # idiom SNAP-023 already fixes for manifest_core.host_id. A missing key is + # ambiguous - old schema, conditional serialization, or a defect - where + # null says the field belongs here and no value was established. devices.append({ "name": name, # /sys/block/*/size is in 512-byte sectors regardless of logical size. "size_bytes": int(size.value.strip()) * 512 if size.ok else None, - "type": kind, - "removable": is_removable, + "kernel_subsystem": subsystem, + "queue_rotational": rot, + "kernel_removable": removable_flag, + "scsi_peripheral_type": peripheral, + "vendor": devvendor.value.strip() if devvendor.ok else None, "model": devmodel.value.strip() if devmodel.ok else None, }) diff --git a/lib/isedraf/inventory/model.py b/lib/isedraf/inventory/model.py index a1a2be5..6522765 100644 --- a/lib/isedraf/inventory/model.py +++ b/lib/isedraf/inventory/model.py @@ -51,12 +51,21 @@ # --- block device classes --------------------------------------------------------------- # `rotational == 0` does not mean "SSD". An optical drive reports 0 too, which is how a # QEMU DVD-ROM was classified as SOLID_STATE in the first sample report anyone read. -DEVICE_NVME = "NVME" -DEVICE_SOLID_STATE = "SOLID_STATE" -DEVICE_ROTATIONAL = "ROTATIONAL" -DEVICE_OPTICAL = "OPTICAL" -DEVICE_VIRTUAL = "VIRTUAL" -DEVICE_UNKNOWN = "UNKNOWN" +# D-114: the overloaded `type` enum is RETIRED, not deprecated. It conflated kernel +# subsystem (NVME, VIRTUAL), block-queue behaviour (ROTATIONAL, SOLID_STATE) and +# peripheral class (OPTICAL) into one apparently authoritative answer, and the +# `rotational == 0 -> SOLID_STATE` branch made an SD card a solid-state drive and, +# before that, a DVD-ROM one. +# +# A convenient but semantically broken field that remains readable continues to be read, +# so there is no compatibility alias. Replaced by the dimensions Linux actually exposes: +# kernel_subsystem, queue_rotational, kernel_removable, scsi_peripheral_type. +# +# STORAGE-SEMANTICS-001: +# queue_rotational = false MUST NOT imply SOLID_STATE +# kernel_subsystem = scsi MUST NOT imply SATA / SAS / USB / iSCSI / FC +# kernel_subsystem = nvme MUST NOT imply a solid-state physical medium +# a block-layer object MUST NOT be assumed to represent one physical device # --- IPv6 address classes --------------------------------------------------------------- # Flattening these is how privacy addresses become permanent false churn: RFC 4941 diff --git a/lib/isedraf/report/render.py b/lib/isedraf/report/render.py index aec1ede..f2a16f8 100644 --- a/lib/isedraf/report/render.py +++ b/lib/isedraf/report/render.py @@ -172,15 +172,31 @@ def data(name): out.append("") devices = storage.get("devices") or [] if devices: - out.append("| Device | Size | Class | Removable | Model |") - out.append("|---|---|---|---|---|") + # D-114. Column headers name the SOURCE, so a reader cannot mistake a kernel + # flag for a physical fact: "Kernel removable flag", not "Removable". + out.append("| Device | Size | Kernel subsystem | Queue rotational | " + "Kernel removable flag | Vendor | Model |") + out.append("|---|---|---|---|---|---|---|") for dev in devices: size = "%.1f GB" % (dev["size_bytes"] / 1e9) if dev.get("size_bytes") else "—" - out.append("| `%s` | %s | %s | %s | %s |" - % (dev["name"], size, dev.get("type") or "—", - "yes" if dev.get("removable") else "no", + + def tri(v): + return "—" if v is None else ("true" if v else "false") + + out.append("| `%s` | %s | %s | %s | %s | %s | %s |" + % (dev["name"], size, + dev.get("kernel_subsystem") or "—", + tri(dev.get("queue_rotational")), + tri(dev.get("kernel_removable")), + dev.get("vendor") or "—", dev.get("model") or "—")) out.append("") + out.append("*Kernel-reported block-device attributes. `Queue rotational` and " + "`Kernel removable flag` describe how Linux presents the block " + "queue; neither establishes the physical storage medium, and a " + "block device does not necessarily correspond to one physical " + "disk (D-114).*") + out.append("") filesystems = storage.get("filesystems") or [] if filesystems: out.append("| Mount point | Source | Type | Mode | Options |") diff --git a/scripts/ci/check_storage_vocabulary.py b/scripts/ci/check_storage_vocabulary.py new file mode 100644 index 0000000..8a409bc --- /dev/null +++ b/scripts/ci/check_storage_vocabulary.py @@ -0,0 +1,179 @@ +# ============================================================================= +# ISEDRAF — Linux Host Assurance, State Delta & Evidence Engine (codename) +# ============================================================================= +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 Antonios Voulvoulis / ITCMS +# +# Purpose: Keep a retired inference from coming back as vocabulary. +# Implements: D-114, STORAGE-SEMANTICS-001, GOV-002 +# +# D-114 removed a storage field named `type` whose value was one of ROTATIONAL, +# SOLID_STATE, OPTICAL, REMOVABLE, VIRTUAL or NVME. Every one of those was a guess +# dressed as an observation: Linux exposes `queue/rotational`, a subsystem symlink and +# a SCSI peripheral type, and none of them establishes the physical medium. The engine +# now records what the kernel exposes and names the source in the column header. +# +# The code change was the easy half. The word ROTATIONAL had also travelled into a +# published sample report, where it sat as an example of what ISEDRAF claims, three +# schema versions after the field was gone. Nothing failed, because no gate reads +# documentation for retired vocabulary — the sample was the last consumer anyone +# thought to check, and it was found by hand. +# +# So this gate reads the whole published surface, not just the source. A file may use +# the retired words only to STATE that they are retired; a file that uses one as a +# value, a column or a field is rejected and told which one. +# +# meta:type="ci-gate" +# meta:owner="Antonios Voulvoulis / ITCMS" +# meta:stability="EXPERIMENTAL" +# meta:privilege="unprivileged" +# meta:mutates="none" +# meta:binaries="git,python3" +# ============================================================================= + +"""usage: check_storage_vocabulary.py [--self-test]""" +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True).strip()) + +# The retired values. Word-bounded and upper-case: these were enum values, and matching +# them case-insensitively would flag the ordinary English words "optical" and "virtual". +RETIRED_VALUES = ("ROTATIONAL", "SOLID_STATE", "OPTICAL", "REMOVABLE", "VIRTUAL", "NVME") +VALUE_RE = re.compile(r"\bDEVICE_(?:%s)\b|[\"']\b(?:%s)\b[\"']" + % ("|".join(RETIRED_VALUES), "|".join(RETIRED_VALUES))) + +# The retired FIELD, in the two shapes a storage device record can carry it: a JSON key, +# and a Markdown column header that means the same thing. `type` is far too common a word +# to match bare - `state_dimension`, `fstype` and `meta:type` are all legitimate - so the +# field check is anchored to a storage device record. +DEVICE_KEY_RE = re.compile(r"\"type\"\s*:\s*\"(?:%s)\"" % "|".join(RETIRED_VALUES)) +DEVICE_COL_RE = re.compile(r"^\|\s*Device\s*\|.*\|\s*(?:Class|Type)\s*\|", re.M) + +# Files that exist to RECORD the prohibition, and are therefore exempt from all three +# checks below. Each one must be able to write the retired vocabulary in every shape - +# as a value, as a device key, as a table column - because forbidding a shape means +# quoting it: the register and the amendment that retired it, the instruction file, the +# document that explains why the sample was withdrawn, the two tests that assert the +# words are ABSENT from rendered output, the harness that injects them to prove this +# gate fires, and this gate, whose self-test corpus is made of them. +# +# The risk this accepts is a real defect hiding in one of these ten files. It is small +# and bounded: none of them renders a device, none is a sample, and the four files that +# actually produce storage output - collectors.py, render.py, cli.py and the report +# model - are NOT on this list and are fully checked. The strength of the gate is the +# other 230 files, and the self-test proves each of the three checks independently of +# any file in the tree. +EXEMPT = { + "CLAUDE.md", + "scripts/ci/check_storage_vocabulary.py", + "scripts/ci/falsifiable.sh", + "docs/IMPLEMENTATION_QUESTIONS.md", + "docs/architecture/DECISIONS_REGISTER.md", + "docs/architecture/AMENDMENTS.md", + "docs/architecture/INTERNAL_RECORDS.md", + "docs/reference/CONTROL_EVIDENCE_MAP.md", + "lib/isedraf/inventory/model.py", + "tests/test_inventory.py", + "tests/test_report.py", +} + +SCANNED_SUFFIXES = (".py", ".md", ".json", ".sh", ".txt", ".in", ".yml", ".yaml") +FAIL = [] + + +def bad(msg): + FAIL.append(msg) + print(" FAIL %s" % msg) + + +def tracked_files(): + out = subprocess.check_output(["git", "ls-files"], cwd=str(ROOT), text=True) + for line in out.splitlines(): + p = ROOT / line + if p.suffix in SCANNED_SUFFIXES and p.is_file(): + yield line, p + + +def inspect(rel, text): + """Return the findings for one file. Separated so --self-test can drive it.""" + found = [] + if rel in EXEMPT: + return found + if DEVICE_KEY_RE.search(text): + found.append("carries a storage device `type` with a retired value") + if DEVICE_COL_RE.search(text): + found.append("renders a storage device table with a Class/Type column; the " + "column header must name the kernel source it came from") + m = VALUE_RE.search(text) + if m: + found.append("uses the retired storage vocabulary %s as a value" % m.group(0)) + return found + + +def self_test(): + """A gate that has never been observed to fail is not a gate.""" + must_fail = [ + ('{"name": "sda", "type": "ROTATIONAL"}', "retired value in a device record"), + ('{"name": "sr0", "type": "OPTICAL"}', "retired value in a device record"), + ("| Device | Size | Class | Removable | Model |\n|---|---|---|---|---|", + "device table with a Class column"), + ("| Device | Size | Type | Model |\n|---|---|---|---|", "device table with a Type column"), + ('status = DEVICE_SOLID_STATE', "retired enum constant"), + ("medium = 'ROTATIONAL'", "retired value as a string literal"), + ] + must_pass = [ + '{"name": "sda", "kernel_subsystem": "scsi", "queue_rotational": true}', + '{"name": "sr0", "scsi_peripheral_type": 5, "kernel_removable": true}', + "| Device | Size | Kernel subsystem | Queue rotational | Vendor | Model |", + '"state_dimension": "ACTIVE"', + '{"fstype": "tmpfs", "type": "tmpfs"}', + '# meta:type="ci-gate"', + "The device is optical media in the ordinary English sense.", + "queue_rotational is false; this does not establish a solid-state medium.", + '{"type": "VIRTUAL_MACHINE_LABEL_UNRELATED_TO_STORAGE"}', + ] + errs = 0 + for text, why in must_fail: + if not inspect("some/file.md", text): + print(" SELFTEST FAIL not detected (%s): %r" % (why, text[:60])); errs += 1 + for text in must_pass: + f = inspect("some/file.md", text) + if f: + print(" SELFTEST FAIL false positive: %r -> %s" % (text[:60], f)); errs += 1 + if errs: + print("=== storage vocabulary self-test FAILED ==="); sys.exit(1) + print(" OK storage vocabulary self-test: %d rejected, %d accepted, 0 false positives" + % (len(must_fail), len(must_pass))) + + +if "--self-test" in sys.argv: + self_test() + sys.exit(0) + +scanned = 0 +for rel, path in tracked_files(): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + scanned += 1 + for finding in inspect(rel, text): + bad("%s %s" % (rel, finding)) + +if scanned == 0: + # Z-18: a gate that passes over an empty input is not a gate. + bad("no files were scanned; the gate cannot have verified anything") + +if FAIL: + print("=== storage vocabulary gate FAILED ===") + print(" D-114 retired these words because the kernel does not support them.") + print(" Record what /sys exposes - kernel_subsystem, queue_rotational,") + print(" kernel_removable, scsi_peripheral_type - and name the source in the header.") + sys.exit(1) +print(" OK storage vocabulary: %d files, no retired D-114 device class on the surface" + % scanned) diff --git a/scripts/ci/falsifiable.sh b/scripts/ci/falsifiable.sh index 9877ae4..ac33a47 100755 --- a/scripts/ci/falsifiable.sh +++ b/scripts/ci/falsifiable.sh @@ -339,6 +339,76 @@ inject "D-90 a public artifact is assembled through a symlink leaving the reposi 'ln -s /etc/hostname docs/reference/external-input.txt && git add -f -A' \ 'symlink pointing OUTSIDE|licensing gate FAILED' +# STORAGE-SEMANTICS-001 (D-114). The retired inference must be provably dead, not +# merely absent. Each of these reintroduces it in a different disguise. +inject "STORAGE-SEMANTICS-001 non-rotational is inferred to be solid state" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"queue_rotational\": rot," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, old + "\n \"type\": \"SOLID_STATE\" if rot is False else None,", 1)) +PYX' \ + 'reappeared|SOLID_STATE|FAILED' + +inject "STORAGE-SEMANTICS-001 a physical medium field is reintroduced" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"kernel_removable\": removable_flag," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, old + "\n \"physical_medium\": \"SOLID_STATE\",", 1)) +PYX' \ + 'reappeared|physical_medium|FAILED' + +inject "STORAGE-SEMANTICS-001 the queue observation is deleted instead of the inference" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"queue_rotational\": rot," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, "", 1)) +PYX' \ + 'queue_rotational|FAILED' + +# FIXTURE-CONFINEMENT-001. +# +# The first version of this injection mutated readlink() to realpath() and DID NOT FIRE. +# Measured, not assumed: with a RELATIVE link inside a fixture root, realpath resolves +# within that root, and basename() of either is the same string. The two implementations +# are indistinguishable at this call site, so that mutation had nothing to detect and +# reporting it as a control would have been a green check proving nothing. +# +# The real failure mode is an implementation that VALIDATES the target - exists(), stat(), +# listdir() - because a fixture link legitimately points at something that is not there. +# That is what this mutates, and the test's deliberately non-existent target answers it. +inject "FIXTURE-CONFINEMENT-001 the subsystem link target is validated before use" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " if os.path.islink(link):" +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " if os.path.islink(link) and os.path.exists(link):", 1)) +PYX' \ + 'example_fake_subsystem|FAILED' + +# D-114 Defect A. An incomplete collection reported as complete is the one thing an +# evidence engine cannot be wrong about. +inject "SCOPE-022 DMI absent with virtualization present is reported COLLECTED" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " missing = [n for n, v in ((\"vendor\", vendor), (\"product\", product)) if v is None]" +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " missing = [] if hypervisor else [n for n, v in ((\"vendor\", vendor), (\"product\", product)) if v is None]", 1)) +PYX' \ + 'PARTIAL|reason|FAILED' + # D-111. The native control catalog is authored first and is ISEDRAF's own. The invariant # is frozen; these prove the gate enforcing it can refuse. inject "D-111 a production module is named after a framework provider" \ @@ -437,6 +507,26 @@ inject "D-86 the rpm changelog states a weekday the date never fell on" \ 'sed -i "s|^\* Fri Sep 18 2026|* Thu Sep 18 2026|" packaging/rpm/isedraf.spec.in' \ 'which was a|packaging metadata gate FAILED' +# D-114 / STORAGE-SEMANTICS-001. The retired storage vocabulary survived in a published +# sample report for three schema versions, because every gate read the source and none +# read the documentation. These three prove the gate now reads the whole surface: a +# device record carrying the retired value, a device table whose column hides the source, +# and the retired enum constant returning to the engine. +inject "D-114 a published document reports a storage device type of ROTATIONAL" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n" "{\"name\": \"sda\", \"type\": \"ROTATIONAL\"}" > docs/reference/PLATFORM_COMPATIBILITY.md' \ + 'retired value|storage vocabulary gate FAILED' + +inject "D-114 a storage table column says Class instead of naming its kernel source" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n%s\n" "| Device | Size | Class | Model |" "|---|---|---|---|" > docs/reference/PLATFORM_COMPATIBILITY.md' \ + 'Class/Type column|storage vocabulary gate FAILED' + +inject "D-114 the retired DEVICE_SOLID_STATE constant returns to the engine" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n" "DEVICE_SOLID_STATE = \"SOLID_STATE\"" >> lib/isedraf/inventory/collectors.py' \ + 'retired storage vocabulary|storage vocabulary gate FAILED' + # D-86. The deb and the rpm are built by two different implementations. Dropping a # document from one of them must be caught by comparing them, not by anyone remembering. inject "D-86 the rpm and the deb ship different documentation" \ @@ -602,18 +692,33 @@ inject "D-90 a private key path is committed into docs" \ 'printf "\nKey at ~/.ssh/id_ed25519_release for deploys.\n" >> docs/reference/GLOSSARY.md # privacy:planted-fixture' \ 'SSH_PRIVATE_KEY_PATH' -# W1-C1: an optical drive reports rotational=0 exactly like an SSD. Classifying on that -# alone put a QEMU DVD-ROM in the first published sample as SOLID_STATE. -inject "optical device classified by the rotational flag alone" \ +# W1-C1 -> D-114. An optical drive reports rotational=0 exactly like an SSD, and +# classifying on that alone put a QEMU DVD-ROM in the first published sample as +# SOLID_STATE. The branch this used to mutate no longer exists: D-114 retired the whole +# classification chain. STORAGE-SEMANTICS-002 says such an injection is REPLACED by one +# protecting the corrected model, not deleted - the concern survives, the mechanism +# changed. The peripheral type is now the evidence, and it must be recorded and scoped. +inject "D-114 the SCSI peripheral type is dropped from the observation" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"scsi_peripheral_type\": peripheral," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " \"scsi_peripheral_type\": None,", 1)) +PYX' \ + 'scsi_peripheral_type|FAILED' + +inject "D-114 the peripheral type leaks outside the SCSI family" \ 'python3 tests/test_inventory.py' \ 'python3 - <<'"'"'PYX'"'"' import pathlib p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() -old = " elif name.startswith((\"sr\", \"scd\")) or (" +old = " if subsystem == \"scsi\" and scsi_type.ok:" assert old in s, "mutation anchor miss" -p.write_text(s.replace(old, " elif False and (")) +p.write_text(s.replace(old, " if scsi_type.ok:", 1)) PYX' \ - 'OPTICAL|DEVICE_OPTICAL' + 'leaked into|FAILED' # W1-C1: the report tells its reader that incomplete observations explain themselves. inject "SCOPE-022 an incomplete collection carries no reason" \ diff --git a/scripts/ci/gate_coverage.json b/scripts/ci/gate_coverage.json index 73d60d8..1ec8359 100644 --- a/scripts/ci/gate_coverage.json +++ b/scripts/ci/gate_coverage.json @@ -149,6 +149,16 @@ "packaging/deb/control.in" ] }, + "check-storage-vocabulary": { + "script": "scripts/ci/check_storage_vocabulary.py", + "implements": "D-114, STORAGE-SEMANTICS-001, GOV-002", + "falsification": "D-114 a published document reports a storage device type of ROTATIONAL", + "eligible": [ + "lib/isedraf/inventory/*.py", + "lib/isedraf/report/*.py", + "docs/reference/*.md" + ] + }, "check-public-claims": { "script": "scripts/ci/check_public_claims.py", "implements": "C-01, D-88, D-89, D-90", diff --git a/scripts/ci/project_status.json b/scripts/ci/project_status.json index 6fe9656..1ec6c77 100644 --- a/scripts/ci/project_status.json +++ b/scripts/ci/project_status.json @@ -42,7 +42,8 @@ "inventory": { "status": "IMPLEMENTED", "evidence": "lib/isedraf/inventory/", - "command": "isedraf inventory" + "command": "isedraf inventory", + "note": ". Machine identity reports PARTIAL with an explicit reason whenever DMI is absent or incomplete; virtualization detection is separate and does not establish it" }, "report_json": { "status": "IMPLEMENTED", @@ -233,6 +234,11 @@ "status": "PLANNED", "design": "docs/architecture/ISEDRAF_PRODUCT_HLD.md", "note": "provider-authorized pack plus the customer's own provider entitlement, only after a written agreement. Zero providers approached, zero agreements, and the architecture being ready is not a provider having agreed" + }, + "block_storage_observation_model": { + "status": "IMPLEMENTED", + "evidence": "lib/isedraf/inventory/collectors.py", + "note": "D-114: the overloaded storage `type` is retired, not deprecated. Replaced by kernel_subsystem (from device/subsystem, read lexically), queue_rotational, kernel_removable and scsi_peripheral_type (scoped to the SCSI family), with stable keys and null. No physical_medium, transport or is_aggregate: a block-layer object is not necessarily one physical device" } }, "runtime": { diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 8dc5df6..593696b 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -23,6 +23,7 @@ import io import os import pathlib +import os import shutil import subprocess import sys @@ -309,7 +310,7 @@ class TestDeviceClassification(unittest.TestCase): """`rotational == 0` is true of an optical drive as well as an SSD.""" def device(self, name, rotational=None, removable=None, scsi_type=None, - devmodel=None): + devmodel=None, devvendor=None, subsystem=None): files = {"proc/self/mounts": ""} base = "sys/block/%s" % name files[base + "/size"] = "2097152\n" @@ -321,39 +322,172 @@ def device(self, name, rotational=None, removable=None, scsi_type=None, files[base + "/device/type"] = "%s\n" % scsi_type if devmodel is not None: files[base + "/device/model"] = "%s\n" % devmodel + if devvendor is not None: + files[base + "/device/vendor"] = "%s\n" % devvendor root = build_root(files) self.addCleanup(shutil.rmtree, root, True) + # D-114: kernel_subsystem comes from the kernel's own link, so a fixture that + # claims to test a subsystem must PROVIDE one. A RELATIVE link, and the + # collector reads it lexically - FIXTURE-CONFINEMENT-001. A target that does + # not exist is deliberate in one test: resolving it is not required. + if subsystem is not None: + devdir = os.path.join(root, "sys/block", name, "device") + if not os.path.isdir(devdir): + os.makedirs(devdir) + os.symlink("../../../bus/%s" % subsystem, + os.path.join(devdir, "subsystem")) return collectors.collect_storage(root)["data"]["devices"][0] - def test_optical_is_not_solid_state(self): - """The exact defect found in the first published sample: a QEMU DVD-ROM.""" - dev = self.device("sr0", rotational=0, removable=1, scsi_type=5, - devmodel="QEMU DVD-ROM") - self.assertEqual(dev["type"], model.DEVICE_OPTICAL) - self.assertTrue(dev["removable"]) + # ---- INV-MACHINE-NODMI-001 --------------------------------------------------- - def test_optical_detected_by_scsi_type_even_with_an_odd_name(self): - dev = self.device("sdz", rotational=0, scsi_type=5) - self.assertEqual(dev["type"], model.DEVICE_OPTICAL) + def _machine(self, files): + root = build_root(files) + self.addCleanup(shutil.rmtree, root, True) + return collectors.collect_machine(root) - def test_real_ssd_is_solid_state(self): - dev = self.device("sda", rotational=0, removable=0, scsi_type=0) - self.assertEqual(dev["type"], model.DEVICE_SOLID_STATE) + def test_absent_dmi_is_never_reported_as_collected(self): + """Defect A. Virtualization detection is not machine identity. - def test_spinning_disk(self): - self.assertEqual(self.device("sdb", rotational=1)["type"], - model.DEVICE_ROTATIONAL) + The previous rule counted non-null values across the whole subdomain, and + `virtualized` and `hypervisor` counted toward the threshold - so on any + virtualized host without SMBIOS, DMI could be entirely absent and the + subdomain still reported COLLECTED with vendor and product null and no + reason. An incomplete collection reported as complete. + """ + for name, files in ( + ("no DMI, no hypervisor", + {"proc/cpuinfo": "Hardware\t: BCM2835\n"}), + ("no DMI, hypervisor present", + {"proc/cpuinfo": "flags\t: hypervisor\n", + "sys/hypervisor/type": "kvm\n"})): + sd = self._machine(files) + self.assertEqual(sd["collection_status"], model.PARTIAL, name) + self.assertIsNone(sd["data"]["vendor"], name) + self.assertIsNone(sd["data"]["product"], name) + self.assertTrue(sd.get("reason"), "%s: PARTIAL with no reason" % name) + self.assertIn("SOURCE_ABSENT", sd["reason"], name) + + def test_partial_dmi_is_still_incomplete(self): + """One identity field present and one absent is not a complete collection.""" + sd = self._machine({"proc/cpuinfo": "x\n", + "sys/class/dmi/id/sys_vendor": "QEMU\n"}) + self.assertEqual(sd["collection_status"], model.PARTIAL) + self.assertIn("product", sd["reason"]) + + def test_complete_dmi_collects_without_a_reason(self): + sd = self._machine({"proc/cpuinfo": "x\n", + "sys/class/dmi/id/sys_vendor": "Dell Inc.\n", + "sys/class/dmi/id/product_name": "PowerEdge R640\n"}) + self.assertEqual(sd["collection_status"], model.COLLECTED) + self.assertIsNone(sd.get("reason")) + + # ---- D-114 / STORAGE-SEMANTICS-001 ------------------------------------------- + # + # The tests these replace asserted the DEFECT as expected behaviour. The worst was + # `test_real_ssd_is_solid_state`: its fixture proved only that a SCSI device + # reported non-rotational, and it asserted SOLID_STATE - exactly the inference + # D-114 prohibits. STORAGE-SEMANTICS-002 says such a test is replaced by a test of + # the corrected model, and that deleting it is not sufficient: the inverse must be + # asserted. + + def test_no_device_reports_a_physical_medium(self): + """STORAGE-SEMANTICS-001. The retired field must not come back under any name.""" + for kwargs in ({"rotational": 0, "scsi_type": 0, "subsystem": "scsi"}, + {"rotational": 1, "subsystem": "scsi"}, + {"rotational": 0, "subsystem": "nvme"}, + {"rotational": 0, "subsystem": "mmc"}, + {"rotational": 0, "scsi_type": 5, "subsystem": "scsi"}): + dev = self.device("dev0", **kwargs) + for retired in ("type", "physical_medium", "transport", "is_aggregate"): + self.assertNotIn(retired, dev, + "%s reappeared for %r" % (retired, kwargs)) + self.assertNotIn("SOLID_STATE", repr(dev)) + + def test_non_rotational_scsi_is_not_called_solid_state(self): + """Replaces test_real_ssd_is_solid_state, whose premise was false. + + The fixture proves a SCSI device reports non-rotational. It proves nothing + about the medium, and the record must say exactly that much. + """ + dev = self.device("sda", rotational=0, removable=0, scsi_type=0, + subsystem="scsi") + self.assertEqual(dev["kernel_subsystem"], "scsi") + self.assertIs(dev["queue_rotational"], False) + self.assertEqual(dev["scsi_peripheral_type"], 0) + + def test_queue_rotational_false_is_retained_as_evidence(self): + """Positive control. Without it, a future 'fix' could delete the observation + instead of the inference, and every negative test would still pass.""" + dev = self.device("sdc", rotational=0, subsystem="scsi") + self.assertIn("queue_rotational", dev) + self.assertIs(dev["queue_rotational"], False) + + def test_optical_is_recorded_as_a_scsi_peripheral_type(self): + """The sr0 regression, carried across the schema change. + + Its meaning survives: a DVD-ROM is never reported as solid-state. Its old + assertion could not, because it interrogated the retired field. + """ + dev = self.device("sr0", rotational=0, removable=1, scsi_type=5, + devmodel="QEMU DVD-ROM", subsystem="scsi") + self.assertEqual(dev["scsi_peripheral_type"], 5) + self.assertIs(dev["kernel_removable"], True) + self.assertNotIn("SOLID_STATE", repr(dev)) + + def test_scsi_peripheral_type_is_null_outside_the_scsi_family(self): + """Family scoping belongs to the field definition, not to a branch.""" + for sub in ("nvme", "mmc", "virtio"): + dev = self.device("dev0", rotational=0, scsi_type=5, subsystem=sub) + self.assertIsNone(dev["scsi_peripheral_type"], + "scsi_peripheral_type leaked into %s" % sub) + + def test_subsystem_comes_from_the_kernel_not_the_name(self): + """D-114: device/subsystem is authoritative; a name prefix is not.""" + self.assertIsNone(self.device("nvme0n1", rotational=0)["kernel_subsystem"]) + self.assertEqual( + self.device("sdz", rotational=0, subsystem="nvme")["kernel_subsystem"], + "nvme") - def test_nvme(self): - self.assertEqual(self.device("nvme0n1", rotational=0)["type"], - model.DEVICE_NVME) + def test_subsystem_link_is_read_lexically_and_never_resolved(self): + """FIXTURE-CONFINEMENT-001. The target deliberately does not exist. + + A lexical read returns the name. Any implementation calling realpath, stat or + exists fails here - and would, in a real fixture, read the HOST's /sys/bus + while believing it was reading the fixture root. + """ + dev = self.device("sdq", rotational=0, subsystem="example_fake_subsystem") + self.assertEqual(dev["kernel_subsystem"], "example_fake_subsystem") + + def test_missing_rotational_attribute_is_unknown_not_assumed(self): + dev = self.device("sdd", subsystem="scsi") + self.assertIsNone(dev["queue_rotational"]) + + def test_every_device_carries_stable_keys(self): + """Object shape does not vary by subsystem (SNAP-023 idiom).""" + keys = {"name", "size_bytes", "kernel_subsystem", "queue_rotational", + "kernel_removable", "scsi_peripheral_type", "vendor", "model"} + for sub in ("scsi", "nvme", "mmc", "virtio", None): + dev = self.device("dev0", rotational=0, subsystem=sub) + self.assertEqual(set(dev), keys, "shape varies for subsystem=%r" % sub) + + def test_raid_logical_volume_claims_no_physical_disk(self): + """Measured on a real Smart Array: four disks behind one block device.""" + dev = self.device("sda", rotational=1, removable=0, scsi_type=0, + devvendor="HP", devmodel="LOGICAL VOLUME", + subsystem="scsi") + self.assertEqual(dev["vendor"], "HP") + self.assertEqual(dev["model"], "LOGICAL VOLUME") + self.assertIs(dev["queue_rotational"], True) + self.assertNotIn("is_aggregate", dev) - def test_virtio_names_the_transport_rather_than_guessing_the_medium(self): - self.assertEqual(self.device("vda", rotational=1)["type"], - model.DEVICE_VIRTUAL) def test_unknown_when_nothing_says(self): - self.assertEqual(self.device("xyz0")["type"], model.DEVICE_UNKNOWN) + # D-114: unknown is expressed by null in each dimension, not by one + # UNKNOWN token standing in for four different unanswered questions. + dev = self.device("xyz0") + self.assertIsNone(dev["kernel_subsystem"]) + self.assertIsNone(dev["queue_rotational"]) + self.assertIsNone(dev["scsi_peripheral_type"]) class TestIncompleteAlwaysExplains(unittest.TestCase): diff --git a/tests/test_report.py b/tests/test_report.py index 1769f26..bcd137e 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -73,11 +73,15 @@ def block(status, data, method="fixture", reason=None): {"total_bytes": 34359738368, "swap_total_bytes": 0}), "storage": block(inventory_model.COLLECTED, { "devices": [{"name": "vda", "size_bytes": 250000000000, - "type": inventory_model.DEVICE_VIRTUAL, - "removable": False, "model": "QEMU"}, + "kernel_subsystem": "virtio", + "queue_rotational": False, "kernel_removable": False, + "scsi_peripheral_type": None, "vendor": None, + "model": "QEMU"}, {"name": "sr0", "size_bytes": 1073741824, - "type": inventory_model.DEVICE_OPTICAL, - "removable": True, "model": "QEMU DVD-ROM"}], + "kernel_subsystem": "scsi", + "queue_rotational": False, "kernel_removable": True, + "scsi_peripheral_type": 5, "vendor": None, + "model": "QEMU DVD-ROM"}], "filesystems": [{"source": "/dev/vda1", "mount_point": "/", "fstype": "xfs", "options": ["rw", "relatime"], "read_only": False}, @@ -287,7 +291,14 @@ def test_multiple_storage_devices_and_read_only_mount(self): def test_optical_device_is_not_reported_as_solid_state(self): text = render.to_markdown(self.build()) - self.assertIn("OPTICAL", text) + # D-114 / STORAGE-SEMANTICS-002: the OPTICAL vocabulary is retired. The + # report now carries the kernel subsystem and the queue attribute, with column + # headers naming their SOURCE so a kernel flag cannot read as a physical fact. + self.assertIn("Kernel subsystem", text) + self.assertIn("Queue rotational", text) + self.assertNotIn("OPTICAL", text) + self.assertNotIn("SOLID_STATE", text) + self.assertIn("neither establishes the physical storage medium", text) self.assertNotIn("SOLID_STATE", text) def test_unsynchronized_clock_raises_rec005(self): From b2fdf9e2a63e6d16317425361e91c526a65db7ab Mon Sep 17 00:00:00 2001 From: Antonios Voulvoulis Date: Sun, 20 Sep 2026 12:53:11 +0300 Subject: [PATCH 2/2] The coverage gate verified only what it had been told existed check_gate_coverage reports that every gate is wired into `make check`, reached by CI and falsifiable. It established that by iterating over gate_coverage.json - so its answer was only ever about gates that file already named. A gate nobody declared was not reported as uncovered. It was not reported at all, and the summary line still said every gate was accounted for. That is the failure this repository exists to prevent, one level up: an absence that reads as a pass. It surfaced when a new gate was added, wired into the Makefile, given falsification injections, and still passed coverage without ever being mentioned in the authority file. Reconciling the declaration against scripts/ci/ in both directions found seven live gate scripts that had never been declared: the meta-gate itself, five release-time gates that need built artifacts, and the private provider alignment gate. None was dead - every one is invoked by the Makefile, the release workflow or the falsification harness - but none was accounted for, and nothing would have noticed if one had been orphaned or deleted. A check_* script must now appear either in `gates`, aggregated into `make check`, or in `gates_outside_make_check` with the thing that runs it and the reason it is not aggregated. A declared script that no longer exists is also rejected, so the authority file cannot drift from the directory in either direction. Two injections prove both directions fire: an undeclared gate script appearing in scripts/ci/, and a declared gate script disappearing from the tree. Implements: GOV-001, GOV-002 Assisted-by: Claude (defect identification, implementation) --- docs/CURRENT_STATE.md | 2 +- scripts/ci/check_gate_coverage.py | 25 ++++++++++++++++++++ scripts/ci/falsifiable.sh | 13 +++++++++++ scripts/ci/gate_coverage.json | 38 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 511f0b9..40a1f84 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -112,7 +112,7 @@ Not asserted. Each number is counted at generation time. | | | |---|---| | Gates | 19 | -| Falsification injections | 109 | +| Falsification injections | 111 | | Golden vector cases | 15 | | Frozen artifacts | 7 | | Test files | 3 | diff --git a/scripts/ci/check_gate_coverage.py b/scripts/ci/check_gate_coverage.py index 696a8b6..a8c5c6c 100644 --- a/scripts/ci/check_gate_coverage.py +++ b/scripts/ci/check_gate_coverage.py @@ -123,6 +123,31 @@ def job_blocks(text): else: fail.append(f"{name}: declared scope {pat!r} matches no files - stale declaration") +# Every check_* script on disk must be accounted for in exactly one place. +# +# Until this existed, the gate verified only what gate_coverage.json DECLARED. A gate +# that nobody declared was not reported as uncovered - it was not reported at all, and +# the summary line still said every gate was wired, reached and falsifiable. That is the +# failure mode this whole file exists to prevent, one level up: an absence that reads as +# a pass. A newly added gate is now either aggregated into `make check` or listed with +# the reason it is not, and an orphaned script cannot sit in scripts/ci/ unrun. +declared_scripts = {g["script"] for g in spec["gates"].values()} +outside = spec.get("gates_outside_make_check", {}) +declared_scripts |= {g["script"] for k, g in outside.items() if not k.startswith("$")} + +for script in sorted(ROOT.glob("scripts/ci/check_*")): + if script.suffix not in (".py", ".sh"): + continue + rel = script.relative_to(ROOT).as_posix() + if rel not in declared_scripts: + fail.append(f"{rel}: a gate script that gate_coverage.json does not account for. " + f"Add it to `gates` if `make check` should run it, or to " + f"`gates_outside_make_check` with the reason it does not [GOV-001]") + +for rel in sorted(declared_scripts): + if not (ROOT / rel).exists(): + fail.append(f"{rel}: declared in gate_coverage.json but the script does not exist") + if fail: print("=== gate coverage FAILED ===", file=sys.stderr) for f in sorted(set(fail)): diff --git a/scripts/ci/falsifiable.sh b/scripts/ci/falsifiable.sh index ac33a47..4917f62 100755 --- a/scripts/ci/falsifiable.sh +++ b/scripts/ci/falsifiable.sh @@ -507,6 +507,19 @@ inject "D-86 the rpm changelog states a weekday the date never fell on" \ 'sed -i "s|^\* Fri Sep 18 2026|* Thu Sep 18 2026|" packaging/rpm/isedraf.spec.in' \ 'which was a|packaging metadata gate FAILED' +# GOV-001. The coverage gate used to verify only what was DECLARED, so an undeclared +# gate passed by not being mentioned. These two prove it now reconciles the declaration +# against the directory in both directions. +inject "GOV-001 a gate script is added that nothing declares or runs" \ + 'python3 scripts/ci/check_gate_coverage.py' \ + 'printf "import sys\nsys.exit(0)\n" > scripts/ci/check_orphan_example.py && git add -A' \ + 'does not account for|gate coverage FAILED' + +inject "GOV-001 a declared gate script is deleted from the tree" \ + 'python3 scripts/ci/check_gate_coverage.py' \ + 'git rm -q --cached scripts/ci/check_sbom.py && rm -f scripts/ci/check_sbom.py' \ + 'does not exist|gate coverage FAILED' + # D-114 / STORAGE-SEMANTICS-001. The retired storage vocabulary survived in a published # sample report for three schema versions, because every gate read the source and none # read the documentation. These three prove the gate now reads the whole surface: a diff --git a/scripts/ci/gate_coverage.json b/scripts/ci/gate_coverage.json index 1ec8359..8bba2bd 100644 --- a/scripts/ci/gate_coverage.json +++ b/scripts/ci/gate_coverage.json @@ -195,5 +195,43 @@ "private_only_gates": { "$comment": "Gates that read material outside the repository and therefore cannot run in public CI. They are NOT in `make check` and that is deliberate, not a coverage gap. Listed so the omission is visible rather than silent.", "check-provider-alignment": "reads the private provider licensing registry, located by the ISEDRAF_PROVIDER_REGISTRY environment variable and absent on a runner; skips with an explicit message and never reports a pass it did not earn" + }, + "gates_outside_make_check": { + "$comment": "Every check_* script that `make check` does NOT aggregate, with the reason and the thing that actually runs it. This section exists because the coverage gate used to verify only what was DECLARED: a new gate nobody listed was invisible to it, and passed coverage by not being mentioned. The gate now reconciles this file against scripts/ci/ and fails on any check_* script that appears in neither list, so a gate cannot be added without being accounted for, and one cannot be quietly orphaned.", + "check-gate-coverage": { + "script": "scripts/ci/check_gate_coverage.py", + "invoked_by": "make check-gate-coverage, CI, scripts/ci/falsifiable.sh", + "reason": "the meta-gate. It verifies the others, so aggregating it into the target it verifies would make it report on itself." + }, + "check-sbom": { + "script": "scripts/ci/check_sbom.py", + "invoked_by": "make check-sbom, scripts/ci/falsifiable.sh", + "reason": "needs built packages and rpm's own recorded per-file digests; there is nothing to cross-check before a build." + }, + "check-package-payload": { + "script": "scripts/ci/check_package_payload.sh", + "invoked_by": "make check-package-payload, scripts/ci/release_export.sh, scripts/ci/falsifiable.sh", + "reason": "compares the built deb and rpm payloads against each other; requires both artifacts." + }, + "check-reproducible": { + "script": "scripts/ci/check_reproducible.sh", + "invoked_by": "make check-reproducible, scripts/ci/falsifiable.sh", + "reason": "builds twice and compares bytes; too slow for the pre-commit path and meaningless without a build." + }, + "check-deb-ordering": { + "script": "scripts/ci/check_deb_ordering.sh", + "invoked_by": "make check-deb-ordering, scripts/ci/falsifiable.sh", + "reason": "reads the member order inside a built .deb archive." + }, + "check-attestation-falsifiable": { + "script": "scripts/ci/check_attestation_falsifiable.sh", + "invoked_by": ".github/workflows/release-candidate.yml", + "reason": "requires a real attestation from the release workflow; it cannot be produced or verified on a workstation." + }, + "check-provider-alignment": { + "script": "scripts/ci/check_provider_alignment.py", + "invoked_by": "make check-provider-alignment", + "reason": "private: reads the provider licensing registry located by ISEDRAF_PROVIDER_REGISTRY, which is absent on a runner. Also recorded in private_only_gates." + } } }