From 6e8d0ab7558dd08f47aa0668d8e21cd578dbc166 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:35:28 +0000 Subject: [PATCH] Harden compliance checks and add tested core ledger primitives Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/SECURITY.md | 412 +----------------- .github/workflows/estate-rules.yml | 30 ++ .github/workflows/rust-ci.yml | 3 +- .machine_readable/STATE.a2ml | 32 +- .machine_readable/identifier-allow.txt | 4 + .machine_readable/root-allow.txt | 3 + .tool-versions | 3 + 0-AI-MANIFEST.a2ml | 13 +- Justfile | 52 +-- PROOF-STATUS.adoc | 8 + README.adoc | 6 + SECURITY.adoc | 42 ++ build/just/validate.just | 55 +-- docs/AI_INSTALLATION_GUIDE.adoc | 43 ++ .../0006-ledger-and-proof-boundaries.adoc | 143 ++++++ docs/governance/COMPLIANCE-REVIEW.adoc | 158 +++++++ scripts/check-state.sh | 16 + scripts/test-core.sh | 21 + scripts/validate-template.sh | 54 +-- src/control/ladder.zig | 57 +++ src/control/safety.zig | 31 ++ src/ledger/README.adoc | 35 ++ src/ledger/lmdb.zig | 330 ++++++++++++++ src/signal/classifier.zig | 107 +++-- src/signal/sampler.zig | 231 ++++++++-- tests/workflows/compliance_regression_test.sh | 46 ++ tests/workflows/validate_workflows_test.sh | 15 +- 27 files changed, 1344 insertions(+), 606 deletions(-) create mode 100644 docs/AI_INSTALLATION_GUIDE.adoc create mode 100644 docs/decisions/0006-ledger-and-proof-boundaries.adoc create mode 100644 docs/governance/COMPLIANCE-REVIEW.adoc create mode 100644 scripts/check-state.sh create mode 100644 scripts/test-core.sh create mode 100644 src/control/ladder.zig create mode 100644 src/control/safety.zig create mode 100644 src/ledger/README.adoc create mode 100644 src/ledger/lmdb.zig create mode 100644 tests/workflows/compliance_regression_test.sh diff --git a/.github/SECURITY.md b/.github/SECURITY.md index b467420..3c60d7b 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,406 +1,12 @@ -# Security Policy +# Security policy for llm-grace - +Report vulnerabilities privately through +[GitHub private vulnerability reporting](https://github.com/hyperpolymath/llm-grace/security/advisories/new) +(if enabled), or email **j.d.a.jewell@open.ac.uk**. Do not post secrets, +private prompts, ledger contents, or exploit details in public issues. -We take security seriously. We appreciate your efforts to responsibly disclose vulnerabilities and will make every effort to acknowledge your contributions. - -## Table of Contents - -- [Reporting a Vulnerability](#reporting-a-vulnerability) -- [What to Include](#what-to-include) -- [Response Timeline](#response-timeline) -- [Disclosure Policy](#disclosure-policy) -- [Scope](#scope) -- [Safe Harbour](#safe-harbour) -- [Recognition](#recognition) -- [Security Updates](#security-updates) -- [Security Best Practices](#security-best-practices) - ---- - -## Reporting a Vulnerability - -### Preferred Method: GitHub Security Advisories - -The preferred method for reporting security vulnerabilities is through GitHub's Security Advisory feature: - -1. Navigate to [Report a Vulnerability](https://github.com/hyperpolymath/llm-grace/security/advisories/new) -2. Click **"Report a vulnerability"** -3. Complete the form with as much detail as possible -4. Submit — we'll receive a private notification - -This method ensures: - -- End-to-end encryption of your report -- Private discussion space for collaboration -- Coordinated disclosure tooling -- Automatic credit when the advisory is published - -### Alternative: Encrypted Email - -If you cannot use GitHub Security Advisories, you may email us directly: - -| | | -|---|---| -| **Email** | j.d.a.jewell@open.ac.uk | -| **PGP Key** | [Download Public Key]({{PGP_KEY_URL}}) | -| **Fingerprint** | `{{PGP_FINGERPRINT}}` | - -```bash -# Import our PGP key -curl -sSL {{PGP_KEY_URL}} | gpg --import - -# Verify fingerprint -gpg --fingerprint j.d.a.jewell@open.ac.uk - -# Encrypt your report -gpg --armor --encrypt --recipient j.d.a.jewell@open.ac.uk report.txt -``` - -> **⚠️ Important:** Do not report security vulnerabilities through public GitHub issues, pull requests, discussions, or social media. - ---- - -## What to Include - -A good vulnerability report helps us understand and reproduce the issue quickly. - -### Required Information - -- **Description**: Clear explanation of the vulnerability -- **Impact**: What an attacker could achieve (confidentiality, integrity, availability) -- **Affected versions**: Which versions/commits are affected -- **Reproduction steps**: Detailed steps to reproduce the issue - -### Helpful Additional Information - -- **Proof of concept**: Code, scripts, or screenshots demonstrating the vulnerability -- **Attack scenario**: Realistic attack scenario showing exploitability -- **CVSS score**: Your assessment of severity (use [CVSS 3.1 Calculator](https://www.first.org/cvss/calculator/3.1)) -- **CWE ID**: Common Weakness Enumeration identifier if known -- **Suggested fix**: If you have ideas for remediation -- **References**: Links to related vulnerabilities, research, or advisories - -### Example Report Structure - -```markdown -## Summary -[One-sentence description of the vulnerability] - -## Vulnerability Type -[e.g., SQL Injection, XSS, SSRF, Path Traversal, etc.] - -## Affected Component -[File path, function name, API endpoint, etc.] - -## Affected Versions -[Version range or specific commits] - -## Severity Assessment -- CVSS 3.1 Score: [X.X] -- CVSS Vector: [CVSS:3.1/AV:X/AC:X/PR:X/UI:X/S:X/C:X/I:X/A:X] - -## Description -[Detailed technical description] - -## Steps to Reproduce -1. [First step] -2. [Second step] -3. [...] - -## Proof of Concept -[Code, curl commands, screenshots, etc.] - -## Impact -[What can an attacker achieve?] - -## Suggested Remediation -[Optional: your ideas for fixing] - -## References -[Links to related issues, CVEs, research] -``` - ---- - -## Response Timeline - -We commit to the following response times: - -| Stage | Timeframe | Description | -|-------|-----------|-------------| -| **Initial Response** | 48 hours | We acknowledge receipt and confirm we're investigating | -| **Triage** | 7 days | We assess severity, confirm the vulnerability, and estimate timeline | -| **Status Update** | Every 7 days | Regular updates on remediation progress | -| **Resolution** | 90 days | Target for fix development and release (complex issues may take longer) | -| **Disclosure** | 90 days | Public disclosure after fix is available (coordinated with you) | - -> **Note:** These are targets, not guarantees. Complex vulnerabilities may require more time. We'll communicate openly about any delays. - ---- - -## Disclosure Policy - -We follow **coordinated disclosure** (also known as responsible disclosure): - -1. **You report** the vulnerability privately -2. **We acknowledge** and begin investigation -3. **We develop** a fix and prepare a release -4. **We coordinate** disclosure timing with you -5. **We publish** security advisory and fix simultaneously -6. **You may publish** your research after disclosure - -### Our Commitments - -- We will not take legal action against researchers who follow this policy -- We will work with you to understand and resolve the issue -- We will credit you in the security advisory (unless you prefer anonymity) -- We will notify you before public disclosure -- We will publish advisories with sufficient detail for users to assess risk - -### Your Commitments - -- Report vulnerabilities promptly after discovery -- Give us reasonable time to address the issue before disclosure -- Do not access, modify, or delete data beyond what's necessary to demonstrate the vulnerability -- Do not degrade service availability (no DoS testing on production) -- Do not share vulnerability details with others until coordinated disclosure - -### Disclosure Timeline - -``` -Day 0 You report vulnerability -Day 1-2 We acknowledge receipt -Day 7 We confirm vulnerability and share initial assessment -Day 7-90 We develop and test fix -Day 90 Coordinated public disclosure - (earlier if fix is ready; later by mutual agreement) -``` - -If we cannot reach agreement on disclosure timing, we default to 90 days from your initial report. - ---- - -## Scope - -### In Scope ✅ - -The following are within scope for security research: - -- This repository (`hyperpolymath/llm-grace`) and all its code -- Official releases and packages published from this repository -- Documentation that could lead to security issues -- Build and deployment configurations in this repository -- Dependencies (report here, we'll coordinate with upstream) - -### Out of Scope ❌ - -The following are **not** in scope: - -- Third-party services we integrate with (report directly to them) -- Social engineering attacks against maintainers -- Physical security -- Denial of service attacks against production infrastructure -- Spam, phishing, or other non-technical attacks -- Issues already reported or publicly known -- Theoretical vulnerabilities without proof of concept - -### Qualifying Vulnerabilities - -We're particularly interested in: - -- Remote code execution -- SQL injection, command injection, code injection -- Authentication/authorisation bypass -- Cross-site scripting (XSS) and cross-site request forgery (CSRF) -- Server-side request forgery (SSRF) -- Path traversal / local file inclusion -- Information disclosure (credentials, PII, secrets) -- Cryptographic weaknesses -- Deserialisation vulnerabilities -- Memory safety issues (buffer overflows, use-after-free, etc.) -- Supply chain vulnerabilities (dependency confusion, etc.) -- Significant logic flaws - -### Non-Qualifying Issues - -The following generally do not qualify as security vulnerabilities: - -- Missing security headers on non-sensitive pages -- Clickjacking on pages without sensitive actions -- Self-XSS (requires victim to paste code) -- Missing rate limiting (unless it enables a specific attack) -- Username/email enumeration (unless high-risk context) -- Missing cookie flags on non-sensitive cookies -- Software version disclosure -- Verbose error messages (unless exposing secrets) -- Best practice deviations without demonstrable impact - ---- - -## Safe Harbour - -We support security research conducted in good faith. - -### Our Promise - -If you conduct security research in accordance with this policy: - -- ✅ We will not initiate legal action against you -- ✅ We will not report your activity to law enforcement -- ✅ We will work with you in good faith to resolve issues -- ✅ We consider your research authorised under the Computer Fraud and Abuse Act (CFAA), UK Computer Misuse Act, and similar laws -- ✅ We waive any potential claim against you for circumvention of security controls - -### Good Faith Requirements - -To qualify for safe harbour, you must: - -- Comply with this security policy -- Report vulnerabilities promptly -- Avoid privacy violations (do not access others' data) -- Avoid service degradation (no destructive testing) -- Not exploit vulnerabilities beyond proof-of-concept -- Not use vulnerabilities for profit (beyond bug bounties where offered) - -> **⚠️ Important:** This safe harbour does not extend to third-party systems. Always check their policies before testing. - ---- - -## Recognition - -We believe in recognising security researchers who help us improve. - -### Hall of Fame - -Researchers who report valid vulnerabilities will be acknowledged in our [Security Acknowledgments](SECURITY-ACKNOWLEDGMENTS.md) (unless they prefer anonymity). - -Recognition includes: - -- Your name (or chosen alias) -- Link to your website/profile (optional) -- Brief description of the vulnerability class -- Date of report - -### What We Offer - -- ✅ Public credit in security advisories -- ✅ Acknowledgment in release notes -- ✅ Entry in our Hall of Fame -- ✅ Reference/recommendation letter upon request (for significant findings) - -### What We Don't Currently Offer - -- ❌ Monetary bug bounties -- ❌ Hardware or swag -- ❌ Paid security research contracts - -> **Note:** We're a community project with limited resources. Your contributions help everyone who uses this software. - ---- - -## Security Updates - -### Receiving Updates - -To stay informed about security updates: - -- **Watch this repository**: Click "Watch" → "Custom" → Select "Security alerts" -- **GitHub Security Advisories**: Published at [Security Advisories](https://github.com/hyperpolymath/llm-grace/security/advisories) -- **Release notes**: Security fixes noted in [CHANGELOG](CHANGELOG.md) - -### Update Policy - -| Severity | Response | -|----------|----------| -| **Critical/High** | Patch release as soon as fix is ready | -| **Medium** | Included in next scheduled release (or earlier) | -| **Low** | Included in next scheduled release | - -### Supported Versions - - - -| Version | Supported | Notes | -|---------|-----------|-------| -| `main` branch | ✅ Yes | Latest development | -| Latest release | ✅ Yes | Current stable | -| Previous minor release | ✅ Yes | Security fixes backported | -| Older versions | ❌ No | Please upgrade | - ---- - -## Security Best Practices - -When using llm-grace, we recommend: - -### General - -- Keep dependencies up to date -- Use the latest stable release -- Subscribe to security notifications -- Review configuration against security documentation -- Follow principle of least privilege - -### For Contributors - -- Never commit secrets, credentials, or API keys -- Use signed commits (`git config commit.gpgsign true`) -- Review dependencies before adding them -- Run security linters locally before pushing -- Report any concerns about existing code - ---- - -## Additional Resources - -- [Our PGP Public Key]({{PGP_KEY_URL}}) -- [Security Advisories](https://github.com/hyperpolymath/llm-grace/security/advisories) -- [Changelog](CHANGELOG.md) -- [Contributing Guidelines](CONTRIBUTING.md) -- [CVE Database](https://cve.mitre.org/) -- [CVSS Calculator](https://www.first.org/cvss/calculator/3.1) - ---- - -## Contact - -| Purpose | Contact | -|---------|---------| -| **Security issues** | [Report via GitHub](https://github.com/hyperpolymath/llm-grace/security/advisories/new) or j.d.a.jewell@open.ac.uk | -| **General questions** | [GitHub Discussions](https://github.com/hyperpolymath/llm-grace/discussions) | -| **Other enquiries** | See [README](README.md) for contact information | - ---- - -## Policy Changes - -This security policy may be updated from time to time. Significant changes will be: - -- Committed to this repository with a clear commit message -- Noted in the changelog -- Announced via GitHub Discussions (for major changes) - ---- - -*Thank you for helping keep llm-grace and its users safe.* 🛡️ - ---- - -Last updated: 2026 · Policy version: 1.0.0 +This is development software. No stable-version support or backport schedule +has been established. See the canonical policy for scope, safe research, +response targets, and deployment precautions. diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml index 7fce3df..3081b70 100644 --- a/.github/workflows/estate-rules.yml +++ b/.github/workflows/estate-rules.yml @@ -45,3 +45,33 @@ jobs: - name: Neutral template (no project identifiers) run: bash scripts/check-no-project-identifiers.sh . + + - name: State required fields + run: bash scripts/check-state.sh + + - name: Required workflow capabilities + run: bash tests/workflows/validate_workflows_test.sh + + - name: Compliance guardrail regression tests + run: bash tests/workflows/compliance_regression_test.sh + + core-tests: + name: Zig core and LMDB crash tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Install LMDB and pinned Zig distribution + run: | + sudo apt-get update + sudo apt-get install --yes liblmdb-dev python3-venv + python3 -m venv "$RUNNER_TEMP/grace-tools" + "$RUNNER_TEMP/grace-tools/bin/pip" install ziglang==0.15.2 + ZIG_PATH=$("$RUNNER_TEMP/grace-tools/bin/python" -c 'import pathlib, ziglang; print(pathlib.Path(ziglang.__file__).parent / "zig")') + ln -s "$ZIG_PATH" "$RUNNER_TEMP/grace-tools/bin/zig" + echo "$RUNNER_TEMP/grace-tools/bin" >> "$GITHUB_PATH" + - name: Run bounded core tests + run: bash scripts/test-core.sh + - name: Run optimized core tests + run: bash scripts/test-core.sh -O ReleaseSafe diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 458bd43..fde99ee 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -36,8 +36,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 with: - toolchain: master - with: + toolchain: stable components: clippy, rustfmt - name: Cache cargo registry and build diff --git a/.machine_readable/STATE.a2ml b/.machine_readable/STATE.a2ml index e76237a..ea1ec1d 100644 --- a/.machine_readable/STATE.a2ml +++ b/.machine_readable/STATE.a2ml @@ -2,26 +2,22 @@ ;; Project state — update throughout each session (state (metadata - (version "1.0.1") - (project "rsr-template-repo") - (last-updated "2026-04-04")) + (version "1.0.2") + (project "llm-grace") + (last-updated "2026-09-28")) (project-context - (description "RSR Standard Repository Template — baseline for all hyperpolymath projects") + (description "Graceful degradation for concurrent LLM/agent terminals") (primary-language "Idris2 (ABI) + Zig (FFI)") - (status "testing-complete")) + (status "development; release readiness not established")) (current-position - (phase "testing") - (completion-percentage 100) - (milestone "CRG C - Testing & Benchmarking complete")) + (phase "implementation-and-validation") + (milestone "Issue #4: ledger and checked sampler primitives implemented; monitor integration pending")) (testing-summary - (validation-script "scripts/validate-template.sh: PASS (0 errors)") - (workflow-tests "tests/workflows/validate_workflows_test.sh: PASS (21/21 workflows)") - (integration-tests "test/integration_test.zig: PASS (placeholder template)") - (e2e-tests "tests/e2e/template_instantiation_test.sh: READY") - (benchmarks "benches/template_bench.sh: PASS (5 suites)") - (zig-build "Zig 0.15.2 compatible: PASS")) + (repository-structure "Local structural checks pass; not a release gate") + (compiler-checks "Zig 0.15.2: core sampler/control/ledger tests; Idris2 unavailable; FFI build is scaffolding") + (security-scans "Not run in compliance audit: scanners unavailable") + (compliance-review "docs/governance/COMPLIANCE-REVIEW.adoc")) (critical-next-actions - ("Commit test suite" - "Push to GitHub" - "Verify CI workflows pass" - "Document test instantiation patterns"))) + ("Owner reconcile issue #2, ADR-0005 and draft REUSE.toml without automated relicensing" + "Run compiler, integration and security gates with required toolchains" + "Verify hosted governance and branch protection before release"))) diff --git a/.machine_readable/identifier-allow.txt b/.machine_readable/identifier-allow.txt index 7526b68..785960e 100644 --- a/.machine_readable/identifier-allow.txt +++ b/.machine_readable/identifier-allow.txt @@ -27,3 +27,7 @@ .machine_readable/ai/.cursorrules AffineScript .machine_readable/ai/.windsurfrules AffineScript docs/RSR_OUTLINE.adoc AffineScript +# Shared label taxonomy and classifier examples, not project identity. +.github/label-classifier.json AffineScript +.github/labels.json AffineScript +.github/scripts/classify-issue.jq AffineScript diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt index e825861..632cd50 100644 --- a/.machine_readable/root-allow.txt +++ b/.machine_readable/root-allow.txt @@ -78,3 +78,6 @@ container/ # may host Containerfile if not at build/ .gitlab-ci.yml # TODO: relocate to ci/.gitlab-ci.yml after GitLab project-setting update .pre-commit-config.yaml # TODO: relocate to ci/.pre-commit-config.yaml after invocation pattern decided tools/ # TODO: consolidate with scripts/ or document the split (pending decision) + +# Local Zig test/build cache; ignored by Git, created by `just test`. +.zig-cache/ diff --git a/.tool-versions b/.tool-versions index ce60c32..a643145 100644 --- a/.tool-versions +++ b/.tool-versions @@ -8,3 +8,6 @@ # zig 0.14.0 # idris2 0.7.0 rust nightly + +# Core sampler/ledger test toolchain (do not use a floating Zig version). +zig 0.15.2 diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 4b2acf7..36c1bdc 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -5,7 +5,7 @@ # [metadata] version = "0.1.0" -last-updated = "2026-05-18" +last-updated = "2026-09-28" [project] name = "llm-grace" @@ -32,3 +32,14 @@ items = [ { agent = "GEMINI", task = "estate audits, cross-repo sweeps, long-context triage, pattern detection" }, { agent = "VIBE", task = "UI/frontend, PanLL panels, ReScript components, theming, rapid prototyping" }, ] + +[compliance-review] +report = "docs/governance/COMPLIANCE-REVIEW.adoc" +licensing = "Issue #2 remains owner-manual only; no existing SPDX or license declarations changed" +status = "Local guardrail repairs; full RSR and release compliance not certified" + +[core-implementation] +issue = "https://github.com/hyperpolymath/llm-grace/issues/4" +decision = "docs/decisions/0006-ledger-and-proof-boundaries.adoc" +status = "Tested primitives; no live monitor, session autoconnection, or global deployment" +proofs = "Finite policy tests only; no new formal proofs claimed" diff --git a/Justfile b/Justfile index 2fb20e8..4ca20ee 100644 --- a/Justfile +++ b/Justfile @@ -124,36 +124,22 @@ clean-all: clean # TEST & QUALITY # ═══════════════════════════════════════════════════════════════════════════════ -# Run all tests +# Run real core tests (LMDB headers/library required; optional LMDB_PREFIX). test *args: - @echo "Running tests..." - # TODO: Replace with your test command - # Examples: - # cargo test {{args}} - # mix test {{args}} - # zig build test {{args}} - # deno test {{args}} - @echo "Tests passed!" + bash scripts/test-core.sh {{args}} -# Run tests with verbose output +# Zig's test runner already reports each named test and its outcome. test-verbose: - @echo "Running tests (verbose)..." - # TODO: Replace with verbose test command + just test -# Smoke test +# Pure signal smoke test (no LMDB required). test-smoke: - @echo "Smoke test..." - # TODO: Add basic sanity checks + zig test src/signal/sampler.zig -# Run end-to-end tests (full pipeline: build → run → verify) +# End-to-end monitor/hook tests are not implemented; never report fake success. e2e: - @echo "Running E2E tests..." - # TODO: Replace with your E2E test command. Examples: - # bash tests/e2e.sh # Shell-based E2E - # npx playwright test # Browser E2E - # mix test test/integration/e2e_test.exs # Elixir E2E - # cargo test --test end_to_end # Rust E2E - @echo "E2E tests passed!" + @echo "ERROR: live monitor/hook acceptance suite is not implemented (issue #4)" >&2 + @exit 1 # Run aspect tests (cross-cutting concern validation) aspect: @@ -287,14 +273,9 @@ deps: # Audit dependencies for vulnerabilities deps-audit: - @echo "Auditing for vulnerabilities..." - # TODO: Replace with your audit command - # Examples: - # cargo audit - # mix audit - @command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL --quiet . || true - @command -v gitleaks >/dev/null && gitleaks detect --source . --no-git --quiet || true - @echo "Audit complete" + @command -v trivy >/dev/null || { echo "ERROR: trivy is required" >&2; exit 1; } + trivy fs --severity HIGH,CRITICAL --exit-code 1 . + # ═══════════════════════════════════════════════════════════════════════════════ # DOCUMENTATION @@ -529,10 +510,8 @@ install-hooks: # Run security audit security: deps-audit - @echo "=== Security Audit ===" - @command -v gitleaks >/dev/null && gitleaks detect --source . --verbose || true - @command -v trivy >/dev/null && trivy fs --severity HIGH,CRITICAL . || true - @echo "Security audit complete" + @command -v gitleaks >/dev/null || { echo "ERROR: gitleaks is required" >&2; exit 1; } + gitleaks detect --source . --redact # Generate SBOM sbom: @@ -678,7 +657,8 @@ maint-assault: # Run panic-attacker pre-commit scan (foundational floor-raise requirement) assail: - @command -v panic-attack >/dev/null 2>&1 && panic-attack assail . || echo "WARN: panic-attack not found — install from https://github.com/hyperpolymath/panic-attacker" + @command -v panic-attack >/dev/null 2>&1 || { echo "ERROR: panic-attack is required" >&2; exit 1; } + panic-attack assail . # Self-diagnostic — checks dependencies, permissions, paths diff --git a/PROOF-STATUS.adoc b/PROOF-STATUS.adoc index 0f27320..8af7326 100644 --- a/PROOF-STATUS.adoc +++ b/PROOF-STATUS.adoc @@ -98,3 +98,11 @@ panic-attack assail --proofs-only | 2026-04-04 | Initial proof status tracking | Template |=== + +== Core Ledger and Control Review (2026-09-28) + +No new formal proofs are claimed. The finite control model now has exhaustive +state/event tests in `src/control/ladder.zig`; ledger process-crash tests and +checked sampler fixtures are executable evidence, not Lean/Agda/Idris proofs. +See `docs/decisions/0006-ledger-and-proof-boundaries.adoc` for concrete proof +targets and scoped applications of the owner's type-theory repositories. diff --git a/README.adoc b/README.adoc index ffd8986..6d2f8e6 100644 --- a/README.adoc +++ b/README.adoc @@ -75,3 +75,9 @@ link:docs/decisions/0005-licensing-mpl-now-pmpl-overlay-later.adoc[ADR-0005]. Scaffolded from `+rsr-template-repo+` (the neutral RSR skeleton). The full RSR placeholder bootstrap and the per-file SPDX relicense are tracked build sub-issues rather than rushed inline — foundation-first. + +== AI-Assisted Installation + +There is no production installer yet. For a safe development checkout, see +link:docs/AI_INSTALLATION_GUIDE.adoc[AI-assisted development setup]. +Local structural checks are not proof of runtime safety or release readiness. diff --git a/SECURITY.adoc b/SECURITY.adoc index 7fa19c7..cebcf42 100644 --- a/SECURITY.adoc +++ b/SECURITY.adoc @@ -9,3 +9,45 @@ assessment within 7 days - Fix or mitigation within 90 days *Safe harbour:* We will not pursue legal action against security researchers who follow responsible disclosure. + + +=== Scope and Supported Versions + +llm-grace is development software; no stable-release or historical-version +support promise is established. Reports against the current development branch +are welcome. Response timelines above are targets, not guaranteed remediation +deadlines. No monetary bounty is offered. + +Private reporting is also available at +https://github.com/hyperpolymath/llm-grace/security/advisories/new[GitHub Security Advisories] +when enabled; use the email address above if it is unavailable. +Do not publish credentials, private prompts, process environments, or ledger +contents in public issues. Include the affected commit, minimal reproduction, +impact, and a redacted diagnostic sample. + +=== Safe Research and Deployment + +Test only systems and sessions you own or have explicit permission to test. +Do not load-test shared hosts, terminate unrelated processes, or inspect another +user's session data. Safe harbour does not extend to third-party infrastructure. + +Run memory-balloon and process-signal experiments in an isolated, disposable +session with resource limits. Do not run the sampler as root or enable global +process control before the isolation and recovery tests pass. Treat ledger +files and diagnostic artefacts as potentially sensitive; restrict access and +redact them before sharing. + +=== Contributor Security and Quality Gates + +* Never commit credentials or unredacted scanner output. +* `just security` requires Trivy and Gitleaks and fails on scanner errors or + configured findings; a missing scanner is not a clean scan. +* `just assail` requires panic-attack and propagates its exit status. +* `just validate` checks repository structure and documentation. It is not + evidence that runtime tests, proofs, external governance, or security scans pass. +* Review CI permissions, external action/tool provenance, dependencies, and + changes to process targeting, resource limits, and crash recovery. +* Follow `AUDIT.adoc` and `READINESS.adoc`; do not infer release readiness from + template tests or skipped jobs. + +See `docs/governance/COMPLIANCE-REVIEW.adoc` for outstanding audit limitations. diff --git a/build/just/validate.just b/build/just/validate.just index e775072..0bf72e5 100644 --- a/build/just/validate.just +++ b/build/just/validate.just @@ -10,56 +10,15 @@ # Validate RSR compliance validate-rsr: - #!/usr/bin/env bash - echo "=== RSR Compliance Check ===" - MISSING="" - for f in .editorconfig .gitignore Justfile README.adoc LICENSE; do - [ -f "$f" ] || MISSING="$MISSING $f" - done - for f in .machine_readable/STATE.a2ml .machine_readable/META.a2ml .machine_readable/ECOSYSTEM.a2ml .machine_readable/anchors/ANCHOR.a2ml .machine_readable/policies/MAINTENANCE-AXES.a2ml .machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml .machine_readable/policies/SOFTWARE-DEVELOPMENT-APPROACH.a2ml; do - [ -f "$f" ] || MISSING="$MISSING $f" - done - for f in licensing/exhibits/EXHIBIT-A-ETHICAL-USE.txt licensing/exhibits/EXHIBIT-B-QUANTUM-SAFE.txt licensing/texts/MPL-2.0.txt; do - [ -f "$f" ] || MISSING="$MISSING $f" - done - if [ ! -d "src/interface/Abi" ] && [ ! -d "src/interface/abi" ]; then - MISSING="$MISSING src/interface/Abi" - fi - for f in src/interface/ffi src/interface/generated; do - [ -d "$f" ] || MISSING="$MISSING $f" - done - for f in docs/governance/MAINTENANCE-CHECKLIST.adoc docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc; do - [ -f "$f" ] || MISSING="$MISSING $f" - done - if [ -f ".machine_readable/META.a2ml" ]; then - grep -q 'axis-1 = "must > intend > like"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:axis-1" - grep -q 'axis-2 = "corrective > adaptive > perfective"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:axis-2" - grep -q 'axis-3 = "systems > compliance > effects"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:axis-3" - grep -q 'scoping-first = true' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:scoping-first" - grep -q 'idris-unsound-scan = "believe_me/assert_total"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:idris-unsound-scan" - grep -q 'audit-focus = "systems in place, documentation explains actual state, safety/security accounted for, observed effects reviewed"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:audit-focus" - grep -q 'compliance-focus = "seams/compromises/exception register, bounded exceptions, anti-drift checks"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:compliance-focus" - grep -q 'effects-evidence = "benchmark execution/results and maintainer status dialogue/review"' .machine_readable/META.a2ml || MISSING="$MISSING META.a2ml:effects-evidence" - grep -q 'compliance-tooling = "panic-attack"' .machine_readable/policies/MAINTENANCE-AXES.a2ml || MISSING="$MISSING MAINTENANCE-AXES.a2ml:compliance-tooling" - grep -q 'effects-tooling = "ecological checking with sustainabot guidance"' .machine_readable/policies/MAINTENANCE-AXES.a2ml || MISSING="$MISSING MAINTENANCE-AXES.a2ml:effects-tooling" - grep -q 'source-human = "docs/governance/MAINTENANCE-CHECKLIST.adoc"' .machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml || MISSING="$MISSING MAINTENANCE-CHECKLIST.a2ml:source-human" - grep -q 'source-human = "docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc"' .machine_readable/policies/SOFTWARE-DEVELOPMENT-APPROACH.a2ml || MISSING="$MISSING SOFTWARE-DEVELOPMENT-APPROACH.a2ml:source-human" - fi - if [ -n "$MISSING" ]; then - echo "MISSING:$MISSING" - exit 1 - fi - echo "RSR compliance: PASS" + bash scripts/validate-template.sh . + bash scripts/check-root-shape.sh . + bash scripts/check-no-md-in-docs.sh . + bash tests/workflows/validate_workflows_test.sh -# Validate STATE.a2ml syntax +# Structural check for the repository's S-expression STATE format, not TOML. +# This is a required-field check, not a full A2ML parser or readiness proof. validate-state: - @if [ -f ".machine_readable/STATE.a2ml" ]; then \ - grep -q '^\[metadata\]' .machine_readable/STATE.a2ml && \ - grep -q 'project\s*=' .machine_readable/STATE.a2ml && \ - echo "STATE.a2ml: valid" || echo "STATE.a2ml: INVALID (missing required sections)"; \ - else \ - echo "No .machine_readable/STATE.a2ml found"; \ - fi + bash scripts/check-state.sh .machine_readable/STATE.a2ml # Validate AI installation guide completeness (finishbot pre-release check) validate-ai-install: diff --git a/docs/AI_INSTALLATION_GUIDE.adoc b/docs/AI_INSTALLATION_GUIDE.adoc new file mode 100644 index 0000000..9b5c448 --- /dev/null +++ b/docs/AI_INSTALLATION_GUIDE.adoc @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += llm-grace: AI-Assisted Development Setup + +[[ai-implementation]] +== Current Implementation Boundary + +This is a development checkout, not an installable production service. +The sampler and crash-safe ledger work is tracked in GitHub issue #4. +Do not install a global daemon, wire production hooks, or promise recovery of +user work based on this scaffolding. + +== Obtain and Inspect the Source + +[source,shell] +---- +git clone https://github.com/hyperpolymath/llm-grace.git +cd llm-grace +bash scripts/check-root-shape.sh . +bash scripts/check-state.sh +bash tests/workflows/compliance_regression_test.sh +---- + +Read `README.adoc`, `AUDIT.adoc`, `READINESS.adoc`, `SECURITY.adoc`, and +`docs/decisions/0003-graceful-degradation-architecture.adoc` before making changes. +With Just installed, `just validate` runs the local structural/documentation +gates. Zig and Idris2 are needed for compiler checks; skipped checks do not +constitute build evidence. `just security` additionally requires Trivy and +Gitleaks. Consult `.tool-versions` and the component build documentation before +selecting compiler versions. Do not pipe remote installation scripts into a shell +without reviewing and verifying their provenance. + +== Privacy and Isolation + +Never send credentials, private prompts, ledger records, or full process +environments to an assistant. Review commands before execution. Keep experimental +memory pressure and process signalling inside a disposable, resource-limited +session; no privileged or global deployment is authorized by this guide. + +== Licensing Boundary + +Issue #2 reserves existing license/SPDX changes to manual owner review. +Agents must not relicense files, expand blanket licensing globs, or rewrite +third-party headers as part of setup. Report conflicts instead. diff --git a/docs/decisions/0006-ledger-and-proof-boundaries.adoc b/docs/decisions/0006-ledger-and-proof-boundaries.adoc new file mode 100644 index 0000000..c3c24ac --- /dev/null +++ b/docs/decisions/0006-ledger-and-proof-boundaries.adoc @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += ADR-0006: Retain LMDB; make evidence and failure boundaries explicit +:status: ACCEPTED FOR IMPLEMENTATION +:revdate: 2026-09-28 + +== Decision + +Retain the LMDB choice in ADR-0003 for issue #4. The owner agreed to retaining +LMDB rather than switching to CubDB. This review found no new requirement that +justifies a different database. This is a fit decision, not a comparative +benchmark or a claim that LMDB cannot fail. + +SQLite is the strongest fallback candidate if schema/query needs or integration +complexity change. It merits a prototype if those needs arise; it does not +remove the need to configure durability, bound contention, handle full storage, +or test recovery. Do not build a second backend speculatively. + +CubDB remains reasonable for an intentionally Elixir/OTP-based coordinator. +Its single owning instance per directory would require a service boundary for +our independent Zig clients. That is an architecture change, not a transparent +replacement for a multi-process LMDB environment. No database can recover work +that was never captured, or guarantee a finite storage-operation latency under +an unresponsive kernel/device. + +== Implemented Boundary + +`src/ledger/lmdb.zig` is a primitive, not the full coordinator: + +* Three named stores: latest session records, ordered immutable events, metadata. +* Schema marker `1`; unsupported versions are rejected, not silently migrated. +* Record <= 4096 bytes; nonempty session identifier <= 128 bytes. +* One write transaction updates the session, event, and monotone sequence. +* Events carry a supplied wall-clock timestamp; order uses the sequence, not + the wall clock. The caller must supply correct session identity and content. +* Default-sync commits with no relaxed durability or writable-map flags. +* Fixed map size, explicit map-full/disk-full failures, no automatic growth. +* Reads copy data before ending the read transaction; no mmap pointer escapes. + +Deployment preconditions: trusted private directory, local storage, restricted +file access, one environment per process/path, no inherited live environment +after fork, no concurrent close against users. LMDB files use mode 0600 when +created; this does not fix permissions on pre-existing files or parent paths. + +Writer-lock acquisition and fsync are not bounded-time operations. Hooks must +not call this primitive synchronously. The future coordinator/IPC boundary must +provide bounded waits and backpressure; a timeout means *outcome unknown*, not +"the commit did not happen". Retried commands will need stable idempotency keys. +The current append primitive does not deduplicate retries. + +`src/control/safety.zig` checks the plain OFF entry without touching LMDB. Any +entry, including a dangling symlink, disables enforcement. Unexpected stat +errors also disable enforcement. Call it before entering IPC/ledger operations; +a future wait loop must recheck it rather than only checking once at startup. + +`src/control/ladder.zig` is a pure finite policy kernel, with exhaustive finite +transition tests. It does not send signals or authenticate events. A caller must +only emit `checkpoint_committed` after its session checkpoint transaction really +succeeds. Recovery resumes PAUSE, not CHECKPOINT; TERMINATED is absorbing; OFF +bypasses enforcement without erasing a recorded latch. + +== Mathematical Help from the Owner's Type Repositories + +These are source-review findings and proposed applications, not imported proofs. +No Lean/Agda library has been vendored, executed, or connected to the Zig code. +Pinned inspected revisions: + +[cols="1,2,3",options="header"] +|=== +|Repository |Revision |Useful boundary +|https://github.com/hyperpolymath/tropical-types[tropical-types] +|`9a5c34f20aba2589d44e8b1bbf32540551ffa395` +|Max-plus resource algebra: sequential latency budgets add; alternative-path +budgets take max. Use additive grades for simultaneously retained memory. +Do not use max to undercount concurrent memory demand. + +|https://github.com/hyperpolymath/epistemic-types[epistemic-types] +|`d97ecaab042ce3b793e151dbead43f9ffb9b88d7` +|Distinguish observations, hypotheses, commit receipts and justified claims. +Its store-history/read-consistency model is especially relevant: relabelling +an old sample with a new timestamp cannot make it current evidence. + +|https://github.com/hyperpolymath/echo-types[echo-types] +|`39a7a99cbe19a918843e9624010510b5fc3b8366` +|Model checkpoint projection and information loss. If two full states produce +the same checkpoint, a general exact-recovery function is not justified. +A fiber witness is not free compression or recovery of uncaptured RAM. + +|https://github.com/hyperpolymath/residual-evidence-types[residual-evidence-types] +|`eb01e18a384f34489fbb14907b19bc4a7b4675bd` +|Research direction for retaining bounded uncertainty after partial observations. +Its narrow checked core is not a ready-made crash-recovery proof. + +|https://github.com/hyperpolymath/choreographic-types[choreographic-types] +|`8ab0d8f540581498d551d482bad68f0eeaaa2a04` +|Potential future model for multi-session protocol consistency. Its README calls +K-CUT OPEN and says the general formalisation is not complete. Do not rely on +repository metadata suggesting otherwise. + +|https://github.com/hyperpolymath/occupancy-types[occupancy-types] +|`5fe6f8676caeb8396996c6d130a2a6fcda5bb336` +|The inspected README is still a scaffold. No applicable result was established +by this review; do not infer one from the repository name. +|=== + +== Concrete Proof Targets, Not Yet Discharged + +. *Policy safety*: checkpoint cannot auto-resume, termination requires a prior + acknowledged checkpoint, and terminated sessions cannot be auto-relaunched. + Finite transition tests now cover the policy model, but event authenticity, + crash interleavings, and correspondence to a controller remain obligations. +. *Freshness*: successful classification requires valid observations from the + intended sample epoch. Missing readings and reset counters are not zeros. + The checked sampler rejects these cases; its Raw type is not proof-indexed. +. *Atomicity*: session record, event and sequence appear together or not at all. + Tests exercise LMDB's contract; they are not a mechanised storage-engine proof. +. *Conditional budget*: if each primitive has a certified bound, compose bounds + for a monitor iteration. Unbounded fsync/lock acquisition cannot be assigned + an invented finite grade. Isolate them behind a deadline-aware interface. +. *Checkpoint adequacy*: define exactly which resume facts must be preserved and + prove recovery for that projection, rather than claiming full-state recovery. + +== Closure Criteria for Issue #4 + +Completed locally: checked sampler primitives, bounded ledger primitive, +process-crash/concurrent-writer tests, plain-file bypass helper, finite ladder +model, and real Debug/ReleaseSafe test entry points. + +Still required before closure: + +* Live single-monitor ownership and bounded /proc reads on a monotonic schedule. +* Per-session process identity (including PID reuse), RSS and motion observation. +* Trusted whole-device selection; use `diskBusyPercent` rather than summing all + diskstats rows. The legacy raw parser/reducer remains for fixtures and must + not be mistaken for the checked monitor boundary. +* Session startup autoconnection, versioned record schema, heartbeat/retention + policy, retry deduplication, and bounded IPC/backpressure. +* End-to-end OFF rechecks while waits/storage are stalled; real hook integration. +* Isolated resource-pressure acceptance run, realistic disk-full/I/O-failure + injection, and explicitly scoped host/VM crash-durability evidence. + +No system-wide hooks, background monitor, process-killing controller, or memory +balloon is enabled by these changes. Issue #4 remains open until the above is +implemented and tested. Issue #2 remains strictly manual owner-only. diff --git a/docs/governance/COMPLIANCE-REVIEW.adoc b/docs/governance/COMPLIANCE-REVIEW.adoc new file mode 100644 index 0000000..e0e6147 --- /dev/null +++ b/docs/governance/COMPLIANCE-REVIEW.adoc @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Licensing, Security and RSR Compliance Review +:revdate: 2026-09-28 + +== Scope and Disposition + +Repository-local review against issue #2, ADR-0005, `AUDIT.adoc`, the existing +validation scripts, and the current upstream template overview. This is not a +full security audit, legal opinion, or certification of RSR/release readiness. +Upstream `hyperpolymath/rsr-template-repo` was inspected at +`fcfbac22116053c208bf5f8ff353cbf95e4b2141`; its `docs/RSR_OUTLINE.adoc` explicitly +warns that older hand-written directory maps are superseded. Do not blindly +copy its current skeleton or license declarations into this derived project. + +== Owner-Only Licensing Decision: Still Open + +https://github.com/hyperpolymath/llm-grace/issues/2[Issue #2] prohibits any automated +SPDX migration, including an agent pass. Its June 2 owner comment reaffirms the +exclusion from the estate-wide sweep. No existing SPDX headers, copyright +attributions, `LICENSE`, `LICENSES/`, or `REUSE.toml` were changed in this review. +Third-party and component declarations are not ours to alter. + +The owner must reconcile these records manually: + +* Issue #2 still describes approximately 199 PMPL headers needing manual review. + A read-only tracked-text search during this review found no SPDX lines matching + `PMPL`, `MPL-2.0-or-later`, or `AGPL`. This is not provenance or legal clearance. +* ADR-0005 says there is no licensing debt and withdraws the previous premise, + while preserving the owner-only constraint. It also describes new files as + CC-BY-SA-4.0 despite the project-code MPL declaration. +* `REUSE.toml` is marked DRAFT and asserts broad owner copyright/MPL coverage. + Its comment says existing headers are retained, but blanket annotations still + need review for interaction with document licenses and other authors' work. + Its explicit paths include pre-migration `.md` filenames. +* Its AGPL carve-out comment for `k9iser-regen.yml` conflicts with the current + workflow's MPL header. Flagged only; neither the header nor annotation changed. + +Only the owner can determine provenance, correct individual declarations, approve +REUSE scope, and decide whether to close or retitle issue #2. Do not use a clean +text search, root license, or this report as authorization to relicense files. + +== Repairs Made + +* Structural validation now uses this checkout's `src/interface/Abi` casing, + current `LICENSES/` layout, and consolidated governance workflow rather than + requiring retired per-policy workflows and nonexistent licensing exhibits. +* Missing structural inputs accumulate in the summary. Compiler exit status, + not diagnostic wording, decides success. Workflow presence failures are errors. +* STATE validation checks the actual S-expression fields and fails for absent or + wrong-project state. It does not claim to parse all A2ML syntax. Template claims + of 100% completion and completed tests were removed from the project state. +* Local security recipes propagate failures and reject missing required tools. + Trivy has an explicit findings exit code; Gitleaks output is redacted. +* The GitHub security entry point now links to the canonical AsciiDoc policy, + with no invented stable-release/backport promise or unfilled PGP URL. +* Added development-only AI setup guidance, privacy/isolation precautions, and + a README entry point, without pretending there is a production installer. +* Fixed duplicate YAML `with` keys in Rust CI and the invalid `master` toolchain. +* Scoped language-name exceptions to label-taxonomy/classifier files, not whole + directories. Existing project-subtree and identity guards remain active. +* Added CI regression tests for missing state/workflows/files and silently + failing compilers. These use mock compilers, not runtime integration evidence. + +== Verification and Remaining Gates + +Local checks performed: + +* Root shape and AsciiDoc-default checks: pass. +* Project-identifier check: pass after narrow taxonomy exceptions. +* Workflow structure/presence check: 23 workflows, zero errors/warnings. +* STATE required-field check and compliance failure-path regression tests: pass. +* Template validator: zero errors, three warnings (Just interpolation detected + by the broad placeholder heuristic; Zig and Idris2 unavailable). +* Shell syntax and `git diff --check`: pass. + +Not verified or still needing work: + +* Just, Zig, Idris2, REUSE, ShellCheck, Trivy, Gitleaks, and panic-attack were not + available in this audit environment. Do not claim their checks passed. +* Full RSR schema/semantic validation, proof obligations, runtime isolation, + crash recovery, realistic fuzzing and benchmarks remain release gates. +* `static-analysis-gate.yml` still downloads an unverified latest scanner and + can produce empty findings for unavailable scans. Its output contract and + missing-scan semantics need a separately tested integration repair; a green + scan job is not proof that the scanner ran. Review other remote tool downloads + and mutable action refs using the estate's actions-lock tooling. +* Hosted branch-protection inspection returned HTTP 403 (integration lacks + access). An administrator must verify required checks, reviews, least-privilege + workflow permissions, private reporting, and secret-scanning settings. +* Current upstream template layout differs from this historical checkout. + Migration of machine-readable metadata, generated skeleton contracts, and + duplicated governance documents requires a versioned migration, not wholesale + copying. The current checks establish local structure, not complete upstream + parity. Several inherited build/release recipes are still scaffolding. + +Issue #2 remains open. No remote settings, issue status, releases, branch +protection, or licensing records were mutated by this review. + +== Follow-up: Tool Installation and Executed Checks + +Later in the same review, the owner authorized installing the missing tools. +Installed outside the repository under `/home/user/toolchains/`: + +* Zig 0.15.2 (PyPI `ziglang` distribution; direct ziglang.org download unavailable). +* Just 1.58.0 (PyPI `rust-just` distribution). +* ShellCheck 0.11.0 (PyPI `shellcheck-py` distribution). +* REUSE 6.2.0 with charset-normalizer support. +* LMDB 0.9.33, built from upstream tag `LMDB_0.9.33`, commit + `3a29a24777c82a0165de813ae696a5068b5add30`; headers and libraries at + `/home/user/toolchains/lmdb-install/`. A compiled C linkage smoke test passed. + +This supersedes the earlier tool-unavailability statement for those tools only: + +* `zig test src/signal/classifier.zig`: 2 tests pass. +* `zig test src/signal/sampler.zig`: 5 tests pass (includes the same 2 classifier + tests; 5 distinct test blocks overall, not 7). +* `just validate`: passes, with warnings for the broad placeholder heuristic + and unavailable Idris2. The FFI `zig build` target is scaffolding and its + success is not evidence of a working FFI implementation. +* ShellCheck on the changed validation/state scripts and `tests/workflows/*.sh`: + passes. +* Compliance regression tests: pass. +* `reuse lint`: fails compliance. Copyright information found for 298/313 files; + license information for 306/313; zero invalid SPDX expressions or missing + license texts. No licensing metadata was rewritten to silence these findings. + +Idris2 and the security scanners remain unavailable. No crash-durability or +live memory-pressure test was performed. Installation artefacts are outside +Git; these are sandbox tools, not new runtime dependencies of llm-grace. + +== Follow-up: Issue #4 Implementation Progress + +The LMDB decision was retained after considering a switch. ADR-0006 records the +reasoning, a pinned source review of the owner's type-theory repositories, +concrete proof targets, and the remaining closure criteria. + +`just test` now executes real core tests rather than printing a placeholder +success message. With Zig 0.15.2 and LMDB 0.9.33, all 19 tests pass in both Debug +and ReleaseSafe: 10 signal/classifier, 2 finite-ladder, 2 file-bypass, and 5 ledger +tests. Ledger tests cover abort, reopen, SIGKILL before/after commit, map-full, +and independent concurrent writers. An external timeout bounds each test suite. +The existing Estate Rules workflow now runs core tests in both build modes; +hosted execution has not yet been observed. + +`just validate`, the compliance regression suite, ShellCheck on reviewed scripts, +Zig formatting, and `git diff --check` pass. The root allowlist now explicitly +permits the ignored `.zig-cache/` produced by those real tests. +`just e2e` explicitly fails until the live monitor/hook acceptance suite exists; +it no longer claims success without running anything. + +REUSE remains noncompliant: 304/319 files have copyright information and 312/319 +have license information at this checkpoint, with zero invalid SPDX expressions +or missing license texts. Existing SPDX/copyright lines and `LICENSE`, `LICENSES/`, +`REUSE.toml` are unchanged. This is still an owner review, not an agent fix queue. + +The live monitor, per-session collection/autoconnection, bounded IPC, retention, +and isolated resource-pressure acceptance tests remain incomplete. No issue was +closed, and no global hooks or active load-shedding were installed. diff --git a/scripts/check-state.sh b/scripts/check-state.sh new file mode 100644 index 0000000..b4dad5e --- /dev/null +++ b/scripts/check-state.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Required-field check only; full A2ML validation remains an external gate. +set -euo pipefail +state=${1:-.machine_readable/STATE.a2ml} +if [[ ! -f "$state" ]]; then + echo "ERROR: missing state: $state" >&2 + exit 1 +fi +for pattern in '^\(state' '\(metadata' '\(project "llm-grace"\)' '\(last-updated "[0-9]{4}-[0-9]{2}-[0-9]{2}"\)' '\(phase "[^"]+"\)'; do + if ! grep -Eq "$pattern" "$state"; then + echo "ERROR: $state missing required field matching $pattern" >&2 + exit 1 + fi +done +echo "PASS: STATE required fields (not full syntax or readiness validation)" diff --git a/scripts/test-core.sh b/scripts/test-core.sh new file mode 100644 index 0000000..7a40bd9 --- /dev/null +++ b/scripts/test-core.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# No system load injection: fork/SIGKILL tests target only their own child. +set -euo pipefail +cd "$(dirname "$0")/.." +command -v zig >/dev/null || { echo 'ERROR: Zig 0.15.2 is required' >&2; exit 1; } +[[ $(zig version) == 0.15.2 ]] || { echo 'ERROR: this suite is pinned to Zig 0.15.2' >&2; exit 1; } +command -v timeout >/dev/null || { echo 'ERROR: GNU timeout is required' >&2; exit 1; } +lmdb=(-llmdb) +if [[ -n ${LMDB_PREFIX:-} ]]; then + [[ -f "$LMDB_PREFIX/include/lmdb.h" && -f "$LMDB_PREFIX/lib/liblmdb.a" ]] || { + echo 'ERROR: LMDB_PREFIX must contain include/lmdb.h and lib/liblmdb.a' >&2 + exit 1 + } + lmdb=(-I "$LMDB_PREFIX/include" "$LMDB_PREFIX/lib/liblmdb.a") +fi +# An independent timeout prevents a writer-lock regression from hanging CI. +timeout --kill-after=5s 60s zig test src/signal/sampler.zig "$@" +timeout --kill-after=5s 60s zig test src/control/ladder.zig "$@" +timeout --kill-after=5s 60s zig test src/control/safety.zig "$@" +timeout --kill-after=5s 60s zig test src/ledger/lmdb.zig "${lmdb[@]}" -lc "$@" diff --git a/scripts/validate-template.sh b/scripts/validate-template.sh index 5557c36..05b6b5b 100755 --- a/scripts/validate-template.sh +++ b/scripts/validate-template.sh @@ -8,7 +8,7 @@ # Exit codes: # 0 = validation passed # 1 = validation failed with errors -# 2 = validation failed with warnings (but can proceed) +# Warnings are reported separately; they do not establish build readiness. set -euo pipefail @@ -51,7 +51,7 @@ check_file_exists() { return 0 else log_error "Required file missing: $file ${description:+(${description})}" - return 1 + return 0 fi } @@ -63,7 +63,7 @@ check_dir_exists() { return 0 else log_error "Required directory missing: $dir ${description:+(${description})}" - return 1 + return 0 fi } @@ -101,7 +101,7 @@ check_file_exists "AUDIT.adoc" "Release audit gate" # Directories check_dir_exists ".machine_readable" "Machine-readable metadata" check_dir_exists ".github" "GitHub community metadata" -check_dir_exists "src/interface/abi" "Idris2 ABI definitions" +check_dir_exists "src/interface/Abi" "Idris2 ABI definitions" check_dir_exists "src/interface/ffi" "Zig FFI implementation" check_dir_exists "src/interface/generated/abi" "Generated C headers" check_dir_exists "docs" "Documentation" @@ -119,9 +119,15 @@ check_file_exists ".machine_readable/META.a2ml" "Architecture decisions" check_file_exists ".machine_readable/ECOSYSTEM.a2ml" "Ecosystem position" check_file_exists ".machine_readable/anchors/ANCHOR.a2ml" "Semantic boundary anchor" check_file_exists ".machine_readable/policies/MAINTENANCE-AXES.a2ml" "Maintenance axes" +check_file_exists ".machine_readable/policies/MAINTENANCE-CHECKLIST.a2ml" "Maintenance checklist" +check_file_exists ".machine_readable/policies/SOFTWARE-DEVELOPMENT-APPROACH.a2ml" "Development approach" +check_file_exists "docs/governance/MAINTENANCE-CHECKLIST.adoc" "Human maintenance checklist" +check_file_exists "docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc" "Human development approach" +check_file_exists "LICENSES/MPL-2.0.txt" "Declared project license text (no relicensing)" + #============================================================================== -# VALIDATION PHASE 3: REQUIRED WORKFLOWS (17 minimum) +# VALIDATION PHASE 3: REQUIRED WORKFLOW CAPABILITIES #============================================================================== echo "" @@ -129,19 +135,11 @@ log_info "Phase 3: GitHub Actions workflows" echo "" REQUIRED_WORKFLOWS=( + "governance.yml" + "estate-rules.yml" "hypatia-scan.yml" "codeql.yml" "scorecard.yml" - "quality.yml" - "mirror.yml" - "instant-sync.yml" - "guix-nix-policy.yml" - "rsr-antipattern.yml" - "security-policy.yml" - "wellknown-enforcement.yml" - "workflow-linter.yml" - "npm-bun-blocker.yml" - "ts-blocker.yml" "scorecard-enforcer.yml" "secret-scanner.yml" ) @@ -159,11 +157,7 @@ done WORKFLOW_FILES=$(find "$REPO_ROOT/.github/workflows" -name "*.yml" -type f 2>/dev/null || true) WORKFLOW_COUNT=$(echo "$WORKFLOW_FILES" | grep -c "." || true) -if [ "$WORKFLOW_COUNT" -ge 15 ]; then - log_pass "Found $WORKFLOW_COUNT workflows (>= 15 expected)" -else - log_warning "Found only $WORKFLOW_COUNT workflows (expected >= 15)" -fi +log_info "Found $WORKFLOW_COUNT workflows; required capabilities checked above" # Spot-check workflow files for issues while IFS= read -r workflow_file; do @@ -189,9 +183,9 @@ log_info "Phase 4: Idris2 ABI and Zig FFI source files" echo "" # Idris2 ABI files -check_file_exists "src/interface/abi/Types.idr" "Core type definitions" -check_file_exists "src/interface/abi/Layout.idr" "Memory layout specifications" -check_file_exists "src/interface/abi/Foreign.idr" "FFI foreign declarations" +check_file_exists "src/interface/Abi/Types.idr" "Core type definitions" +check_file_exists "src/interface/Abi/Layout.idr" "Memory layout specifications" +check_file_exists "src/interface/Abi/Foreign.idr" "FFI foreign declarations" # Zig FFI files check_file_exists "src/interface/ffi/build.zig" "Zig build configuration" @@ -263,13 +257,11 @@ echo "" # Check Zig build if [ -f "$REPO_ROOT/src/interface/ffi/build.zig" ]; then if command -v zig &> /dev/null; then - cd "$REPO_ROOT/src/interface/ffi" - if zig build 2>&1 | grep -q "error"; then - log_error "Zig build failed" - else + if (cd "$REPO_ROOT/src/interface/ffi" && zig build); then log_pass "Zig build successful" + else + log_error "Zig build failed" fi - cd - > /dev/null else log_warning "Zig compiler not found - skipping Zig build check" fi @@ -279,13 +271,13 @@ fi # Check Idris2 syntax (if available) if command -v idris2 &> /dev/null; then - IDS_FILES=$(find "$REPO_ROOT/src/interface/abi" -name "*.idr" -type f 2>/dev/null || true) + IDS_FILES=$(find "$REPO_ROOT/src/interface/Abi" -name "*.idr" -type f 2>/dev/null || true) while IFS= read -r ids_file; do if [ -z "$ids_file" ]; then continue; fi - if ! idris2 --check "$ids_file" 2>&1 | grep -q "Error"; then + if idris2 --check "$ids_file"; then log_pass "Idris2 syntax OK: $(basename "$ids_file")" else - log_warning "Idris2 syntax issue: $(basename "$ids_file")" + log_error "Idris2 syntax issue: $(basename "$ids_file")" fi done <<< "$IDS_FILES" else diff --git a/src/control/ladder.zig b/src/control/ladder.zig new file mode 100644 index 0000000..09b8e35 --- /dev/null +++ b/src/control/ladder.zig @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Finite policy kernel, not a process controller or a formal proof. +const std = @import("std"); +pub const State = enum { active, paused, checkpointed, terminated }; +pub const Event = enum { + pressure, + recovery, + checkpoint_committed, + terminate, + human_resume, + unknown_sample, +}; + +/// A checkpoint_committed event is permitted ONLY after the caller receives a +/// successful ledger commit for that session. This model does not verify that +/// external fact. OFF bypasses enforcement without erasing the stored latch. +pub fn step(state: State, event: Event, off: bool) State { + if (off or state == .terminated) return state; + return switch (event) { + .unknown_sample => state, + .pressure => if (state == .active) .paused else state, + .recovery => if (state == .paused) .active else state, + .checkpoint_committed => if (state == .paused) .checkpointed else state, + .terminate => if (state == .checkpointed) .terminated else state, + .human_resume => if (state == .checkpointed) .active else state, + }; +} + +test "exhaustive finite ladder: latch, terminal, checkpoint-before-terminate, OFF" { + const states = std.enums.values(State); + const events = std.enums.values(Event); + for (states) |state| { + for (events) |event| { + try std.testing.expectEqual(state, step(state, event, true)); + const next = step(state, event, false); + if (state == .terminated) try std.testing.expectEqual(State.terminated, next); + if (state == .checkpointed and event != .human_resume and event != .terminate) + try std.testing.expectEqual(State.checkpointed, next); + if (next == .terminated and state != .terminated) + try std.testing.expect(state == .checkpointed and event == .terminate); + if (event == .unknown_sample) try std.testing.expectEqual(state, next); + } + } +} + +test "pause recovers automatically; checkpoint requires explicit human release" { + var state: State = .active; + state = step(state, .pressure, false); + try std.testing.expectEqual(State.paused, state); + state = step(state, .recovery, false); + try std.testing.expectEqual(State.active, state); + state = step(state, .pressure, false); + state = step(state, .checkpoint_committed, false); + try std.testing.expectEqual(State.checkpointed, step(state, .recovery, false)); + try std.testing.expectEqual(State.active, step(state, .human_resume, false)); + try std.testing.expectEqual(State.terminated, step(state, .terminate, false)); +} diff --git a/src/control/safety.zig b/src/control/safety.zig new file mode 100644 index 0000000..b4e76ed --- /dev/null +++ b/src/control/safety.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Filesystem-only bypass: must be checked BEFORE any IPC or ledger operation. +const std = @import("std"); + +/// Any OFF directory entry (even a dangling symlink), or inability to inspect +/// the trusted control directory, disables enforcement. No LMDB dependency. +/// OFF does not authorize clearing a checkpoint latch or relaunching a session. +pub fn enforcementAllowed(control_dir: std.fs.Dir) bool { + _ = std.posix.fstatat(control_dir.fd, "OFF", std.posix.AT.SYMLINK_NOFOLLOW) catch |err| { + return err == error.FileNotFound; + }; + return false; +} + +test "plain OFF file bypass is independent of missing or broken ledger" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try std.testing.expect(enforcementAllowed(tmp.dir)); + const off = try tmp.dir.createFile("OFF", .{}); + off.close(); + try std.testing.expect(!enforcementAllowed(tmp.dir)); + try tmp.dir.deleteFile("OFF"); + try std.testing.expect(enforcementAllowed(tmp.dir)); +} + +test "dangling OFF symlink disables enforcement" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.symLink("nonexistent", "OFF", .{}); + try std.testing.expect(!enforcementAllowed(tmp.dir)); +} diff --git a/src/ledger/README.adoc b/src/ledger/README.adoc new file mode 100644 index 0000000..196a6bd --- /dev/null +++ b/src/ledger/README.adoc @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += LMDB Ledger Primitive + +This is a bounded transactional building block for issue #4, not a production +session coordinator. See +link:../../docs/decisions/0006-ledger-and-proof-boundaries.adoc[ADR-0006] for +contracts, concurrency preconditions, durability limitations, and remaining work. + +== Tests + +Zig 0.15.2 and LMDB development headers/library are required. Use system LMDB: + +[source,shell] +---- +just test +just test -O ReleaseSafe +---- + +Or point to an independently installed prefix containing `include/lmdb.h` and +`lib/liblmdb.a`: + +[source,shell] +---- +LMDB_PREFIX=/path/to/lmdb-prefix just test +---- + +The sandbox review used upstream LMDB 0.9.33, commit +`3a29a24777c82a0165de813ae696a5068b5add30`. CI uses the Ubuntu development package; +toolchain/database differences should be recorded when comparing test evidence. +No LMDB source or binary is vendored here. + +The tests create disposable directories, write only their test data, fork their +own child writers, and kill only those children. They do not induce memory +pressure or terminate user sessions. Each suite has an external timeout. +A process-kill test does not establish power-loss or VM-crash durability. diff --git a/src/ledger/lmdb.zig b/src/ledger/lmdb.zig new file mode 100644 index 0000000..4c5a4f8 --- /dev/null +++ b/src/ledger/lmdb.zig @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: MPL-2.0 +// Bounded ledger primitive. No process control, automatic map growth, or relaxed sync. +const std = @import("std"); +const c = @cImport({ + @cInclude("lmdb.h"); + @cInclude("unistd.h"); + @cInclude("sys/wait.h"); + @cInclude("signal.h"); + @cInclude("errno.h"); +}); + +pub const max_record_bytes = 4096; +pub const max_session_bytes = 128; +pub const default_map_bytes = 16 * 1024 * 1024; + +fn check(rc: c_int) !void { + switch (rc) { + 0 => {}, + c.MDB_NOTFOUND => return error.NotFound, + c.MDB_MAP_FULL => return error.MapFull, + c.MDB_MAP_RESIZED => return error.MapResized, + c.MDB_READERS_FULL => return error.ReadersFull, + c.ENOSPC => return error.DiskFull, + c.ENOMEM => return error.OutOfMemory, + c.EACCES => return error.AccessDenied, + c.EIO => return error.StorageFailure, + c.MDB_CORRUPTED, c.MDB_INVALID, c.MDB_VERSION_MISMATCH => return error.InvalidDatabase, + else => return error.LmdbFailure, + } +} + +fn value(bytes: []const u8) c.MDB_val { + return .{ .mv_size = bytes.len, .mv_data = @ptrCast(@constCast(bytes.ptr)) }; +} + +fn slice(v: c.MDB_val) []const u8 { + return @as([*]const u8, @ptrCast(v.mv_data))[0..v.mv_size]; +} + +/// One environment handle per process/path. Do not copy or use after close/fork. +/// Caller owns a private, trusted directory on a local filesystem and must +/// serialize open/close against its other users. LMDB serializes write txns; +/// acquiring its writer lock is NOT a bounded-time operation. Hook callers must +/// use a bounded IPC boundary, never invoke this API on their blocking path. +pub const Ledger = struct { + env: *c.MDB_env, + sessions: c.MDB_dbi, + events: c.MDB_dbi, + meta: c.MDB_dbi, + + pub fn open(path: [:0]const u8, map_bytes: usize) !Ledger { + if (map_bytes < 64 * 1024) return error.MapTooSmall; + var maybe_env: ?*c.MDB_env = null; + try check(c.mdb_env_create(&maybe_env)); + const env = maybe_env.?; + errdefer c.mdb_env_close(env); + try check(c.mdb_env_set_maxdbs(env, 3)); + try check(c.mdb_env_set_mapsize(env, map_bytes)); + // flags=0: no NOSYNC, NOMETASYNC, WRITEMAP, MAPASYNC, or NOLOCK. + try check(c.mdb_env_open(env, path.ptr, 0, 0o600)); + var maybe_txn: ?*c.MDB_txn = null; + try check(c.mdb_txn_begin(env, null, 0, &maybe_txn)); + const txn = maybe_txn.?; + var owned = true; + defer if (owned) c.mdb_txn_abort(txn); + var self = Ledger{ .env = env, .sessions = 0, .events = 0, .meta = 0 }; + try check(c.mdb_dbi_open(txn, "sessions", c.MDB_CREATE, &self.sessions)); + try check(c.mdb_dbi_open(txn, "events", c.MDB_CREATE, &self.events)); + try check(c.mdb_dbi_open(txn, "meta", c.MDB_CREATE, &self.meta)); + var key = value("schema"); + var existing: c.MDB_val = undefined; + const rc = c.mdb_get(txn, self.meta, &key, &existing); + if (rc == c.MDB_NOTFOUND) { + var version = value("1"); + try check(c.mdb_put(txn, self.meta, &key, &version, 0)); + } else { + try check(rc); + if (!std.mem.eql(u8, slice(existing), "1")) return error.UnsupportedSchema; + } + owned = false; // commit consumes the transaction even on failure + try check(c.mdb_txn_commit(txn)); + return self; + } + + pub fn close(self: *Ledger) void { + c.mdb_env_close(self.env); + self.* = undefined; + } + + /// Atomically persist latest session record, ordered event, and sequence. + /// Records are opaque versioned bytes supplied by the caller. Events encode + /// [8-byte wall_ms][2-byte session length][session][record]. All integers BE. + /// Ordering is the transaction sequence, not the potentially jumping clock. + /// Success means default-sync commit succeeded, NOT that hardware is infallible. + pub fn append(self: *Ledger, session: []const u8, record: []const u8, wall_ms: u64) !u64 { + try validate(session, record); + var maybe_txn: ?*c.MDB_txn = null; + try check(c.mdb_txn_begin(self.env, null, 0, &maybe_txn)); + const txn = maybe_txn.?; + var owned = true; + defer if (owned) c.mdb_txn_abort(txn); + const seq = try self.stage(txn, session, record, wall_ms); + owned = false; + try check(c.mdb_txn_commit(txn)); + return seq; + } + + fn validate(session: []const u8, record: []const u8) !void { + if (session.len == 0 or session.len > max_session_bytes) return error.InvalidSession; + if (record.len > max_record_bytes) return error.RecordTooLarge; + } + + fn stage(self: *Ledger, txn: *c.MDB_txn, session: []const u8, record: []const u8, wall_ms: u64) !u64 { + var seq_key = value("sequence"); + var old: c.MDB_val = undefined; + const rc = c.mdb_get(txn, self.meta, &seq_key, &old); + var seq: u64 = 0; + if (rc != c.MDB_NOTFOUND) { + try check(rc); + if (old.mv_size != 8) return error.InvalidDatabase; + seq = std.mem.readInt(u64, slice(old)[0..8], .big); + } + seq = try std.math.add(u64, seq, 1); + var encoded: [8]u8 = undefined; + std.mem.writeInt(u64, &encoded, seq, .big); + var sequence = value(&encoded); + var session_key = value(session); + var data = value(record); + try check(c.mdb_put(txn, self.sessions, &session_key, &data, 0)); + var event: [10 + max_session_bytes + max_record_bytes]u8 = undefined; + std.mem.writeInt(u64, event[0..8], wall_ms, .big); + std.mem.writeInt(u16, event[8..10], @intCast(session.len), .big); + @memcpy(event[10..][0..session.len], session); + @memcpy(event[10 + session.len ..][0..record.len], record); + var event_value = value(event[0 .. 10 + session.len + record.len]); + try check(c.mdb_put(txn, self.events, &sequence, &event_value, c.MDB_NOOVERWRITE)); + try check(c.mdb_put(txn, self.meta, &seq_key, &sequence, 0)); + return seq; + } + + /// Copy out while the read txn is alive. Never leak mmap pointers to callers. + pub fn read(self: *Ledger, allocator: std.mem.Allocator, session: []const u8) ![]u8 { + return self.readDb(allocator, self.sessions, session); + } + + pub fn readEvent(self: *Ledger, allocator: std.mem.Allocator, seq: u64) ![]u8 { + var encoded: [8]u8 = undefined; + std.mem.writeInt(u64, &encoded, seq, .big); + return self.readDb(allocator, self.events, &encoded); + } + + fn readDb(self: *Ledger, allocator: std.mem.Allocator, db: c.MDB_dbi, bytes: []const u8) ![]u8 { + var maybe_txn: ?*c.MDB_txn = null; + try check(c.mdb_txn_begin(self.env, null, c.MDB_RDONLY, &maybe_txn)); + const txn = maybe_txn.?; + defer c.mdb_txn_abort(txn); + var key = value(bytes); + var result: c.MDB_val = undefined; + try check(c.mdb_get(txn, db, &key, &result)); + return allocator.dupe(u8, slice(result)); + } +}; + +const a = std.testing.allocator; +fn tempPath(dir: std.fs.Dir) ![:0]u8 { + const path = try dir.realpathAlloc(a, "."); + defer a.free(path); + return a.dupeZ(u8, path); +} +fn expectRecord(db: *Ledger, session: []const u8, expected: []const u8) !void { + const found = try db.read(a, session); + defer a.free(found); + try std.testing.expectEqualStrings(expected, found); +} + +test "state and event survive reopen; sequence survives clock regression" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tempPath(tmp.dir); + defer a.free(path); + { + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + try std.testing.expectEqual(@as(u64, 1), try db.append("s1", "active", 100)); + try std.testing.expectEqual(@as(u64, 2), try db.append("s1", "checkpoint", 90)); + try std.testing.expectError(error.InvalidSession, db.append("", "", 0)); + try std.testing.expectError(error.RecordTooLarge, db.append("s1", &([_]u8{0} ** 4097), 0)); + } + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + try expectRecord(&db, "s1", "checkpoint"); + const event = try db.readEvent(a, 2); + defer a.free(event); + try std.testing.expectEqual(@as(u64, 90), std.mem.readInt(u64, event[0..8], .big)); + try std.testing.expectEqualStrings("s1checkpoint", event[10..]); + try std.testing.expectEqual(@as(u64, 3), try db.append("s2", "active", 80)); +} + +test "aborted writer exposes neither latest state nor event" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tempPath(tmp.dir); + defer a.free(path); + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + _ = try db.append("s", "before", 0); + var txn: ?*c.MDB_txn = null; + try check(c.mdb_txn_begin(db.env, null, 0, &txn)); + _ = db.stage(txn.?, "s", "after", 1) catch |err| { + c.mdb_txn_abort(txn.?); + return err; + }; + c.mdb_txn_abort(txn.?); + try expectRecord(&db, "s", "before"); + try std.testing.expectError(error.NotFound, db.readEvent(a, 2)); + try std.testing.expectEqual(@as(u64, 2), try db.append("s", "retry", 2)); +} + +// These are process-crash tests, NOT host-power-loss tests. No memory balloon. +// Child opens its own environment after fork; no inherited environment is used. +fn crashChild(path: [:0]const u8, commit: bool) noreturn { + var db = Ledger.open(path, default_map_bytes) catch c._exit(10); + if (commit) { + _ = db.append("s", "committed", 2) catch c._exit(11); + } else { + var txn: ?*c.MDB_txn = null; + check(c.mdb_txn_begin(db.env, null, 0, &txn)) catch c._exit(12); + _ = db.stage(txn.?, "s", "uncommitted", 1) catch c._exit(13); + } + _ = c.kill(c.getpid(), c.SIGKILL); + c._exit(14); +} + +test "SIGKILL before and after commit preserves atomicity and releases writer lock" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tempPath(tmp.dir); + defer a.free(path); + { + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + _ = try db.append("s", "before", 0); + } + for ([_]bool{ false, true }) |commit| { + const pid = c.fork(); + if (pid < 0) return error.ForkFailed; + if (pid == 0) crashChild(path, commit); + var status: c_int = 0; + if (c.waitpid(pid, &status, 0) != pid) return error.WaitFailed; + try std.testing.expectEqual(@as(c_int, c.SIGKILL), status & 0x7f); + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + try expectRecord(&db, "s", if (commit) "committed" else "before"); + if (!commit) { + try std.testing.expectError(error.NotFound, db.readEvent(a, 2)); + } else { + const event = try db.readEvent(a, 2); + defer a.free(event); + try std.testing.expectEqualStrings("scommitted", event[10..]); + } + } +} + +test "bounded map fills cleanly without losing last successful commit" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tempPath(tmp.dir); + defer a.free(path); + var db = try Ledger.open(path, 64 * 1024); + defer db.close(); + var record = [_]u8{42} ** max_record_bytes; + var last: ?u8 = null; + for (0..100) |i| { + record[0] = @intCast(i); + _ = db.append("s", &record, @intCast(i)) catch |err| { + try std.testing.expectEqual(error.MapFull, err); + try std.testing.expect(last != null); + const found = try db.read(a, "s"); + defer a.free(found); + try std.testing.expectEqual(last.?, found[0]); + try std.testing.expectError(error.NotFound, db.readEvent(a, @intCast(i + 1))); + return; + }; + last = record[0]; + } + return error.ExpectedMapFull; +} + +fn writerChild(path: [:0]const u8, session: []const u8) noreturn { + var db = Ledger.open(path, default_map_bytes) catch c._exit(20); + for (0..10) |i| { + _ = db.append(session, "heartbeat", @intCast(i)) catch c._exit(21); + } + db.close(); + c._exit(0); +} + +test "independent processes share serialized event sequence without lost updates" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tempPath(tmp.dir); + defer a.free(path); + var pids: [2]c.pid_t = undefined; + var started: usize = 0; + // Reap even if a later fork fails or an assertion fails. + defer for (pids[0..started]) |pid| { + var status: c_int = 0; + _ = c.waitpid(pid, &status, 0); + }; + for ([_][]const u8{ "one", "two" }, 0..) |session, i| { + pids[i] = c.fork(); + if (pids[i] < 0) return error.ForkFailed; + if (pids[i] == 0) writerChild(path, session); + started += 1; + } + for (pids) |pid| { + var status: c_int = 0; + if (c.waitpid(pid, &status, 0) != pid) return error.WaitFailed; + try std.testing.expectEqual(@as(c_int, 0), status); + } + var db = try Ledger.open(path, default_map_bytes); + defer db.close(); + try expectRecord(&db, "one", "heartbeat"); + try expectRecord(&db, "two", "heartbeat"); + for (1..21) |seq| { + const event = try db.readEvent(a, @intCast(seq)); + a.free(event); + } + try std.testing.expectError(error.NotFound, db.readEvent(a, 21)); +} diff --git a/src/signal/classifier.zig b/src/signal/classifier.zig index 4e4feab..d2cc107 100644 --- a/src/signal/classifier.zig +++ b/src/signal/classifier.zig @@ -114,7 +114,7 @@ const IDLE_IO: u8 = 3; fn memHealthy(s: Snapshot) bool { // Available is comfortably above the cliff fraction. - return s.mem_available_kb * MEM_CLIFF_FRAC_DEN > s.mem_total_kb * MEM_CLIFF_FRAC_NUM; + return @as(u128, s.mem_available_kb) * MEM_CLIFF_FRAC_DEN > @as(u128, s.mem_total_kb) * MEM_CLIFF_FRAC_NUM; } pub fn classify(s: Snapshot) State { @@ -173,77 +173,126 @@ const TABLE = [_]Case{ .{ .name = "swap-death pre-OOM signature (disk thrash + low CPU)", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 300_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 300_000, .mem_available_slope_kbps = -120_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 200_000, - .pswpout_delta = 45_000, .iowait_pct = 55, .io_ticks_pct = 98, - .load1 = 42.0, .nproc = 20, .cpu_user_pct = 8, .gpu_util_pct = null, + .swap_total_kb = 8_000_000, + .swap_free_kb = 200_000, + .pswpout_delta = 45_000, + .iowait_pct = 55, + .io_ticks_pct = 98, + .load1 = 42.0, + .nproc = 20, + .cpu_user_pct = 8, + .gpu_util_pct = null, }, .want = .swap_death, }, .{ .name = "memory cliff: available collapsing, swap not yet moving", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 1_200_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 1_200_000, .mem_available_slope_kbps = -90_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 5, .io_ticks_pct = 20, - .load1 = 6.0, .nproc = 20, .cpu_user_pct = 40, .gpu_util_pct = null, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 5, + .io_ticks_pct = 20, + .load1 = 6.0, + .nproc = 20, + .cpu_user_pct = 40, + .gpu_util_pct = null, }, .want = .memory_cliff, }, .{ .name = "compute saturated: honest heavy build, NOT swap-death", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 9_000_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 9_000_000, .mem_available_slope_kbps = -1_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 6, .io_ticks_pct = 30, - .load1 = 21.0, .nproc = 20, .cpu_user_pct = 96, .gpu_util_pct = null, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 6, + .io_ticks_pct = 30, + .load1 = 21.0, + .nproc = 20, + .cpu_user_pct = 96, + .gpu_util_pct = null, }, .want = .compute_saturated, }, .{ .name = "gpu saturated: training pins VRAM, RAM fine", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 10_000_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 10_000_000, .mem_available_slope_kbps = 0, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 2, .io_ticks_pct = 5, - .load1 = 4.0, .nproc = 20, .cpu_user_pct = 30, .gpu_util_pct = 99, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 2, + .io_ticks_pct = 5, + .load1 = 4.0, + .nproc = 20, + .cpu_user_pct = 30, + .gpu_util_pct = 99, }, .want = .gpu_saturated, }, .{ .name = "idle tell: parked, near-zero cpu and io", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 12_000_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 12_000_000, .mem_available_slope_kbps = 0, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 0, .io_ticks_pct = 1, - .load1 = 0.2, .nproc = 20, .cpu_user_pct = 1, .gpu_util_pct = null, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 0, + .io_ticks_pct = 1, + .load1 = 0.2, + .nproc = 20, + .cpu_user_pct = 1, + .gpu_util_pct = null, }, .want = .idle_tell, }, .{ .name = "friendly: healthy normal operation", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 11_000_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 11_000_000, .mem_available_slope_kbps = -2_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 4, .io_ticks_pct = 25, - .load1 = 6.0, .nproc = 20, .cpu_user_pct = 45, .gpu_util_pct = 20, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 4, + .io_ticks_pct = 25, + .load1 = 6.0, + .nproc = 20, + .cpu_user_pct = 45, + .gpu_util_pct = 20, }, .want = .friendly, }, .{ .name = "heavy disk IO but mem fine + no swap-out: NOT swap-death", .s = .{ - .mem_total_kb = 16_000_000, .mem_available_kb = 8_000_000, + .mem_total_kb = 16_000_000, + .mem_available_kb = 8_000_000, .mem_available_slope_kbps = -3_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout_delta = 0, .iowait_pct = 40, .io_ticks_pct = 95, - .load1 = 18.0, .nproc = 20, .cpu_user_pct = 30, .gpu_util_pct = null, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout_delta = 0, + .iowait_pct = 40, + .io_ticks_pct = 95, + .load1 = 18.0, + .nproc = 20, + .cpu_user_pct = 30, + .gpu_util_pct = null, }, .want = .friendly, }, diff --git a/src/signal/sampler.zig b/src/signal/sampler.zig index ea667f7..cfe532d 100644 --- a/src/signal/sampler.zig +++ b/src/signal/sampler.zig @@ -45,8 +45,8 @@ pub fn parseMeminfo(text: []const u8, r: *Raw) void { pub fn parseVmstatPswpout(text: []const u8) u64 { var lines = std.mem.tokenizeScalar(u8, text, '\n'); while (lines.next()) |ln| { - if (std.mem.startsWith(u8, ln, "pswpout ")) { - var it = std.mem.tokenizeAny(u8, ln["pswpout ".len..], " \t"); + var it = std.mem.tokenizeAny(u8, ln, " \t"); + if (std.mem.eql(u8, it.next() orelse continue, "pswpout")) { if (it.next()) |v| return std.fmt.parseInt(u64, v, 10) catch 0; } } @@ -54,6 +54,7 @@ pub fn parseVmstatPswpout(text: []const u8) u64 { } pub fn parseStat(text: []const u8, r: *Raw) void { + r.nproc = 0; var lines = std.mem.tokenizeScalar(u8, text, '\n'); while (lines.next()) |ln| { if (std.mem.startsWith(u8, ln, "cpu ")) { @@ -64,8 +65,8 @@ pub fn parseStat(text: []const u8, r: *Raw) void { var iowait: u64 = 0; while (it.next()) |tok| : (i += 1) { const n = std.fmt.parseInt(u64, tok, 10) catch break; - if (i < 8) total += n; // user nice system idle iowait irq softirq steal - if (i == 0 or i == 1) user += n; // user + nice + if (i < 8) total +|= n; // user nice system idle iowait irq softirq steal + if (i == 0 or i == 1) user +|= n; // user + nice if (i == 4) iowait = n; } r.cpu_total = total; @@ -74,7 +75,7 @@ pub fn parseStat(text: []const u8, r: *Raw) void { } else if (ln.len > 3 and std.mem.startsWith(u8, ln, "cpu") and (ln[3] >= '0' and ln[3] <= '9')) { - r.nproc += 1; + r.nproc +|= 1; } } } @@ -103,7 +104,7 @@ pub fn parseDiskstats(text: []const u8) u64 { std.mem.startsWith(u8, name, "ram")) continue; // /proc/diskstats: after major minor name, field 10 (1-based) // = ms doing I/O => token index 2 + 10 = 12. - sum += std.fmt.parseInt(u64, fields[12], 10) catch 0; + sum +|= std.fmt.parseInt(u64, fields[12], 10) catch 0; } return sum; } @@ -118,9 +119,60 @@ pub fn sample(meminfo: []const u8, vmstat: []const u8, stat: []const u8, loadavg return r; } +/// Checked entry point for monitor use. Missing data is never a healthy or +/// dangerous observation. The caller must retain/report an unknown state. +/// diskstats must contain only caller-selected, non-overlapping whole devices. +/// An empty selection (e.g. no block devices) is valid and yields zero activity. +pub fn sampleChecked(meminfo: []const u8, vmstat: []const u8, stat: []const u8, loadavg: []const u8, diskstats: []const u8) !Raw { + const keys = [_][]const u8{ "MemTotal:", "MemAvailable:", "SwapTotal:", "SwapFree:" }; + for (keys) |key| { + var seen: usize = 0; + var lines = std.mem.tokenizeScalar(u8, meminfo, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, key)) continue; + seen += 1; + var fields = std.mem.tokenizeAny(u8, line[key.len..], " \t"); + _ = try std.fmt.parseInt(u64, fields.next() orelse return error.InvalidSample, 10); + if (!std.mem.eql(u8, fields.next() orelse return error.InvalidSample, "kB")) return error.InvalidSample; + } + if (seen != 1) return error.InvalidSample; + } + var vm_lines = std.mem.tokenizeScalar(u8, vmstat, '\n'); + var swaps: usize = 0; + while (vm_lines.next()) |line| { + var fields = std.mem.tokenizeAny(u8, line, " \t"); + if (!std.mem.eql(u8, fields.next() orelse continue, "pswpout")) continue; + swaps += 1; + _ = try std.fmt.parseInt(u64, fields.next() orelse return error.InvalidSample, 10); + } + if (swaps != 1) return error.InvalidSample; + var cpu_lines = std.mem.tokenizeScalar(u8, stat, '\n'); + var cpus: usize = 0; + while (cpu_lines.next()) |line| { + var fields = std.mem.tokenizeAny(u8, line, " \t"); + if (!std.mem.eql(u8, fields.next() orelse continue, "cpu")) continue; + cpus += 1; + var count: usize = 0; + var total: u64 = 0; + while (fields.next()) |field| : (count += 1) { + const n = try std.fmt.parseInt(u64, field, 10); + if (count < 8) total = try std.math.add(u64, total, n); + } + if (count < 5) return error.InvalidSample; + } + if (cpus != 1) return error.InvalidSample; + var load_fields = std.mem.tokenizeAny(u8, loadavg, " \t\n"); + const load = try std.fmt.parseFloat(f32, load_fields.next() orelse return error.InvalidSample); + if (!std.math.isFinite(load) or load < 0) return error.InvalidSample; + const raw = sample(meminfo, vmstat, stat, loadavg, diskstats); + if (raw.mem_total_kb == 0 or raw.mem_available_kb > raw.mem_total_kb or + raw.swap_free_kb > raw.swap_total_kb or raw.nproc == 0 or raw.cpu_total == 0) return error.InvalidSample; + return raw; +} + fn pct(part: u64, whole: u64) u8 { if (whole == 0) return 0; - const v = part * 100 / whole; + const v = @as(u128, part) * 100 / whole; return @intCast(@min(v, 100)); } @@ -134,9 +186,9 @@ pub fn reduce(prev: Raw, cur: Raw, interval_ms: u64, gpu_util_pct: ?u8) cls.Snap const slope: i64 = blk: { if (interval_ms == 0) break :blk 0; - const cur_a: i64 = @intCast(cur.mem_available_kb); - const prev_a: i64 = @intCast(prev.mem_available_kb); - break :blk @divTrunc((cur_a - prev_a) * 1000, @as(i64, @intCast(interval_ms))); + const delta = @as(i128, cur.mem_available_kb) - @as(i128, prev.mem_available_kb); + const rate = @divTrunc(delta * 1000, @as(i128, interval_ms)); + break :blk @intCast(std.math.clamp(rate, std.math.minInt(i64), std.math.maxInt(i64))); }; return .{ @@ -186,16 +238,30 @@ test "parsers extract expected fields" { test "reduce + classify: swap-death signature end-to-end" { const prev = Raw{ - .mem_total_kb = 16_000_000, .mem_available_kb = 600_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 400_000, - .pswpout = 100_000, .cpu_total = 100_000, .cpu_user = 5_000, - .cpu_iowait = 1_000, .io_ticks = 50_000, .load1 = 40, .nproc = 20, + .mem_total_kb = 16_000_000, + .mem_available_kb = 600_000, + .swap_total_kb = 8_000_000, + .swap_free_kb = 400_000, + .pswpout = 100_000, + .cpu_total = 100_000, + .cpu_user = 5_000, + .cpu_iowait = 1_000, + .io_ticks = 50_000, + .load1 = 40, + .nproc = 20, }; const cur = Raw{ - .mem_total_kb = 16_000_000, .mem_available_kb = 300_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 200_000, - .pswpout = 145_000, .cpu_total = 101_000, .cpu_user = 5_080, - .cpu_iowait = 1_600, .io_ticks = 59_800, .load1 = 42, .nproc = 20, + .mem_total_kb = 16_000_000, + .mem_available_kb = 300_000, + .swap_total_kb = 8_000_000, + .swap_free_kb = 200_000, + .pswpout = 145_000, + .cpu_total = 101_000, + .cpu_user = 5_080, + .cpu_iowait = 1_600, + .io_ticks = 59_800, + .load1 = 42, + .nproc = 20, }; const snap = reduce(prev, cur, 10_000, null); // 10s interval try std.testing.expectEqual(@as(u64, 45_000), snap.pswpout_delta); @@ -208,17 +274,130 @@ test "reduce + classify: swap-death signature end-to-end" { test "reduce + classify: quiet box is friendly" { const prev = Raw{ - .mem_total_kb = 16_000_000, .mem_available_kb = 11_000_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout = 7, .cpu_total = 100_000, .cpu_user = 30_000, - .cpu_iowait = 2_000, .io_ticks = 10_000, .load1 = 6, .nproc = 20, + .mem_total_kb = 16_000_000, + .mem_available_kb = 11_000_000, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout = 7, + .cpu_total = 100_000, + .cpu_user = 30_000, + .cpu_iowait = 2_000, + .io_ticks = 10_000, + .load1 = 6, + .nproc = 20, }; const cur = Raw{ - .mem_total_kb = 16_000_000, .mem_available_kb = 10_990_000, - .swap_total_kb = 8_000_000, .swap_free_kb = 8_000_000, - .pswpout = 7, .cpu_total = 101_000, .cpu_user = 30_450, - .cpu_iowait = 2_040, .io_ticks = 10_250, .load1 = 6, .nproc = 20, + .mem_total_kb = 16_000_000, + .mem_available_kb = 10_990_000, + .swap_total_kb = 8_000_000, + .swap_free_kb = 8_000_000, + .pswpout = 7, + .cpu_total = 101_000, + .cpu_user = 30_450, + .cpu_iowait = 2_040, + .io_ticks = 10_250, + .load1 = 6, + .nproc = 20, }; const snap = reduce(prev, cur, 10_000, 20); try std.testing.expectEqual(cls.State.friendly, cls.classify(snap)); } + +test "checked samples reject missing, contradictory and non-finite evidence" { + const mem = "MemTotal: 1000 kB\nMemAvailable: 500 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n"; + const cpu = "cpu 1 2 3 4 5 6 7 8\ncpu0 1 2\n"; + const valid = try sampleChecked(mem, "pswpout\t42", cpu, "0.5", ""); + try std.testing.expectEqual(@as(u64, 42), valid.pswpout); + try std.testing.expectError(error.InvalidSample, sampleChecked("", "pswpout 0", cpu, "0", "")); + try std.testing.expectError(error.InvalidSample, sampleChecked(mem, "", cpu, "0", "")); + try std.testing.expectError(error.InvalidSample, sampleChecked(mem, "pswpout 0", "cpu 1 2", "0", "")); + try std.testing.expectError(error.InvalidSample, sampleChecked(mem, "pswpout 0", cpu, "nan", "")); + try std.testing.expectError(error.InvalidSample, sampleChecked(mem, "pswpout 0", cpu, "-1", "")); +} + +test "wide arithmetic is bounded for extreme observations" { + const max = std.math.maxInt(u64); + try std.testing.expectEqual(@as(u8, 100), pct(max, max)); + try std.testing.expectEqual(@as(u8, 100), pct(max, 1)); + const snap = reduce(.{}, .{ .mem_available_kb = max }, 1, null); + try std.testing.expectEqual(std.math.maxInt(i64), snap.mem_available_slope_kbps); + const falling = reduce(.{ .mem_available_kb = max }, .{}, 1, null); + try std.testing.expectEqual(std.math.minInt(i64), falling.mem_available_slope_kbps); + _ = reduce(.{}, .{ .mem_available_kb = max }, max, null); +} + +test "CPU count does not accumulate when parser target is reused" { + var raw = Raw{}; + parseStat("cpu 1 2 3 4 5\ncpu0 1 2", &raw); + parseStat("cpu 1 2 3 4 5\ncpu0 1 2", &raw); + try std.testing.expectEqual(@as(u32, 1), raw.nproc); +} + +/// Maximum device utilisation, not a sum across disks/partitions. Caller selects +/// non-overlapping whole devices (for example from sysfs) once per sample pair. +/// Missing/replaced devices and counter resets invalidate the interval. +pub fn diskBusyPercent(previous: []const u8, current: []const u8, devices: []const []const u8, interval_ms: u64) !u8 { + if (interval_ms == 0) return error.InvalidInterval; + var busy: u8 = 0; + for (devices, 0..) |device, i| { + for (devices[0..i]) |other| { + if (std.mem.eql(u8, device, other)) return error.DuplicateDevice; + } + const before = try deviceTicks(previous, device); + const after = try deviceTicks(current, device); + if (after < before) return error.CounterReset; + busy = @max(busy, pct(after - before, interval_ms)); + } + return busy; +} + +fn deviceTicks(text: []const u8, device: []const u8) !u64 { + var result: ?u64 = null; + var lines = std.mem.tokenizeScalar(u8, text, '\n'); + while (lines.next()) |line| { + var fields = std.mem.tokenizeAny(u8, line, " \t"); + _ = fields.next() orelse continue; + _ = fields.next() orelse return error.InvalidSample; + const name = fields.next() orelse return error.InvalidSample; + if (!std.mem.eql(u8, name, device)) continue; + if (result != null) return error.DuplicateDevice; + for (0..9) |_| _ = fields.next() orelse return error.InvalidSample; + result = try std.fmt.parseInt(u64, fields.next() orelse return error.InvalidSample, 10); + } + return result orelse error.MissingDevice; +} + +/// Use with sampleChecked and diskBusyPercent. Unknown/reset intervals must not +/// be used to resume a checkpoint or to manufacture a pressure classification. +pub fn reduceChecked(prev: Raw, cur: Raw, interval_ms: u64, disk_busy_pct: u8, gpu_util_pct: ?u8) !cls.Snapshot { + if (interval_ms == 0) return error.InvalidInterval; + if (disk_busy_pct > 100) return error.InvalidSample; + if (gpu_util_pct) |g| if (g > 100) return error.InvalidSample; + if (cur.nproc != prev.nproc or cur.cpu_total <= prev.cpu_total or + cur.cpu_user < prev.cpu_user or cur.cpu_iowait < prev.cpu_iowait or + cur.pswpout < prev.pswpout) return error.CounterReset; + var snap = reduce(prev, cur, interval_ms, gpu_util_pct); + snap.io_ticks_pct = disk_busy_pct; + return snap; +} + +test "disk saturation is per selected device, not a sum or partition double count" { + const prev = "8 0 sda 0 0 0 0 0 0 0 0 0 0 0\n8 1 sda1 0 0 0 0 0 0 0 0 0 0 0\n8 16 sdb 0 0 0 0 0 0 0 0 0 0 0\n"; + const cur = "8 0 sda 0 0 0 0 0 0 0 0 0 500 0\n8 1 sda1 0 0 0 0 0 0 0 0 0 500 0\n8 16 sdb 0 0 0 0 0 0 0 0 0 500 0\n"; + try std.testing.expectEqual(@as(u8, 50), try diskBusyPercent(prev, cur, &.{ "sda", "sdb" }, 1000)); + try std.testing.expectError(error.DuplicateDevice, diskBusyPercent(prev, cur, &.{ "sda", "sda" }, 1000)); + try std.testing.expectError(error.MissingDevice, diskBusyPercent(prev, cur, &.{"nvme0n1"}, 1000)); + try std.testing.expectError(error.CounterReset, diskBusyPercent(cur, prev, &.{"sda"}, 1000)); + try std.testing.expectError(error.InvalidInterval, diskBusyPercent(prev, cur, &.{"sda"}, 0)); +} + +test "checked reduction rejects zero intervals, resets and CPU topology changes" { + const before = Raw{ .cpu_total = 100, .cpu_user = 50, .nproc = 1 }; + const after = Raw{ .cpu_total = 200, .cpu_user = 60, .nproc = 1 }; + try std.testing.expectError(error.InvalidInterval, reduceChecked(before, after, 0, 0, null)); + try std.testing.expectError(error.CounterReset, reduceChecked(after, before, 1000, 0, null)); + var hotplug = after; + hotplug.nproc = 2; + try std.testing.expectError(error.CounterReset, reduceChecked(before, hotplug, 1000, 0, null)); + try std.testing.expectEqual(@as(u8, 55), (try reduceChecked(before, after, 1000, 55, null)).io_ticks_pct); +} diff --git a/tests/workflows/compliance_regression_test.sh b/tests/workflows/compliance_regression_test.sh new file mode 100644 index 0000000..b5bc383 --- /dev/null +++ b/tests/workflows/compliance_regression_test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Exercise failure paths without modifying the checkout or invoking real compilers. +set -euo pipefail +root=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +expect_failure() { + if "$@" >"$tmp/output" 2>&1; then + echo "FAIL: expected failure: $*" >&2 + exit 1 + fi +} +bash "$root/scripts/check-state.sh" "$root/.machine_readable/STATE.a2ml" +expect_failure bash "$root/scripts/check-state.sh" "$tmp/missing" +printf '(state (metadata (project "wrong")))\n' > "$tmp/state" +expect_failure bash "$root/scripts/check-state.sh" "$tmp/state" +cp -R "$root/.github/workflows" "$tmp/workflows" +rm "$tmp/workflows/governance.yml" +expect_failure bash "$root/tests/workflows/validate_workflows_test.sh" "$tmp/workflows" +mkdir "$tmp/repo" +# Copy only validator inputs; no credentials, .git, or generated build trees. +for path in scripts .machine_readable .github src/interface docs LICENSES README.adoc EXPLAINME.adoc LICENSE Justfile AUDIT.adoc TOPOLOGY.adoc; do + mkdir -p "$tmp/repo/$(dirname "$path")" + cp -R "$root/$path" "$tmp/repo/$path" +done +mkdir "$tmp/bin" +# Failing silently must still fail the gate: never grep compiler diagnostics. +printf '#!/bin/sh\nexit 1\n' > "$tmp/bin/zig" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/idris2" +chmod +x "$tmp/bin/zig" "$tmp/bin/idris2" +expect_failure env PATH="$tmp/bin:$PATH" bash "$root/scripts/validate-template.sh" "$tmp/repo" +grep -q 'Zig build failed' "$tmp/output" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/zig" +printf '#!/bin/sh\nexit 1\n' > "$tmp/bin/idris2" +expect_failure env PATH="$tmp/bin:$PATH" bash "$root/scripts/validate-template.sh" "$tmp/repo" +grep -q 'Idris2 syntax issue' "$tmp/output" +printf '#!/bin/sh\nexit 0\n' > "$tmp/bin/idris2" +bash_output=$(PATH="$tmp/bin:$PATH" bash "$root/scripts/validate-template.sh" "$tmp/repo" 2>&1) +[[ "$bash_output" == *'Validation PASSED'* ]] +rm "$tmp/repo/LICENSE" "$tmp/repo/README.adoc" +expect_failure env PATH="$tmp/bin:$PATH" bash "$root/scripts/validate-template.sh" "$tmp/repo" +grep -q 'Required file missing: LICENSE' "$tmp/output" +grep -q 'Required file missing: README.adoc' "$tmp/output" +grep -q 'VALIDATION SUMMARY' "$tmp/output" +echo 'PASS: compliance regression tests (missing state/workflow/files, silent compiler failures)' diff --git a/tests/workflows/validate_workflows_test.sh b/tests/workflows/validate_workflows_test.sh index 614bb5a..5fa2a2b 100755 --- a/tests/workflows/validate_workflows_test.sh +++ b/tests/workflows/validate_workflows_test.sh @@ -89,19 +89,11 @@ log_info "Checking for required workflows" echo "" REQUIRED_WORKFLOWS=( + "governance.yml" + "estate-rules.yml" "hypatia-scan.yml" "codeql.yml" "scorecard.yml" - "quality.yml" - "mirror.yml" - "instant-sync.yml" - "guix-nix-policy.yml" - "rsr-antipattern.yml" - "security-policy.yml" - "wellknown-enforcement.yml" - "workflow-linter.yml" - "npm-bun-blocker.yml" - "ts-blocker.yml" "scorecard-enforcer.yml" "secret-scanner.yml" ) @@ -112,8 +104,7 @@ for required in "${REQUIRED_WORKFLOWS[@]}"; do log_pass "Found: $required" FOUND_COUNT=$((FOUND_COUNT + 1)) else - log_warning "Missing: $required" - WARNINGS=$((WARNINGS + 1)) + log_error "Missing: $required" fi done