diff --git a/.cicd-hygiene-allow b/.cicd-hygiene-allow index 1513822..369000c 100644 --- a/.cicd-hygiene-allow +++ b/.cicd-hygiene-allow @@ -10,3 +10,11 @@ # these paths, resolve or issue-link it there — do not widen this list. build/container/entrypoint.sh tests/e2e.sh +# +# heron-polyfeed-reader: THIRD-PARTY VENDORED CODE. Its TODO/FIXME/XXX markers +# are upstream's (SQLite amalgamation; a patched copy of Zig std's TLS client, +# see src/vendor/NOTICE), not this repo's debt, and estate policy makes +# third-party code untouchable. Excluded by exact path; nothing else here. +vendor/sqlite/sqlite3.c +vendor/sqlite/sqlite3.h +src/vendor/tls_client.zig diff --git a/.clinerules b/.clinerules index 18fd60b..1fc45c4 100644 --- a/.clinerules +++ b/.clinerules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/practice/AI-CONVENTIONS.adoc # STARTUP: Read the repo deed (*_chora.deed at the repo root) first, then docs/status/ROADMAP.adoc. @@ -8,7 +8,7 @@ # All original code: MPL-2.0. # Never AGPL-3.0. MPL-2.0 only as platform-required fallback. # SPDX header required on every source file. -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES # No .a2ml file may exist anywhere: the format is retired. State, metadata, diff --git a/.cursorrules b/.cursorrules index d9cd1d2..8ad5b4c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/practice/AI-CONVENTIONS.adoc # Read the repo deed (*_chora.deed in the repo root) FIRST: canonical file locations @@ -7,7 +7,7 @@ # LICENSE # All original code: MPL-2.0 (SPDX header required on every file). -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES # No .a2ml file may exist anywhere: the format is retired. State, metadata, diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index f69a893..b20545a 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# Dev Container image for {{PROJECT_NAME}} +# Dev Container image for heron-polyfeed-reader # Base: Chainguard Wolfi (minimal, supply-chain-secure) -# Build: podman build -t {{PROJECT_NAME}}-dev -f .devcontainer/Containerfile . +# Build: podman build -t heron-polyfeed-reader-dev -f .devcontainer/Containerfile . # `:latest` suits a dev container (rebuilt daily); pin by digest # (...@sha256:) if you need a reproducible dev environment. # @@ -44,7 +44,7 @@ RUN set -eu; \ # devcontainer.json maps your host user onto it with --userns=keep-id. # Set workspace directory -WORKDIR /workspaces/{{PROJECT_NAME}} +WORKDIR /workspaces/heron-polyfeed-reader # Default shell ENV SHELL=/bin/bash diff --git a/.devcontainer/README.adoc b/.devcontainer/README.adoc index 6a0114e..4a31c4a 100644 --- a/.devcontainer/README.adoc +++ b/.devcontainer/README.adoc @@ -1,7 +1,7 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell = Dev Container Usage -:author: {{AUTHOR}} <{{AUTHOR_EMAIL}}> +:author: Jonathan D.A. Jewell == Overview @@ -35,7 +35,7 @@ There are two configurations for the same image: . Install the *Dev Containers* extension (`ms-vscode-remote.remote-containers`). . Set `dev.containers.dockerPath` to `podman` in VS Code settings. . Open the repo folder, then choose **Reopen in Container** and pick - **{{PROJECT_NAME}} (Podman)**. + **heron-polyfeed-reader (Podman)**. == devcontainer CLI (Podman) @@ -47,7 +47,7 @@ bunx @devcontainers/cli up --workspace-folder . --docker-path podman == GitHub Codespaces . From the repository on GitHub, click **Code > Codespaces > ... > New with options**. -. Choose the **{{PROJECT_NAME}} (Docker / Codespaces)** configuration. +. Choose the **heron-polyfeed-reader (Docker / Codespaces)** configuration. == Gitpod @@ -56,6 +56,6 @@ bunx @devcontainers/cli up --workspace-folder . --docker-path podman == Customization -Replace the `{{PROJECT_NAME}}` placeholders in both `devcontainer.json` files and +Replace the `heron-polyfeed-reader` placeholders in both `devcontainer.json` files and the `Containerfile` with your actual project name. `just deps` runs automatically after the container is created. Run it again to check the environment. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4fa5d13..45c2553 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,13 +1,13 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// Dev Container configuration for {{PROJECT_NAME}} — rootless Podman (default). +// Dev Container configuration for heron-polyfeed-reader — rootless Podman (default). // Works with: VS Code Dev Containers with `dev.containers.dockerPath` = podman, // and the devcontainer CLI (`devcontainer up --docker-path podman`). // Docker, GitHub Codespaces and Gitpod: use .devcontainer/docker/devcontainer.json // instead — Docker rejects the Podman-only `--userns=keep-id` below. { - "name": "{{PROJECT_NAME}} (Podman)", + "name": "heron-polyfeed-reader (Podman)", "build": { "dockerfile": "Containerfile", diff --git a/.devcontainer/docker/devcontainer.json b/.devcontainer/docker/devcontainer.json index fadee84..90b9f05 100644 --- a/.devcontainer/docker/devcontainer.json +++ b/.devcontainer/docker/devcontainer.json @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// Dev Container configuration for {{PROJECT_NAME}} — Docker engines. +// Dev Container configuration for heron-polyfeed-reader — Docker engines. // Works with: GitHub Codespaces, Gitpod, and VS Code Dev Containers on Docker. // The estate default is rootless Podman: .devcontainer/devcontainer.json. // Same image (../Containerfile); only the user-namespace settings differ. { - "name": "{{PROJECT_NAME}} (Docker / Codespaces)", + "name": "heron-polyfeed-reader (Docker / Codespaces)", "build": { "dockerfile": "../Containerfile", diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4793d38 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.zig-cache +zig-out +.git +*.db* +# Estate scaffolding that the Zig build does not read (build.zig.zon .paths lists what the package is) +.github +.machine_readable +archetypes +ci +docs +features +session +tests +verification +www diff --git a/.editorconfig b/.editorconfig index 808e241..3e76d6e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # Hyperpolymath estate canonical .editorconfig (standards#343 phase 1) -# Canon lives in rsr-template-repo; do not add a per-repo name to this header. +# Canon lives in heron-polyfeed-reader; do not add a per-repo name to this header. # https://editorconfig.org root = true diff --git a/.envrc b/.envrc index ec7a7ad..ac80437 100644 --- a/.envrc +++ b/.envrc @@ -13,7 +13,7 @@ if has guix && [ -f build/manifest.scm ]; then fi # Project environment variables -export PROJECT_NAME="{{PROJECT_NAME}}" +export PROJECT_NAME="heron-polyfeed-reader" export RSR_TIER="infrastructure" # Add non-secret project env vars above (e.g. DATABASE_URL, service endpoints). # Real secrets belong in .env (gitignored) — never commit them to .envrc. diff --git a/.gitattributes b/.gitattributes index c85fa95..06e05b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # Hyperpolymath estate canonical .gitattributes (standards#343 phase 1) -# Canon lives in rsr-template-repo; do not add a per-repo name to this header. +# Canon lives in heron-polyfeed-reader; do not add a per-repo name to this header. # # Only diff drivers that git actually ships are used here. MEASURED on git # 2.47.3: diff=go and diff=zig are NOT drivers (git silently falls back to the diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6bdfc8a..6e4a4e2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,14 +2,14 @@ # CODEOWNERS - Define code review assignments # See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # -# Replace {{OWNER}} with your GitHub username or team +# Replace hyperpolymath with your GitHub username or team # Default owners for everything -* @{{OWNER}} +* @hyperpolymath # Security-sensitive files require explicit review -SECURITY.md @{{OWNER}} -.github/workflows/ @{{OWNER}} -.machine_readable/contractiles/trust/ @{{OWNER}} -*_chora.deed @{{OWNER}} -.machine_readable/ @{{OWNER}} +SECURITY.md @hyperpolymath +.github/workflows/ @hyperpolymath +.machine_readable/contractiles/trust/ @hyperpolymath +*_chora.deed @hyperpolymath +.machine_readable/ @hyperpolymath diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index c7f72ae..c695689 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -4,29 +4,9 @@ Copyright (c) Jonathan D.A. Jewell --> # Code of Conduct - - ## Our Pledge -We as members, contributors, and leaders pledge to make participation in rsr-template-repo a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, colour, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in heron-polyfeed-reader a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, colour, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. @@ -140,7 +120,7 @@ If you experience or witness unacceptable behaviour, or have any other concerns, | Method | Details | Best For | |--------|---------|----------| -| **Email** | {{CONDUCT_EMAIL}} | Detailed reports, sensitive matters | +| **Email** | j.d.a.jewell@open.ac.uk | Detailed reports, sensitive matters | | **Private Message** | Contact any maintainer directly | Quick questions, minor issues | **What to Include** @@ -155,8 +135,8 @@ If you experience or witness unacceptable behaviour, or have any other concerns, **What Happens Next** -1. You will receive acknowledgment within **{{RESPONSE_TIME}}** -2. The {{CONDUCT_TEAM}} will review the report +1. You will receive acknowledgment within **48 hours** +2. The Code of Conduct Committee will review the report 3. We may ask for additional information 4. We will determine appropriate action 5. We will inform you of the outcome (respecting others' privacy) @@ -172,7 +152,7 @@ All reports will be handled with discretion: ### Conflicts of Interest -If the {{CONDUCT_TEAM}} are themselves involved in an incident: +If the Code of Conduct Committee are themselves involved in an incident: - They will recuse themselves from the process - Another maintainer or external party will handle the report @@ -182,7 +162,7 @@ If the {{CONDUCT_TEAM}} are themselves involved in an incident: ## Enforcement Guidelines -The {{CONDUCT_TEAM}} will follow these guidelines in determining consequences: +The Code of Conduct Committee will follow these guidelines in determining consequences: ### 1. Correction @@ -242,13 +222,13 @@ For contributors with elevated access (Perimeter 2 or 1): If you believe an enforcement decision was made in error: 1. **Wait 7 days** after the decision (cooling-off period) -2. **Email** {{CONDUCT_EMAIL}} with subject line "Appeal: [Original Report ID]" +2. **Email** j.d.a.jewell@open.ac.uk with subject line "Appeal: [Original Report ID]" 3. **Explain** why you believe the decision should be reconsidered 4. **Provide** any new information not previously available **Appeals Process** -- Appeals are reviewed by the {{CONDUCT_TEAM}}, excluding anyone involved in the original +- Appeals are reviewed by the Code of Conduct Committee, excluding anyone involved in the original - You will receive a response within 14 days - The appeals decision is final - You may only appeal once per incident @@ -321,8 +301,8 @@ We thank these communities for their leadership in creating welcoming spaces. If you have questions about this Code of Conduct: -- Open a [Discussion](https://github.com/hyperpolymath/rsr-template-repo/discussions) (for general questions) -- Email {{CONDUCT_EMAIL}} (for private questions) +- Open a [Discussion](https://github.com/hyperpolymath/heron-polyfeed-reader/discussions) (for general questions) +- Email j.d.a.jewell@open.ac.uk (for private questions) - Contact any maintainer directly --- @@ -335,4 +315,4 @@ We're all here because we care about this project. Let's make it a place where e --- -Last updated: {{CURRENT_YEAR}} · Based on Contributor Covenant 2.1 +Last updated: 2026 · Based on Contributor Covenant 2.1 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ba9e87a..4ff6bf7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,15 +4,15 @@ Copyright (c) Jonathan D.A. Jewell --> ``` # Clone the repository -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader # Using Guix (recommended for reproducibility) guix shell -D -f build/guix.scm # Or using toolbox/distrobox -toolbox create rsr-template-repo-dev -toolbox enter rsr-template-repo-dev +toolbox create heron-polyfeed-reader-dev +toolbox enter heron-polyfeed-reader-dev # Install dependencies manually # Verify setup @@ -39,7 +39,7 @@ which is precisely why the map is now generated rather than typed. **Before reporting**: 1. Search existing issues -2. Check if it's already fixed in `{{MAIN_BRANCH}}` +2. Check if it's already fixed in `main` 3. Determine which perimeter the bug affects **When reporting**: @@ -72,10 +72,10 @@ Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md) an Look for issues labelled: -- [`good first issue`](https://github.com/hyperpolymath/rsr-template-repo/labels/good%20first%20issue) — Simple Perimeter 3 tasks -- [`help wanted`](https://github.com/hyperpolymath/rsr-template-repo/labels/help%20wanted) — Community help needed -- [`documentation`](https://github.com/hyperpolymath/rsr-template-repo/labels/documentation) — Docs improvements -- [`perimeter-3`](https://github.com/hyperpolymath/rsr-template-repo/labels/perimeter-3) — Community sandbox scope +- [`good first issue`](https://github.com/hyperpolymath/heron-polyfeed-reader/labels/good%20first%20issue) — Simple Perimeter 3 tasks +- [`help wanted`](https://github.com/hyperpolymath/heron-polyfeed-reader/labels/help%20wanted) — Community help needed +- [`documentation`](https://github.com/hyperpolymath/heron-polyfeed-reader/labels/documentation) — Docs improvements +- [`perimeter-3`](https://github.com/hyperpolymath/heron-polyfeed-reader/labels/perimeter-3) — Community sandbox scope --- diff --git a/.github/GOVERNANCE.md b/.github/GOVERNANCE.md index 246a51a..0f91c79 100644 --- a/.github/GOVERNANCE.md +++ b/.github/GOVERNANCE.md @@ -4,13 +4,13 @@ Copyright (c) Jonathan D.A. Jewell --> # Project Governance -This document describes the governance model for **{{PROJECT_NAME}}**. +This document describes the governance model for **heron-polyfeed-reader**. --- ## Project Governance Model -{{PROJECT_NAME}} follows a **Benevolent Dictator For Life (BDFL)** governance model. +heron-polyfeed-reader follows a **Benevolent Dictator For Life (BDFL)** governance model. This model is well-suited for solo maintainers and small project teams where rapid, consistent decision-making is more valuable than formal consensus processes. @@ -157,4 +157,4 @@ with the community before adoption, even though the BDFL retains final authority --- -Copyright (c) {{CURRENT_YEAR}} {{OWNER}}. Licensed under MPL-2.0. +Copyright (c) 2026 hyperpolymath. Licensed under MPL-2.0. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5e508a7..c5a6a5e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,8 +6,8 @@ blank_issues_enabled: true contact_links: - name: Security vulnerability - url: https://{{FORGE}}/{{OWNER}}/{{REPO}}/security/advisories/new + url: https://github.com/hyperpolymath/heron-polyfeed-reader/security/advisories/new about: Report privately via a security advisory. Do NOT open a public issue. - name: Question or support request - url: https://{{FORGE}}/{{OWNER}}/{{REPO}}/discussions + url: https://github.com/hyperpolymath/heron-polyfeed-reader/discussions about: Ask a question. See .github/SUPPORT.md for what to expect. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 9d376eb..f5a7527 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -33,7 +33,7 @@ We take security seriously. We appreciate your efforts to responsibly disclose v The preferred method for reporting security vulnerabilities is through GitHub's Security Advisory feature: -1. Navigate to [Report a Vulnerability](https://github.com/hyperpolymath/rsr-template-repo/security/advisories/new) +1. Navigate to [Report a Vulnerability](https://github.com/hyperpolymath/heron-polyfeed-reader/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 @@ -51,7 +51,7 @@ If you cannot use GitHub Security Advisories, you may email us directly: | | | |---|---| -| **Email** | {{SECURITY_EMAIL}} | +| **Email** | j.d.a.jewell@open.ac.uk | This mailbox is not encrypted. For anything sensitive enough to need encryption, prefer GitHub Security Advisories above — the report stays private @@ -187,7 +187,7 @@ If we cannot reach agreement on disclosure timing, we default to 90 days from yo The following are within scope for security research: -- This repository (`hyperpolymath/rsr-template-repo`) and all its code +- This repository (`hyperpolymath/heron-polyfeed-reader`) 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 @@ -306,7 +306,7 @@ Recognition includes: 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/rsr-template-repo/security/advisories) +- **GitHub Security Advisories**: Published at [Security Advisories](https://github.com/hyperpolymath/heron-polyfeed-reader/security/advisories) - **Release notes**: Security fixes noted in [CHANGELOG](../CHANGELOG.md) ### Update Policy @@ -332,7 +332,7 @@ To stay informed about security updates: ## Security Best Practices -When using rsr-template-repo, we recommend: +When using heron-polyfeed-reader, we recommend: ### General @@ -354,7 +354,7 @@ When using rsr-template-repo, we recommend: ## Additional Resources -- [Security Advisories](https://github.com/hyperpolymath/rsr-template-repo/security/advisories) +- [Security Advisories](https://github.com/hyperpolymath/heron-polyfeed-reader/security/advisories) - [Changelog](../CHANGELOG.md) - [Contributing Guidelines](CONTRIBUTING.md) - [CVE Database](https://cve.mitre.org/) @@ -366,8 +366,8 @@ When using rsr-template-repo, we recommend: | Purpose | Contact | |---------|---------| -| **Security issues** | [Report via GitHub](https://github.com/hyperpolymath/rsr-template-repo/security/advisories/new) or {{SECURITY_EMAIL}} | -| **General questions** | [GitHub Discussions](https://github.com/hyperpolymath/rsr-template-repo/discussions) | +| **Security issues** | [Report via GitHub](https://github.com/hyperpolymath/heron-polyfeed-reader/security/advisories/new) or j.d.a.jewell@open.ac.uk | +| **General questions** | [GitHub Discussions](https://github.com/hyperpolymath/heron-polyfeed-reader/discussions) | | **Other enquiries** | See [README](../README.adoc) for contact information | --- @@ -382,8 +382,8 @@ This security policy may be updated from time to time. Significant changes will --- -*Thank you for helping keep rsr-template-repo and its users safe.* 🛡️ +*Thank you for helping keep heron-polyfeed-reader and its users safe.* 🛡️ --- -Last updated: {{CURRENT_YEAR}} · Policy version: 1.0.0 +Last updated: 2026 · Policy version: 1.0.0 diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index b06c59a..c79e1bf 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -2,6 +2,6 @@ For questions, help, and community discussion: -- GitHub Discussions: https://github.com/{{OWNER}}/{{REPO}}/discussions -- GitHub Issues: https://github.com/{{OWNER}}/{{REPO}}/issues +- GitHub Discussions: https://github.com/hyperpolymath/heron-polyfeed-reader/discussions +- GitHub Issues: https://github.com/hyperpolymath/heron-polyfeed-reader/issues - Documentation: See README.adoc in the root directory. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b109979..775ff30 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell --> - + # Copilot Instructions @@ -17,7 +17,7 @@ Copyright (c) Jonathan D.A. Jewell - SPDX: `MPL-2.0` on all new files. - Never use AGPL-3.0. -- Copyright: `{{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}>` +- Copyright: `Jonathan D.A. Jewell (hyperpolymath) ` ## Code Style diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8327806..51e4769 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -41,42 +41,3 @@ updates: # "security only, not routine bumps". Pair with the # dependabot-automerge.yml workflow for low-touch security # maintenance. - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 - - # Elixir/Mix - - package-ecosystem: "mix" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 3 - groups: - dependency-updates: - patterns: ["*"] - - # Node.js/npm - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 3 - groups: - dependency-updates: - patterns: ["*"] - - # Python/pip - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 3 - groups: - dependency-updates: - patterns: ["*"] - - # NOTE: Nix is banned estate-wide — Guix is the sole package manager. - # Do not add a `nix` ecosystem here. Guix has no Dependabot ecosystem; - # pin Guix channels/manifests via build/ tooling instead. diff --git a/.github/hooks/validate-deed.sh b/.github/hooks/validate-deed.sh index 4acbc1d..f9c6efa 100755 --- a/.github/hooks/validate-deed.sh +++ b/.github/hooks/validate-deed.sh @@ -202,7 +202,7 @@ validate_deed() { # policies/*, integrations/*, …). Per the RSR convention these carry # identity structurally — owning repo + path + filename — not via an # in-file `name`/`agent-id`. This generalises the `.machine_readable/descriptiles/` - # rationale above to the whole tree: rsr-template-repo itself ships these + # rationale above to the whole tree: heron-polyfeed-reader itself ships these # files without an in-file identity key, so requiring one produces # estate-wide false positives on every repo built from the canonical # template. Files outside `.machine_readable/` are still validated. diff --git a/.github/settings.yml b/.github/settings.yml index 46df341..93d21ca 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -12,10 +12,10 @@ # It carries NO `name`, `description`, `homepage` or `private` key, and it must # never gain one. The reason is a real incident, not a hypothetical: # -# This file previously read `name: "rsr-template-repo"`. probot/settings applies it on +# This file previously read `name: "heron-polyfeed-reader"`. probot/settings applies it on # every push to the default branch, so it submitted the literal string -# `rsr-template-repo` as the repository name. GitHub sanitises an invalid name by -# collapsing each run of illegal characters to a dash — `rsr-template-repo` became +# `heron-polyfeed-reader` as the repository name. GitHub sanitises an invalid name by +# collapsing each run of illegal characters to a dash — `heron-polyfeed-reader` became # `-REPO-`. The template renamed itself on every push, its old URL 404'd, and # it was mistaken for a deleted repository. `description` was likewise left # reading the literal `Canonical RSR (Rhodium Standard Repository) template — governance, CI/CD, machine-readable metadata, ABI/FFI seam and formal-verification scaffolding that hyperpolymath projects are instantiated from.` on the live repo. diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f0aea84..4e2a878 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -90,6 +90,8 @@ workflows: '.github/workflows/workflow-linter.yml': - 'actions/checkout@v7.0.1' - 'oven-sh/setup-bun@v2.2.0' + '.github/workflows/zig-ci.yml': + - 'actions/checkout@v7.0.1' dependencies: 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': ref: '6323deb102c322ba6fcbdcafc7e3dddab59af2b6' diff --git a/.github/workflows/deed-validate.yml b/.github/workflows/deed-validate.yml index 5a985bd..d51001f 100644 --- a/.github/workflows/deed-validate.yml +++ b/.github/workflows/deed-validate.yml @@ -50,7 +50,7 @@ jobs: :warning: **No .deed manifest found.** Every RSR-compliant repo should have a repo deed (`_chora.deed`) at its root. - Copy it from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo). Manifests are validated against the DEED grammar (standards `1-formats/deed/`). The `.a2ml` format is retired; no `.a2ml` file may exist. + Copy it from [heron-polyfeed-reader](https://github.com/hyperpolymath/heron-polyfeed-reader). Manifests are validated against the DEED grammar (standards `1-formats/deed/`). The `.a2ml` format is retired; no `.a2ml` file may exist. EOF else echo "## DEED Manifest Validation" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 75dd9e9..1dea3f2 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -58,7 +58,7 @@ jobs: :warning: **No .deed manifest found.** Every RSR-compliant repo should have a repo deed (`_chora.deed`) at its root. - Copy it from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo). Manifests are validated against the DEED grammar (standards `1-formats/deed/`). The `.a2ml` format is retired; no `.a2ml` file may exist. + Copy it from [heron-polyfeed-reader](https://github.com/hyperpolymath/heron-polyfeed-reader). Manifests are validated against the DEED grammar (standards `1-formats/deed/`). The `.a2ml` format is retired; no `.a2ml` file may exist. EOF else echo "## DEED Manifest Validation" >> "$GITHUB_STEP_SUMMARY" @@ -328,7 +328,7 @@ jobs: # The canon<->spine binding. hyperpolymath/standards publishes canon.lock: the # released identity of the law, with a sha256 per law artefact. This repo # declares the SAME hashes in the `(canon …)` clause of its repo deed, - # rsr-template-repo_chora.deed. + # heron-polyfeed-reader_chora.deed. # # The pin used to live in a retired .a2ml profile, which D43 froze, so it # could not follow canon 2.1.2 and this job went red on every PR (#215). The @@ -614,7 +614,7 @@ jobs: | eclexiaiser | ${ECLEX_STATUS} | Energy/carbon budgets for container services | --- - *Generated by the [Dogfood Gate](https://github.com/hyperpolymath/rsr-template-repo) workflow.* + *Generated by the [Dogfood Gate](https://github.com/hyperpolymath/heron-polyfeed-reader) workflow.* *Dogfooding is guinea pig fooding — we test our tools on ourselves.* EOF diff --git a/.github/workflows/ossf-best-practices.yml b/.github/workflows/ossf-best-practices.yml index 695a842..ac7c91f 100644 --- a/.github/workflows/ossf-best-practices.yml +++ b/.github/workflows/ossf-best-practices.yml @@ -72,7 +72,7 @@ jobs: fi # Project state is the (status …) clause of the repo deed - # (rsr-template-repo#209), which replaced descriptiles/STATE.a2ml. + # (heron-polyfeed-reader#209), which replaced descriptiles/STATE.a2ml. if ! DEED=$(bash scripts/deed-field.sh --find .); then echo "::error::exactly one _chora.deed is required at the root" exit 1 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 2f386fd..b893c87 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -3,7 +3,7 @@ # SonarQube Cloud (SonarCloud) static analysis. Analysis scope + exclusions live # in sonar-project.properties. Requires the SONAR_TOKEN repository secret # (Settings -> Secrets and variables -> Actions) and a SonarCloud project: -# https://sonarcloud.io/project/overview?id=hyperpolymath_rsr-template-repo +# https://sonarcloud.io/project/overview?id=hyperpolymath_heron-polyfeed-reader # Mirrors the boj-server arrangement. # # WHY THE TOKEN GUARD (added 2026-07-28) diff --git a/.github/workflows/zig-ci.yml b/.github/workflows/zig-ci.yml new file mode 100644 index 0000000..963fb65 --- /dev/null +++ b/.github/workflows/zig-ci.yml @@ -0,0 +1,69 @@ +# This workflow is managed by gh actions-lock. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Zig CI — the project's own gate. The estate workflows cover shape, docs, +# shell and security; nothing else runs `zig build`. The toolchain is the +# exact release build.zig.zon requires, verified by SHA-256 against the same +# digest the Containerfile pins, so CI, mise.lock and the container test the +# same bits. No third-party setup action: one fewer dependency to lock. +name: Zig CI +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Read-only check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + ZIG_VERSION: "0.17.0" + # Must equal the Containerfile's ADD --checksum and mise.lock linux-x64. + ZIG_SHA256: "1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026" + +jobs: + zig: + name: fmt, test, release build, smoke + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install Zig (version-pinned, checksum-verified) + run: | + set -euo pipefail + curl -sSfL --proto '=https' --tlsv1.2 -o /tmp/zig.tar.xz "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - + mkdir -p "$HOME/zig" + tar -xJf /tmp/zig.tar.xz -C "$HOME/zig" --strip-components=1 + echo "$HOME/zig" >> "$GITHUB_PATH" + - name: Zig version + run: zig version + - name: Format check + run: zig fmt --check build.zig src + - name: Unit tests + run: zig build test --summary all + - name: Release build (static musl, as the container does) + run: zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl + - name: Smoke (help, example config validates) + run: | + ./zig-out/bin/heron help >/dev/null + ./zig-out/bin/heron --config examples/heron.json --data "$(mktemp -d)" check-config + + container: + name: Containerfile test and runtime stages + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + - name: Test stage (runs the unit suite on the pinned toolchain) + run: docker build -f Containerfile --target test -t heron-test . + - name: Runtime stage (static binary on scratch) + run: docker build -f Containerfile --target runtime -t heron . + - name: Runtime image answers `help` + run: docker run --rm heron help >/dev/null diff --git a/.gitignore b/.gitignore index 638db64..f88e180 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ _build/ # Dependencies node_modules/ -/vendor/ +/vendor/* +# heron: vendored SQLite amalgamation is tracked source (build.zig compiles it) +!/vendor/sqlite/ /deps/ /.elixir_ls/ @@ -144,3 +146,9 @@ verification/proofs/coq/.*.aux # Agda compiled proof artifacts verification/proofs/agda/*.agdai + +# Zig (heron-polyfeed-reader): build cache, install prefix, local SQLite state +zig-out/ +*.db +*.db-wal +*.db-shm diff --git a/.machine_readable/ai/PLACEHOLDERS.adoc b/.machine_readable/ai/PLACEHOLDERS.adoc deleted file mode 100644 index 3eb5054..0000000 --- a/.machine_readable/ai/PLACEHOLDERS.adoc +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -= Template Placeholders -# Template Placeholders - -All placeholders in this template follow the `{{PLACEHOLDER}}` pattern. -After cloning, replace them with your project-specific values. - -## Recommended: Interactive Bootstrap - -```bash -just repo-init -``` - -This interactively prompts for all values, replaces every placeholder, -validates the result, and runs k9-svc checks if available. - -## Manual Replace - -```bash -# If you prefer manual replacement (run from repo root) - -sed -i 's/{{AUTHOR}}/Jane Doe/g' $(grep -rl '{{AUTHOR}}' .) -sed -i 's/{{AUTHOR_EMAIL}}/jane@example.org/g' $(grep -rl '{{AUTHOR_EMAIL}}' .) -sed -i 's/{{OWNER}}/my-org/g' $(grep -rl '{{OWNER}}' .) -sed -i 's/{{PROJECT_NAME}}/my-project/g' $(grep -rl '{{PROJECT_NAME}}' .) -sed -i 's/{{PROJECT}}/MY_PROJECT/g' $(grep -rl '{{PROJECT}}' .) -sed -i 's/{{project}}/my_project/g' $(grep -rl '{{project}}' .) -sed -i 's/{{REPO}}/my-project/g' $(grep -rl '{{REPO}}' .) -sed -i 's/{{FORGE}}/github.com/g' $(grep -rl '{{FORGE}}' .) -sed -i "s/{{CURRENT_YEAR}}/$(date +%Y)/g" $(grep -rl '{{CURRENT_YEAR}}' .) -sed -i "s/{{CURRENT_DATE}}/$(date +%Y-%m-%d)/g" $(grep -rl '{{CURRENT_DATE}}' .) -``` - -## Placeholder Reference - -### Author & Copyright - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{AUTHOR}}` | Full legal name | `Jane Doe` | SPDX headers (all files), MAINTAINERS.md, .mailmap, .reuse/dep5, docs/AI-CONVENTIONS.md | -| `{{AUTHOR_EMAIL}}` | Primary contact email | `jane@example.org` | SPDX headers (all files), .mailmap, .reuse/dep5, www/.well-known/humans.txt | -| `{{AUTHOR_EMAIL_ALT}}` | Previous/secondary email (for .mailmap) | `old@example.com` | .mailmap | -| `{{AUTHOR_ORG}}` | Author's organization/affiliation | `Acme University` | project-metadata.k9.ncl | -| `{{AUTHOR_LAST}}` | Author surname (for citations) | `Doe` | docs/CITATIONS.adoc | -| `{{AUTHOR_FIRST}}` | Author first name (for citations) | `Jane` | docs/CITATIONS.adoc | -| `{{AUTHOR_INITIALS}}` | Author initials (for citations) | `J.` | docs/CITATIONS.adoc | -| `{{AUTHOR_ORCID}}` | Author ORCID iD, bare form; blank drops the line (default: `git config user.orcid`) | `0000-0002-1825-0097` | CITATION.cff | - -### Project Identity - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{PROJECT_NAME}}` | Human-readable project name | `My Project` | SECURITY.md, CODE_OF_CONDUCT.md, TOPOLOGY.md, docs/status/ROADMAP.adoc (via build/templates/ROADMAP.adoc.in), Justfile, GOVERNANCE.md, MAINTAINERS.md, devcontainer.json | -| `{{PROJECT_DESCRIPTION}}` | One-line description | `A tool for X` | README.adoc, build/container/Containerfile | -| `{{PROJECT}}` | Uppercase identifier (for Idris2 modules, C macros) | `MY_PROJECT` | ABI-FFI-README.md, src/interface/abi/*.idr, src/interface/ffi/*.zig | -| `{{project}}` | Lowercase identifier (for C symbols, filenames) | `my_project` | ABI-FFI-README.md, src/interface/ffi/*.zig | -| `{{REPO}}` | Repository name (slug) | `my-project` | CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, cliff.toml | -| `{{OWNER}}` | GitHub/GitLab org or username | `my-org` | SPDX headers, CONTRIBUTING.md, SECURITY.md, GOVERNANCE.md, MAINTAINERS.md, CODEOWNERS, mirror.yml, cliff.toml | -| `{{FORGE}}` | Git forge domain | `github.com` | CONTRIBUTING.md | - -### Dates - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{CURRENT_YEAR}}` | Current year | `2026` | SPDX headers (all files), GOVERNANCE.md, MAINTAINERS.md | -| `{{CURRENT_DATE}}` | Current date (ISO) | `2026-02-14` | `*_chora.deed` (via build/templates/identity.deed.in), MAINTAINERS.md | -| `{{DATE}}` | Last updated date | `2026-02-14` | TOPOLOGY.md, THREAT-MODEL.md | - -### Contact & Security - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{SECURITY_EMAIL}}` | Security contact email | `security@example.org` | SECURITY.md | -| `{{PGP_FINGERPRINT}}` | 40-char PGP fingerprint | `ABCD 1234 ...` | SECURITY.md | -| `{{PGP_KEY_URL}}` | URL to public PGP key | `https://keys.openpgp.org/...` | SECURITY.md | -| `{{WEBSITE}}` | Project website | `https://example.org` | SECURITY.md | -| `{{CONDUCT_EMAIL}}` | Conduct reports email | `conduct@example.org` | CODE_OF_CONDUCT.md | -| `{{CONDUCT_TEAM}}` | Conduct committee name | `Code of Conduct Committee` | CODE_OF_CONDUCT.md | -| `{{RESPONSE_TIME}}` | SLA for initial response | `48 hours` | CODE_OF_CONDUCT.md | - -### Git - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{MAIN_BRANCH}}` | Main branch name | `main` | CONTRIBUTING.md | - -### Build - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{LICENSE}}` | License name | `MPL-2.0` | ABI-FFI-README.md | -| `{{PROJECT_PURPOSE}}` | One-line project description | `FFI bridges between languages` | `*_chora.deed` `(anchor (identity))` :one-sentence (set from `{{PROJECT_DESCRIPTION}}` by `just repo-init`) | -| `{{OPENSSF_BP_ID}}` | OpenSSF Best Practices numeric project ID (from registering at bestpractices.dev). Optional — leave blank until registered; `just repo-init` then surfaces it in the post-init "remaining placeholders" notice as a reminder. | `1234` | README.adoc (OpenSSF Best Practices badge) | - -### Project Content & Onboarding (manual-fill, post-init) - -These tokens carry free-form, per-project content that the interactive -`just repo-init` bootstrap does **not** prompt for (you cannot sensibly answer -"required invariants" in a 30-second bootstrap). Like the `{{PGP_*}}` tokens, -fill them by hand after `init`; the post-init "remaining placeholders" notice -surfaces any left unfilled. - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `{{PACKAGE_NAME}}` | Package / module name (slug) | `my-project` | docs/onboarding/QUICKSTART-MAINTAINER.adoc | -| `{{DESCRIPTION}}` | Short repo description (forge metadata) | `A tool for X` | .github/settings.yml | -| `{{LANG_STACK}}` | Language / toolchain stack | `Idris2 + Zig` | docs/onboarding/QUICKSTART-DEV.adoc | -| `{{BUILD_CMD}}` | Build command | `just build` | docs/onboarding/QUICKSTART-DEV.adoc | -| `{{TEST_CMD}}` | Test command | `just test` | docs/onboarding/QUICKSTART-DEV.adoc | -| `{{BUILD_OUTPUT_PATH}}` | Build artefact output path | `build/` | docs/onboarding/QUICKSTART-MAINTAINER.adoc | -| `{{DEPS}}` | Project dependencies summary | `Idris2 0.7, Zig 0.13` | docs/onboarding/QUICKSTART-MAINTAINER.adoc | -| `{{MUST_INVARIANTS}}` | Required project invariants | `no believe_me; totality on` | docs/onboarding/QUICKSTART-DEV.adoc | -| `{{MAIN_FUNCTION}}` | Primary function / purpose | `FFI bridge generation` | no file in this template (the retired Intentfile is now `.machine_readable/contractiles/intend/intend.k9.ncl`, which does not carry the token) | -| `{{TARGET_AUDIENCE}}` | Intended audience | `library authors` | no file in this template (the retired Intentfile is now `.machine_readable/contractiles/intend/intend.k9.ncl`, which does not carry the token) | -| `{{EXPECTED_AUTHOR}}` | Expected author identity (trust anchor) | `hyperpolymath` | no file in this template (the retired Trustfile is now `.machine_readable/contractiles/trust/trust.k9.ncl`, which does not carry the token) | -| `{{PROJECT_UNIQUE_STRENGTH}}` | What this project does better than alternatives | `proof-carrying FFI` | `*_chora.deed` `(bot-directives (methodology (methodology (unique-strength))))` :description | -| `{{PROJECT_KIND}}` | Project kind / category | `library` | `*_chora.deed` `(anchor (identity))` :kind | -| `{{PROJECT_DOMAIN}}` | Project domain | `formal-methods` | `*_chora.deed` `(anchor (identity))` :domain | -| `{{OPENSSF_PROJECT_ID}}` | OpenSSF Best Practices project ID (same value as `{{OPENSSF_BP_ID}}`) | `1234` | docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc | - -### Repo deed (folded family-7 allocation manifest) - -| Placeholder | Description | Example | Files | -|---|---|---|---| -| `[YOUR-REPO-NAME]` | Repository name | `my-project` | `*_chora.deed` (repo deed) | -| `[YOUR-NAME/ORG]` | Maintainer name | `hyperpolymath` | `*_chora.deed` (repo deed) | - -(`[DATE]` was listed here for the root AI manifest but no file ever carried the -literal token — row removed with the fold; standards#837 pilot.) - -### AI Installation Guide - -| Marker | Description | Files | -|---|---|---| -| `[TODO-AI-INSTALL]` | Unfilled section in AI installation guide | `docs/AI_INSTALLATION_GUIDE.adoc`, `docs/AI-INSTALL-README-SECTION.adoc`, `README.adoc` | - -These are **not** standard `{{PLACEHOLDER}}` markers -- they are TODO markers -that must be replaced with project-specific content before release. They mark -sections where the developer (or AI) must fill in: - -- What questions the AI should ask the user -- Exact prerequisite check and install commands -- Privacy notice specific to this project -- Complete installation command block -- Credential setup instructions (URLs, scopes, env vars) -- Verification commands and expected output -- Error handling table -- Example conversation - -**finishbot checks:** `just validate-ai-install` verifies no `[TODO-AI-INSTALL]` markers remain. - -## Deletion Markers - -Some files contain deletion instructions: - -| Marker | Meaning | File | -|---|---|---| -| `{{~ ... ~}}` | Delete this entire line after reading | ABI-FFI-README.md (line 1) | - -## Verification - -After replacing all placeholders, verify none remain: - -```bash -grep -rn '{{' . --include='*.md' --include='*.adoc' --include='*.deed' \ - --include='*.scm' --include='*.idr' --include='*.zig' --include='*.res' \ - --include='Justfile' --include='*.nix' --include='*.toml' --include='*.yml' \ - --include='*.yaml' --include='*.hs' --include='*.ncl' --include='*.txt' \ - --include='*.json' --include='Containerfile' --include='dep5' \ - | grep -v 'PLACEHOLDERS.md' | grep -v 'node_modules' -``` - -If the above command produces no output, all placeholders have been replaced. diff --git a/.machine_readable/arrival-pack/README.adoc b/.machine_readable/arrival-pack/README.adoc index 8c85701..9f3ffc4 100644 --- a/.machine_readable/arrival-pack/README.adoc +++ b/.machine_readable/arrival-pack/README.adoc @@ -24,7 +24,7 @@ Two halves of the generated region: position, constraints, golden path, state. The repo deed (`_chora.deed`) is the single source of truth; it took -over the former CLADE / ECOSYSTEM / AGENTIC / STATE records (rsr-template-repo#209), and +over the former CLADE / ECOSYSTEM / AGENTIC / STATE records (heron-polyfeed-reader#209), and `extract.sh` reads it through `scripts/deed-field.sh`. The golden path comes from its `(anchor (golden-path))` clause. CLAUDE.md is a *view*. Content outside the `ARRIVAL-PACK:BEGIN/END` markers is hand-authorable and preserved. diff --git a/.machine_readable/arrival-pack/claude-md.k9.ncl b/.machine_readable/arrival-pack/claude-md.k9.ncl index d4af629..30c9cc0 100644 --- a/.machine_readable/arrival-pack/claude-md.k9.ncl +++ b/.machine_readable/arrival-pack/claude-md.k9.ncl @@ -34,7 +34,7 @@ K9! # Inputs the projection reads (single source of truth). inputs = { # The repo deed (_chora.deed) is the only input; the golden path is - # its (anchor (golden-path)) clause (rsr-template-repo#209). + # its (anchor (golden-path)) clause (heron-polyfeed-reader#209). descriptiles = [ "_chora.deed", ], diff --git a/.machine_readable/arrival-pack/extract.sh b/.machine_readable/arrival-pack/extract.sh index 717959c..39aadd4 100755 --- a/.machine_readable/arrival-pack/extract.sh +++ b/.machine_readable/arrival-pack/extract.sh @@ -8,7 +8,7 @@ # (_chora.deed: identity, clade, lineage, status, maturity, ecosystem, # agentic, and the golden path in its (anchor) clause) into a single # deterministic JSON document on stdout. Every deed read goes through -# scripts/deed-field.sh; there are no .a2ml inputs (rsr-template-repo#209). +# scripts/deed-field.sh; there are no .a2ml inputs (heron-polyfeed-reader#209). # # This is a READER only — it authors nothing. The deed remains the single # source of truth; arrival-pack.ncl renders this JSON into the CLAUDE.md region. diff --git a/.machine_readable/coaptation/README.adoc b/.machine_readable/coaptation/README.adoc index 17ed7bc..181e1b8 100644 --- a/.machine_readable/coaptation/README.adoc +++ b/.machine_readable/coaptation/README.adoc @@ -88,7 +88,7 @@ the "how far is too far" that proposes a re-anchor: `green` (nothing) / [cols="1,3"] |=== | `extract-clauses.sh` | Normative-side atomiser. Lifts the records of the `declaration` field of the six `/.k9.ncl` files (`nickel export` piped to `jq`) into `clauses.json` (stable id `verb.slug`). Reader only. -| `extract-facts.sh` | Descriptive-side atomiser. Projects the repo deed (through `scripts/deed-field.sh`), including its `(anchor …)` clause, into `facts.json` (stable id `family.key`). The family prefixes `clade.` `state.` `ecosystem.` `agentic.` name the deed clauses that replaced the retired CLADE / STATE / ECOSYSTEM / AGENTIC records (rsr-template-repo#209) and are kept so the witness map does not change. +| `extract-facts.sh` | Descriptive-side atomiser. Projects the repo deed (through `scripts/deed-field.sh`), including its `(anchor …)` clause, into `facts.json` (stable id `family.key`). The family prefixes `clade.` `state.` `ecosystem.` `agentic.` name the deed clauses that replaced the retired CLADE / STATE / ECOSYSTEM / AGENTIC records (heron-polyfeed-reader#209) and are kept so the witness map does not change. | `witness-map.ncl` | The hand-authored *knowledge* layer: which fact witnesses which clause, under what predicate. The one file to review for honesty. | `grades.ncl` | Grade type per verb + aggregation tolerances. | `coapt.ncl` | The Yard comparator. Imports the atoms + map + grades; emits the receipt (`nickel export --format raw`). diff --git a/.machine_readable/coaptation/coapt.k9.ncl b/.machine_readable/coaptation/coapt.k9.ncl index d15eace..a859263 100644 --- a/.machine_readable/coaptation/coapt.k9.ncl +++ b/.machine_readable/coaptation/coapt.k9.ncl @@ -45,7 +45,7 @@ K9! descriptiles = [ # The repo deed at the root (_chora.deed), read through # scripts/deed-field.sh; it replaced CLADE/STATE/ECOSYSTEM/AGENTIC.a2ml - # (rsr-template-repo#209). + # (heron-polyfeed-reader#209). # Its (anchor …) clause replaced descriptiles/anchors/ANCHOR.a2ml. "*_chora.deed", ], diff --git a/.machine_readable/coaptation/coapt.ncl b/.machine_readable/coaptation/coapt.ncl index e480601..c47f323 100644 --- a/.machine_readable/coaptation/coapt.ncl +++ b/.machine_readable/coaptation/coapt.ncl @@ -136,8 +136,8 @@ let contractile_prov = "intend@%{cp.intend} must@%{cp.must} trust@%{cp.trust} ad let descriptile_prov = "deed@%{fp.deed} anchor@%{fp.anchor}" in # repo name comes from the repo deed, not a hardcoded literal — this -# script is shared verbatim by every repo instantiated from rsr-template-repo, -# so a literal "rsr-template-repo" here would misreport every one of them. +# script is shared verbatim by every repo instantiated from heron-polyfeed-reader, +# so a literal "heron-polyfeed-reader" here would misreport every one of them. let repo_name = (lookup "clade.canonical-name").value in let s = std.to_string in diff --git a/.machine_readable/coaptation/coapt.sh b/.machine_readable/coaptation/coapt.sh index 0e50366..b1ee32f 100755 --- a/.machine_readable/coaptation/coapt.sh +++ b/.machine_readable/coaptation/coapt.sh @@ -46,7 +46,7 @@ case "$MODE" in basis="$CO/receipts/reanchor-basis.toml" # repo name from the repo deed, not a hardcoded literal — this # script is shared verbatim by every repo instantiated from - # rsr-template-repo, so a literal "rsr-template-repo" here would + # heron-polyfeed-reader, so a literal "heron-polyfeed-reader" here would # misreport every one of them. repo_name="$(bash "$ROOT/scripts/deed-field.sh" "$(bash "$ROOT/scripts/deed-field.sh" --find "$ROOT")" "" canonical-name)" { diff --git a/.machine_readable/coaptation/extract-facts.sh b/.machine_readable/coaptation/extract-facts.sh index f0f40f8..346bbc6 100755 --- a/.machine_readable/coaptation/extract-facts.sh +++ b/.machine_readable/coaptation/extract-facts.sh @@ -6,7 +6,7 @@ # # Step 1 of the build path (descriptile half): give each descriptive fact a # STABLE ID. Reads this repo's deed (_chora.deed, which took over -# CLADE / STATE / ECOSYSTEM / AGENTIC .a2ml in rsr-template-repo#209) through +# CLADE / STATE / ECOSYSTEM / AGENTIC .a2ml in heron-polyfeed-reader#209) through # scripts/deed-field.sh, including its (anchor …) clause (which took over # descriptiles/anchors/ANCHOR.a2ml), and projects the # fields that can bear witness to contractile obligations into one diff --git a/.machine_readable/coaptation/receipts/latest.toml b/.machine_readable/coaptation/receipts/latest.toml index e3e84bb..4061231 100644 --- a/.machine_readable/coaptation/receipts/latest.toml +++ b/.machine_readable/coaptation/receipts/latest.toml @@ -5,13 +5,13 @@ [receipt] schema = "hyperpolymath.coaptation/1" -repo = "rsr-template-repo" -band = "amber" -proposed-action = "advisory — soft drift within bands (posture/intend). Open an advisory issue; no re-anchor." +repo = "heron-polyfeed-reader" +band = "green" +proposed-action = "none — the descriptile self-model coapts with the contractile set-point within tolerance." [coverage] clauses-total = 52 -descriptile-witnessed = 1 +descriptile-witnessed = 2 probe-deferred-to-hunt = 26 must-trust-unmeasured-gaps = 0 note = "Yard sees descriptile evidence only; probe-deferred obligations need a Hunt-tier run for a full hard-gate." @@ -26,8 +26,8 @@ gate = "pass" [reading.adjust] grade = "posture (advisory; accessibility / inclusive design)" -satisfied = "0/1" -posture-ok = false +satisfied = "1/1" +posture-ok = true [reading.intend] grade = "tropical (min-plus distance-to-north-star; wishes excluded)" @@ -78,7 +78,7 @@ trust.no-dockerfile = "probe-deferred" trust.site-security-headers = "probe-deferred" trust.site-well-known = "probe-deferred" trust.site-resource-records = "probe-deferred" -adjust.placeholder-drift = "refuted" +adjust.placeholder-drift = "witnessed" adjust.template-version-drift = "unmeasured" adjust.readme-completeness = "unmeasured" adjust.example-accuracy = "unmeasured" @@ -98,5 +98,5 @@ bust.sync-drift-between-repos = "latent" bust.contractile-parse-error = "latent" [provenance] -contractiles = "intend@feaaa58c4690 must@6a15dd3ccc3a trust@71566e6b64fc adjust@d7e4225fe5a8 dust@448489330834 bust@ea93d531c781" -descriptiles = "deed@ab944221ee82 anchor@51194a4e21e8" +contractiles = "intend@b82732e6920b must@2fbc17ad11c9 trust@7c76aa04ee1d adjust@a85a0899806f dust@b8a0c28b9a25 bust@e3f475931150" +descriptiles = "deed@386ac8942b14 anchor@68f11b773a0d" diff --git a/.machine_readable/coaptation/witness-map.ncl b/.machine_readable/coaptation/witness-map.ncl index 025d42c..74a7973 100644 --- a/.machine_readable/coaptation/witness-map.ncl +++ b/.machine_readable/coaptation/witness-map.ncl @@ -35,7 +35,7 @@ }, # must.six-directory-present has NO descriptile witness. Its probe needs # descriptiles/PLAYBOOK + NEUROSYM and exactly one deed, and no fact here - # attests that: state and ecosystem are deed clauses since rsr-template-repo#209, + # attests that: state and ecosystem are deed clauses since heron-polyfeed-reader#209, # and the anchor facts come from the deed. It is probe-deferred to the Hunt tier. # --- TRUST: deliberately NO descriptile witness. --- @@ -56,15 +56,15 @@ }, # --- ADJUST: accessibility/hygiene posture (advisory; never gates) --- - # The ANCHOR identity block still carries {{PROJECT_NAME}}; for the template + # The ANCHOR identity block still carries heron-polyfeed-reader; for the template # itself that is expected, but it is honestly a posture dip worth surfacing. "adjust.placeholder-drift" = { kind = "descriptile", witnesses = [{ fact = "anchor.project", op = 'not_contains, arg = "{{" }], - note = "deed (anchor (identity :project)) is un-substituted ({{PROJECT_NAME}}) ⇒ posture dip", + note = "deed (anchor (identity :project)) is un-substituted (heron-polyfeed-reader) ⇒ posture dip", }, - # --- BUST: deliberately NO descriptile witness since rsr-template-repo#209. --- + # --- BUST: deliberately NO descriptile witness since heron-polyfeed-reader#209. --- # The bust entries used to read STATE.a2ml open-failures / last-result. Those # were hand-written values ("0", "unknown") that no maintenance run ever # updated, and the deed has no such field, so there is no descriptive evidence diff --git a/.machine_readable/compliance/reuse/dep5 b/.machine_readable/compliance/reuse/dep5 index 637157c..3dc644a 100644 --- a/.machine_readable/compliance/reuse/dep5 +++ b/.machine_readable/compliance/reuse/dep5 @@ -1,56 +1,56 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: {{PROJECT_NAME}} -Upstream-Contact: {{AUTHOR}} <{{AUTHOR_EMAIL}}> -Source: https://github.com/{{OWNER}}/{{REPO}} +Upstream-Name: heron-polyfeed-reader +Upstream-Contact: Jonathan D.A. Jewell +Source: https://github.com/hyperpolymath/heron-polyfeed-reader # Default: all files are MPL-2.0 Files: * -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Configuration files that cannot carry headers Files: .editorconfig .gitignore .gitattributes .tool-versions .mailmap -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Machine-readable state files Files: .machine_readable/*.a2ml -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Bot directives Files: .machine_readable/bot_directives/* -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Contractiles Files: .machine_readable/contractiles/* -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # GitHub/CI configuration Files: .github/* .github/**/* -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Generated files Files: generated/* -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Lockfiles and auto-generated Files: *.lock Cargo.lock flake.lock -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Devcontainer config (JSON, no comments) Files: .devcontainer/*.json -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Git-cliff config Files: cliff.toml -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: MPL-2.0 # Narrative documentation is CC-BY-SA-4.0 (estate Rule 1). @@ -58,5 +58,5 @@ License: MPL-2.0 # including the GitHub community-health .md files. Machine-readable .a2ml is a # different extension and stays MPL-2.0 per its stanza above. Files: *.adoc *.md -Copyright: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright: 2026 Jonathan D.A. Jewell (hyperpolymath) License: CC-BY-SA-4.0 diff --git a/.machine_readable/configs/eclexiaiser.toml b/.machine_readable/configs/eclexiaiser.toml index abc542e..7d2657d 100644 --- a/.machine_readable/configs/eclexiaiser.toml +++ b/.machine_readable/configs/eclexiaiser.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MPL-2.0 [project] -name = "{{REPO}}" +name = "heron-polyfeed-reader" [[functions]] name = "build" diff --git a/.machine_readable/configs/git-cliff/cliff.toml b/.machine_readable/configs/git-cliff/cliff.toml index 5c39b48..9c8bb20 100644 --- a/.machine_readable/configs/git-cliff/cliff.toml +++ b/.machine_readable/configs/git-cliff/cliff.toml @@ -1,12 +1,12 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # git-cliff configuration for conventional commit changelog generation. # https://git-cliff.org/docs/configuration # # Placeholders — replace before first use: -# {{OWNER}} — GitHub organization or username -# {{REPO}} — GitHub repository name +# hyperpolymath — GitHub organization or username +# heron-polyfeed-reader — GitHub repository name [changelog] # Changelog header @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # https://keats.github.io/tera/docs/#introduction body = """ {%- macro remote_url() -%} - https://github.com/{{OWNER}}/{{REPO}} + https://github.com/hyperpolymath/heron-polyfeed-reader {%- endmacro -%} {% if version -%} @@ -57,7 +57,7 @@ body = """ # Template for the changelog footer footer = """ {%- macro remote_url() -%} - https://github.com/{{OWNER}}/{{REPO}} + https://github.com/hyperpolymath/heron-polyfeed-reader {%- endmacro -%} {% for release in releases -%} diff --git a/.machine_readable/configs/stapeln.toml b/.machine_readable/configs/stapeln.toml index 7bf5d48..b2272c8 100644 --- a/.machine_readable/configs/stapeln.toml +++ b/.machine_readable/configs/stapeln.toml @@ -1,16 +1,16 @@ # SPDX-License-Identifier: MPL-2.0 -# stapeln.toml — Layer-based container build for {{SERVICE_NAME}} +# stapeln.toml — Layer-based container build for heron # # stapeln builds containers as composable layers (German: "to stack"). # Each layer is independently cacheable, verifiable, and signable. [metadata] -name = "{{SERVICE_NAME}}" +name = "heron" version = "0.1.0" -description = "{{SERVICE_NAME}} container service" +description = "heron container service" author = "Jonathan D.A. Jewell " license = "MPL-2.0" -registry = "{{REGISTRY}}" +registry = "ghcr.io/hyperpolymath" [build] containerfile = "Containerfile" @@ -32,7 +32,7 @@ packages = [] cache = true [layers.build] -description = "{{SERVICE_NAME}} build" +description = "heron build" extends = "toolchain" commands = [] @@ -43,7 +43,7 @@ packages = ["ca-certificates", "curl"] copy-from = [ { layer = "build", src = "/app/", dst = "/app/" }, ] -entrypoint = ["/app/{{SERVICE_NAME}}"] +entrypoint = ["/app/heron"] user = "nonroot" # ── Security ─────────────────────────────────────────────────── diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index a83f204..f35eac0 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -22,9 +22,9 @@ import? "build/contractile.just" mod provision 'build/just/provision.just' # Project metadata — customize these -project := "rsr-template-repo" +project := "heron-polyfeed-reader" OWNER := "hyperpolymath" -REPO := "rsr-template-repo" +REPO := "heron-polyfeed-reader" version := "0.1.0" tier := "infrastructure" # 1 | 2 | infrastructure @@ -51,7 +51,7 @@ help recipe="": # Show this project's info info: - @echo "Project: {{project}}" + @echo "Project: heron_polyfeed_reader" @echo "Version: {{version}}" @echo "RSR Tier: {{tier}}" @echo "Recipes: $(just --summary | wc -w)" @@ -228,7 +228,7 @@ run-verbose: run # Install to user path install: build-release - @echo "Installing {{project}}..." + @echo "Installing heron_polyfeed_reader..." # TODO: Replace with your install command # ═══════════════════════════════════════════════════════════════════════════════ @@ -320,7 +320,7 @@ cookbook: #!/usr/bin/env bash mkdir -p docs OUTPUT="docs/just-cookbook.adoc" - echo "= {{project}} Justfile Cookbook" > "$OUTPUT" + echo "= heron_polyfeed_reader Justfile Cookbook" > "$OUTPUT" echo ":toc: left" >> "$OUTPUT" echo ":toclevels: 3" >> "$OUTPUT" echo "" >> "$OUTPUT" @@ -346,10 +346,10 @@ cookbook: man: #!/usr/bin/env bash mkdir -p docs/man - cat > docs/man/{{project}}.1 << EOF - .TH {{project}} 1 "$(date +%Y-%m-%d)" "{{version}}" "{{project}} Manual" + cat > docs/man/heron_polyfeed_reader.1 << EOF + .TH heron_polyfeed_reader 1 "$(date +%Y-%m-%d)" "{{version}}" "heron_polyfeed_reader Manual" .SH NAME - {{project}} \- RSR-compliant project + heron_polyfeed_reader \- RSR-compliant project .SH SYNOPSIS .B just [recipe] [args...] @@ -358,7 +358,7 @@ man: .SH AUTHOR $(git config user.name 2>/dev/null || echo "Author") <$(git config user.email 2>/dev/null || echo "email")> EOF - echo "Generated: docs/man/{{project}}.1" + echo "Generated: docs/man/heron_polyfeed_reader.1" # ═══════════════════════════════════════════════════════════════════════════════ # CI & AUTOMATION @@ -408,7 +408,7 @@ import? "build/just/validate.just" # ═══════════════════════════════════════════════════════════════════════════════ # Repo state lives in the repo deed's (status …) and (maturity …) clauses -# (rsr-template-repo#209); the journal is docs/status/ROADMAP.adoc. There is +# (heron-polyfeed-reader#209); the journal is docs/status/ROADMAP.adoc. There is # no last-updated stamp to touch: git log is the record of when it changed. # Show the lifecycle phase and maturity from the repo deed @@ -574,7 +574,7 @@ opsm: provision::opsm # Guided tour of key features tour: - @echo "=== rsr-template-repo Tour ===" + @echo "=== heron-polyfeed-reader Tour ===" @echo "" @echo "1. Project structure:" @ls -la @@ -589,12 +589,12 @@ tour: # Open feedback channel with diagnostic context help-me: - @echo "=== rsr-template-repo Help ===" + @echo "=== heron-polyfeed-reader Help ===" @echo "Platform: $(uname -s) $(uname -m)" @echo "Shell: $SHELL" @echo "" @echo "To report an issue:" - @echo " https://github.com/hyperpolymath/rsr-template-repo/issues/new" + @echo " https://github.com/hyperpolymath/heron-polyfeed-reader/issues/new" @echo "" @echo "Include the output of 'just doctor' in your report." diff --git a/.machine_readable/contractiles/README.adoc b/.machine_readable/contractiles/README.adoc index 3402581..1cfae12 100644 --- a/.machine_readable/contractiles/README.adoc +++ b/.machine_readable/contractiles/README.adoc @@ -5,7 +5,7 @@ :sectnums: This directory contains the canonical contractile templates for the -hyperpolymath estate. `rsr-template-repo` mirrors the standards master +hyperpolymath estate. `heron-polyfeed-reader` mirrors the standards master structure; copy `.machine_readable/contractiles/` into a new repo to establish a consistent operational, validation, trust, recovery, aspiration, and service-automation framework. diff --git a/.machine_readable/contractiles/adjust/adjust.k9.ncl b/.machine_readable/contractiles/adjust/adjust.k9.ncl index 8e5cef3..0807a7d 100644 --- a/.machine_readable/contractiles/adjust/adjust.k9.ncl +++ b/.machine_readable/contractiles/adjust/adjust.k9.ncl @@ -67,7 +67,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "adjust trident — canonical template in rsr-template-repo. (Yard, advisory) authority pattern. Specialises in cumulative-drift catchment — tolerance bands + trend tracking + auto-fix-where-declared. Advisory (continue-with-warnings). Copy this trident into a new repo and define its drift tolerances and corrective actions.", + context = "adjust trident — canonical template in heron-polyfeed-reader. (Yard, advisory) authority pattern. Specialises in cumulative-drift catchment — tolerance bands + trend tracking + auto-fix-where-declared. Advisory (continue-with-warnings). Copy this trident into a new repo and define its drift tolerances and corrective actions.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "Adjustfile.a2ml and adjust.ncl pre-existed. This manifest + adjust.k9.ncl complete the trident. Exercises the Yard tier + advisory authority for the first time; on_exceeded = 'warn_and_attempt_fix rather than 'fail. adjust-specific track_drift_trend_over_sessions + flag_accelerating_drift." }, @@ -85,8 +85,8 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Adjustfile — Drift-tolerance contract for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# Cumulative-drift catchment: tolerance bands + corrective actions.\n# Authority: advisory (Yard) — continue-with-warnings; auto_fix where deterministic.\n# Run with: adjust check\n# Fix with: adjust fix (applies deterministic patches; advisory otherwise)\n", - abstract = "Drift tolerances and corrective actions for rsr-template-repo. Unlike\nMUST (hard gate), ADJUST tracks cumulative drift against tolerance bands\nand proposes corrective actions. Advisory — it warns and trends, it does\nnot block.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Adjustfile — Drift-tolerance contract for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# Cumulative-drift catchment: tolerance bands + corrective actions.\n# Authority: advisory (Yard) — continue-with-warnings; auto_fix where deterministic.\n# Run with: adjust check\n# Fix with: adjust fix (applies deterministic patches; advisory otherwise)\n", + abstract = "Drift tolerances and corrective actions for heron-polyfeed-reader. Unlike\nMUST (hard gate), ADJUST tracks cumulative drift against tolerance bands\nand proposes corrective actions. Advisory — it warns and trends, it does\nnot block.", prose = [ ], requirements = [ diff --git a/.machine_readable/contractiles/bust/bust.k9.ncl b/.machine_readable/contractiles/bust/bust.k9.ncl index 6b0df6b..9c09912 100644 --- a/.machine_readable/contractiles/bust/bust.k9.ncl +++ b/.machine_readable/contractiles/bust/bust.k9.ncl @@ -69,7 +69,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "bust trident — canonical template in rsr-template-repo. Completes the blocking-authority triple (must + trust + bust). Specialises in deprecated-path-reintroduction catchment. Declares hard-stop / expiry / must-not-run conditions. Copy this trident into a new repo and declare its real breakage / expiry conditions.", + context = "bust trident — canonical template in heron-polyfeed-reader. Completes the blocking-authority triple (must + trust + bust). Specialises in deprecated-path-reintroduction catchment. Declares hard-stop / expiry / must-not-run conditions. Copy this trident into a new repo and declare its real breakage / expiry conditions.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "Bustfile.a2ml and bust.ncl pre-existed. This manifest + bust.k9.ncl complete the trident. Inherits the full negotiation+accountability schema from intend.k9.ncl v2.0.0 + trust/must extensions; adds flag_deprecated_reintroduction for C2-defense specificity." }, @@ -87,8 +87,8 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Bustfile — failure mode contractile for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: bust.ncl\n# Verb: bust\n# Semantics: Every declared failure mode must have a working recovery path\n# that has been exercised. Status moves:\n# declared → drilled (probe run) → verified (recovery confirmed)\n# or → failing (recovery broken)\n#\n# CLI:\n# contractile bust check → list failure modes + recovery status\n# contractile bust drill → inject failures, verify recovery paths\n#\n# This repository: rsr-template-repo is the canonical template for RSR compliance.\n# Failure modes here relate to template distribution and substitution.\n", - abstract = "Bustfile for rsr-template-repo. Lists failure modes specific to the template\nrepository itself, particularly around template distribution, substitution,\nand synchronization across the hyperpolymath estate.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Bustfile — failure mode contractile for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: bust.ncl\n# Verb: bust\n# Semantics: Every declared failure mode must have a working recovery path\n# that has been exercised. Status moves:\n# declared → drilled (probe run) → verified (recovery confirmed)\n# or → failing (recovery broken)\n#\n# CLI:\n# contractile bust check → list failure modes + recovery status\n# contractile bust drill → inject failures, verify recovery paths\n#\n# This repository: heron-polyfeed-reader is the canonical template for RSR compliance.\n# Failure modes here relate to template distribution and substitution.\n", + abstract = "Bustfile for heron-polyfeed-reader. Lists failure modes specific to the template\nrepository itself, particularly around template distribution, substitution,\nand synchronization across the hyperpolymath estate.", prose = [ ], failure_modes = [ @@ -97,7 +97,7 @@ let base = import "../_base.ncl" in section = "Failure Modes", class = 'template_processing, description = "Template substitution fails when initializing a new repo from this template", - injection_probe = "cp -r rsr-template-repo test-repo && cd test-repo && sed -i 's/rsr-template-repo/TEST/g' .machine_readable/contractiles/intend/intend.k9.ncl && grep -q 'TEST' .machine_readable/contractiles/intend/intend.k9.ncl", + injection_probe = "cp -r heron-polyfeed-reader test-repo && cd test-repo && sed -i 's/heron-polyfeed-reader/TEST/g' .machine_readable/contractiles/intend/intend.k9.ncl && grep -q 'TEST' .machine_readable/contractiles/intend/intend.k9.ncl", recovery_probe = "git -C test-repo diff --quiet .machine_readable/contractiles/intend/intend.k9.ncl", expected_recovery_time_seconds = 10, status = 'declared, @@ -107,7 +107,7 @@ let base = import "../_base.ncl" in id = "sync-drift-between-repos", section = "Failure Modes", class = 'synchronization, - description = "Drift occurs between rsr-template-repo and other repos after template updates", + description = "Drift occurs between heron-polyfeed-reader and other repos after template updates", injection_probe = "echo 'template_updated' > /tmp/test_drift_marker", recovery_probe = "test -f /tmp/test_drift_marker && rm /tmp/test_drift_marker", expected_recovery_time_seconds = 60, @@ -119,8 +119,8 @@ let base = import "../_base.ncl" in section = "Failure Modes", class = 'contractile_format, description = "A contractile file fails to parse due to syntax errors", - injection_probe = "echo 'invalid syntax' >> rsr-template-repo/.machine_readable/contractiles/intend/intend.k9.ncl", - recovery_probe = "git checkout rsr-template-repo/.machine_readable/contractiles/intend/intend.k9.ncl", + injection_probe = "echo 'invalid syntax' >> heron-polyfeed-reader/.machine_readable/contractiles/intend/intend.k9.ncl", + recovery_probe = "git checkout heron-polyfeed-reader/.machine_readable/contractiles/intend/intend.k9.ncl", expected_recovery_time_seconds = 5, status = 'declared, notes = "Every .k9.ncl must typecheck; validate with .github/hooks/validate-k9.sh and nickel typecheck", diff --git a/.machine_readable/contractiles/dust/dust.k9.ncl b/.machine_readable/contractiles/dust/dust.k9.ncl index 8c3612e..cbd6054 100644 --- a/.machine_readable/contractiles/dust/dust.k9.ncl +++ b/.machine_readable/contractiles/dust/dust.k9.ncl @@ -78,7 +78,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "dust trident — canonical template in rsr-template-repo. Specialises in audit-trail preservation + rollback-path verification. Yard tier with destructive-action gating (dry-run default; --apply + per-item approval required for mutations). Copy this trident into a new repo and describe how it actually rolls back or retires behaviour.", + context = "dust trident — canonical template in heron-polyfeed-reader. Specialises in audit-trail preservation + rollback-path verification. Yard tier with destructive-action gating (dry-run default; --apply + per-item approval required for mutations). Copy this trident into a new repo and describe how it actually rolls back or retires behaviour.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "Dustfile.a2ml and dust.ncl pre-existed. This manifest + dust.k9.ncl complete the trident and the full verb set. All 6 verbs now on trident shape: intend (Hunt, reporting), trust (Hunt, blocking), must (Hunt-read-only, blocking), bust (Hunt-read-only, blocking), adjust (Yard, advisory), dust (Yard, advisory)." }, @@ -97,8 +97,8 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Dustfile — Cleanup and hygiene contract for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: dust.ncl\n# Verb: dust\n# Semantics: What should be cleaned up. Housekeeping, not blockers.\n#\n# This repository: rsr-template-repo is the canonical template.\n# Cleanup items here ensure the template itself remains pristine.\n", - abstract = "Cleanup and hygiene items for rsr-template-repo. These are maintenance tasks\nthat ensure the template repository remains clean and ready for distribution\nto new repositories.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Dustfile — Cleanup and hygiene contract for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: dust.ncl\n# Verb: dust\n# Semantics: What should be cleaned up. Housekeeping, not blockers.\n#\n# This repository: heron-polyfeed-reader is the canonical template.\n# Cleanup items here ensure the template itself remains pristine.\n", + abstract = "Cleanup and hygiene items for heron-polyfeed-reader. These are maintenance tasks\nthat ensure the template repository remains clean and ready for distribution\nto new repositories.", prose = [ ], removal_candidates = [ @@ -144,10 +144,10 @@ let base = import "../_base.ncl" in { id = "no-stale-template-references", section = "Template Hygiene", - description = "No references to rsr-template-repo in generic template files", - probe = "test -z \"$(grep -r 'rsr-template-repo' machine-readable-design/ 2>/dev/null)\"", + description = "No references to heron-polyfeed-reader in generic template files", + probe = "test -z \"$(grep -r 'heron-polyfeed-reader' machine-readable-design/ 2>/dev/null)\"", severity = 'warning, - notes = "Generic templates should use {{PROJECT_NAME}} or similar placeholders", + notes = "Generic templates should use heron-polyfeed-reader or similar placeholders", }, { id = "version-sync-checked", diff --git a/.machine_readable/contractiles/intend/intend.k9.ncl b/.machine_readable/contractiles/intend/intend.k9.ncl index 9e2f988..0f8cd7e 100644 --- a/.machine_readable/contractiles/intend/intend.k9.ncl +++ b/.machine_readable/contractiles/intend/intend.k9.ncl @@ -82,7 +82,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "intend trident — canonical template in rsr-template-repo. North-star verb: reports progress toward committed next-actions ([[intents]]) and lists horizon aspirations ([[wishes]]). Non-gating (reporting authority). Copy this trident into a new repo and replace the declaration with project-specific content.", + context = "intend trident — canonical template in heron-polyfeed-reader. North-star verb: reports progress toward committed next-actions ([[intents]]) and lists horizon aspirations ([[wishes]]). Non-gating (reporting authority). Copy this trident into a new repo and replace the declaration with project-specific content.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "intend/Intentfile.a2ml pre-existed (f380b62, lust absorption). This manifest + intend.k9.ncl complete the trident for the first time." }, @@ -101,12 +101,12 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Intentfile — north-star contractile for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: intend.ncl\n# Verb: intend\n#\n# Semantics: North-star contractile. Declares BOTH concrete committed\n# next-actions AND horizon aspirations the project wishes to\n# become. Two sections share one file because they answer\n# the same question at different ranges:\n# [[intents]] — \"we WILL do this; track progress\"\n# status: declared → in_progress → done |\n# deferred | retired\n# [[wishes]] — \"we WISH this were true; revisit later\"\n# status: declared → in_progress → achieved |\n# abandoned\n# grouped by horizon: near / mid / far.\n# Non-gating — this is a report, not a gate. See the `must`\n# contractile for hard gates.\n", - abstract = "North-star contractile for rsr-template-repo. This repository is the\ncanonical template for Rhodium Standard Repository compliance. It provides\nthe scaffold that all hyperpolymath repos should copy and customize.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Intentfile — north-star contractile for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# Paired runner: intend.ncl\n# Verb: intend\n#\n# Semantics: North-star contractile. Declares BOTH concrete committed\n# next-actions AND horizon aspirations the project wishes to\n# become. Two sections share one file because they answer\n# the same question at different ranges:\n# [[intents]] — \"we WILL do this; track progress\"\n# status: declared → in_progress → done |\n# deferred | retired\n# [[wishes]] — \"we WISH this were true; revisit later\"\n# status: declared → in_progress → achieved |\n# abandoned\n# grouped by horizon: near / mid / far.\n# Non-gating — this is a report, not a gate. See the `must`\n# contractile for hard gates.\n", + abstract = "North-star contractile for heron-polyfeed-reader. This repository is the\ncanonical template for Rhodium Standard Repository compliance. It provides\nthe scaffold that all hyperpolymath repos should copy and customize.", prose = [ { section = "Purpose", - text = "The rsr-template-repo serves as the master template for all hyperpolymath\nrepositories. It contains the complete set of contractile files, machine-readable\nspecifications, and governance documentation that define the Rhodium Standard.\n\nEvery new repository in the hyperpolymath estate should be initialized by\ncopying this template and substituting the placeholder values with\nrepo-specific content.", + text = "The heron-polyfeed-reader serves as the master template for all hyperpolymath\nrepositories. It contains the complete set of contractile files, machine-readable\nspecifications, and governance documentation that define the Rhodium Standard.\n\nEvery new repository in the hyperpolymath estate should be initialized by\ncopying this template and substituting the placeholder values with\nrepo-specific content.", }, { section = "Anti-Purpose", diff --git a/.machine_readable/contractiles/must/must.k9.ncl b/.machine_readable/contractiles/must/must.k9.ncl index bea41e1..69f1d39 100644 --- a/.machine_readable/contractiles/must/must.k9.ncl +++ b/.machine_readable/contractiles/must/must.k9.ncl @@ -94,7 +94,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "must trident — canonical template in rsr-template-repo. Blocking-authority verb (paired with trust). Specialises in subtle invariant-erosion catchment vs trust's outrageous-attack catchment. Hard gate: any failing invariant blocks merge. Copy this trident into a new repo and replace the declaration with project-specific invariants.", + context = "must trident — canonical template in heron-polyfeed-reader. Blocking-authority verb (paired with trust). Specialises in subtle invariant-erosion catchment vs trust's outrageous-attack catchment. Hard gate: any failing invariant blocks merge. Copy this trident into a new repo and replace the declaration with project-specific invariants.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "Mustfile.a2ml and must.ncl pre-existed. This manifest + must.k9.ncl complete the trident. Inherits on_open schema from intend.k9.ncl v2.0.0; inherits block_session_close_on_critical_drift + variance-severity-acknowledgement from trust.k9.ncl v1.0.0; adds must-specific track_per_session_trend + flag_silent_regression + probe_scope = 'read_only (must doesn't do active exploit attempts — that's trust's safe_hacking territory)." }, @@ -112,8 +112,8 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Mustfile — Physical state contract for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# What MUST be true about this repository. Hard requirements.\n# Run with: must check\n# Fix with: must fix (where a deterministic fix exists)\n", - abstract = "Physical-state invariants for rsr-template-repo. This is the canonical\nRSR template repository. These are hard requirements — CI and pre-commit\nhooks fail if any check fails.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Mustfile — Physical state contract for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# What MUST be true about this repository. Hard requirements.\n# Run with: must check\n# Fix with: must fix (where a deterministic fix exists)\n", + abstract = "Physical-state invariants for heron-polyfeed-reader. This is the canonical\nRSR template repository. These are hard requirements — CI and pre-commit\nhooks fail if any check fails.", prose = [ { section = "Template Integrity", diff --git a/.machine_readable/contractiles/trust/trust.k9.ncl b/.machine_readable/contractiles/trust/trust.k9.ncl index fa7431a..5cbc21e 100644 --- a/.machine_readable/contractiles/trust/trust.k9.ncl +++ b/.machine_readable/contractiles/trust/trust.k9.ncl @@ -100,7 +100,7 @@ let base = import "../_base.ncl" in signed_by = { user = "Jonathan D.A. Jewell", date = "2026-04-18", - context = "trust trident — canonical template in rsr-template-repo. (Hunt, blocking) authority pattern. Primary catchment for adversarial drift test scenarios (firewall-off, cleartext-auth, PQ-downgrade, CSP-weaken). Hard gate: failed verification blocks merge. Copy this trident into a new repo and point it at the real keys, policies, and authority boundaries.", + context = "trust trident — canonical template in heron-polyfeed-reader. (Hunt, blocking) authority pattern. Primary catchment for adversarial drift test scenarios (firewall-off, cleartext-auth, PQ-downgrade, CSP-weaken). Hard gate: failed verification blocks merge. Copy this trident into a new repo and point it at the real keys, policies, and authority boundaries.", }, history = [ { date = "2026-04-18", event = "trident-born", note = "Trustfile.a2ml and trust.ncl pre-existed. This manifest + trust.k9.ncl complete the trident. Inherits on_open negotiation + accountability + plain-language-translation schema from intend.k9.ncl v2.0.0; adds trust-specific threat_model_foregrounding + block_session_close_on_critical_drift + stricter accountability pledge (user cannot disable verification to unblock merges)." }, @@ -118,12 +118,12 @@ let base = import "../_base.ncl" in # Read by .machine_readable/coaptation/extract-clauses.sh. # ------------------------------------------------------------------- declaration = { - preamble = "# SPDX-License-Identifier: MPL-2.0\n# Trustfile — Trust boundaries and integrity invariants for rsr-template-repo\n# Author: Jonathan D.A. Jewell \n#\n# Defines what LLM/SLM agents are trusted to do without asking, and\n# integrity invariants that verify the repo has not been tampered with.\n", - abstract = "Trust boundaries and integrity checks for rsr-template-repo. This file\ncombines the trust-level definitions from the original TRUST.contractile\nwith the integrity invariants from the old Trustfile.a2ml. It defines\nwhat AI agents may do autonomously and what requires human approval,\nplus checks that verify repository integrity.", + preamble = "# SPDX-License-Identifier: MPL-2.0\n# Trustfile — Trust boundaries and integrity invariants for heron-polyfeed-reader\n# Author: Jonathan D.A. Jewell \n#\n# Defines what LLM/SLM agents are trusted to do without asking, and\n# integrity invariants that verify the repo has not been tampered with.\n", + abstract = "Trust boundaries and integrity checks for heron-polyfeed-reader. This file\ncombines the trust-level definitions from the original TRUST.contractile\nwith the integrity invariants from the old Trustfile.a2ml. It defines\nwhat AI agents may do autonomously and what requires human approval,\nplus checks that verify repository integrity.", prose = [ { section = "Trust Levels", - text = "The rsr-template-repo operates at trust level: maximal\n\nTrust levels:\n- maximal: Agent may read, build, test, lint, format, heal freely.\n Only destructive/external actions require approval.\n- standard: Agent may read and build. Test/lint need approval.\n- restricted: Agent may read only. All modifications need approval.\n- minimal: Agent may read specific files only. Everything else blocked.\n\nCurrent trust level: maximal", + text = "The heron-polyfeed-reader operates at trust level: maximal\n\nTrust levels:\n- maximal: Agent may read, build, test, lint, format, heal freely.\n Only destructive/external actions require approval.\n- standard: Agent may read and build. Test/lint need approval.\n- restricted: Agent may read only. All modifications need approval.\n- minimal: Agent may read specific files only. Everything else blocked.\n\nCurrent trust level: maximal", }, ], verifications = [ diff --git a/.machine_readable/descriptiles/README.adoc b/.machine_readable/descriptiles/README.adoc index 5f7207c..ecdf655 100644 --- a/.machine_readable/descriptiles/README.adoc +++ b/.machine_readable/descriptiles/README.adoc @@ -30,7 +30,7 @@ grammar specification, never against a neighbouring file. The descriptiles are clauses of the repo deed (`*_chora.deed`), not separate files; no `.a2ml` file may be recreated anywhere -(rsr-template-repo#209): +(heron-polyfeed-reader#209): * `(neurosym)`: symbolic semantics, composition algebra; * `(playbook)`: executable plans, operational runbooks; diff --git a/.machine_readable/descriptiles/anchors/README.adoc b/.machine_readable/descriptiles/anchors/README.adoc index 9d2f62f..911d646 100644 --- a/.machine_readable/descriptiles/anchors/README.adoc +++ b/.machine_readable/descriptiles/anchors/README.adoc @@ -10,7 +10,7 @@ anchor file lives in this directory, and no `.a2ml` file may be created here. - `(anchor)` - Project recalibration, scope intervention, canonical authority -Read a field with `scripts/deed-field.sh rsr-template-repo_chora.deed anchor `. +Read a field with `scripts/deed-field.sh heron-polyfeed-reader_chora.deed anchor `. ## Multiple Versions diff --git a/.machine_readable/descriptiles/provisioning_praxis.deed b/.machine_readable/descriptiles/provisioning_praxis.deed index 5445982..251d6fa 100644 --- a/.machine_readable/descriptiles/provisioning_praxis.deed +++ b/.machine_readable/descriptiles/provisioning_praxis.deed @@ -10,8 +10,8 @@ ;; Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc (praxis-deed :schema-version "1.0.0" - :canonical-name "rsr-template-repo-provisioning" - :repo "hyperpolymath/rsr-template-repo" + :canonical-name "heron-polyfeed-reader-provisioning" + :repo "hyperpolymath/heron-polyfeed-reader" :archetype "library" ; app | library | tool | theory | docs :languages "idris2 zig" ; detected; informational ;; Contract-verb overrides ("" = per-language default, which `just langs` shows) diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt index 3bb68f2..e6a75d2 100644 --- a/.machine_readable/root-allow.txt +++ b/.machine_readable/root-allow.txt @@ -32,7 +32,7 @@ LICENSE LICENSES/ # REUSE licence texts, dual-licence model (code MPL-2.0 / docs CC-BY-SA-4.0) CHANGELOG.adoc # AsciiDoc is the estate-standard documentation format CITATION.cff # citation metadata; GitHub reads it from the root of the default branch only -rsr-template-repo_chora.deed # the repo deed: universal AI entry point; family-7 allocation manifest + ply tree folded in (standards#837 pilot). Filename carries the repo slug (deed dispatch _chora.deed), so repo-init renames it at mint +heron-polyfeed-reader_chora.deed # the repo deed: universal AI entry point; family-7 allocation manifest + ply tree folded in (standards#837 pilot). Filename carries the repo slug (deed dispatch _chora.deed), so repo-init renames it at mint CLAUDE.md # generated arrival pack; the generator, pre-commit and Claude Code all read it from the ROOT (do not hand-edit; edit the repo deed) ?GEMINI.md # pointer to CLAUDE.md for repos without AGENTS.md yet Justfile # thin; delegates phases to build/just/*.just @@ -84,3 +84,10 @@ tests/ ?REQUIRES_INITIALISATION.md # written by the minting process, removed once `just repo-init` runs ?Project.toml # julia-library archetype: Pkg reads the package manifest from the package root only ?test/ # julia-library archetype: Pkg.test runs test/runtests.jl, a fixed path (the spine keeps tests/) + +# ─── Project (heron-polyfeed-reader): tool-required at root ────────────────── +build.zig # Zig reads the build script from the package root only +build.zig.zon # Zig package manifest; beside build.zig by definition +Containerfile # OCI build (test + runtime stages); podman/docker read it from the build context root +.dockerignore # read by podman/docker beside the Containerfile +vendor/ # vendored SQLite amalgamation (tracked source; .gitignore un-ignores vendor/sqlite/) diff --git a/.machine_readable/scripts/maintenance/maint-assault.sh b/.machine_readable/scripts/maintenance/maint-assault.sh index f170cab..47b49b7 100644 --- a/.machine_readable/scripts/maintenance/maint-assault.sh +++ b/.machine_readable/scripts/maintenance/maint-assault.sh @@ -8,7 +8,7 @@ set -euo pipefail -BINARY_NAME="{{project}}" +BINARY_NAME="heron_polyfeed_reader" REPORT_PATH="docs/reports/security/assault-latest.json" PA_BIN="${PANIC_ATTACK_BIN:-panic-attack}" diff --git a/.machine_readable/self-validating/README.adoc b/.machine_readable/self-validating/README.adoc index 6484b4e..778ab2e 100644 --- a/.machine_readable/self-validating/README.adoc +++ b/.machine_readable/self-validating/README.adoc @@ -131,7 +131,7 @@ K9 contractiles integrate with other RSR standards: ⚠️ **Never run as root unless required** + ⚠️ **Sandbox external components** -**See:** https://github.com/{{OWNER}}/k9-svc/blob/main/docs/SECURITY-BEST-PRACTICES.adoc +**See:** https://github.com/hyperpolymath/k9-svc/blob/main/docs/SECURITY-BEST-PRACTICES.adoc == Template Files @@ -155,14 +155,14 @@ chmod +x nickel && sudo mv nickel /usr/local/bin/ cargo install just # Clone K9-SVC (for must shim and tooling) -git clone https://github.com/{{OWNER}}/k9-svc.git +git clone https://github.com/hyperpolymath/k9-svc.git ---- == Learn More -- **K9-SVC Specification:** https://github.com/{{OWNER}}/k9-svc/blob/main/SPEC.adoc -- **K9 User Guide:** https://github.com/{{OWNER}}/k9-svc/blob/main/GUIDE.adoc -- **Security Documentation:** https://github.com/{{OWNER}}/k9-svc/blob/main/docs/SECURITY-FAQ.adoc +- **K9-SVC Specification:** https://github.com/hyperpolymath/k9-svc/blob/main/SPEC.adoc +- **K9 User Guide:** https://github.com/hyperpolymath/k9-svc/blob/main/GUIDE.adoc +- **Security Documentation:** https://github.com/hyperpolymath/k9-svc/blob/main/docs/SECURITY-FAQ.adoc - **IANA Media Type:** `application/vnd.k9+nickel` == Contributing @@ -175,4 +175,4 @@ When adding K9 contractiles to your repository: 4. Sign Hunt-level components before committing 5. Add K9 validation to CI/CD pipeline -**Questions?** Open an issue on https://github.com/{{OWNER}}/k9-svc +**Questions?** Open an issue on https://github.com/hyperpolymath/k9-svc diff --git a/.machine_readable/self-validating/examples/ci-config.k9.ncl b/.machine_readable/self-validating/examples/ci-config.k9.ncl index 1f38e2d..9fe314e 100644 --- a/.machine_readable/self-validating/examples/ci-config.k9.ncl +++ b/.machine_readable/self-validating/examples/ci-config.k9.ncl @@ -19,7 +19,7 @@ K9! name = "ci-config", version = "1.0.0", description = "CI/CD configuration with runtime validation", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, }, diff --git a/.machine_readable/self-validating/examples/project-metadata.k9.ncl b/.machine_readable/self-validating/examples/project-metadata.k9.ncl index c8e6ebf..5de965d 100644 --- a/.machine_readable/self-validating/examples/project-metadata.k9.ncl +++ b/.machine_readable/self-validating/examples/project-metadata.k9.ncl @@ -19,7 +19,7 @@ K9! name = "project-metadata", version = "1.0.0", description = "Pure data configuration for project metadata", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, }, @@ -30,14 +30,14 @@ K9! description = "A project following Rhodium Standard Repositories", repository = { - url = "https://github.com/{{OWNER}}/my-project", + url = "https://github.com/hyperpolymath/my-project", type = "git", }, author = { - name = "{{AUTHOR}}", - email = "{{AUTHOR_EMAIL}}", - organization = "{{AUTHOR_ORG}}", + name = "Jonathan D.A. Jewell", + email = "j.d.a.jewell@open.ac.uk", + organization = "", }, license = "MPL-2.0", @@ -45,7 +45,7 @@ K9! keywords = [ "rhodium-standard", "rsr", - "{{OWNER}}", + "hyperpolymath", ], }, diff --git a/.machine_readable/self-validating/examples/setup-repo.k9.ncl b/.machine_readable/self-validating/examples/setup-repo.k9.ncl index f86df82..c38e0a9 100644 --- a/.machine_readable/self-validating/examples/setup-repo.k9.ncl +++ b/.machine_readable/self-validating/examples/setup-repo.k9.ncl @@ -20,7 +20,7 @@ K9! name = "setup-repo", version = "1.0.0", description = "Automated repository setup with RSR standards", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, warnings = [ "This component has full system access", @@ -104,8 +104,8 @@ K9! description = "Initialize Git repository", commands = [ "git init -b %{config.git.default_branch}", - "git config user.name '{{AUTHOR}}'", - "git config user.email '{{AUTHOR_EMAIL}}'", + "git config user.name 'Jonathan D.A. Jewell'", + "git config user.email 'j.d.a.jewell@open.ac.uk'", "echo '✓ Git initialized'", ], }, @@ -114,7 +114,7 @@ K9! description = "Add RSR-compliant workflows", commands = [ # This would copy workflow templates - # In a real implementation, would fetch from rsr-template-repo + # In a real implementation, would fetch from heron-polyfeed-reader "echo '✓ Workflows configured'", ], }, diff --git a/.machine_readable/self-validating/methodology-guard.k9.ncl b/.machine_readable/self-validating/methodology-guard.k9.ncl index 41c12d5..37efa16 100644 --- a/.machine_readable/self-validating/methodology-guard.k9.ncl +++ b/.machine_readable/self-validating/methodology-guard.k9.ncl @@ -1,6 +1,6 @@ K9! # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # K9 Validator: Methodology Guard # Checks that agent work respects methodology constraints declared in the @@ -56,7 +56,7 @@ let methodology_guard = { state_not_template = { description = "The ROADMAP journal must not contain template placeholders", severity = "warning", - # The journal, not the repo deed: a deed names rsr-template-repo in + # The journal, not the repo deed: a deed names heron-polyfeed-reader in # (lineage :instantiated-from) by design, so this pattern would always # fire there. file = "docs/status/ROADMAP.adoc", @@ -68,8 +68,8 @@ let methodology_guard = { # out of the file (in this comment too) so init cannot match it, while # Nickel still evaluates the element back to the full token. Do not # "simplify" it to a plain string. (The PLACEHOLDER token and - # "rsr-template-repo" are not init tokens, so they survive as-is.) - reject_patterns = ["{{PLACEHOLDER}}", "{{" ++ "PROJECT}}", "rsr-template-repo"], + # "heron-polyfeed-reader" are not init tokens, so they survive as-is.) + reject_patterns = ["{{PLACEHOLDER}}", "{{" ++ "PROJECT}}", "heron-polyfeed-reader"], }, coverage_updated = { diff --git a/.machine_readable/self-validating/template-hunt.k9.ncl b/.machine_readable/self-validating/template-hunt.k9.ncl index a9cc350..b3fcb47 100644 --- a/.machine_readable/self-validating/template-hunt.k9.ncl +++ b/.machine_readable/self-validating/template-hunt.k9.ncl @@ -20,7 +20,7 @@ K9! name = "TODO: component-name", version = "1.0.0", description = "TODO: Detailed description of what this component does", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, warnings = [ "This component has full system access", diff --git a/.machine_readable/self-validating/template-kennel.k9.ncl b/.machine_readable/self-validating/template-kennel.k9.ncl index fa7e3f3..4228b26 100644 --- a/.machine_readable/self-validating/template-kennel.k9.ncl +++ b/.machine_readable/self-validating/template-kennel.k9.ncl @@ -19,7 +19,7 @@ K9! name = "TODO: component-name", version = "1.0.0", description = "TODO: Brief description of what this component contains", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, }, diff --git a/.machine_readable/self-validating/template-yard.k9.ncl b/.machine_readable/self-validating/template-yard.k9.ncl index 358671c..a723f5a 100644 --- a/.machine_readable/self-validating/template-yard.k9.ncl +++ b/.machine_readable/self-validating/template-yard.k9.ncl @@ -19,7 +19,7 @@ K9! name = "TODO: component-name", version = "1.0.0", description = "TODO: Brief description with validation details", - author = "{{AUTHOR}} <{{AUTHOR_EMAIL}}>", + author = "Jonathan D.A. Jewell ", }, }, diff --git a/.windsurfrules b/.windsurfrules index b16b511..10ecb83 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/practice/AI-CONVENTIONS.adoc # STARTUP: Read the repo deed (*_chora.deed at the repo root) first, then docs/status/ROADMAP.adoc. @@ -8,7 +8,7 @@ # All original code: MPL-2.0. # Never AGPL-3.0. MPL-2.0 only as platform-required fallback. # SPDX header required on every source file. -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES # No .a2ml file may exist anywhere: the format is retired. State, metadata, diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index eae99c0..09f77d2 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,108 +6,79 @@ The format is based on https://keepachangelog.com/en/1.1.0/[Keep a Changelog], and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. +The template this repository was minted from keeps its own changelog at +`hyperpolymath/rsr-template-repo`; the deed's `(provenance …)` clause records +the template commit. + === [Unreleased] ==== Added -* `www/` site-operations bundle (issue #53): canonical `.well-known/` - metadata, publishable content, policy sources, error bodies, - security-header templates, Caddy/nginx/Apache integration examples, an - authoritative-only BIND 9 starting hand with record templates, - encrypted-DNS/privacy and TLS guidance, explicit opt-in profiles, - schemas, planted-control tests and operational runbooks. -* `scripts/migrate-wellknown-to-www.sh` — conflict-safe migration of a - repository-root `.well-known/` into `www/.well-known/` (identical copies - de-duplicated, root-only content moved, divergent content quarantined — - see Changed). -* `scripts/sweep-wellknown.sh` — stage 5 (#119) batch driver: classify, - migrate, test and commit across the estate in batches of 25, holding - Pages-served and non-RSR repositories for a human decision instead of - sweeping them blind. -* `www/runbooks/stage5-wellknown-sweep.adoc` — how to run the sweep, and - how to recover from a quarantine. -* Mint wiring for the `www/` bundle: `repo-init` now invokes - `scripts/migrate-wellknown-to-www.sh` when the tree being minted still - carries a legacy root `.well-known/`, and runs `www/tests/run-all.sh` - afterwards. Both are no-ops for a repository minted from the current - template; they exist for running `repo-init` over a tree that predates - #53. #106 described both as already present — neither was, which is a - fair part of why the estate still carries root `.well-known/` directories. -* `scripts/merge-actions-lock.rs` — merges an archetype's lock fragment - (`archetypes//actions.lock`) into the spine's - `.github/workflows/actions.lock` at mint. A record that differs between - the two stops the mint. `tests/workflows/archetype_lock_merge_test.sh` - (wired into estate-rules) checks the merged lock with - `check-lock-sync.sh`, and checks that the old clobber, a missing record - and a conflicting record all fail. - -==== Changed - -* Canonical `.well-known/` location moved from the repository root to - `www/.well-known/`. Well-known enforcement, Groove checks, the root-shape - allowlist, the SSG bootstrap and the documentation now prefer the `www/` - location and accept the legacy root location with a warning during the - migration window. - -==== Changed - -* Divergent root/`www/` `.well-known/` content is now quarantined to - `www/.legacy-well-known-/` instead of being left in place: the - estate-wide sweep (#119) is unattended, so a conflict must not halt it, and - must not be silent either. The `www/` copy is untouched, both hashes are - printed, and the run exits non-zero. `--in-place` keeps the previous - contract (preserve both, exit 1) for hand resolution. The quarantine lives - under `www/` rather than at the root because the root allowlist is matched - literally and bidirectionally, so a dated root directory would report as - drift in every repository swept. +* M0: Zig 0.17.0 scaffold, vendored SQLite 3.50.4 amalgamation (FTS5, + THREADSAFE=2, no load-extension) built by `zig cc`, hand-written FFI + externs, `Containerfile` with a checksum-pinned toolchain. +* M1: item model, adapter interface (`Adapter`, `Registry`, `Cond`, + `Outcome`, `FailureClass`), tolerant XML DOM (XXE-safe), encoding + detection and conversion, tolerant date parsing, RSS 2.0 / Atom / RDF feed + adapter, guid/id then link+date de-duplication, recorded and hand-made + fixtures. +* M2: scheduler over an injected clock and PRNG: intervals, jitter, + exponential backoff, `Retry-After` floor, startup spread, no busy loop. +* M3: NDJSON log as source of truth, SQLite state, FTS5 trigram index + rebuilt from the log, crash replay via `meta.indexed_offset`, lock file. +* M4: HTTP(S) transport (select-based wall-clock timeout, manual redirects, + size cap, certificate probe), JSON config, engine, daemon with signals, + CLI. +* M5: monitor adapter: up/down transitions (DOWN after two failures, first + success silent), content-change hash with hourly rate limit, TLS leaf + expiry, robots.txt (RFC 9309); `monitors` command. +* M6: localhost-only web UI (list, search, item, read marks, status) with + Host-header allowlist, Origin check on POST, CSP and no JavaScript; + `web.allow_non_loopback` required for a non-loopback bind; end-to-end test. +* M7: repository minted from `rsr-template-repo` (deed, roadmap, + provisioning, gates); `zig-ci.yml` runs format check, unit tests, static + release build, config smoke and both `Containerfile` stages; Zig pinned to + 0.17.0 in `mise.toml` / `mise.lock`. ==== Fixed -* Julia mints start with Lock Sync green. The julia-library overlay - shipped its own `actions.lock`, which replaced the spine's at mint, so - every spine workflow lost its records. The records now ship beside the - overlay and are merged in. The overlay workflows gain SPDX headers and - top-level permissions, and julia-docs builds with plain steps instead of - the allowlist-refused julia-buildpkg/julia-docdeploy. julia-actions/cache - moves to v3.5.0. The root allowlist admits `?Project.toml` and `?test/`. - The docs checkout sets `persist-credentials: false`, so package code run - while the docs environment is set up finds no stored token. deploydocs - still receives GITHUB_TOKEN. -* `just repo-init julia-library` accepts a `.jl` slug. The PROJECT - tokens map its dot to an underscore, and the default service name is the - slug in lower case. +* Review findings on the import PR (Codex), each with a test where one was possible: + an RSS channel without a title no longer panics the daemon; `deepText` keeps + mixed content in document order; mistyped booleans in the config are rejected + instead of silently taking the default; `heron fetch NAME` fails on an unknown + name; a stale robots.txt `blocked` flag is cleared when `check_robots` is turned + off and the cached robots.txt is no longer truncated below the fetch cap; the + web UI accepts its own bound address in Host, clamps the page number (no u32 + overflow), does not mark items read on HEAD, refreshes its cached log length + before reading a record the poller appended, and bounds each connection with a + 10 s socket deadline; records larger than the replay limit are refused at + ingest; relative Atom/RSS links are resolved against the feed URL; the scratch + image ships `/data` owned by the runtime UID; the template Tier-A image + (`build/container/Containerfile`) now builds and runs heron. +* Second review round: unknown config keys and wrongly typed integers are + rejected (the documentation already promised the first); a failed SQLite + transaction after a durable log append no longer lets the next ingest skip + those records; `plainText` treats a bare `<` as text; change detection + decodes character references before hashing; the transport reports the + post-redirect URL (used as the base for relative links) and the Location of + an unfollowed redirect; with `check_robots` on, the monitor steps through + redirects itself and refuses a hop to another origin before fetching it; + the compose stack mounts a config, publishes the UI on host loopback only + and drops the unusable health check; `.dockerignore` no longer hides the + Tier-A entrypoint from its own build. +* Third review round: network-derived text printed by the CLI (`list`, + `search`, `show`, `sources`, `monitors`) has control characters replaced, + so a feed cannot inject terminal escape sequences; `check-config` rejects + non-string values for string fields and source types no adapter serves; + a log replaced by a shorter one clears the derived index before + reindexing; web pagination keeps the source filter; `24:mm` timestamps + other than `24:00:00` are rejected; `heron fetch` cannot overflow its + failure counter; the compose build context is the repository root; the + documented monitor defaults and robots.txt behaviour now match the code. -* Dev container starts for everyone: dropped the ghcr.io dev container - features (anonymous pulls returned 403), installing just from Wolfi and a - SHA-256-checked Nickel 1.17.0 binary instead. The default - `.devcontainer/devcontainer.json` now targets rootless Podman - (`--userns=keep-id:uid=65532,gid=65532`, `label=disable`) so the workspace - is writable as wolfi-base's built-in `nonroot`; Docker, Codespaces and - Gitpod use `.devcontainer/docker/devcontainer.json`. -* `dot-wellknown-enforcement.yml` now drives the migrator that #106 described - but never wired up: previously the workflow only printed advice to run it, - so nothing verified that a repository would survive the migration. It now - runs the migrator in `--dry-run` and turns every conflict into a - `::warning` annotation — a warning, not an error, for the migration window. -* The migrator's acceptance test had encoded the in-place contract as the - required behaviour, contradicting #106's stated design; it now proves the - quarantine contract across nine scenarios, including the collision case - where a quarantine name is already taken. -* `tests/e2e/template_instantiation_test.sh` fed the three container answers - only when `container/` existed, but `build/just/repo-init.just` asks those - questions when `build/container/` does. On any complete checkout — which is - what CI clones — the recipe asked three questions the test had no answers - for, `read` hit EOF, and the recipe exited 1, so the instantiation e2e was - red at the container prompt. The guard now matches the recipe's own path. - (The test also needed `ruby` for the two mint tools. Those are Rust as - of 2026-09-18, so it no longer stops there.) +==== Changed -* Restore the modular `just` recipes removed by the root-layout refactor, so - `just repo-init`, validation, assessment, container, Groove, and proof tasks - are available again. -* Require lowercase hyphenated repository slugs at initialization and verify - that both Guix package definitions receive the rendered slug and project URL. -* Keep template validation aligned with authority documents moved under - `docs/`, and repair the cross-platform `cloak`/`uncloak` recipe syntax. -* Restore the tracked-file language-ban gate with regression fixtures proving - that supported Zig passes while prohibited V-language remnants fail. +* Renamed to heron-polyfeed-reader: binary `heron`, environment + `HERON_CONFIG` / `HERON_DATA`, lock file `heron.lock`. +* Seven source files reformatted by `zig fmt` 0.17 (`@intFromEnum` → + `@backingInt`, literal layout); no behaviour change, 85/85 tests still pass. diff --git a/CITATION.cff b/CITATION.cff index ba88b6a..efb789f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,14 +1,13 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." authors: - - family-names: "{{AUTHOR_LAST}}" - given-names: "{{AUTHOR_FIRST}}" - orcid: "https://orcid.org/{{AUTHOR_ORCID}}" -title: "{{PROJECT_NAME}}" + - family-names: "Jewell" + given-names: "Jonathan D.A." +title: "heron-polyfeed-reader" version: 0.1.0 -date-released: "{{CURRENT_DATE}}" -url: "https://{{FORGE}}/{{OWNER}}/{{REPO}}" -repository-code: "https://{{FORGE}}/{{OWNER}}/{{REPO}}" +date-released: "2026-10-27" +url: "https://github.com/hyperpolymath/heron-polyfeed-reader" +repository-code: "https://github.com/hyperpolymath/heron-polyfeed-reader" license: MPL-2.0 keywords: - "rsr" diff --git a/CLAUDE.md b/CLAUDE.md index 39dde1b..b0970f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell - + # You are in the hyperpolymath estate — orient before acting @@ -38,7 +38,7 @@ The descriptive family (working name *descriptiles*) describes what-is; the **co | File | Answers | |---|---| -| `rsr-template-repo_chora.deed` | **CHORA**, the repo deed. Its `(identity)` `(clade)` `(lineage)` clauses say what this repo IS (registers into `gv-clade-index`); `(meta)` the architecture axes; `(agentic)` *may I act now?*; `(ecosystem)` where it sits and what it is not; `(status)` and `(maturity)` where it is now. Read fields with `scripts/deed-field.sh`. | +| `heron-polyfeed-reader_chora.deed` | **CHORA**, the repo deed. Its `(identity)` `(clade)` `(lineage)` clauses say what this repo IS (registers into `gv-clade-index`); `(meta)` the architecture axes; `(agentic)` *may I act now?*; `(ecosystem)` where it sits and what it is not; `(status)` and `(maturity)` where it is now. Read fields with `scripts/deed-field.sh`. | | `(anchor)` | *Semantic authority + golden path* — what downstream may extend-not-redefine; if a recognised-drift / re-anchor marker is present, it **supersedes** accumulated context — read it first. | | `(neurosym)` | *Meaning* of operations — proof obligations. | | `(playbook)` | *How* permitted actions run. | @@ -57,17 +57,17 @@ Use plain JavaScript when this tooling is needed. TypeScript and ReScript migrat --- -# This repo: `rsr-template-repo` · clade `rm-rsr-template-repo` +# This repo: `heron-polyfeed-reader` · clade `UNASSIGNED-heron-polyfeed-reader` -- **Identity** — uuid `a5ea1382-a34c-5334-8a46-a2ebe904c810`; clade `rm` (secondary ``); born 2026-03-16; forge `hyperpolymath/rsr-template-repo`. -- **IS** — Canonical RSR-compliant repository template: scaffolding (CI/CD, AI manifests, ABI/FFI standards, container ecosystem, governance) that new hyperpolymath projects are instantiated from. -- **IS-NOT** — a project in its own right · Scaffoldia (the full-featured repo designer) · standards (the canon source this template operationalises) -- **Where it sits** — pipeline position **foundation**; chain `standards → rsr-template-repo → (every estate repo)`; coordination = `standards`. +- **Identity** — uuid `92d77a2e-a0d2-5a05-bb0a-d95cfe1a8cf2`; clade `UNASSIGNED` (secondary ``); born 2026-10-27; forge `hyperpolymath/heron-polyfeed-reader`. +- **IS** — A small local feed reader and site monitor daemon (RSS/Atom/RDF polling, up/down and content-change and TLS-expiry monitoring, NDJSON log + SQLite FTS5 index, localhost-only web UI) in Zig, one user, one machine. +- **IS-NOT** — a hosted or multi-user service · a WebSub subscriber · a browser or page-content extractor · the RSR template it was minted from +- **Where it sits** — pipeline position ****; chain ``; coordination = `standards`. - **Constraints here** (AGENTIC) — fail-closed; evidence-per-step; no-silent-skip; rerun-after-fix; release-claim-requires-hard-pass. Never: banned langs (above), secrets, state files in repo root, AGPL. Details: the deed's `(bot-directives (methodology) (coverage) (debt))` clauses. - **Golden path** (ANCHOR) — `just test && just quality` → Core tests pass; Quality gates pass; No unresolved critical security findings. -- **State** — phase active; maturity production. Milestones and next actions: `docs/status/ROADMAP.adoc`. +- **State** — phase incubating; maturity experimental. Milestones and next actions: `docs/status/ROADMAP.adoc`. - + ## Estate-Wide Format Migration Directives @@ -103,7 +103,7 @@ hand; that migration is done per repo (ADR-008 phase P3). In deed files use - Grammars: `standards/1-formats/deed/spec/abnf/deed.abnf` (authoritative over the deed README, D308) and `standards/1-formats/k9/` for k9. - Descriptiles (anchor, meta, state, ecosystem, agentic, neurosym, playbook) - are clauses of `rsr-template-repo_chora.deed` (D312). + are clauses of `heron-polyfeed-reader_chora.deed` (D312). - Contractiles (intend, must, trust, adjust, bust, dust) are k9 contracts in `.machine_readable/contractiles//`. The deed attests to them (`:contractiles_required`, and a `(contractiles …)` clause per D312). diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index b271386..a6292a4 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -1,44 +1,68 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: Jonathan D.A. Jewell (hyperpolymath) -= Contributing — rsr-template-repo += Contributing — heron-polyfeed-reader :toc: left -Contributors work on the *spine*: the template every RSR repo is minted -from. Changes here propagate to the whole estate at mint time, so the bar -is the estate's bar. +`heron` is a small Zig daemon with no dependencies beyond the vendored SQLite +amalgamation. Most changes are a unit test plus the code that makes it pass. == Local-dev setup +Needs *Zig 0.17.0 exactly*. `std.Io`, `std.http` and the TLS APIs changed from +0.15/0.16; check against the installed `std`, not against older examples. + [source,bash] ---- -# Clone the repository -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader -# Using Guix (recommended for reproducibility) -guix shell -D -f build/guix.scm +# Toolchain: mise pins zig = "0.17.0" (mise.toml / mise.lock) +just setup # or: mise install +just doctor # PASS/WARN/FAIL with codes -# Or using toolbox/distrobox -toolbox create rsr-template-repo-dev -toolbox enter rsr-template-repo-dev -# Install dependencies manually +# The project's own loop +zig build test --summary all # 85 unit tests; fixtures under src/testdata/ +zig fmt --check build.zig src # what `just lint` runs +zig build -Doptimize=ReleaseSafe # zig-out/bin/heron -# Verify setup -just check # or: cargo check / mix compile / etc. -just test # Run test suite +# Containerised (podman or docker) +podman build -f Containerfile --target test -t heron-test . ---- +`just test` / `just build` / `just lint` go through the estate provisioning +library and run every detected language; the template's Idris2 ABI seam under +`src/interface/` is detected too, so those recipes also want `idris2` (from +pack). `zig build …` alone is enough for the project. + == Repository structure The authoritative map is *generated* from the tree and checked in CI, so it cannot drift: link:docs/architecture/REPOSITORY-MAP.adoc[docs/architecture/REPOSITORY-MAP.adoc]. -Regenerate it with `just repo-map`. - -A hand-written tree used to live here. It described `lib/`, `extensions/`, -`plugins/` and `spec/` directories that this repository has never -contained, which is precisely why the map is now generated rather than -typed. +Regenerate it with `just repo-map`. The short version is in +link:README.adoc#_layout[README § Layout]. + +== Conventions to keep + +* SPDX headers on every source file: `SPDX-License-Identifier: MPL-2.0` and + `SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell` (code); `CC-BY-SA-4.0` + for prose. +* No Python in the repo (estate language policy; see `CLAUDE.md`). FFI is + hand-written Zig externs (`src/sqlite.zig`), not generated bindings: + `@cImport` no longer exists in Zig 0.17. +* Containerise build and tests by default; the toolchain is pinned by version + and SHA-256 in the `Containerfile` and in `.github/workflows/zig-ci.yml`. + Change the pin in both, and in `mise.toml` / `mise.lock`, together. +* `src/vendor/tls_client.zig` is a patched copy of std's TLS client (`[sfr patch]` + markers: `host.sni_only`, `leaf_not_after`). Re-sync it whenever Zig is + upgraded; `src/vendor/NOTICE` says how. +* All HTTP tests use the local fixture server (`src/localserver.zig`); never + make a test depend on a live site. +* Commit at each milestone, with the milestone in the subject + (`M8: …`). Record anything decided without the owner's input in + link:docs/status/ROADMAP.adoc[the roadmap] so it can be revisited. +* Report faithfully: say what was run and what passed; a skipped check is + reported as skipped, never as green. == How to contribute @@ -80,10 +104,10 @@ and include: Look for issues labelled: -* https://github.com/hyperpolymath/rsr-template-repo/labels/good%20first%20issue[`good first issue`] — Simple Perimeter 3 tasks -* https://github.com/hyperpolymath/rsr-template-repo/labels/help%20wanted[`help wanted`] — Community help needed -* https://github.com/hyperpolymath/rsr-template-repo/labels/documentation[`documentation`] — Docs improvements -* https://github.com/hyperpolymath/rsr-template-repo/labels/perimeter-3[`perimeter-3`] — Community sandbox scope +* https://github.com/hyperpolymath/heron-polyfeed-reader/labels/good%20first%20issue[`good first issue`] — Simple Perimeter 3 tasks +* https://github.com/hyperpolymath/heron-polyfeed-reader/labels/help%20wanted[`help wanted`] — Community help needed +* https://github.com/hyperpolymath/heron-polyfeed-reader/labels/documentation[`documentation`] — Docs improvements +* https://github.com/hyperpolymath/heron-polyfeed-reader/labels/perimeter-3[`perimeter-3`] — Community sandbox scope == Development workflow diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..176251a --- /dev/null +++ b/Containerfile @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Build + test + runtime image. Works with podman or docker: +# podman build -f Containerfile --target test -t heron-test . # runs the unit tests +# podman build -f Containerfile --target runtime -t heron . # static binary on scratch +# The Zig toolchain is pinned by version and verified by SHA-256 (ADD --checksum). +ARG BASE=docker.io/library/alpine:3 +FROM ${BASE} AS toolchain +ARG ZIG_VERSION=0.17.0 +ADD --checksum=sha256:1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 \ + https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz /tmp/zig.tar.xz +RUN mkdir /opt/zig && tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 && rm /tmp/zig.tar.xz +ENV PATH=/opt/zig:$PATH +# An empty, runtime-UID-owned directory to seed /data in the scratch image (see the runtime stage). +RUN mkdir -p /seed/data && chown 10001:10001 /seed/data + +FROM toolchain AS test +WORKDIR /src +COPY . . +RUN zig build test --summary all + +FROM toolchain AS build +WORKDIR /src +COPY . . +RUN zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl + +FROM scratch AS runtime +COPY --from=build /src/zig-out/bin/heron /heron +# CA roots for HTTPS (Zig reads /etc/ssl/certs/ca-certificates.crt). +COPY --from=toolchain /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +# /data must exist in the image, owned by the runtime UID: an anonymous or fresh named volume +# inherits this directory's ownership, whereas a mount point the engine creates itself is root's +# and heron (UID 10001) could not create its log, index or lock file there. +COPY --from=toolchain --chown=10001:10001 /seed/data /data +USER 10001:10001 +ENV HERON_DATA=/data HERON_CONFIG=/config/heron.json +VOLUME ["/data"] +# The web UI binds 127.0.0.1 by default. Inside a container that is unreachable from the host; +# set "web": {"bind": "0.0.0.0", "allow_non_loopback": true} only together with `-p 127.0.0.1:8484:8484` on the host side. +ENTRYPOINT ["/heron"] +CMD ["daemon"] diff --git a/Justfile b/Justfile index a83f204..498b3f0 100644 --- a/Justfile +++ b/Justfile @@ -22,9 +22,9 @@ import? "build/contractile.just" mod provision 'build/just/provision.just' # Project metadata — customize these -project := "rsr-template-repo" +project := "heron-polyfeed-reader" OWNER := "hyperpolymath" -REPO := "rsr-template-repo" +REPO := "heron-polyfeed-reader" version := "0.1.0" tier := "infrastructure" # 1 | 2 | infrastructure @@ -51,7 +51,7 @@ help recipe="": # Show this project's info info: - @echo "Project: {{project}}" + @echo "Project: heron_polyfeed_reader" @echo "Version: {{version}}" @echo "RSR Tier: {{tier}}" @echo "Recipes: $(just --summary | wc -w)" @@ -95,12 +95,7 @@ build: provision::build # Build in release mode with optimizations build-release *args: - # TODO: Replace with your release build command - # Examples: - # cargo build --release {{args}} - # MIX_ENV=prod mix compile {{args}} - # zig build -Doptimize=ReleaseFast {{args}} - @echo "FAIL: 'just build-release' is not wired yet — nothing was built. Edit the 'build-release' recipe." >&2; exit 1 + zig build -Doptimize=ReleaseSafe {{args}} # Build and watch for changes (requires entr or similar) build-watch: @@ -120,7 +115,7 @@ clean: # directory in an RSR repo: it holds 11 tracked files, including # build/just/repo-init.just, which the root Justfile imports at line 65. # Deleting it destroys `just repo-init`, `just verify` and the proof gates. - rm -rf target/ _build/ dist/ out/ obj/ bin/ + rm -rf target/ _build/ dist/ out/ obj/ bin/ zig-out/ .zig-cache/ # Deep clean including caches [reversible: rebuild] clean-all: clean @@ -141,8 +136,9 @@ test-verbose: provision::test # Smoke test test-smoke: - # TODO: Add basic sanity checks - @echo "FAIL: 'just test-smoke' is not wired yet — no smoke check ran. Edit the 'test-smoke' recipe." >&2; exit 1 + zig build + ./zig-out/bin/heron help >/dev/null + ./zig-out/bin/heron --config examples/heron.json --data "$(mktemp -d)" check-config # Run end-to-end tests (full pipeline: build → run → verify) e2e: @@ -228,7 +224,7 @@ run-verbose: run # Install to user path install: build-release - @echo "Installing {{project}}..." + @echo "Installing heron_polyfeed_reader..." # TODO: Replace with your install command # ═══════════════════════════════════════════════════════════════════════════════ @@ -320,7 +316,7 @@ cookbook: #!/usr/bin/env bash mkdir -p docs OUTPUT="docs/just-cookbook.adoc" - echo "= {{project}} Justfile Cookbook" > "$OUTPUT" + echo "= heron_polyfeed_reader Justfile Cookbook" > "$OUTPUT" echo ":toc: left" >> "$OUTPUT" echo ":toclevels: 3" >> "$OUTPUT" echo "" >> "$OUTPUT" @@ -346,10 +342,10 @@ cookbook: man: #!/usr/bin/env bash mkdir -p docs/man - cat > docs/man/{{project}}.1 << EOF - .TH {{project}} 1 "$(date +%Y-%m-%d)" "{{version}}" "{{project}} Manual" + cat > docs/man/heron_polyfeed_reader.1 << EOF + .TH heron_polyfeed_reader 1 "$(date +%Y-%m-%d)" "{{version}}" "heron_polyfeed_reader Manual" .SH NAME - {{project}} \- RSR-compliant project + heron_polyfeed_reader \- RSR-compliant project .SH SYNOPSIS .B just [recipe] [args...] @@ -358,7 +354,7 @@ man: .SH AUTHOR $(git config user.name 2>/dev/null || echo "Author") <$(git config user.email 2>/dev/null || echo "email")> EOF - echo "Generated: docs/man/{{project}}.1" + echo "Generated: docs/man/heron_polyfeed_reader.1" # ═══════════════════════════════════════════════════════════════════════════════ # CI & AUTOMATION @@ -408,7 +404,7 @@ import? "build/just/validate.just" # ═══════════════════════════════════════════════════════════════════════════════ # Repo state lives in the repo deed's (status …) and (maturity …) clauses -# (rsr-template-repo#209); the journal is docs/status/ROADMAP.adoc. There is +# (heron-polyfeed-reader#209); the journal is docs/status/ROADMAP.adoc. There is # no last-updated stamp to touch: git log is the record of when it changed. # Show the lifecycle phase and maturity from the repo deed @@ -574,7 +570,7 @@ opsm: provision::opsm # Guided tour of key features tour: - @echo "=== rsr-template-repo Tour ===" + @echo "=== heron-polyfeed-reader Tour ===" @echo "" @echo "1. Project structure:" @ls -la @@ -589,12 +585,12 @@ tour: # Open feedback channel with diagnostic context help-me: - @echo "=== rsr-template-repo Help ===" + @echo "=== heron-polyfeed-reader Help ===" @echo "Platform: $(uname -s) $(uname -m)" @echo "Shell: $SHELL" @echo "" @echo "To report an issue:" - @echo " https://github.com/hyperpolymath/rsr-template-repo/issues/new" + @echo " https://github.com/hyperpolymath/heron-polyfeed-reader/issues/new" @echo "" @echo "Include the output of 'just doctor' in your report." diff --git a/README.adoc b/README.adoc index f99da06..cd4295c 100644 --- a/README.adoc +++ b/README.adoc @@ -1,7 +1,7 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell (hyperpolymath) -= {{PROJECT_NAME}} +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += heron-polyfeed-reader :toc: :toc-placement: preamble @@ -12,159 +12,182 @@ image:https://img.shields.io/badge/Provenance-Quantum--Safe-blueviolet[Quantum-S // ── Standard & quality gates ──────────────────────────────────────────────── image:https://img.shields.io/badge/RSR-Rhodium_Standard-9C27B0[Rhodium Standard Repository,link="https://github.com/hyperpolymath/rhodium-standard-repositories"] -image:https://www.bestpractices.dev/projects/{{OPENSSF_BP_ID}}/badge[OpenSSF Best Practices,link="https://www.bestpractices.dev/projects/{{OPENSSF_BP_ID}}"] -image:https://api.scorecard.dev/projects/github.com/{{OWNER}}/{{REPO}}/badge[OpenSSF Scorecard,link="https://scorecard.dev/viewer/?uri=github.com/{{OWNER}}/{{REPO}}"] -image:https://sonarcloud.io/api/project_badges/measure?project={{OWNER}}_{{REPO}}&metric=alert_status[SonarQube Quality Gate,link="https://sonarcloud.io/summary/new_code?id={{OWNER}}_{{REPO}}"] -image:https://archive.softwareheritage.org/badge/origin/https://{{FORGE}}/{{OWNER}}/{{REPO}}/[Archived in Software Heritage,link="https://archive.softwareheritage.org/browse/origin/?origin_url=https://{{FORGE}}/{{OWNER}}/{{REPO}}"] +image:https://api.scorecard.dev/projects/github.com/hyperpolymath/heron-polyfeed-reader/badge[OpenSSF Scorecard,link="https://scorecard.dev/viewer/?uri=github.com/hyperpolymath/heron-polyfeed-reader"] +image:https://sonarcloud.io/api/project_badges/measure?project=hyperpolymath_heron-polyfeed-reader&metric=alert_status[SonarQube Quality Gate,link="https://sonarcloud.io/summary/new_code?id=hyperpolymath_heron-polyfeed-reader"] +image:https://archive.softwareheritage.org/badge/origin/https://github.com/hyperpolymath/heron-polyfeed-reader/[Archived in Software Heritage,link="https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/hyperpolymath/heron-polyfeed-reader"] // ── Provenance & ecosystem ────────────────────────────────────────────────── -image:https://img.shields.io/badge/Idris-Inside-5E5086?logo=idris&logoColor=white[Idris Inside,link="https://github.com/hyperpolymath/proven"] -image:https://img.shields.io/badge/Zig-Zero--overhead-F7A41D?logo=zig&logoColor=black[Zig Zero-overhead FFI,link="https://github.com/hyperpolymath/typed-wasm"] -image:https://api.thegreenwebfoundation.org/greencheckimage/{{FORGE}}[Green Web,link="https://www.thegreenwebfoundation.org/green-web-check/?url={{FORGE}}"] +image:https://img.shields.io/badge/Zig-0.17.0-F7A41D?logo=zig&logoColor=black[Zig 0.17.0,link="https://ziglang.org/download/#release-0.17.0"] +image:https://api.thegreenwebfoundation.org/greencheckimage/github.com[Green Web,link="https://www.thegreenwebfoundation.org/green-web-check/?url=github.com"] -{{PROJECT_DESCRIPTION}} - -[IMPORTANT] -==== -*This file is a template.* You are reading `README.adoc` from the *RSR template repo* -(or a freshly-cloned copy of it). Run `just repo-init` to replace every `{{PLACEHOLDER}}` -token with your project's details, then delete this admonition. Until you do, the -`{{...}}` markers below are intentional placeholders, not content. -==== +A small local feed reader and site monitor. `heron` polls RSS, Atom and RDF +feeds, watches sites for downtime, content change and TLS certificate expiry +(honouring robots.txt), keeps everything in an append-only NDJSON log with a SQLite +(FTS5) index rebuilt from it, and serves a localhost-only web UI. Written in +Zig; Linux first. == What this is -A new repository scaffolded from the *Rhodium Standard Repository (RSR)* template: -a batteries-included starting point that ships with CI/CD, machine-readable project -metadata, an AI-agent gatekeeper protocol, a formally-typed ABI/FFI seam -(Idris2 + Zig), container and reproducible-build scaffolding, and governance -infrastructure — all wired and passing the RSR validators on day one. - -Replace this section with a description of *your* project once initialised. +* *A feed reader.* Tolerant XML, encoding and date parsing (recorded real + feeds and hand-made edge cases are fixtures under `src/testdata/`); + guid/id then link+date de-duplication; conditional requests (ETag, + Last-Modified); per-source intervals with jitter, exponential backoff and + Retry-After. +* *A site monitor.* Up/down transitions (DOWN after two failures, first + success is a silent baseline), content-change hashes (alerts rate-limited to + one per hour), robots.txt compliance (RFC 9309: a BLOCKED alert when it + disallows the monitored path) and leaf-certificate expiry. Alerts are + stored as items of kind `alert`, so they appear in the same lists and + searches as articles. +* *A daemon with a CLI.* One binary, one JSON config, one data directory. + The web UI is off unless configured and binds `127.0.0.1:8484` by default. + +== What this is not + +* Not a hosted or multi-user service: one user, one machine, loopback only + unless you say otherwise (`web.allow_non_loopback`). +* Not a WebSub subscriber (deliberately), and not a browser: the web UI has + no JavaScript and only follows http(s) links. +* Not yet proven beyond HTTP: the adapter seam is designed for NNTP, Gopher, + Gemini, ActivityPub and AT Protocol, but no second adapter exists + (link:docs/status/ROADMAP.adoc[roadmap M8]). == Quick start +Needs *Zig 0.17.0 exactly* (`build.zig.zon` says so; the `std.Io`, `std.http` +and TLS APIs changed from 0.15/0.16). `mise.toml` pins it; `just setup` +installs it. + [source,bash] ---- -# 1. Create a repo from this template (or clone it), then from the repo root: -just repo-init # interactive bootstrap: fills every {{PLACEHOLDER}} - -# 2. See the available tasks: -just # lists all phases (build, test, validate, audit, ...) - -# 3. Check the repo still satisfies the RSR shape: -just validate # structure + metadata checks +zig build test --summary all # 85 unit tests, fixtures from src/testdata/ +zig build -Doptimize=ReleaseSafe # -> zig-out/bin/heron +cp examples/heron.json ~/.config/heron.json # edit sources, monitors, web +heron --config ~/.config/heron.json check-config +heron --config ~/.config/heron.json daemon # foreground: poll, serve the UI if enabled ---- -`just repo-init` prompts for the project name, owner, author, licence contact, and the -other values listed in `.machine_readable/ai/PLACEHOLDERS.adoc`, substitutes them -across the tree, validates the result, and (if available) runs the `k9-svc` checks. - -[[ai-install]] -== AI-Assisted Installation (Recommended) +Through `just`: `just test`, `just lint` (`zig fmt --check`), `just build`, +`just build-release`, `just test-smoke`, `just quality`. -=== Just say it +[NOTE] +==== +`just test` and `just build` run every language the estate provisioning +library detects. This tree also carries the template's Idris2 ABI seam +(`src/interface/abi.ipkg`), so those recipes need `idris2` on PATH as well; +`zig build test` alone runs the project's own suite. +==== -Say this to any AI assistant that can read a URL and run commands (or give you -commands to paste): +=== Commands [source,text] ---- -Create a new repository from https://github.com/hyperpolymath/rsr-template-repo +heron [--config FILE] [--data DIR] [args] + + daemon run in the foreground: poll sources, serve the web UI if enabled + fetch [NAME...] poll the named sources (default: all) once, now + list [--source NAME] [--unread] [--limit N] + search TEXT substring search over titles and text (needs 3+ characters) + show ID print an item and mark it read + read ID | unread ID change an item's read state + mark-all-read [NAME] + sources per-source status: last check, failures, next poll + monitors monitor status: up/down, HTTP status, TLS certificate expiry + rebuild rebuild the index and search tables from the log + check-config validate the config file + help ---- -The URL is the key: it leads the assistant to -link:docs/AI_INSTALLATION_GUIDE.adoc[`docs/AI_INSTALLATION_GUIDE.adoc`], the -complete step-by-step recipe. The assistant checks your system, installs the -prerequisites, instantiates the template with `just repo-init`, and verifies the -result with `just doctor`. - -* "Set up rsr-template-repo so I can work on the template itself — https://github.com/hyperpolymath/rsr-template-repo" -* `./launcher.sh --ai-setup` (or `just ai-setup`) prints the line and the guide path. - -=== What you will be asked - -. Your operating system (Linux, macOS, or Windows via WSL2). -. The new project's name, owner, author and licence contact (the values listed in - `.machine_readable/ai/PLACEHOLDERS.adoc`). -. To confirm the privacy notice below. - -=== Privacy and security +Environment: `HERON_CONFIG` and `HERON_DATA` stand in for `--config` and +`--data`. The data directory holds the NDJSON log (source of truth), the +SQLite index and the `heron.lock` lock file. -[IMPORTANT] -==== -Setting up rsr-template-repo clones this repository and installs its developer -tools per-user with mise (no administrator rights, nothing sent anywhere). -`just repo-init` rewrites files only inside the new checkout; it creates no -remote repository and pushes nothing until you do. -==== +=== Container -=== The one trap +[source,bash] +---- +podman build -f Containerfile --target test -t heron-test . # runs the unit tests +podman build -f Containerfile --target runtime -t heron . # static binary on scratch (~23 MB) +---- -If you are an AI agent: **do not set `RSR_NON_INTERACTIVE=1`**. It stubs the -shell builtin `read`, which also disables the loops that perform token -substitution — the run never terminates and substitutes nothing. Pipe answers to -stdin instead. +The toolchain is pinned by version and verified by SHA-256 inside the +`Containerfile`; CI (`.github/workflows/zig-ci.yml`) pins the same digest. +Inside a container the default loopback bind is unreachable from the host: +set `"web": {"bind": "0.0.0.0", "allow_non_loopback": true}` only together +with `-p 127.0.0.1:8484:8484` on the host side. -=== Troubleshooting - -Run `just doctor`; each FAIL carries a code explained in -link:docs/SETUP.adoc#troubleshooting[`docs/SETUP.adoc` §Troubleshooting], and -`just heal` applies the automatic fixes. Prefer to do it yourself? The full -manual route is link:docs/SETUP.adoc[`docs/SETUP.adoc`]; developers and -maintainers can brief an AI with `just ai-warmup dev` / `just ai-warmup maintainer`. - -== What you get - -* *Machine-readable metadata* (`.machine_readable/descriptiles/`) — `STATE`, `META`, - `ECOSYSTEM`, `PLAYBOOK`, `AGENTIC`, `NEUROSYM`, `CLADE`, and `anchors/ANCHOR`, - in deed, so tools and agents can read the project's state and boundaries. -* *AI gatekeeper protocol* — `rsr-template-repo_chora.deed` (the repo deed) is the - universal entry point that tells an AI agent how to work in this repo before it - touches anything; it carries the AI allocation policy and the ply directory tree. -* *Typed ABI/FFI seam* — `src/interface/Abi/` (Idris2 type + layout proofs) over - `src/interface/ffi/` (Zig implementation), with generated C headers. -* *CI/CD* — GitHub Actions for quality, security (CodeQL, Scorecard, secret - scanning), multi-forge mirroring, and RSR anti-pattern enforcement. -* *Supply-chain & reproducibility* — container layering (stapeln), Guix shells, - SBOM, and signing hooks. -* *Governance* — `GOVERNANCE.adoc`, `MAINTAINERS.adoc`, `.github/` community health - files, and a release `AUDIT.adoc` gate. - -== Repository map - -The authoritative map is generated, so it cannot drift from the tree: -link:docs/architecture/REPOSITORY-MAP.adoc[docs/architecture/REPOSITORY-MAP.adoc] -(regenerate with `just repo-map`; CI fails if it is stale). - -The short version: +== Layout [cols="1,3"] |=== | Path | What lives there -| `README.adoc`, `CLAUDE.md` | Start here - humans and AI agents respectively. -| `src/`, `tests/` | The code and its tests. -| `docs/` | Human documentation, including the full map above. -| `.machine_readable/` | Manifests, contractiles and policies that tools read. -| `build/`, `Justfile` | Every task runs through `just`; phases live in `build/just/`. -| `ci/`, `.github/` | CI configuration; GitHub reads `.github/` and no other path. +| `build.zig`, `build.zig.zon` | The build: core module `heron` (everything but `main.zig`), the vendored SQLite compiled by `zig cc`, `test` and `run` steps. +| `src/item.zig`, `src/adapter.zig` | Item model; Adapter vtable, Registry, `Cond` (etag, last_modified, opaque state), `Outcome` (items / not_modified / failed), `FailureClass`, `parseRetryAfter`. +| `src/transport.zig`, `src/http.zig` | Fakeable Transport interface; the HTTP(S) implementation (select-based wall-clock timeout, manual redirects, size cap, certificate probe). +| `src/xml.zig`, `src/encoding.zig`, `src/dates.zig`, `src/feed.zig`, `src/feed_adapter.zig`, `src/dedupe.zig` | Tolerant XML DOM (XXE-safe), encoding detection and conversion, tolerant dates, RSS2/Atom/RDF mapping, de-duplication. +| `src/scheduler.zig` | Pure logic over an injected clock and PRNG; `FakeClock` for tests. +| `src/store.zig`, `src/sqlite.zig` | NDJSON log plus SQLite state and FTS5 trigram index; crash replay via `meta.indexed_offset`; hand-written SQLite externs (`@cImport` no longer exists in Zig 0.17). +| `src/monitor.zig`, `src/robots.zig` | Monitor adapter and robots.txt. +| `src/engine.zig`, `src/app.zig`, `src/daemon.zig`, `src/cli.zig`, `src/web.zig`, `src/localserver.zig`, `src/config.zig`, `src/util.zig` | Poll orchestration, wiring, daemon loop and signals, CLI, web UI, tiny HTTP server, JSON config. +| `src/vendor/tls_client.zig` | Patched copy of std's TLS client used only to read the leaf certificate's expiry; re-sync on every Zig upgrade (`src/vendor/NOTICE`). +| `vendor/sqlite/` | SQLite 3.50.4 amalgamation (FTS5, THREADSAFE=2, no load-extension). +| `src/testdata/` | Recorded real feeds (`real/`) and hand-made edge cases. +| `examples/heron.json` | A complete example configuration. +| `src/interface/`, `docs/`, `.machine_readable/`, `build/`, `www/` | Estate scaffolding from the RSR template: ABI/FFI seam, documentation, the repo deed's machine-readable companions, `just` phase modules, site-operations bundle. |=== == Where to go next +* link:docs/status/ROADMAP.adoc[ROADMAP] — milestones, blockers, next actions, known gaps. +* link:docs/usage.adoc[Usage] and link:docs/architecture.adoc[Architecture] — configuration reference and internals. +* link:CONTRIBUTING.adoc[CONTRIBUTING] — toolchain, conventions, signing. +* link:heron-polyfeed-reader_chora.deed[The repo deed] and link:CLAUDE.md[CLAUDE.md] — what this repo IS, for tools and agents. * link:docs/architecture/REPOSITORY-MAP.adoc[The repository map] — generated; what every directory is for. -* link:docs/EXPLAINME.adoc[EXPLAINME] — the engineering deep-dive: how the pieces actually work. -* link:docs/AFFIRMATION.adoc[AFFIRMATION] — the dated, signed honesty snapshot of the repo's true state. -* link:docs/AUDIT.adoc[AUDIT] — the release audit gate. -* link:docs/AI_INSTALLATION_GUIDE.adoc[AI installation guide] — for agents instantiating this template. -* link:.machine_readable/ai/PLACEHOLDERS.adoc[PLACEHOLDERS] — the full placeholder reference. == Licence Code, configuration and scripts are link:LICENSE[Mozilla Public License 2.0] (`MPL-2.0`); prose documentation is `CC-BY-SA-4.0`. Both texts live in `LICENSES/`, and per-file `SPDX-License-Identifier` headers are authoritative. -The GitHub-detected licence is MPL-2.0 (the root `LICENSE`). Long-term -attribution uses Quantum-Safe Provenance — see +Long-term attribution uses Quantum-Safe Provenance — see link:docs/legal/EXHIBIT-B-QUANTUM-SAFE.txt[the Quantum-Safe Provenance exhibit]. +SQLite is public domain (`vendor/sqlite`); the patched TLS client in +`src/vendor/` carries the Zig standard library's MIT licence (`src/vendor/NOTICE`). + +[[ai-install]] +== AI-Assisted Installation (Recommended) + +=== Just say it + +Say this to any AI assistant that can read a URL and run commands (or give you +commands to paste): + +[source,text] +---- +Set up heron-polyfeed-reader from https://github.com/hyperpolymath/heron-polyfeed-reader — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo. +---- + +The URL leads the assistant to +link:docs/AI_INSTALLATION_GUIDE.adoc[`docs/AI_INSTALLATION_GUIDE.adoc`]. The +assistant checks your system, installs Zig 0.17.0 through mise (`just setup`), +builds and tests `heron`, and proves the result with `just doctor`. +`./launcher.sh --ai-setup` (or `just ai-setup`) prints the same line. + +=== Privacy and security + +[IMPORTANT] +==== +Setting up heron-polyfeed-reader clones this repository and installs its +developer tools per-user with mise (no administrator rights, nothing sent +anywhere). `heron` itself talks only to the feeds and sites you configure, and +its web UI listens on loopback unless you say otherwise. +==== + +=== Troubleshooting + +Run `just doctor`; each FAIL carries a code explained in +link:docs/SETUP.adoc#troubleshooting[`docs/SETUP.adoc` §Troubleshooting], and +`just heal` applies the automatic fixes. The manual route is +link:docs/SETUP.adoc[`docs/SETUP.adoc`]; `docs/troubleshooting.adoc` lists +`heron`'s own known failure modes. diff --git a/archetypes/README.adoc b/archetypes/README.adoc deleted file mode 100644 index e51fbe2..0000000 --- a/archetypes/README.adoc +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -= Archetypes — template-only overlay data for `just repo-init` - -An *archetype* specialises the spine for one kind of repository (a Julia -library, a WordPress plugin, …) **as data, not as a fork**. Each archetype is -a directory here: - - archetypes//_praxis.deed required — the contract (see below) - archetypes//overlay/ optional — files copied over the spine - archetypes//actions.lock required when the overlay ships workflows - -Decision record: `docs/decisions/0003-forge-and-sustain-lifecycle.adoc`. -Normative lifecycle: standards `spec/SCAFFOLD-LIFECYCLE.adoc`. - -== The contract (`_praxis.deed`) - -A `praxis-deed` (standards `1-formats/deed/spec/abnf/deed.abnf`), read by -`just repo-init` through `scripts/deed-field.sh`. Values are single-line -strings: - -- `(metadata :archetype … :schema_version …)`. -- `(capability :preset "")` — MUST name a preset in standards - `.machine_readable/template-capability-gates.toml [presets]`. The archetype - directory name and the preset name are the same string by convention. -- `(tokens :TOKEN "value" …)` — one pair per token. Only tokens the archetype can - honestly determine (`PROJECT_KIND`, `PROJECT_DOMAIN`, `LANG_STACK`, - `BUILD_CMD`, `TEST_CMD`, `DEPS`, `BUILD_OUTPUT_PATH`, `MAIN_FUNCTION`). - Values MUST NOT contain `|` (the init sed delimiter — init skips such - values with a warning). Judgement-only tokens - (`PROJECT_UNIQUE_STRENGTH`, `TARGET_AUDIENCE`, `MUST_INVARIANTS`, - per-repo names like `PACKAGE_NAME`) MUST be omitted so they stay - `UNASSIGNED` — an archetype never writes fiction. - -`just repo-init ` applies the overlay directory (if present), adds the -token substitutions, and writes the preset into the minted repo's -deed's `(rsr-profile)` clause. This whole `archetypes/` directory is -template-only and is removed from the minted tree, exactly like -`build/templates/`. - -== Workflows and their lock records - -An overlay that ships workflows ships their lock records **beside** the -overlay, as `archetypes//actions.lock`, and never inside it. An -`overlay/.github/workflows/actions.lock` would replace the spine's lock when -the overlay is copied, and every spine workflow would then be refused at -startup for want of a record. That is how the first Julia mints -(ZeroInflatedCounts.jl, ResidualEvidenceTypes.jl) began with Lock Sync red. - -`just repo-init` merges the fragment into `.github/workflows/actions.lock` -with `scripts/merge-actions-lock.rs`: entries are unioned, and a key present -in both files with a different record stops the mint. Write the fragment with -the authoritative tool, never by hand: - -. Copy the overlay workflows into an empty git repository, under - `.github/workflows/` and under their minted names (drop the `.in`). -. Run `gh actions-lock --no-narrow` there, then `gh actions-lock --no-fix` - to check it. -. Copy the resulting `.github/workflows/actions.lock` to - `archetypes//actions.lock`. - -Regenerate it in the same PR as any `uses:` change in the overlay. -`tests/workflows/archetype_lock_merge_test.sh` mints each archetype's -workflow set and runs `scripts/check-lock-sync.sh` on the merged lock. - -== Current archetypes - -- `julia-library` — reference instance, harvested from - rsr-julia-library-template-repo's variant record (ADR-0002) and the - dormant scaffoldia registry's `julia/package.ncl`. The variant repo - remains the full hand-maintained reference until the scaffoldia - inversion; this archetype covers the `just repo-init` path. - -Declared, content to follow (presets already reserved in the canon): -`wordpress-plugin`, `zotero-plugin`, `userscript`. Do not stub them empty — -an archetype exists when its contract and overlay are real. diff --git a/archetypes/julia-library/actions.lock b/archetypes/julia-library/actions.lock deleted file mode 100644 index 4d55562..0000000 --- a/archetypes/julia-library/actions.lock +++ /dev/null @@ -1,29 +0,0 @@ -# This file is machine-generated by `gh actions-lock`. -# Do not edit by hand; run `gh actions-lock` to update. -# Docs: https://gh.io/actions-lockfile -version: 'v0.0.2' -workflows: - '.github/workflows/julia-ci.yml': - - 'actions/checkout@v7.0.1' - - 'julia-actions/cache@v3.5.0' - - 'julia-actions/setup-julia@v3.0.2' - '.github/workflows/julia-docs.yml': - - 'actions/checkout@v7.0.1' - - 'julia-actions/cache@v3.5.0' - - 'julia-actions/setup-julia@v3.0.2' -dependencies: - 'actions/checkout@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 - 'julia-actions/cache@v3.5.0': - ref: 'v3.5.0' - commit: 'sha1-9353db09a0f2bbbdec6f41791918c75d37391b36' - owner_id: 53965732 - repo_id: 441908600 - 'julia-actions/setup-julia@v3.0.2': - ref: 'v3.0.2' - commit: 'sha1-fa02766e078afaaf09b14210362cee14137e6a32' - owner_id: 53965732 - repo_id: 202020219 diff --git a/archetypes/julia-library/julia-library_praxis.deed b/archetypes/julia-library/julia-library_praxis.deed deleted file mode 100644 index 497ea93..0000000 --- a/archetypes/julia-library/julia-library_praxis.deed +++ /dev/null @@ -1,37 +0,0 @@ -;; SPDX-License-Identifier: MPL-2.0 -;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -;; -;; julia-library_praxis.deed — the julia-library archetype for `just repo-init` -;; (ADR-0003). repo-init reads it with scripts/deed-field.sh: -;; (capability :preset) the capability preset; and -;; (tokens :) each value fills {{}} at mint. -;; -;; Harvested 2026-07-21 from rsr-julia-library-template-repo (its variant -;; record, the ADR-0002 reference instance) and the dormant scaffoldia registry -;; entry registry/julia/package.ncl. The variant repo remains the full -;; hand-maintained reference (Project.toml [compat] closure, Aqua+JET, -;; Documenter, flagship rubric); this archetype covers the `just repo-init` path. -;; -;; Contract rules: archetypes/README.adoc. Single-line values; no `|` in token -;; values (init's sed delimiter). Tokens omitted here stay UNASSIGNED on -;; purpose: PACKAGE_NAME and MUST_INVARIANTS are per-repo judgement, and an -;; archetype must not write fiction. -(praxis-deed - :schema-version "1.0.0" - :canonical-name "rsr-template-repo-archetype-julia-library" - (metadata - :archetype "julia-library" - :name "julia-library" - :schema_version "0.1.0" - :harvested-from "rsr-julia-library-template-repo variant record + scaffoldia registry julia/package.ncl") - (capability - ; Preset defined in standards .machine_readable/template-capability-gates.toml - :preset "julia-library") - (tokens - :PROJECT_KIND "library" - :PROJECT_DOMAIN "julia" - :LANG_STACK "Julia (1.9+)" - :BUILD_CMD "julia --project=. -e 'using Pkg; Pkg.instantiate()'" - :TEST_CMD "julia --project=. -e 'using Pkg; Pkg.test()'" - :DEPS "Julia General registry via Project.toml [deps] with [compat] closure" - :BUILD_OUTPUT_PATH "src/")) diff --git a/archetypes/julia-library/overlay/.github/workflows/julia-ci.yml.in b/archetypes/julia-library/overlay/.github/workflows/julia-ci.yml.in deleted file mode 100644 index 227f965..0000000 --- a/archetypes/julia-library/overlay/.github/workflows/julia-ci.yml.in +++ /dev/null @@ -1,119 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# {{PROJECT_NAME}} — Julia CI (julia-library archetype) -# -# Pin posture (estate ruling R2, 2026-09-02; D38/D39 resolution 2026-09-19): -# symbolic refs here, pinned by .github/workflows/actions.lock (same dir). -# The lock records come from archetypes/julia-library/actions.lock, merged -# into the spine's lock at mint. Refs resolved 2026-10-09: -# actions/checkout@v7.0.1 = 3d3c42e5aac5ba805825da76410c181273ba90b1 -# julia-actions/setup-julia@v3.0.2 = fa02766e078afaaf09b14210362cee14137e6a32 -# julia-actions/cache@v3.5.0 = 9353db09a0f2bbbdec6f41791918c75d37391b36 -# Regenerate the lock in the SAME PR as any uses: change (estate MUST). - -name: Julia CI - -on: - push: - branches: [main, master] - pull_request: - -# No job here writes to the repository. -permissions: - contents: read - -# No `continue-on-error` on a MUST check (R1–R9): a red job is the gate -# working. Coverage (R4) is reported by the estate's standard coverage -# upload where present; it is not a blocker at template level. -jobs: - fmt: - name: Format (JuliaFormatter) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7.0.1 - - uses: julia-actions/setup-julia@v3.0.2 - with: - version: "1" - - name: Check formatting (check mode — never writes in CI) - run: | - julia --project=. -e ' - using Pkg - Pkg.add("JuliaFormatter") - using JuliaFormatter - for f in filter(isfile, Iterators.flatten(( - [joinpath("src", x) for x in readdir("src")], - [joinpath("test", x) for x in readdir("test") if x != "cases"], - [joinpath("test", "cases", x) for x in readdir(joinpath("test", "cases"))], - ))) - text = read(f, String) - formatted = JuliaFormatter.format_string(text) - if text != formatted - @error "not formatted" file=f - exit(1) - end - end' - - jet: - name: Static analysis (JET) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7.0.1 - - uses: julia-actions/setup-julia@v3.0.2 - with: - version: "1" - - uses: julia-actions/cache@v3.5.0 - - name: JET analysis (supported API) - # JET's path/string entry points were REMOVED in v0.12.0, having been - # deprecated since v0.11: `report_package(::AbstractString)`, - # `report_package()` and the corresponding `test_package` forms. The - # package must be loaded and its MODULE passed instead — - # `report_package(PkgModule)`. `target_defined_modules` was removed in - # the same release (use `target_modules`). - # - # The previous invocation was `JET.test_package(path=".", - # julia_version="1")`, which is the removed string form. It failed with - # `MethodError: no method matching report_package(; toplevel_logger, - # path, julia_version)` on JET v0.12.2 before any analysis ran. Because - # the analyzer was installed with an unconstrained `Pkg.add`, the - # breakage arrived with an upstream release, not with a template change. - # - # The analyzer now goes into a throwaway environment with a pinned - # version, so it no longer mutates this package's Project.toml on every - # run, and it cannot float to a new major/minor unannounced. - run: | - julia -e ' - using Pkg - Pkg.activate(mktempdir()) - Pkg.develop(path=".") - Pkg.add(Pkg.PackageSpec(name="JET", version="0.12")) - using {{PROJECT_NAME}}, JET - - # Fail with the actual reason rather than a MethodError if the - # upstream API moves again. This is the tripwire for the class of - # defect this job previously shipped: a call that never ran. - if !hasmethod(JET.test_package, (Module,)) - error("JET ", pkgversion(JET), " has no test_package(::Module) — " * - "the julia-library overlay pins an API that no longer exists.") - end - - JET.test_package({{PROJECT_NAME}})' - - test: - name: Test (Test + Aqua) - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - # The [compat] floor IS tested — an untested floor is an unproven - # floor (measured estate gap: Axiology-class repos declare julia 1.9 - # but test 1.10/1.11 only). - julia: ["1.9", "1"] - os: [ubuntu-24.04] - steps: - - uses: actions/checkout@v7.0.1 - - uses: julia-actions/setup-julia@v3.0.2 - with: - version: ${{ matrix.julia }} - - uses: julia-actions/cache@v3.5.0 - - name: Pkg.test() - run: julia --project=. -e 'using Pkg; Pkg.test()' diff --git a/archetypes/julia-library/overlay/.github/workflows/julia-docs.yml.in b/archetypes/julia-library/overlay/.github/workflows/julia-docs.yml.in deleted file mode 100644 index 2bd847d..0000000 --- a/archetypes/julia-library/overlay/.github/workflows/julia-docs.yml.in +++ /dev/null @@ -1,58 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# {{PROJECT_NAME}} — docs build & deploy (julia-library archetype) -# -# Fails LOUD while docs/ is absent: a declared `docs-site` capability with -# no build is a defect (criterion 5.2.6, na-honesty). Once -# docs/Project.toml + docs/make.jl + docs/src/ exist, this builds the docs -# and Documenter's own `deploydocs` (called from docs/make.jl) pushes the -# site to gh-pages with GITHUB_TOKEN. -# -# The build runs as two plain steps instead of julia-actions/julia-buildpkg -# and julia-actions/julia-docdeploy. Those two actions are refused at -# startup by the estate's actions allowlist, so a minted repo's Docs -# workflow could never start; the steps below are what they ran. -# -# Pin posture (estate ruling R2 / D38-D39): symbolic refs, pinned by -# .github/workflows/actions.lock. Regenerate the lock in the same PR as -# any uses: change. - -name: Docs - -on: - push: - branches: [main, master] - workflow_dispatch: - -permissions: - contents: read - -jobs: - docs: - name: Build and deploy - runs-on: ubuntu-24.04 - # deploydocs pushes the built site to the gh-pages branch. - permissions: - contents: write - steps: - - uses: actions/checkout@v7.0.1 - # deploydocs pushes with GITHUB_TOKEN from its own env, so the - # checkout keeps no credential for package code run during setup. - with: - persist-credentials: false - - name: Layout gate — fail loud while docs/ is absent - run: | - if [ ! -f docs/Project.toml ] || [ ! -f docs/make.jl ] || [ ! -d docs/src ]; then - echo "::error::docs-site declared but no Documenter build: docs/Project.toml, docs/make.jl and docs/src/ are required. Either add the build or drop the docs-site capability from the repo deed's (rsr-profile) clause." - exit 1 - fi - - uses: julia-actions/setup-julia@v3.0.2 - with: - version: "1" - - uses: julia-actions/cache@v3.5.0 - - name: Instantiate the docs environment - run: julia --project=docs -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - - name: Build and deploy (Documenter deploydocs) - run: julia --project=docs docs/make.jl - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/archetypes/julia-library/overlay/Project.toml.in b/archetypes/julia-library/overlay/Project.toml.in deleted file mode 100644 index ce0bc79..0000000 --- a/archetypes/julia-library/overlay/Project.toml.in +++ /dev/null @@ -1,32 +0,0 @@ -# minted by: just repo-init julia-library ({{CURRENT_YEAR}}) -# owner: {{OWNER}} -# -# uuid = "{{PACKAGE_UUID}}" is DERIVED at mint (UUIDv5 over the repo -# identity — see repo-init's julia-library rule); it is stable across -# re-mints of the same repo and is never hand-edited. If the mint could not -# derive it (no uuidgen AND no python3 on the operator machine) it is -# UNASSIGNED and Pkg refuses to load the package until the owner assigns one. - -name = "{{PROJECT_NAME}}" -uuid = "{{PACKAGE_UUID}}" -description = "{{PROJECT_DESCRIPTION}}" -authors = ["{{AUTHOR}} <{{AUTHOR_EMAIL}}"] -license = "MPL-2.0" -version = "0.1.0" - -[deps] - -# Test uuid = stdlib, resolved via Base.identify_package("Test") 2026-09-19 -[extras] -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" - -# Aqua's deps_compat check requires a compat bound for EVERY extra — -# a template that omits these fails its own gate (measured 2026-09-19). -[targets] -test = ["Test", "Aqua"] - -[compat] -julia = "1.9" -Test = "1.9" -Aqua = "0.8" diff --git a/archetypes/julia-library/overlay/src/PACKAGE.jl.in b/archetypes/julia-library/overlay/src/PACKAGE.jl.in deleted file mode 100644 index d526338..0000000 --- a/archetypes/julia-library/overlay/src/PACKAGE.jl.in +++ /dev/null @@ -1,15 +0,0 @@ -module {{PROJECT_NAME}} - -# {{PROJECT_NAME}} — {{PROJECT_DESCRIPTION}} -# -# Forged from the hyperpolymath julia-library archetype ({{CURRENT_YEAR}}). -# Provenance: the repo deed's (provenance) clause, written at mint. -# -# The public API is whatever is `export`ed below; Aqua's exports/ambiguity -# checks gate it from the first mint, so keep exports deliberate. - -# --- public API ---------------------------------------------------------- - -# --- implementation ------------------------------------------------------ - -end # module {{PROJECT_NAME}} diff --git a/archetypes/julia-library/overlay/test/cases/smoke.jl b/archetypes/julia-library/overlay/test/cases/smoke.jl deleted file mode 100644 index e3cddcb..0000000 --- a/archetypes/julia-library/overlay/test/cases/smoke.jl +++ /dev/null @@ -1,11 +0,0 @@ -# smoke.jl — the archetype's load-check case. -# -# This case proves the package LOADS (module resolution, no parse/load -# errors, top-level evaluation clean). It is deliberately not a behaviour -# test: a repo whose only case is this file has a NECESSARY-but-not- -# sufficient green. Replace it with real behaviour tests as they land — -# do not delete it (the Aqua suite already covers shape; this covers load). - -@testset "smoke — package loads" begin - @test @isdefined {{PROJECT_NAME}} -end diff --git a/archetypes/julia-library/overlay/test/runtests.jl b/archetypes/julia-library/overlay/test/runtests.jl deleted file mode 100644 index 0cb4fef..0000000 --- a/archetypes/julia-library/overlay/test/runtests.jl +++ /dev/null @@ -1,31 +0,0 @@ -# {{PROJECT_NAME}} — test entry point (julia-library archetype) -# -# Two suites, in this order: -# 1. Aqua package-shape gate — runs FIRST: a package that does not load -# cleanly, whose deps are not compat-closed, or whose exports are -# ambiguous is rejected before any behaviour test can pass. -# 2. Behaviour — every test/cases/*.jl is included. The shipped case -# (smoke.jl) is a LOAD check only; replace/extend it as real -# behaviour tests land. Until then, `Pkg.test()` passing is necessary -# but NOT sufficient — record that in docs/status/ROADMAP.adoc (smoke-only suite). - -using Test -using {{PROJECT_NAME}} - -@testset "{{PROJECT_NAME}} — Aqua package shape" begin - using Aqua - Aqua.test_all({{PROJECT_NAME}}) -end - -@testset "{{PROJECT_NAME}} — behaviour" begin - # NOTE: no `continue`/`break` inside a `for` inside @testset — Test's - # loop-form detection re-parses the loop body at top level, where - # `continue` is a ParseError. Filter with a guarded call instead. - # (Measured trap, 2026-09-19 — see the Julia testing guide.) - cases = joinpath(@__DIR__, "cases") - if isdir(cases) - for path in sort(readdir(cases, join = true)) - endswith(path, ".jl") && include(path) - end - end -end diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..3651e57 --- /dev/null +++ b/build.zig @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Core library module: everything except the CLI entry point. + const core = b.addModule("heron", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // Vendored SQLite amalgamation, compiled by `zig cc`. Only the options we need. + core.addIncludePath(b.path("vendor/sqlite")); + core.addCSourceFile(.{ + .file = b.path("vendor/sqlite/sqlite3.c"), + .flags = &.{ + "-DSQLITE_ENABLE_FTS5=1", + "-DSQLITE_THREADSAFE=2", // multi-thread: one connection per thread + "-DSQLITE_OMIT_LOAD_EXTENSION=1", + "-DSQLITE_DEFAULT_MEMSTATUS=0", + "-DSQLITE_OMIT_DEPRECATED=1", + "-DSQLITE_OMIT_SHARED_CACHE=1", + "-DSQLITE_DQS=0", + "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", + "-DSQLITE_USE_ALLOCA=1", + "-fno-sanitize=undefined", + }, + }); + + const exe = b.addExecutable(.{ + .name = "heron", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "heron", .module = core }}, + .link_libc = true, + }), + }); + b.installArtifact(exe); + + const run = b.addRunArtifact(exe); + run.step.dependOn(b.getInstallStep()); + run.addPassthruArgs(); + b.step("run", "Run heron").dependOn(&run.step); + + const core_tests = b.addTest(.{ .root_module = core }); + const run_core_tests = b.addRunArtifact(core_tests); + // Fixtures are located relative to the project root. + run_core_tests.setCwd(b.path(".")); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_core_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..dcdbd2c --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,8 @@ +.{ + .name = .heron_polyfeed_reader, + .fingerprint = 0xfec83e78247d622f, + .version = "0.1.0", + .minimum_zig_version = "0.17.0", + .dependencies = .{}, + .paths = .{ "build.zig", "build.zig.zon", "src", "vendor", "tests", "LICENSE" }, +} diff --git a/build/container/Containerfile b/build/container/Containerfile index 6dbff80..15efd9d 100644 --- a/build/container/Containerfile +++ b/build/container/Containerfile @@ -1,121 +1,68 @@ # SPDX-License-Identifier: MPL-2.0 -# {{PROJECT_NAME}} Container Image +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# heron-polyfeed-reader — Tier-A OCI image (the build/container/ story from the RSR template). # -# Multi-stage build template for Chainguard Wolfi base images. -# Customise the builder stage for your language and copy the -# resulting binary/release into the minimal runtime stage. -# -# This is the Tier-A artifact: a plain OCI image, buildable by ANY -# OCI-compliant engine — podman (recommended), nerdctl, or docker. -# It carries no hard dependency on the stapeln tier; the cerro-torre / -# svalinn files under build/container/stapeln/ are referenced only as labels. -# -# Build (engine-agnostic — substitute podman | nerdctl | docker): -# podman build -t {{SERVICE_NAME}}:latest -f build/container/Containerfile . +# The project's canonical image is the root `Containerfile` (scratch runtime, used by +# .github/workflows/zig-ci.yml). This file is the same build on the Chainguard Wolfi runtime +# base the stapeln tooling (build/container/stapeln/ct-build.sh, cerro-torre, svalinn) expects. +# KEEP THE TOOLCHAIN PIN IN STEP WITH /Containerfile, mise.toml and zig-ci.yml: one Zig +# version, one SHA-256, moved together. # +# Build (engine-agnostic — substitute podman | nerdctl | docker), from the repo root: +# podman build -t heron:latest -f build/container/Containerfile . # Run: -# podman run -p {{PORT}}:{{PORT}} {{SERVICE_NAME}}:latest -# -# Run with persistent volume: -# podman run -p {{PORT}}:{{PORT}} -v {{SERVICE_NAME}}-data:/data {{SERVICE_NAME}}:latest -# +# podman run -v heron-data:/data -v ./heron.json:/config/heron.json:ro heron:latest # Via the Justfile (auto-detects engine, prefers cerro-torre when present): # just container-build # ============================================================================ -# Stage 1: Builder +# Stage 1: Builder — pinned Zig toolchain, verified by SHA-256 # ============================================================================ -# -# Install build tools and compile the application. -# This stage is discarded after the build — only the compiled output -# is copied into the runtime stage. -# -# Language-specific examples (uncomment the one you need): -# -# --- Rust --- -# RUN apk add --no-cache rust pkgconf build-base -# COPY Cargo.toml Cargo.lock ./ -# COPY src/ ./src/ -# RUN cargo build --release -# # Output: /build/target/release/{{SERVICE_NAME}} -# -# --- Elixir --- -# RUN apk add --no-cache erl27-elixir-1.18 erlang-27 erlang-27-dev git build-base -# COPY mix.exs mix.lock ./ -# COPY lib/ ./lib/ -# COPY config/ ./config/ -# ENV MIX_ENV=prod -# RUN mix local.hex --force && mix local.rebar --force && \ -# mix deps.get --only prod && mix compile && mix release -# # Output: /build/_build/prod/rel/{{SERVICE_NAME}}/ -# -# --- Zig --- -# RUN apk add --no-cache zig build-base -# COPY build.zig build.zig.zon ./ -# COPY src/ ./src/ -# RUN zig build -Doptimize=ReleaseFast -# # Output: /build/zig-out/bin/{{SERVICE_NAME}} -# -# Base image: `:latest` is right for a template scaffold (Chainguard rebuilds -# Wolfi daily). For PRODUCTION builds, pin by digest for reproducibility/supply- -# chain integrity, e.g.: -# FROM cgr.dev/chainguard/wolfi-base:latest@sha256: AS builder -# (cerro-torre's .ctp signing in build/container/stapeln/ records the resolved digest.) -FROM cgr.dev/chainguard/wolfi-base:latest AS builder - -# TODO: Install your language toolchain -RUN apk add --no-cache build-base - +FROM docker.io/library/alpine:3 AS builder +ARG ZIG_VERSION=0.17.0 +ADD --checksum=sha256:1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 \ + https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz /tmp/zig.tar.xz +RUN mkdir /opt/zig && tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 && rm /tmp/zig.tar.xz +ENV PATH=/opt/zig:$PATH WORKDIR /build - -# TODO: Copy source files and build -COPY . . -# RUN +COPY build.zig build.zig.zon ./ +COPY src/ ./src/ +COPY vendor/ ./vendor/ +RUN zig build test --summary all && zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl +# Output: /build/zig-out/bin/heron (static) # ============================================================================ -# Stage 2: Runtime +# Stage 2: Runtime — minimal Wolfi base, non-root, no toolchain # ============================================================================ # -# Minimal production image. Only the compiled binary/release and runtime -# dependencies are included. No compilers, no source code, no build tools. -# +# Base image: `:latest` is right for a template scaffold (Chainguard rebuilds Wolfi daily). For +# PRODUCTION builds, pin by digest for reproducibility/supply-chain integrity, e.g.: +# FROM cgr.dev/chainguard/wolfi-base:latest@sha256: +# (cerro-torre's .ctp signing in build/container/stapeln/ records the resolved digest.) FROM cgr.dev/chainguard/wolfi-base:latest # OCI image labels (compatible with cerro-torre .ctp bundle metadata) -LABEL org.opencontainers.image.title="{{PROJECT_NAME}}" \ - org.opencontainers.image.description="{{PROJECT_DESCRIPTION}}" \ - org.opencontainers.image.url="https://{{FORGE}}/{{OWNER}}/{{REPO}}" \ - org.opencontainers.image.source="https://{{FORGE}}/{{OWNER}}/{{REPO}}" \ - org.opencontainers.image.vendor="{{OWNER}}" \ - org.opencontainers.image.licenses="{{LICENSE}}" \ - org.opencontainers.image.authors="{{AUTHOR}} <{{AUTHOR_EMAIL}}>" \ +LABEL org.opencontainers.image.title="heron-polyfeed-reader" \ + org.opencontainers.image.description="A small local feed reader and site monitor: polls RSS, Atom and RDF feeds and watches sites for downtime, content change and TLS certificate expiry. Zig, vendored SQLite with FTS5, localhost-only web UI." \ + org.opencontainers.image.url="https://github.com/hyperpolymath/heron-polyfeed-reader" \ + org.opencontainers.image.source="https://github.com/hyperpolymath/heron-polyfeed-reader" \ + org.opencontainers.image.vendor="hyperpolymath" \ + org.opencontainers.image.licenses="MPL-2.0" \ + org.opencontainers.image.authors="Jonathan D.A. Jewell " \ dev.cerrotorre.manifest="build/container/stapeln/manifest.toml" \ dev.cerrotorre.gatekeeper="build/container/stapeln/.gatekeeper.yaml" \ dev.stapeln.compose="build/container/stapeln/compose.toml" -# Install minimal runtime dependencies. -# Adjust this list for your application: -# - ca-certificates: TLS root certificates -# - curl: health check probe -# - libstdc++: C++ standard library (if needed by native deps) -# - ncurses: terminal UI (if needed, e.g. Elixir IEx) -RUN apk add --no-cache ca-certificates curl +# CA roots for HTTPS (heron reads /etc/ssl/certs/ca-certificates.crt). Nothing else: the binary is static. +RUN apk add --no-cache ca-certificates-bundle -# Create non-root user for the application. -# Running as root inside containers is a security anti-pattern. +# Non-root user. Running as root inside containers is a security anti-pattern. RUN addgroup -S appuser && adduser -S appuser -G appuser WORKDIR /app - -# TODO: Copy compiled binary/release from builder stage. -# Examples: -# COPY --from=builder /build/target/release/{{SERVICE_NAME}} /app/{{SERVICE_NAME}} -# COPY --from=builder /build/_build/prod/rel/{{SERVICE_NAME}} /app/release/ -# COPY --from=builder /build/zig-out/bin/{{SERVICE_NAME}} /app/{{SERVICE_NAME}} - -# Copy entrypoint script +COPY --from=builder /build/zig-out/bin/heron /app/heron COPY build/container/entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh && chown -R appuser:appuser /app # OPTIONAL (Tier C — stapeln). A pure OCI image does NOT need these baked in: # svalinn reads .gatekeeper.yaml from a mounted volume (see stapeln/compose.toml), @@ -124,29 +71,24 @@ RUN chmod +x /app/entrypoint.sh # COPY build/container/stapeln/.gatekeeper.yaml /etc/svalinn/gatekeeper.yaml # COPY build/container/stapeln/manifest.toml /app/manifest.toml -# Create data directory for persistent storage (mountable volume) -RUN mkdir -p /data && chown appuser:appuser /data +# Data directory for the NDJSON log, SQLite index and lock file (mountable volume). It must exist +# in the image, owned by the runtime user, so a fresh volume inherits writable ownership. +RUN mkdir -p /data /config && chown appuser:appuser /data -# Set ownership of the application directory -RUN chown -R appuser:appuser /app +# heron's own environment (HERON_CONFIG / HERON_DATA stand in for --config / --data). +ENV HERON_DATA=/data +ENV HERON_CONFIG=/config/heron.json -# Environment variables — customise for your application -ENV APP_HOST=[::] -ENV APP_PORT={{PORT}} -ENV APP_LOG_FORMAT=json -ENV APP_DATA_DIR=/data - -# Declare /data as a volume for persistent storage VOLUME ["/data"] - -# Run as non-root USER appuser -# Expose the application port -EXPOSE {{PORT}} +# The web UI binds 127.0.0.1 by default, which is unreachable from the host. To expose it, set +# "web": {"bind": "0.0.0.0", "allow_non_loopback": true} in heron.json AND publish loopback-only +# on the host side (-p 127.0.0.1:8484:8484); the UI has no authentication. +EXPOSE 8484 -# Health check — the application must respond 2xx at /health -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:${APP_PORT}/health || exit 1 +# No HEALTHCHECK: the web UI is off unless configured and heron exposes no /health endpoint. +# The daemon exits non-zero on a fatal error, which is the liveness signal an engine restarts on. ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["daemon"] diff --git a/build/container/README.adoc b/build/container/README.adoc index 18d1160..39448c6 100644 --- a/build/container/README.adoc +++ b/build/container/README.adoc @@ -1,6 +1,6 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= {{PROJECT_NAME}} Containerisation += heron-polyfeed-reader Containerisation :toc: left :toclevels: 3 :sectnums: @@ -138,7 +138,7 @@ The sovereign tier wires together the stapeln container stack: (Kennel / Yard / Hunt). The request path in the full stack is: -`svalinn` (edge) → `rokur` (secrets gate) → `{{SERVICE_NAME}}` (app). +`svalinn` (edge) → `rokur` (secrets gate) → `heron` (app). == Initialise diff --git a/build/container/compose.yaml b/build/container/compose.yaml index 4b5087f..d52f51c 100644 --- a/build/container/compose.yaml +++ b/build/container/compose.yaml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # -# {{PROJECT_NAME}} — portable compose stack (Tier B) +# heron-polyfeed-reader — portable compose stack (Tier B) # # This is the runtime-agnostic, OCI compose-spec file. It works unchanged with: # podman compose -f build/container/compose.yaml up -d @@ -20,27 +20,30 @@ services: # ────────────────────────────────────────────────────────────────────── # Primary application service # ────────────────────────────────────────────────────────────────────── - "{{SERVICE_NAME}}": + "heron": build: - context: .. + context: ../.. # the repository root (relative contexts resolve from this file's directory) dockerfile: build/container/Containerfile - image: "{{REGISTRY}}/{{SERVICE_NAME}}:latest" + image: "ghcr.io/hyperpolymath/heron:latest" + # The web UI has no authentication: publish it on the host loopback only (a mapping without a + # host IP binds 0.0.0.0 on every interface). ports: - - "{{PORT}}:{{PORT}}" + - "127.0.0.1:8484:8484" environment: - APP_HOST: "[::]" - APP_PORT: "{{PORT}}" - APP_LOG_FORMAT: "json" - APP_DATA_DIR: "/data" + HERON_CONFIG: "/config/heron.json" + HERON_DATA: "/data" volumes: - - "{{SERVICE_NAME}}-data:/data" + - "heron-data:/data" + # heron exits with ConfigNotFound without a config. This one binds the UI to 0.0.0.0 INSIDE the + # container (loopback there is unreachable from the host) and relies on the mapping above. + - "./heron.json:/config/heron.json:ro" restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:{{PORT}}/health"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s + # No healthcheck: heron has no /health route and the runtime image carries no curl. heron is + # PID 1 and exits non-zero on a fatal error, which `restart` handles. + # + # The rokur/svalinn gateway services below are template extras. The UI refuses a Host header + # that is not the bound address or a loopback name (DNS-rebinding defence), so a gateway must + # forward Host as 127.0.0.1:8484, or be removed (`just container-keep oci portable`). # ────────────────────────────────────────────────────────────────────── # rokur — secrets-management gate (stapeln). Fronts the app and refuses @@ -50,7 +53,7 @@ services: rokur: image: ghcr.io/hyperpolymath/rokur:latest environment: - ROKUR_BACKEND: "http://{{SERVICE_NAME}}:{{PORT}}" + ROKUR_BACKEND: "http://heron:8484" ROKUR_LISTEN: "[::]:8081" ROKUR_REQUIRED_SECRETS: "" # comma-separated env names that MUST be set ROKUR_AUDIT_LOG: "/var/log/rokur/audit.jsonl" @@ -60,8 +63,8 @@ services: volumes: - rokur-audit:/var/log/rokur depends_on: - "{{SERVICE_NAME}}": - condition: service_healthy + "heron": + condition: service_started restart: unless-stopped healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8081/health"] @@ -95,5 +98,5 @@ services: retries: 3 volumes: - "{{SERVICE_NAME}}-data": + "heron-data": rokur-audit: diff --git a/build/container/entrypoint.sh b/build/container/entrypoint.sh index a7a0369..0f8bddb 100755 --- a/build/container/entrypoint.sh +++ b/build/container/entrypoint.sh @@ -1,63 +1,22 @@ #!/bin/sh # SPDX-License-Identifier: MPL-2.0 -# {{PROJECT_NAME}} container entrypoint +# heron-polyfeed-reader container entrypoint (build/container/Containerfile, Tier-A image). # -# Handles signal propagation, startup logging, and health check -# preparation before exec-ing into the main application process. +# Logs the effective configuration, warns early when the data directory is not writable, then +# execs heron so it is PID 1 and receives the engine's stop signals directly. set -e -# --------------------------------------------------------------------------- -# Signal handling -# --------------------------------------------------------------------------- -# -# Trap SIGTERM and SIGINT so that the application can shut down gracefully -# when Podman sends stop signals (e.g. `podman stop`, `selur-compose down`). - -cleanup() { - echo "Received shutdown signal — stopping {{SERVICE_NAME}}..." - # If the main process is backgrounded, kill it here: - # kill "$MAIN_PID" 2>/dev/null || true - # wait "$MAIN_PID" 2>/dev/null || true - exit 0 -} -trap cleanup TERM INT - -# --------------------------------------------------------------------------- -# Startup logging -# --------------------------------------------------------------------------- +echo "Starting heron..." +echo " Config: ${HERON_CONFIG:-/config/heron.json}" +echo " Data: ${HERON_DATA:-/data}" -echo "Starting {{SERVICE_NAME}}..." -echo " Host: ${APP_HOST:-[::]}" -echo " Port: ${APP_PORT:-{{PORT}}}" -echo " Data: ${APP_DATA_DIR:-/data}" -echo " Log: ${APP_LOG_FORMAT:-json}" - -# --------------------------------------------------------------------------- -# Health check preparation -# --------------------------------------------------------------------------- -# -# Ensure the data directory exists and is writable. -# The VOLUME directive in the Containerfile creates /data, but a bind-mount -# might replace it with an empty directory owned by root. - -if [ -d "${APP_DATA_DIR:-/data}" ]; then - if [ ! -w "${APP_DATA_DIR:-/data}" ]; then - echo "WARNING: ${APP_DATA_DIR:-/data} is not writable by $(whoami)" - fi +# A bind-mounted or engine-created data directory may be owned by root; heron cannot create its +# log, index or lock file there. Say so before the daemon fails with StoreOpenFailed. +if [ -d "${HERON_DATA:-/data}" ] && [ ! -w "${HERON_DATA:-/data}" ]; then + echo "WARNING: ${HERON_DATA:-/data} is not writable by $(id -un 2>/dev/null || echo "uid $(id -u)")" >&2 fi -# --------------------------------------------------------------------------- -# Exec into main process -# --------------------------------------------------------------------------- -# -# Replace the entrypoint shell with the application process so that -# signals are delivered directly and PID 1 is the application. -# -# TODO: Replace the command below with your application binary. -# Examples: -# exec /app/{{SERVICE_NAME}} -# exec /app/release/bin/{{SERVICE_NAME}} start -# exec /app/{{SERVICE_NAME}} serve --host "${APP_HOST}" --port "${APP_PORT}" - -exec "$@" +# Default command is `daemon` (CMD in the Containerfile); any heron sub-command can be passed: +# podman run --rm heron:latest check-config +exec /app/heron "$@" diff --git a/build/container/heron.json b/build/container/heron.json new file mode 100644 index 0000000..f345c0a --- /dev/null +++ b/build/container/heron.json @@ -0,0 +1 @@ +{"monitors":[],"sources":[{"interval_s":900,"name":"lwn","type":"feed","url":"https://lwn.net/headlines/rss"}],"timeout_s":30,"user_agent":"heron-polyfeed-reader/0.1 (+https://github.com/hyperpolymath/heron-polyfeed-reader)","web":{"allow_non_loopback":true,"bind":"0.0.0.0","enabled":true,"port":8484}} diff --git a/build/container/stapeln/.gatekeeper.yaml b/build/container/stapeln/.gatekeeper.yaml index 4aac671..fba2dfe 100644 --- a/build/container/stapeln/.gatekeeper.yaml +++ b/build/container/stapeln/.gatekeeper.yaml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # -# Svalinn gatekeeper policy for {{PROJECT_NAME}} +# Svalinn gatekeeper policy for heron-polyfeed-reader # # Controls which operations are permitted through the edge gateway. # This template provides moderate security defaults — not wide-open test @@ -65,9 +65,9 @@ rate_limits: trust: # Only accept .ctp bundles signed by these keys. trusted_signers: - - key_id: "{{SERVICE_NAME}}-release" + - key_id: "heron-release" algorithm: "Ed25519" - public_key_file: "/etc/svalinn/keys/{{SERVICE_NAME}}-release.pub" + public_key_file: "/etc/svalinn/keys/heron-release.pub" # Require these attestations on all .ctp bundles. required_attestations: diff --git a/build/container/stapeln/compose.toml b/build/container/stapeln/compose.toml index db42fb9..056948b 100644 --- a/build/container/stapeln/compose.toml +++ b/build/container/stapeln/compose.toml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # -# {{PROJECT_NAME}} selur-compose configuration +# heron-polyfeed-reader selur-compose configuration # # Orchestrates the container stack as verified container bundles (.ctp). # Uses selur zero-copy IPC between services on the same host. @@ -10,7 +10,7 @@ # selur-compose up --detach # Start in background # selur-compose verify # Verify all .ctp signatures # selur-compose ps # Check status -# selur-compose logs -f {{SERVICE_NAME}} # Stream logs +# selur-compose logs -f heron # Stream logs # selur-compose down # Stop all services # # Fallback (when selur is not installed): this TOML is selur-native and is @@ -25,18 +25,18 @@ version = "1.0" # ============================================================================ # Primary application service -[services.{{SERVICE_NAME}}] -image = "{{REGISTRY}}/{{SERVICE_NAME}}:latest.ctp" -ports = ["{{PORT}}:{{PORT}}"] +[services.heron] +image = "ghcr.io/hyperpolymath/heron:latest.ctp" +ports = ["8484:8484"] environment = { APP_HOST = "[::]", - APP_PORT = "{{PORT}}", + APP_PORT = "8484", APP_LOG_FORMAT = "json", APP_DATA_DIR = "/data", } -volumes = ["{{SERVICE_NAME}}-data:/data"] +volumes = ["heron-data:/data"] restart = "always" -healthcheck = { test = "curl -sf http://localhost:{{PORT}}/health", interval = "30s", timeout = "5s", retries = 3 } +healthcheck = { test = "curl -sf http://localhost:8484/health", interval = "30s", timeout = "5s", retries = 3 } # Rokur secrets gate: refuses to start (and refuses to proxy) unless the # declared secrets are present; emits a structured audit log of every access. @@ -45,14 +45,14 @@ healthcheck = { test = "curl -sf http://localhost:{{PORT}}/health", interval = " image = "ghcr.io/hyperpolymath/rokur:latest.ctp" ports = ["8081:8081"] environment = { - ROKUR_BACKEND = "http://{{SERVICE_NAME}}:{{PORT}}", + ROKUR_BACKEND = "http://heron:8484", ROKUR_LISTEN = "[::]:8081", ROKUR_REQUIRED_SECRETS = "", ROKUR_AUDIT_LOG = "/var/log/rokur/audit.jsonl", ROKUR_LOG_FORMAT = "json", } volumes = ["rokur-audit:/var/log/rokur"] -depends_on = ["{{SERVICE_NAME}}"] +depends_on = ["heron"] restart = "always" healthcheck = { test = "curl -sf http://localhost:8081/health", interval = "30s", timeout = "5s", retries = 3 } @@ -75,7 +75,7 @@ healthcheck = { test = "curl -sf http://localhost:80/health", interval = "30s", # Volumes # ============================================================================ -[volumes.{{SERVICE_NAME}}-data] +[volumes.heron-data] driver = "local" [volumes.rokur-audit] diff --git a/build/container/stapeln/ct-build.sh b/build/container/stapeln/ct-build.sh index c825b84..a3edd6a 100755 --- a/build/container/stapeln/ct-build.sh +++ b/build/container/stapeln/ct-build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # -# {{PROJECT_NAME}} — Cerro Torre build, sign, and verify pipeline +# heron-polyfeed-reader — Cerro Torre build, sign, and verify pipeline # # Builds the container image, packages it as a verified .ctp bundle, # signs it with Ed25519, and verifies the result. Gracefully degrades @@ -18,8 +18,8 @@ # CT_KEY_ID=my-key ./ct-build.sh # Use specific signing key # # Environment variables: -# CT_KEY_ID — Signing key identifier (default: {{SERVICE_NAME}}-release) -# CT_REGISTRY — OCI registry to push to (default: {{REGISTRY}}) +# CT_KEY_ID — Signing key identifier (default: heron-release) +# CT_REGISTRY — OCI registry to push to (default: ghcr.io/hyperpolymath) # CT_TAG — Image tag (default: latest) set -euo pipefail @@ -44,15 +44,15 @@ for arg in "$@"; do fi done -CT_KEY_ID="${CT_KEY_ID:-{{SERVICE_NAME}}-release}" -CT_REGISTRY="${CT_REGISTRY:-{{REGISTRY}}}" +CT_KEY_ID="${CT_KEY_ID:-heron-release}" +CT_REGISTRY="${CT_REGISTRY:-ghcr.io/hyperpolymath}" CT_TAG="${CT_TAG:-latest}" -IMAGE_NAME="{{SERVICE_NAME}}" +IMAGE_NAME="heron" FULL_IMAGE="${CT_REGISTRY}/${IMAGE_NAME}:${CT_TAG}" CTP_FILE="${SCRIPT_DIR}/${IMAGE_NAME}-${CT_TAG}.ctp" -echo "=== {{PROJECT_NAME}} Cerro Torre Build Pipeline ===" +echo "=== heron-polyfeed-reader Cerro Torre Build Pipeline ===" echo " Image: ${FULL_IMAGE}" echo " Key: ${CT_KEY_ID}" echo " Bundle: ${CTP_FILE}" diff --git a/build/container/stapeln/deploy.k9.ncl b/build/container/stapeln/deploy.k9.ncl index 7282738..696124d 100644 --- a/build/container/stapeln/deploy.k9.ncl +++ b/build/container/stapeln/deploy.k9.ncl @@ -1,6 +1,6 @@ K9! # SPDX-License-Identifier: MPL-2.0 -# deploy.k9.ncl — {{PROJECT_NAME}} deployment component (Hunt level) +# deploy.k9.ncl — heron-polyfeed-reader deployment component (Hunt level) # # k9-svc deployment specification with full pedigree (L1-L5). # Security Level: 'Hunt (requires cryptographic handshake for execution). @@ -19,11 +19,11 @@ let component_pedigree = { # L1: The Snout — Identity # ───────────────────────────────────────────────────────────── metadata = { - name = "{{SERVICE_NAME}}-deploy", - version = "{{VERSION}}", + name = "heron-deploy", + version = "0.1.0", breed = "application/vnd.k9+nickel", magic_number = "K9!", - description = "{{PROJECT_NAME}} deployment component (Hunt level)", + description = "heron-polyfeed-reader deployment component (Hunt level)", }, # ───────────────────────────────────────────────────────────── @@ -97,8 +97,8 @@ let deployment = { # Container configuration container = { - image = "{{REGISTRY}}/{{SERVICE_NAME}}", - port = {{PORT}}, + image = "ghcr.io/hyperpolymath/heron", + port = 8484, health_check = "/health", readiness_check = "/ready", }, @@ -117,7 +117,7 @@ let scripts = { pre_deploy = m%" #!/bin/sh set -eu -echo "K9: Pre-deployment validation for {{SERVICE_NAME}}..." +echo "K9: Pre-deployment validation for heron..." cd build/container/stapeln && selur-compose verify || podman compose --file ../compose.yaml config echo "K9: Validation passed." "%, @@ -127,7 +127,7 @@ echo "K9: Validation passed." #!/bin/sh set -eu ENV="${1:-dev}" -echo "K9: Deploying {{SERVICE_NAME}} to $ENV environment..." +echo "K9: Deploying heron to $ENV environment..." cd build/container/stapeln ./ct-build.sh selur-compose up --detach || podman compose --file ../compose.yaml up --detach @@ -138,7 +138,7 @@ echo "K9: Deployment to $ENV complete." rollback = m%" #!/bin/sh set -eu -echo "K9: Rolling back {{SERVICE_NAME}} deployment..." +echo "K9: Rolling back heron deployment..." cd build/container/stapeln selur-compose down || podman compose --file ../compose.yaml down echo "K9: Rollback complete." @@ -147,7 +147,7 @@ echo "K9: Rollback complete." # Export the component { - pedigree = component_pedigree & { name = "{{SERVICE_NAME}}-deploy" }, + pedigree = component_pedigree & { name = "heron-deploy" }, deployment = deployment, scripts = scripts, diff --git a/build/container/stapeln/manifest.toml b/build/container/stapeln/manifest.toml index f55fcb3..76e592d 100644 --- a/build/container/stapeln/manifest.toml +++ b/build/container/stapeln/manifest.toml @@ -1,27 +1,27 @@ # SPDX-License-Identifier: MPL-2.0 # -# Cerro Torre manifest for {{PROJECT_NAME}} .ctp bundle +# Cerro Torre manifest for heron-polyfeed-reader .ctp bundle # # This manifest describes the container image for verified # container packaging. Used by `ct pack` to create .ctp bundles. [metadata] -name = "{{SERVICE_NAME}}" -version = "{{VERSION}}" +name = "heron" +version = "0.1.0" revision = 1 -summary = "{{PROJECT_DESCRIPTION}}" +summary = "A small local feed reader and site monitor: polls RSS, Atom and RDF feeds and watches sites for downtime, content change and TLS certificate expiry. Zig, vendored SQLite with FTS5, localhost-only web UI." description = """ -{{PROJECT_NAME}} — containerised service packaged as a verified +heron-polyfeed-reader — containerised service packaged as a verified cerro-torre .ctp bundle with Ed25519 signing and full provenance tracking. """ -license = "{{LICENSE}}" -homepage = "https://{{FORGE}}/{{OWNER}}/{{REPO}}" -maintainer = "{{AUTHOR}} <{{EMAIL}}>" +license = "MPL-2.0" +homepage = "https://github.com/hyperpolymath/heron-polyfeed-reader" +maintainer = "Jonathan D.A. Jewell " [provenance] -upstream = "https://{{FORGE}}/{{OWNER}}/{{REPO}}" -import_date = {{CURRENT_DATE}}T00:00:00Z +upstream = "https://github.com/hyperpolymath/heron-polyfeed-reader" +import_date = 2026-10-27T00:00:00Z [dependencies] runtime = ["ca-certificates", "curl"] @@ -32,10 +32,10 @@ system = "podman" [build.environment] APP_HOST = "[::]" -APP_PORT = "{{PORT}}" +APP_PORT = "8484" [outputs] -primary = "{{SERVICE_NAME}}" +primary = "heron" split = [] [attestations] @@ -54,7 +54,7 @@ drop = ["ALL"] add = ["NET_BIND_SERVICE"] [security.network] -listen_tcp = [{{PORT}}] +listen_tcp = [8484] [security.filesystem] read = ["/app/", "/data/"] diff --git a/build/container/stapeln/rokur.toml b/build/container/stapeln/rokur.toml index 361c1ea..84468d0 100644 --- a/build/container/stapeln/rokur.toml +++ b/build/container/stapeln/rokur.toml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # -# Rokur secrets-gate configuration for {{PROJECT_NAME}} +# Rokur secrets-gate configuration for heron-polyfeed-reader # # Rokur (stapeln container stack) is a secrets-management gate: a small # Deno service that sits in front of the application and refuses to pass @@ -16,8 +16,8 @@ # # or, in the stack, via compose (see compose.toml / compose.yaml) [metadata] -name = "{{SERVICE_NAME}}-gate" -version = "{{VERSION}}" +name = "heron-gate" +version = "0.1.0" # ============================================================================ # Listener / routing (env: ROKUR_LISTEN, ROKUR_BACKEND) @@ -27,7 +27,7 @@ version = "{{VERSION}}" # Address rokur listens on (inside the container). listen = "[::]:8081" # The upstream application rokur fronts once the gate is satisfied. -backend = "http://{{SERVICE_NAME}}:{{PORT}}" +backend = "http://heron:8484" # Health/readiness endpoints rokur exposes for vordr / orchestrators. health_path = "/health" ready_path = "/ready" diff --git a/build/container/stapeln/vordr.toml b/build/container/stapeln/vordr.toml index 90084b4..cc0429d 100644 --- a/build/container/stapeln/vordr.toml +++ b/build/container/stapeln/vordr.toml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # -# Vordr runtime monitoring configuration for {{PROJECT_NAME}} +# Vordr runtime monitoring configuration for heron-polyfeed-reader # # Vordr watches container health, detects crashes, tracks resource usage, # and emits structured logs. It runs alongside the application stack and @@ -12,8 +12,8 @@ # vordr report [metadata] -name = "{{SERVICE_NAME}}" -version = "{{VERSION}}" +name = "heron" +version = "0.1.0" # ============================================================================ # Health Monitoring @@ -25,13 +25,13 @@ version = "{{VERSION}}" [health] # Primary health endpoint — must return 2xx. -endpoint = "http://localhost:{{PORT}}/health" +endpoint = "http://localhost:8484/health" interval = "30s" timeout = "5s" failure_threshold = 3 # Readiness endpoint — checked during startup and after restarts. -readiness_endpoint = "http://localhost:{{PORT}}/ready" +readiness_endpoint = "http://localhost:8484/ready" readiness_timeout = "10s" # Action on failure: "alert" (log + notify) or "restart" (alert + restart). @@ -96,7 +96,7 @@ format = "json" level = "info" # Write vordr logs to stdout (captured by Podman) and optionally to file. output = "stdout" -# file = "/var/log/vordr/{{SERVICE_NAME}}.log" +# file = "/var/log/vordr/heron.log" # ============================================================================ # Notifications (optional) @@ -111,7 +111,7 @@ output = "stdout" # on = ["failure", "recovery", "resource_critical"] # [notifications.email] -# to = "{{EMAIL}}" -# from = "vordr@{{SERVICE_NAME}}.local" +# to = "j.d.a.jewell@open.ac.uk" +# from = "vordr@heron.local" # smtp = "smtp://localhost:25" # on = ["failure", "resource_critical"] diff --git a/build/docs-seed/README.adoc b/build/docs-seed/README.adoc deleted file mode 100644 index c4e0266..0000000 --- a/build/docs-seed/README.adoc +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -= Documentation Template -:revdate: 2026-05-26 -:status: SEED - -This directory is the *minimum heavy-wiki seed* for a hyperpolymath repo. - -[NOTE] -==== -*This is NOT a duplicate of `docs/`.* `docs/` holds *this* template's own, -fully-written documentation; `build/docs-seed/` is the un-filled *seed* (with -`{{PLACEHOLDER}}` tokens and illustrative example paths) that is copied into a -*newly scaffolded* repo's `docs/`. Example paths inside `build/docs-seed/` -describe a hypothetical downstream project, so they intentionally do not resolve -against this template's tree (see `.hypatia-ignore` for the matching SD022 -carve-out). -==== - -== How adoption works - -`just repo-init` does the first two steps for you. It was previously a manual -four-step procedure that nothing automated, with the result that minted repos -carried the un-filled seed *and* the spine's own rendered docs side by side. - -. **Automatic** — repo-init copies this seed over `docs/` before substitution. - It is an *overlay*: these files win on collision, and everything else the - spine ships under `docs/` survives (notably `docs/governance/` and - `docs/legal/`, which `build/just/validate.just` requires). This README is not - copied. The seed directory is then removed from your repo alongside - `archetypes/` and `build/templates/`. -. **Automatic** — `{{PROJECT_NAME}}`, `{{OWNER}}`, `{{AUTHOR}}`, - `{{AUTHOR_EMAIL}}` and `{{CURRENT_YEAR}}` are rendered by the same - substitution pass as the rest of the tree. -. **Yours to do** — delete any section that genuinely does not apply. Do not - leave empty scaffolding: the 2026-05-26 doc-debt audit counted 61 estate - repos with empty `docs/` dirs as its own category of debt. -. **Yours to do** — add repo-specific sections. - -== What's in here - -[cols="1,2", options="header"] -|=== -| File | Purpose - -| `README.adoc` | This file. NOT copied into your repo — it documents the seed itself. -| `architecture.adoc` | System overview, component diagram, data flow. -| `usage.adoc` | End-user / consumer perspective. How to invoke / consume / depend on this repo. -| `contributing.adoc` | Developer perspective. Local-dev setup, test running, PR conventions. -| `troubleshooting.adoc` | FAQ-style. Known failure modes + recovery paths. -| `decisions/0001-template.adoc` | ADR (Architecture Decision Record) template. Copy → `0002-...`, `0003-...` etc. -|=== - -== Why these five sections - -The 2026-05-26 estate documentation-debt audit -(link:https://github.com/hyperpolymath/standards/blob/main/docs/audits/2026-05-26-estate-documentation-debt.md[standards#197]) -identified four canonical perspectives every repo should answer: - -. **What is it** — README answers (lives at repo root, not here). -. **How is it built** — `architecture.adoc`. -. **How do I use it** — `usage.adoc`. -. **How do I help build it** — `contributing.adoc`. -. **What goes wrong** — `troubleshooting.adoc`. - -ADRs are the long-running historical record — they don't fit any of -the above but are essential for explaining *why* a particular shape -was chosen. - -== What's intentionally NOT in here - -This seed deliberately omits: - -* `proof-debt.md` — only relevant to proof-bearing repos; see - link:https://github.com/hyperpolymath/standards/blob/main/docs/TRUSTED-BASE-REDUCTION-POLICY.adoc[standards#203]. -* `CHANGELOG.md` — generated automatically by - link:https://github.com/hyperpolymath/standards/blob/main/.github/workflows/changelog-reusable.yml[`changelog-reusable.yml`] - (standards#206), don't write by hand. -* `tech-debt-YYYY-MM-DD.md` — generated by the periodic estate audit. - -== "Heavily-developed wiki" bar (audit definition) - -A repo meets the audit's bar when `docs/` contains ≥ 10 substantive -markdown / asciidoc / rst files with topical organisation. Adopting -this seed gets you to ~5 — add repo-specific deep-dives (typically -one per major component or invariant) to clear the bar. diff --git a/build/docs-seed/architecture.adoc b/build/docs-seed/architecture.adoc deleted file mode 100644 index 685b024..0000000 --- a/build/docs-seed/architecture.adoc +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> -= Architecture — {{PROJECT_NAME}} -:revdate: {{CURRENT_YEAR}}-MM-DD - -== System overview - -One paragraph: what this project does and what it does not do. State the -*invariant* — the property that, if violated, would make the whole project -pointless. Future maintainers will read this paragraph first. - -== Component diagram - -Replace this section with an ASCII or Mermaid diagram. Keep it under 20 -lines — anything bigger belongs in `architecture/`. - -[source] ----- -+------------------+ +------------------+ -| Component A | ---> | Component B | -+------------------+ +------------------+ - | - v -+------------------+ -| Component C | -+------------------+ ----- - -== Data flow - -For each external input, describe: - -* **Source**: where it comes from. -* **Validation**: what guarantees we enforce on entry. -* **Transformation**: high-level processing stages. -* **Sink**: where the result goes. - -== Key invariants - -Enumerate the load-bearing invariants of the system. Each should have: - -. A one-line statement. -. The code location(s) that enforce it. -. The failure mode if the invariant is violated. - -Example: - -[cols="1,2,2,2", options="header"] -|=== -| # | Invariant | Enforced at | Failure mode - -| 1 -| All HTTP requests carry a valid `X-Request-ID`. -| `src/request_id.rs` -| Logs become unjoinable; correlation breaks. - -| 2 -| The output buffer is always flushed before exit. -| `src/main.rs:88-92` (Drop impl) -| Last ~16KB of log lost on crash. -|=== - -== Dependencies - -* **Internal**: list other hyperpolymath repos this depends on. -* **External**: SHA-pinned (see `Cargo.lock` / `.lock` / etc.). -* **Build-time**: tools required to build (just, , cargo, …). - -== Out of scope - -Explicit non-goals. Things we deliberately do *not* do, with a one-line -reason for each. This section saves more time than the rest combined. - -== See also - -* link:./usage.adoc[Usage] — consumer perspective. -* link:./contributing.adoc[Contributing] — developer setup. -* link:./decisions/[ADRs] — historical record of why this shape. diff --git a/build/docs-seed/contributing.adoc b/build/docs-seed/contributing.adoc deleted file mode 100644 index eb22aac..0000000 --- a/build/docs-seed/contributing.adoc +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> -= Contributing — {{PROJECT_NAME}} -:revdate: {{CURRENT_YEAR}}-MM-DD - -== Audience - -Developers working *on* `{{PROJECT_NAME}}`. For consumers (people calling -or depending on it) see link:./usage.adoc[usage.adoc]. - -== Local-dev setup - -Prerequisites — the minimum versions and where to get them: - -* `` v`` — ``. -* `` v`` — ``. -* GPG signing key configured (estate policy — all commits must be - signed). See - link:https://github.com/hyperpolymath/standards/blob/main/docs/secure-coding-training.md[standards/docs/secure-coding-training.md]. - -One-shot setup: - -[source,bash] ----- -git clone git@github.com:{{OWNER}}/{{PROJECT_NAME}}.git -cd {{PROJECT_NAME}} -just setup # installs deps, sets up hooks -just test # runs the full test suite ----- - -== Running tests - -* **Unit**: `just test-unit` — fast, no I/O. -* **Integration**: `just test-int` — uses real services (database, - HTTP, etc.). Estate policy: prefer real over mocked - (see `feedback_integration_tests_real_db` in maintainer's memory). -* **Property**: `just test-prop` — randomised, slower; budget - documented in `docs/proof-debt.md` if applicable. -* **Full**: `just test` — runs all of the above. - -== Code style - -We enforce style via CI (governance-reusable.yml from hyperpolymath/standards). -Locally: - -[source,bash] ----- -just fmt # auto-format -just lint # static checks ----- - -* All commits must be **GPG-signed** (CI enforces; see - link:https://github.com/hyperpolymath/standards[standards]). -* All source files must carry an **SPDX-License-Identifier** header - (CI enforces). -* Conventional commits — `feat`, `fix`, `chore`, `refactor`, `docs`, - `test`, `ci`, `revert` (CHANGELOG is auto-generated from these - via link:https://github.com/hyperpolymath/standards/blob/main/.github/workflows/changelog-reusable.yml[`changelog-reusable.yml`]). - -== Branching & PR workflow - -. Branch off `main` as `claude/` (for AI agents) or - `/` (for humans). -. Make focused, narrow commits — one logical change per commit. -. Open a PR against `main`. -. **Enable auto-merge immediately** on every PR you open - (`gh pr merge --auto --squash`) — estate standing policy - (see standards#196 audit and policies). -. CI must be green. The PR auto-merges when checks pass + reviews land. - -== Adding a new dependency - -. State the *why* in the PR body — what does this dependency unlock? -. Check provenance (maintained, audited, no malicious history). -. Pin to a SHA, not a tag. -. Update `docs/architecture.adoc#Dependencies`. - -== Adding an ADR - -When you make a non-obvious design decision, write it down: - -. Copy `docs/decisions/0001-template.adoc` → `0002-.adoc`. -. Fill in: Context, Decision, Consequences, Alternatives. -. Link the ADR from the README or relevant code as a comment. - -== Reporting issues - -* Bugs in `{{PROJECT_NAME}}`: file at `{{OWNER}}/{{PROJECT_NAME}}/issues`. -* Estate-wide concerns (policy, conventions, CI): file at - `hyperpolymath/standards/issues`. diff --git a/build/docs-seed/troubleshooting.adoc b/build/docs-seed/troubleshooting.adoc deleted file mode 100644 index 8c62e69..0000000 --- a/build/docs-seed/troubleshooting.adoc +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> -= Troubleshooting — {{PROJECT_NAME}} -:revdate: {{CURRENT_YEAR}}-MM-DD - -This file is a *living FAQ* — known failure modes and recovery paths. -Add a new entry every time you debug something that took more than 15 -minutes; future-you (and other maintainers) will thank you. - -== Known failure modes - -=== `` - -**Symptom**: short reproduction. What does the user see? - -**Cause**: root cause (one or two sentences). - -**Fix**: -[source,bash] ----- - ----- - -**Avoidance**: how to not hit this again (config setting, doc link, etc.). - ---- - -=== `` - -(Replace this and the above example with real entries as you encounter -them.) - -== Diagnostic toolkit - -When something is wrong, run these first: - -[source,bash] ----- -just doctor # runs all available self-checks -just version # prints the version & build hash -just log-tail # last N lines of logs ----- - -== When to escalate - -. Filed an issue with: version, OS, exact command, full error output, - and the symptom in plain English. -. Linked from the issue: the relevant ADR(s) and any related issues. -. For *security*-relevant findings, see - link:./../SECURITY.md[SECURITY.md] — do **not** file public issues. - -== Where to look for more help - -* `docs/architecture.adoc` — internals. -* `docs/decisions/` — historical record of why things are this shape. -* `https://github.com/{{OWNER}}/{{PROJECT_NAME}}/issues?q=is%3Aissue+` — has anyone hit this before? -* `https://github.com/hyperpolymath/standards/issues` — for estate-wide problems. diff --git a/build/docs-seed/usage.adoc b/build/docs-seed/usage.adoc deleted file mode 100644 index 8ebc533..0000000 --- a/build/docs-seed/usage.adoc +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> -= Usage — {{PROJECT_NAME}} -:revdate: {{CURRENT_YEAR}}-MM-DD - -== Audience - -This document is for *consumers* of `{{PROJECT_NAME}}` — people who depend -on it, call it, or include it. For developers working *on* it, see -link:./contributing.adoc[contributing.adoc]. - -== Quickstart - -The shortest path from zero to a working call: - -[source,bash] ----- -# 1. Install -just install # or: cargo install --path . / task install - -# 2. Configure -cp examples/config.example.toml ./config.toml -# Edit minimal required fields. - -# 3. Run -just run # or the equivalent for your language ----- - -Expected output: - -[source] ----- -{{PROJECT_NAME}} v0.0.0 -Listening on 127.0.0.1:8080 ----- - -== Common use cases - -For each canonical use case, give: - -. A one-line statement of the goal. -. The minimal invocation. -. Expected output / side effect. - -=== Use case 1: - -(Replace with real content.) - -=== Use case 2: - -== Configuration reference - -Document every configurable field. Keep this section authoritative — if -the code grows a new option, this table must grow too (CI can be wired -to enforce this). - -[cols="1,1,1,3", options="header"] -|=== -| Field | Type | Default | Meaning - -| `` | `` | `` | -|=== - -== Stability guarantees - -Be explicit: - -* **Stable**: API surfaces that follow SemVer (breaking change = major bump). -* **Unstable**: behind a flag / pre-1.0 / explicitly marked. -* **Internal**: documented for reference but no compatibility promise. - -== Limits & known constraints - -* Maximum supported ``: ``. -* `` is not yet implemented; track at issue `#`. -* On ``, `` behaves differently because ``. - -== See also - -* link:./architecture.adoc[Architecture] — how it works internally. -* link:./troubleshooting.adoc[Troubleshooting] — when things go wrong. diff --git a/build/guix.scm b/build/guix.scm index adbe8c3..0f1eff9 100755 --- a/build/guix.scm +++ b/build/guix.scm @@ -1,9 +1,9 @@ ;; SPDX-License-Identifier: MPL-2.0 ;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) ;; -;; guix.scm — rsr-template-repo as a Guix package. +;; guix.scm — heron-polyfeed-reader as a Guix package. ;; -;; guix build -f build/guix.scm # installs the source tree to share/rsr-template-repo +;; guix build -f build/guix.scm # installs the source tree to share/heron-polyfeed-reader ;; guix shell -D -f build/guix.scm # the full development toolchain ;; ;; This is a SOURCE package, and says so: a hermetic compiled build needs this @@ -28,17 +28,17 @@ '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) (package - (name "rsr-template-repo") + (name "heron-polyfeed-reader") (version "0.1.0") - (source (local-file %source-dir "rsr-template-repo-checkout" + (source (local-file %source-dir "heron-polyfeed-reader-checkout" #:recursive? #t #:select? (lambda (file stat) (not (member (basename file) %ignored))))) (build-system copy-build-system) - (arguments (list #:install-plan #~'(("." "share/rsr-template-repo/")))) + (arguments (list #:install-plan #~'(("." "share/heron-polyfeed-reader/")))) (native-inputs (map spec->input (list "git" "bash" "coreutils" "nss-certs" "just" "mise" "shellcheck" "chez-scheme" "gmp" "gcc-toolchain" "zig"))) - (home-page "https://github.com/hyperpolymath/rsr-template-repo") + (home-page "https://github.com/hyperpolymath/heron-polyfeed-reader") (synopsis "Rhodium Standard Repository template") (description "The estate's repository template: the RSR layout, an Idris2 ABI seam with a Zig FFI, CI gates, and the provisioning set (launcher, Justfile, diff --git a/build/just/assess.just b/build/just/assess.just index c4c382f..9a9c50c 100644 --- a/build/just/assess.just +++ b/build/just/assess.just @@ -250,7 +250,7 @@ verify: check_file "LICENSE" check_community_file "CONTRIBUTING" check_either "README.adoc" "README.md" - # Identity, state and ecosystem live in the repo deed (rsr-template-repo#209). + # Identity, state and ecosystem live in the repo deed (heron-polyfeed-reader#209). if DEED=$(bash scripts/deed-field.sh --find . 2>/dev/null) && bash scripts/deed-field.sh --has "$DEED" status; then echo " OK: $DEED (status, meta, ecosystem)" else diff --git a/build/just/container.just b/build/just/container.just index a906c60..3bd52e5 100644 --- a/build/just/container.just +++ b/build/just/container.just @@ -59,14 +59,14 @@ container-init: fi # Prompt for container-specific values - read -rp "Service name (e.g. my-api) [{{project}}]: " _SERVICE_NAME - SERVICE_NAME="${_SERVICE_NAME:-{{project}}}" + read -rp "Service name (e.g. my-api) [heron_polyfeed_reader]: " _SERVICE_NAME + SERVICE_NAME="${_SERVICE_NAME:-heron_polyfeed_reader}" read -rp "Primary port [8080]: " _PORT PORT="${_PORT:-8080}" - read -rp "Container registry [ghcr.io/${OWNER:-{{OWNER}}}]: " _REGISTRY - REGISTRY="${_REGISTRY:-ghcr.io/${OWNER:-{{OWNER}}}}" + read -rp "Container registry [ghcr.io/${OWNER:-hyperpolymath}]: " _REGISTRY + REGISTRY="${_REGISTRY:-ghcr.io/${OWNER:-hyperpolymath}}" echo "" echo " Service: $SERVICE_NAME" @@ -113,9 +113,9 @@ container-build *args: if [ -f "build/container/stapeln/ct-build.sh" ]; then cd build/container/stapeln && CONTAINER_ENGINE="$ENGINE" ./ct-build.sh {{args}} elif [ -f "build/container/Containerfile" ]; then - "$ENGINE" build -t {{project}}:latest -f build/container/Containerfile . + "$ENGINE" build -t heron_polyfeed_reader:latest -f build/container/Containerfile . elif [ -f "Containerfile" ]; then - "$ENGINE" build -t {{project}}:latest -f Containerfile . + "$ENGINE" build -t heron_polyfeed_reader:latest -f Containerfile . else echo "No Containerfile found in build/container/ or project root" exit 1 @@ -177,7 +177,7 @@ container-push: cd build/container/stapeln && CONTAINER_ENGINE="$ENGINE" ./ct-build.sh --push else echo "No build/container/stapeln/ct-build.sh found — falling back to $ENGINE push" - "$ENGINE" push {{project}}:latest + "$ENGINE" push heron_polyfeed_reader:latest fi # Run the container interactively (for debugging) @@ -185,10 +185,10 @@ container-run *args: #!/usr/bin/env bash set -euo pipefail {{_engine}} - "$ENGINE" run --rm -it {{project}}:latest {{args}} + "$ENGINE" run --rm -it heron_polyfeed_reader:latest {{args}} # Container matrix: [build|run|push|shell|scan] x [registry] x [tag] -container-matrix action="build" registry="ghcr.io/{{OWNER}}" tag="latest": +container-matrix action="build" registry="ghcr.io/hyperpolymath" tag="latest": @echo "Container matrix: action={{action}} registry={{registry}} tag={{tag}}" # ── Narrowing / opt-out ────────────────────────────────────────────────────── diff --git a/build/just/groove.just b/build/just/groove.just index 2b2d7ae..e70c66a 100644 --- a/build/just/groove.just +++ b/build/just/groove.just @@ -78,8 +78,8 @@ verify-template: fi # Check for template defaults still present - if grep -q 'rsr-template-repo' Justfile 2>/dev/null; then - echo "⚠ Justfile still references 'rsr-template-repo' — update project name" + if grep -q 'heron-polyfeed-reader' Justfile 2>/dev/null; then + echo "⚠ Justfile still references 'heron-polyfeed-reader' — update project name" FOUND=1 fi diff --git a/build/just/provision-lib.sh b/build/just/provision-lib.sh index fb1d942..106451c 100755 --- a/build/just/provision-lib.sh +++ b/build/just/provision-lib.sh @@ -45,7 +45,7 @@ have() { command -v "$1" >/dev/null 2>&1; } # Where this repository keeps each part of the set. A file sits at the root only # when something needs it there (launcher.sh, Justfile, mise.toml, mise.lock); # the Guix trio and the warm-ups follow the layout the repository already has -# (rsr-template-repo keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). +# (heron-polyfeed-reader keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). # This is the ONE answer: provision-check.sh asks it through `guix-dir` and # `set-files` rather than keeping its own copy. guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } @@ -85,7 +85,7 @@ ARCHETYPE="$(deed archetype "")" # --------------------------------------------------------------------------- # Language detection. Markers at the root or one level down (workspaces). The # estate ABI/FFI pattern nests deeper: Idris2 to two levels down (src/abi/*.ipkg), -# Zig to three (ffi/zig/build.zig, and rsr-template-repo's src/interface/ffi/build.zig: +# Zig to three (ffi/zig/build.zig, and heron-polyfeed-reader's src/interface/ffi/build.zig: # 74 repos have their only build.zig at that depth, measured 2026-09-30). # Order matters only for display. `docs` is reported when nothing else is. first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } diff --git a/build/just/repo-init.just b/build/just/repo-init.just index 8ae21bd..b5e3220 100644 --- a/build/just/repo-init.just +++ b/build/just/repo-init.just @@ -460,7 +460,7 @@ repo-init archetype="": # _chora.deed — deed.abnf v1.0.0). The self-name pass rewrote # the slug inside the file; the file itself is renamed here. Plain mv: # mint runs before the new repo's first commit. Renames (not tokens) - # are how filenames change — a {{REPO}} token in a FILENAME is the + # are how filenames change — a heron-polyfeed-reader token in a FILENAME is the # brace-collapse hazard class repo-init has already been burnt by. if [ -f rsr-template-repo_chora.deed ]; then mv rsr-template-repo_chora.deed "${REPO}_chora.deed" diff --git a/build/just/validate.just b/build/just/validate.just index dc6d724..6586806 100644 --- a/build/just/validate.just +++ b/build/just/validate.just @@ -35,7 +35,7 @@ validate-rsr: [ -f "$f" ] || MISSING="$MISSING $f" done # Identity, lifecycle, maintenance axes, the anchor and the maintenance - # policies are all clauses of the repo deed (rsr-template-repo#209). + # policies are all clauses of the repo deed (heron-polyfeed-reader#209). DEED=$(bash scripts/deed-field.sh --find . || true) if [ -n "$DEED" ] && bash scripts/deed-field.sh --has "$DEED" status; then # Prints the field, or nothing when it is absent. diff --git a/build/manifest.scm b/build/manifest.scm index a05df9c..28bc9af 100644 --- a/build/manifest.scm +++ b/build/manifest.scm @@ -1,7 +1,7 @@ ;; SPDX-License-Identifier: MPL-2.0 ;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) ;; -;; manifest.scm — the Guix development shell for rsr-template-repo. +;; manifest.scm — the Guix development shell for heron-polyfeed-reader. ;; ;; guix shell -m manifest.scm # or: just dev-shell ;; guix time-machine -C channels.scm -- shell -m manifest.scm # the pinned Guix diff --git a/build/templates/ROADMAP.adoc.in b/build/templates/ROADMAP.adoc.in deleted file mode 100644 index 3a465b1..0000000 --- a/build/templates/ROADMAP.adoc.in +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -= {{PROJECT_NAME}} Roadmap - -== Current Status - -Initial development phase. - -== Milestones - -=== v0.1.0 - Foundation -* [ ] Core functionality -* [ ] Basic documentation -* [ ] CI/CD pipeline - -=== v1.0.0 - Stable Release -* [ ] Full feature set -* [ ] Comprehensive tests -* [ ] Production ready - -== Future Directions - -_To be determined based on community feedback._ diff --git a/build/templates/identity.deed.in b/build/templates/identity.deed.in deleted file mode 100644 index 7a97240..0000000 --- a/build/templates/identity.deed.in +++ /dev/null @@ -1,94 +0,0 @@ - ; >>> repo-identity - ; Written by `just repo-init` from build/templates/identity.deed.in, in - ; place of the block the template repo ships (template issue #209). It replaces - ; descriptiles/CLADE.a2ml and ECOSYSTEM.a2ml; the mapping is standards - ; 1-formats/deed/mappings/clade-to-repo-deed.adoc and - ; meta-ecosystem-to-repo-deed.adoc. - ; - ; THE UUID IS DERIVED, NOT ALLOCATED, NOT INVENTED. It is not stored: it is - ; uuid5(namespace = URL, name = the header :repo-uuid string) - ; and anyone can recompute it (gv-clade-index: - ; docs/SPEC-clade-verisim-portal.adoc §Identity Model): - ; uuidgen --sha1 --namespace @url --name "{{FORGE}}/{{OWNER}}/{{REPO}}" - ; The owner is part of the name, so part of the identity: record the owner - ; that is true today, and re-derive if the repo ever moves. - ; - ; CHOOSE A CLADE. A CLOSED TAXONOMY OF 12 CATEGORIES, NOT AN ABBREVIATION - ; SCHEME: the two letters abbreviate the clade's name, never your repo's. - ; fv Formal Verification & Proofs provable correctness, dependent types, theorem proving - ; nl Nextgen Languages compilers, runtimes, language tooling - ; rm Repo Management & Tooling scaffolding, graph analysis, bots, templates - ; gv Governance & Standards licensing, compliance, policy enforcement - ; db Databases database engines, query languages, storage - ; ap Applications end-user apps, web, desktop, services - ; ix Infrastructure & Cloud containers, deployment, sysadmin - ; dx Developer Ecosystem dev tools, package managers, editors, bindings - ; pt Protocols & Interop internet standards, protocol implementations - ; ax AI & Neurosymbolic ML, neural proof synthesis, AI governance - ; gm Games & Interactive game engines, interactive experiences - ; sc Security scanning, vulnerability management, access control - ; paint-type once chose `pt` reading it as "PainT-type" (an image editor is - ; `ap`); gossamer chose `gv` reading it as "Graphical/Visual" (`gv` is - ; GoVernance). Both codes were valid, so every check passed. - ; HOW TO CHOOSE: the primary clade reflects the project's core value - ; proposition, not its implementation. A game written in Idris2 is `gm`. - ; Set BOTH :primary and :primary-name, and make them agree; then set - ; :prefixed-name to "-{{REPO}}". `just validate-rsr` fails while - ; :primary is UNASSIGNED. That is deliberate: an unchosen clade must never - ; look like a chosen one. - ; - ; REGISTRATION IS NOT DERIVATION. Registering means an entry in - ; gv-clade-index verisim/seed/repos.a2ml. It is outward-facing and it is the - ; OWNER's act; an agent must not do it. - (identity - :primary-forge github - :owner "{{OWNER}}" - :prefixed-name "UNASSIGNED-{{REPO}}") - (clade - :primary UNASSIGNED - :primary-name "UNASSIGNED" - :secondary () - :assigned "{{CURRENT_DATE}}" - :rationale "") - (forges - :github "{{OWNER}}/{{REPO}}") - ; :type is one of standalone monorepo monorepo-child inflated deflated hub - ; satellite. A hub COORDINATES an ecosystem family; a satellite is - ; coordinated by one and keeps :parent "" (parent names a monorepo parent, - ; never the coordinating hub). - (lineage - :type standalone - :parent "" - :born "{{CURRENT_DATE}}" - :previous-names () - :instantiated-from "rsr-template-repo") - ; :phase is one of reserved incubating active dormant merged superseded - ; archived extinct. A repo created today has not proven anything yet. - (status - :phase incubating - :since "{{CURRENT_DATE}}" - :present #t - :aliases () - :merged-into "" - :superseded-by "" - :successors () - :ended "" - (history - (entry :phase incubating :since "{{CURRENT_DATE}}" :note "created from rsr-template-repo; clade not yet chosen; not yet registered"))) - ; :level is one of experimental alpha beta production lts (standards - ; 1-formats/deed/vocabulary/maturity.adoc). - (maturity - :level experimental - :since "{{CURRENT_DATE}}") - ; Fill in what this repo IS and IS NOT, and the repos it relates to. - ; :relationship is one of standard-source build-tooling validation-tooling - ; signing-tooling verification-tooling minted-from registry. - (ecosystem - :project "{{REPO}}" - :ecosystem "{{OWNER}}" - :position-type "UNASSIGNED" - :purpose "UNASSIGNED" - :not () - :coordination standards - (related :name "rsr-template-repo" :relationship minted-from :notes "the template this repo was minted from by just repo-init")) - ; <<< repo-identity diff --git a/docs/AFFIRMATION.adoc b/docs/AFFIRMATION.adoc index 90d321c..a58365a 100644 --- a/docs/AFFIRMATION.adoc +++ b/docs/AFFIRMATION.adoc @@ -1,6 +1,6 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -= AFFIRMATION — {{PROJECT_NAME}}, as of += AFFIRMATION — heron-polyfeed-reader, as of :toc: macro :toclevels: 2 :std-docs: https://github.com/hyperpolymath/standards/blob/main/docs @@ -37,12 +37,12 @@ toc::[] == What this is, and how it works -*What it is.* A short, dated, signed snapshot of what {{OWNER}} can honestly and -verifiably claim about *{{PROJECT_NAME}}* at one exact commit. Nothing here is +*What it is.* A short, dated, signed snapshot of what hyperpolymath can honestly and +verifiably claim about *heron-polyfeed-reader* at one exact commit. Nothing here is marketing and nothing is a promise about the future — those live in the README. This file is the receipt. -*What the project is.* {{PROJECT_DESCRIPTION}} +*What the project is.* A small local feed reader and site monitor: polls RSS, Atom and RDF feeds and watches sites for downtime, content change and TLS certificate expiry. Zig, vendored SQLite with FTS5, localhost-only web UI. *How it stays trustworthy.* Three moving parts: @@ -63,7 +63,7 @@ correctness. Treat it as a falsifiable claim, not gospel. == The epistemic contract (read this before you trust _or_ attack) -This document records {{OWNER}}'s *best belief* at the timestamp below. It is +This document records hyperpolymath's *best belief* at the timestamp below. It is *not a guarantee of correctness.* The only guarantee is *no intentional overclaim*: where something is proven we say "proven"; where it is a documented trust boundary, an experiment, or an unwired module, we say so; where a claim is @@ -95,19 +95,19 @@ file. Bring a counter-example, a failing run, or a contradicting source. | Field | Value | Project -| {{PROJECT_NAME}} +| heron-polyfeed-reader | Repo -| `{{OWNER}}/{{REPO}}` +| `hyperpolymath/heron-polyfeed-reader` | Branch -| `{{MAIN_BRANCH}}` +| `main` | Commit (HEAD) | `` | Permalink -| https://{{FORGE}}/{{OWNER}}/{{REPO}}/tree/ +| https://github.com/hyperpolymath/heron-polyfeed-reader/tree/ | Verified (UTC) | `` @@ -120,7 +120,7 @@ file. Bring a counter-example, a failing run, or a contradicting source. | `` | Affirmed by -| {{AUTHOR}} <{{AUTHOR_EMAIL}}> +| Jonathan D.A. Jewell |=== [IMPORTANT] @@ -193,15 +193,15 @@ to prevent — mark it refuted and say what refuted it. [source,bash] ---- -git clone https://{{FORGE}}/{{OWNER}}/{{REPO}} -cd {{REPO}} +git clone https://github.com/hyperpolymath/heron-polyfeed-reader +cd heron-polyfeed-reader git checkout # ---- == One-line characterisation (quote this) -> `` +> `` == Joint attestation @@ -213,7 +213,7 @@ hidden. * *Engineering party (AI):* `` — ran the checks recorded here at `` and stands behind the wording above as a faithful report of those runs. -* *Owner / maintainer:* {{AUTHOR}} <{{AUTHOR_EMAIL}}> — _signs by committing +* *Owner / maintainer:* Jonathan D.A. Jewell — _signs by committing this file with `-S`; the git commit signature over this content, at the commit SHA recorded above, is the cryptographic form of this affirmation._ diff --git a/docs/AI_INSTALLATION_GUIDE.adoc b/docs/AI_INSTALLATION_GUIDE.adoc index afdd6da..2c6ebce 100644 --- a/docs/AI_INSTALLATION_GUIDE.adoc +++ b/docs/AI_INSTALLATION_GUIDE.adoc @@ -30,7 +30,7 @@ local checkouts across this estate are routinely stale or carry sweep debris. [source,bash] ---- -git clone https://github.com/hyperpolymath/rsr-template-repo.git my-project +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git my-project cd my-project rm -rf .git && git init ---- diff --git a/docs/MAINTAINERS.adoc b/docs/MAINTAINERS.adoc index 458fc55..3f1fad4 100644 --- a/docs/MAINTAINERS.adoc +++ b/docs/MAINTAINERS.adoc @@ -12,9 +12,9 @@ earlier duplicates at `.github/MAINTAINERS` and `docs/attribution/MAINTAINERS.ad |=== | Name | Role | Contact -| {{AUTHOR}} +| Jonathan D.A. Jewell | Lead Maintainer -| https://github.com/{{OWNER}}[@{{OWNER}}] +| https://github.com/hyperpolymath[@hyperpolymath] |=== == Responsibilities diff --git a/docs/QUICKSTART.adoc b/docs/QUICKSTART.adoc index fc9fdf9..698a885 100644 --- a/docs/QUICKSTART.adoc +++ b/docs/QUICKSTART.adoc @@ -15,7 +15,7 @@ Get up and running in 60 seconds. [source,bash] ---- -git clone https://github.com/{{OWNER}}/rsr-template-repo my-project +git clone https://github.com/hyperpolymath/heron-polyfeed-reader my-project cd my-project rm -rf .git && git init -b main just repo-init # interactive placeholder replacement diff --git a/docs/RSR-PHILOSOPHY.adoc b/docs/RSR-PHILOSOPHY.adoc index 05a1590..67e095f 100644 --- a/docs/RSR-PHILOSOPHY.adoc +++ b/docs/RSR-PHILOSOPHY.adoc @@ -51,7 +51,7 @@ Two obligations follow from this, and they are not optional: * *Find the source.* Before acting, trace the thing back to where it is actually defined — the template, the generator, the canon, the single point that everything else inherits from. The estate's structure is - `standards → rsr-template-repo → (every repo)`; a fix that belongs at the + `standards → heron-polyfeed-reader → (every repo)`; a fix that belongs at the template does not belong in 380 leaves. * *Be mindful of every up- and down-stream.* A change at a source propagates. Before you make it, know what feeds into the thing you are changing (upstream) diff --git a/docs/SETUP.adoc b/docs/SETUP.adoc index b9ba055..1ffdb09 100644 --- a/docs/SETUP.adoc +++ b/docs/SETUP.adoc @@ -1,6 +1,6 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -= Setting up rsr-template-repo by hand += Setting up heron-polyfeed-reader by hand :toc: :toclevels: 2 @@ -10,7 +10,7 @@ link:AI_INSTALLATION_GUIDE.adoc[AI_INSTALLATION_GUIDE.adoc]), but those do exactly what is written here, so this page is also what to read when they stop. [horizontal] -Repository:: https://github.com/hyperpolymath/rsr-template-repo +Repository:: https://github.com/hyperpolymath/heron-polyfeed-reader Kind:: library (idris2, zig) Toolchain declared in:: `mise.toml` (versions `latest`, made concrete in `mise.lock`), and `build/manifest.scm` / `build/guix.scm` / `build/channels.scm` for Guix @@ -56,12 +56,12 @@ The Guix development shell (`build/manifest.scm`) already includes both. [source,bash] ---- -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader ---- Or with OPSM, the odds-and-sods package manager (see <>): -`opsm install https://github.com/hyperpolymath/rsr-template-repo.git --registry git` +`opsm install https://github.com/hyperpolymath/heron-polyfeed-reader.git --registry git` TIP: To start a *new* project from this template, use GitHub's *Use this template* button (or clone as above), then run `just repo-init` before step 3; it fills every @@ -153,10 +153,10 @@ when it is not. [source,bash] ---- -opsm install https://github.com/hyperpolymath/rsr-template-repo.git --registry git +opsm install https://github.com/hyperpolymath/heron-polyfeed-reader.git --registry git ---- -rsr-template-repo has no registry package, so OPSM fetches it through its git +heron-polyfeed-reader has no registry package, so OPSM fetches it through its git adapter; then provision it with `./launcher.sh --setup`. To get OPSM itself (Erlang/OTP 26+ and Elixir 1.16+ required): @@ -212,4 +212,4 @@ every fix marked *auto* for you. |=== Still stuck? `just doctor > doctor.txt 2>&1` and open an issue at -https://github.com/hyperpolymath/rsr-template-repo/issues with that file attached. +https://github.com/hyperpolymath/heron-polyfeed-reader/issues with that file attached. diff --git a/docs/architecture.adoc b/docs/architecture.adoc new file mode 100644 index 0000000..e2cd1b9 --- /dev/null +++ b/docs/architecture.adoc @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Architecture — heron-polyfeed-reader +:revdate: 2026-10-27 + +== System overview + +`heron` polls a configured set of sources (feeds) and monitors (sites) on a +jittered schedule, turns what it finds into *items*, appends them to an NDJSON +log and indexes them in SQLite for the CLI and a localhost web UI. The +invariant: *the log is the only source of truth; every other file in the data +directory can be deleted and rebuilt from it.* If that stopped being true, +crash recovery and `heron rebuild` would be lies. + +== Component diagram + +[source] +---- + config.zig ──► app.zig ──► engine.zig ──► scheduler.zig (Clock + PRNG, pure) + │ │ + │ ├─► adapter.zig Registry + │ │ ├─ feed_adapter.zig ─► xml / encoding / dates / feed / dedupe + │ │ └─ monitor.zig ──────► robots.zig, cert probe + │ │ (both over transport.zig ─► http.zig) + │ ▼ + │ store.zig: NDJSON log ─► SQLite + FTS5 (sqlite.zig externs) + │ ▲ + daemon.zig / cli.zig │ web.zig over localserver.zig (own SQLite connection) +---- + +== Data flow + +* **Source**: HTTP(S) responses from feeds and monitored pages; the + `Transport` interface is fakeable, and every test uses `localserver.zig`. +* **Validation**: size cap and wall-clock timeout in `http.zig`; manual + redirects; XXE-safe tolerant XML DOM; encoding detection (declaration, + BOM, UTF-16) and conversion; tolerant date parsing; http(s)-only links. +* **Transformation**: adapter maps a response to an `Outcome` (`items`, + `not_modified`, `failed` with a `FailureClass`); feed items are + de-duplicated by guid/id, then by link+date; monitor transitions become + `alert` items. +* **Sink**: `store.zig` appends records to the NDJSON log, then updates the + SQLite state tables and the FTS5 trigram index. `meta.indexed_offset` + records how far the index has consumed the log. + +== Key invariants + +[cols="1,2,2,2", options="header"] +|=== +| # | Invariant | Enforced at | Failure mode + +| 1 +| The index never claims to be ahead of the log. +| `src/store.zig` (`meta.indexed_offset` written after the index transaction) +| A crash between append and index would otherwise lose or duplicate items on replay. + +| 2 +| The scheduler never busy-loops and never polls a source before its `Retry-After`. +| `src/scheduler.zig` (pure functions over an injected `Clock`; `FakeClock` tests) +| Hammering upstreams; getting rate-limited or banned. + +| 3 +| One daemon per data directory. +| `heron.lock` in the data directory (`src/store.zig`) +| Two writers interleave log records. + +| 4 +| The web UI is reachable only from the machine unless explicitly allowed. +| `src/web.zig`: loopback bind by default, `allow_non_loopback` gate, Host-header allowlist, Origin check on POST, CSP, no JavaScript. +| DNS-rebinding or LAN exposure of an unauthenticated UI. + +| 5 +| A monitor alert is raised on a *transition*, not on every check. +| `src/monitor.zig` (first success silent; DOWN after two failures; change alerts rate-limited to one per hour) +| Alert floods that train the user to ignore them. +|=== + +== Dependencies + +* **Internal**: none yet. The roadmap's estate-integration milestone names + the candidates (`zig-dnssec`, `zig-boringtls`, the unified-hexadeca-api + conventions). +* **External, vendored**: SQLite 3.50.4 amalgamation (`vendor/sqlite`, built + by `zig cc` with FTS5, THREADSAFE=2, no load-extension); a patched copy of + Zig std's TLS client (`src/vendor/tls_client.zig`, see `src/vendor/NOTICE`). +* **Build-time**: Zig 0.17.0 exactly (`build.zig.zon`, `mise.toml`, + `Containerfile`, `.github/workflows/zig-ci.yml`), `just` for the estate + recipes. + +== Out of scope + +* WebSub / push: polling is simpler and sufficient for one user. +* Multi-user or hosted operation: no authentication is designed in. +* Full-text extraction from linked pages: the item body is what the feed + carries. +* Any protocol beyond HTTP(S) until a second adapter proves the seam + (link:./status/ROADMAP.adoc[roadmap M8]). + +== See also + +* link:./usage.adoc[Usage] — consumer perspective. +* link:./contributing.adoc[Contributing] — developer setup. +* link:./decisions/[ADRs] — historical record of why this shape. diff --git a/docs/architecture/REPOSITORY-MAP.adoc b/docs/architecture/REPOSITORY-MAP.adoc index ff28fb0..b365761 100644 --- a/docs/architecture/REPOSITORY-MAP.adoc +++ b/docs/architecture/REPOSITORY-MAP.adoc @@ -37,6 +37,10 @@ predecessors did. | optional | VS Code dev container spec; present only where the container capability is declared +| `.dockerignore` +| yes +| read by podman/docker beside the Containerfile + | `.editorconfig` | yes | - @@ -97,6 +101,10 @@ predecessors did. | optional | estate docs gate (standards scripts/check-docs-presence.sh) requires CONTRIBUTING at the ROOT; this is the copy it verifies +| `Containerfile` +| yes +| OCI build (test + runtime stages); podman/docker read it from the build context root + | `GEMINI.md` | optional | pointer to CLAUDE.md for repos without AGENTS.md yet @@ -117,10 +125,6 @@ predecessors did. | yes | - -| `archetypes/` -| optional -| template-only variation seam (ADR-0003); `just repo-init` removes it from minted repos - | `benches/` | optional | Cargo-conventional at package root @@ -129,6 +133,14 @@ predecessors did. | yes | build orchestration: just/ phase modules, container/, docs-seed/, templates/, guix.scm (canon 1.2.1 guix-primary template_ref = build/) +| `build.zig` +| yes +| Zig reads the build script from the package root only + +| `build.zig.zon` +| yes +| Zig package manifest; beside build.zig by definition + | `ci/` | yes | .gitlab-ci.yml + .pre-commit-config.yaml; ci/README.adoc records the out-of-band GitLab project setting these require @@ -149,6 +161,10 @@ predecessors did. | optional | optional feature packs +| `heron-polyfeed-reader_chora.deed` +| yes +| the repo deed: universal AI entry point; family-7 allocation manifest + ply tree folded in (standards#837 pilot). Filename carries the repo slug (deed dispatch _chora.deed), so repo-init renames it at mint + | `launcher.sh` | yes | --setup/--doctor/--heal/--ai-setup (launcher-standard 0.6.0); the one file a newcomer runs first @@ -161,10 +177,6 @@ predecessors did. | yes | the toolchain: every tool at latest (PROVISIONING-STANDARD §4) -| `rsr-template-repo_chora.deed` -| yes -| the repo deed: universal AI entry point; family-7 allocation manifest + ply tree folded in (standards#837 pilot). Filename carries the repo slug (deed dispatch _chora.deed), so repo-init renames it at mint - | `scripts/` | yes | repo helper scripts @@ -185,6 +197,10 @@ predecessors did. | yes | - +| `vendor/` +| yes +| vendored SQLite amalgamation (tracked source; .gitignore un-ignores vendor/sqlite/) + | `verification/` | optional | proofs; only where formal-proofs is declared @@ -205,6 +221,7 @@ your own repo - but do not read their emptiness as neglect here. [cols="1"] |=== +| `.machine_readable/ai/` | `.machine_readable/bot_directives/` | `.machine_readable/descriptiles/anchors/` | `.machine_readable/scripts/verification/` @@ -237,7 +254,6 @@ your own repo - but do not read their emptiness as neglect here. | `docs/whitepapers/academic/` | `docs/whitepapers/industry/` | `docs/whitepapers/outreach/` -| `examples/` | `features/boj-server/` | `features/panic-attacker/` | `src/aspects/` diff --git a/docs/architecture/THREAT-MODEL.adoc b/docs/architecture/THREAT-MODEL.adoc index 43d945d..37241f1 100644 --- a/docs/architecture/THREAT-MODEL.adoc +++ b/docs/architecture/THREAT-MODEL.adoc @@ -2,19 +2,19 @@ // Copyright (c) Jonathan D.A. Jewell = Threat Model // SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -== Threat Model: {{PROJECT_NAME}} +== Threat Model: heron-polyfeed-reader === Document Info |=== | Field | Value -| Project | {{PROJECT_NAME}} +| Project | heron-polyfeed-reader | Version | 1.0 -| Last Reviewed | {{DATE}} -| Author | {{AUTHOR}} +| Last Reviewed | 2026-10-27 +| Author | Jonathan D.A. Jewell | Methodology | STRIDE |=== @@ -39,7 +39,7 @@ === System Overview -Brief description of {{PROJECT_NAME}} and its architecture. +Brief description of heron-polyfeed-reader and its architecture. NOTE: See link:../STATE-VISUALIZER.adoc[STATE-VISUALIZER.adoc] for the full architecture diagram and completion dashboard. diff --git a/docs/attribution/CITATIONS.adoc b/docs/attribution/CITATIONS.adoc index 0a8b419..c51e64f 100644 --- a/docs/attribution/CITATIONS.adoc +++ b/docs/attribution/CITATIONS.adoc @@ -1,36 +1,36 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= {{PROJECT_NAME}} - Citation Guide += heron-polyfeed-reader - Citation Guide :toc: == BibTeX [source,bibtex] ---- -@software{{{PROJECT_NAME}}_{{CURRENT_YEAR}}, - author = {{{AUTHOR_LAST}}, {{AUTHOR_FIRST}}}, - title = {{{PROJECT_NAME}}}, - year = {{{CURRENT_YEAR}}}, - url = {https://github.com/{{OWNER}}/{{PROJECT_NAME}}}, +@software{heron-polyfeed-reader_2026, + author = {Jewell, Jonathan D.A.}, + title = {heron-polyfeed-reader}, + year = {2026}, + url = {https://github.com/hyperpolymath/heron-polyfeed-reader}, license = {MPL-2.0} } ---- == Harvard Style -{{AUTHOR_LAST}}, {{AUTHOR_INITIALS}} ({{CURRENT_YEAR}}) _{{PROJECT_NAME}}_ [Computer software]. Available at: https://github.com/{{OWNER}}/{{PROJECT_NAME}} +Jewell, J. (2026) _heron-polyfeed-reader_ [Computer software]. Available at: https://github.com/hyperpolymath/heron-polyfeed-reader == OSCOLA -{{AUTHOR}}, '{{PROJECT_NAME}}' ({{CURRENT_YEAR}}) +Jonathan D.A. Jewell, 'heron-polyfeed-reader' (2026) == MLA -{{AUTHOR_LAST}}, {{AUTHOR_FIRST}} "{{PROJECT_NAME}}." {{CURRENT_YEAR}}, github.com/{{OWNER}}/{{PROJECT_NAME}}. +Jewell, Jonathan D.A. "heron-polyfeed-reader." 2026, github.com/hyperpolymath/heron-polyfeed-reader. == APA 7 -{{AUTHOR_LAST}}, {{AUTHOR_INITIALS}} ({{CURRENT_YEAR}}). _{{PROJECT_NAME}}_ [Computer software]. GitHub. https://github.com/{{OWNER}}/{{PROJECT_NAME}} +Jewell, J. (2026). _heron-polyfeed-reader_ [Computer software]. GitHub. https://github.com/hyperpolymath/heron-polyfeed-reader == See Also diff --git a/docs/contributing.adoc b/docs/contributing.adoc new file mode 100644 index 0000000..53a62e9 --- /dev/null +++ b/docs/contributing.adoc @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Contributing — heron-polyfeed-reader +:revdate: 2026-10-27 + +== Audience + +Developers working *on* `heron-polyfeed-reader`. For people running it see +link:./usage.adoc[usage.adoc]. The root link:../CONTRIBUTING.adoc[CONTRIBUTING.adoc] +is the authoritative version of this page (the estate docs gate requires it +there); this copy keeps the developer loop short. + +== Local-dev setup + +Prerequisites: + +* Zig *0.17.0 exactly* — `mise install` (pinned in `mise.toml`), or the + tarball from https://ziglang.org/download/ verified against the SHA-256 + in the `Containerfile`. +* `just` — for the estate recipes (`just setup` installs the rest). +* An SSH signing key configured for git (estate policy: every commit signed). + +[source,bash] +---- +git clone git@github.com:hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader +just setup +zig build test --summary all +---- + +== Running tests + +* **Unit**: `zig build test --summary all` — 85 tests, all in-process; HTTP + goes through the fixture server in `src/localserver.zig`, time through + `FakeClock`. +* **Container**: `podman build -f Containerfile --target test -t heron-test .` + — the same suite on the pinned toolchain, from a clean context. +* **Smoke**: `just test-smoke` — builds and validates `examples/heron.json`. +* **Estate**: `just test` — every detected language (Zig and the template's + Idris2 seam); `just quality` — `fmt-check`, `lint`, `test`. + +== Code style + +[source,bash] +---- +zig fmt build.zig src # auto-format (just fmt) +zig fmt --check build.zig src # what just lint runs +---- + +* Every source file carries an **SPDX-License-Identifier** header. +* Hand-written FFI externs, no generated bindings. +* Conventional commits — `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, + `ci`, `revert`; milestone commits carry the milestone (`M8: …`). + +== Branching & PR workflow + +. Branch off `main` as `claude/` (for AI agents) or + `/` (for humans). +. Make focused, narrow commits — one logical change per commit, signed. +. Open a PR against `main`; CI (`zig-ci.yml` and the estate gates) must be + green; squash-merge. + +== Adding a dependency + +`heron` has none beyond the vendored SQLite and the patched TLS client. If one +is needed: say *why* in the PR, check provenance, pin by hash in +`build.zig.zon`, and update link:./architecture.adoc#_dependencies[architecture § Dependencies]. + +== Adding an ADR + +When you make a non-obvious design decision, write it down: + +. Copy `docs/decisions/0001-template.adoc` → `0002-.adoc`. +. Fill in: Context, Decision, Consequences, Alternatives. +. Link the ADR from the README or relevant code as a comment. + +The decisions made without owner input during M0–M6 are listed in +link:./status/ROADMAP.adoc[the roadmap]; each is a candidate ADR. + +== Reporting issues + +* Bugs in `heron-polyfeed-reader`: file at `hyperpolymath/heron-polyfeed-reader/issues`. +* Estate-wide concerns (policy, conventions, CI): file at + `hyperpolymath/standards/issues`. diff --git a/docs/decisions/0000-template.adoc b/docs/decisions/0000-template.adoc index 32c2c2a..0025b64 100644 --- a/docs/decisions/0000-template.adoc +++ b/docs/decisions/0000-template.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell = Architecture Decision Record: 0000-template // SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) == [NUMBER]. [TITLE] diff --git a/docs/decisions/0001-adopt-rsr-standard.adoc b/docs/decisions/0001-adopt-rsr-standard.adoc index cfcc68e..6e0bfeb 100644 --- a/docs/decisions/0001-adopt-rsr-standard.adoc +++ b/docs/decisions/0001-adopt-rsr-standard.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell = Architecture Decision Record: 0001-adopt-rsr-standard // SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) NOTE: Superseded in part (2026-10-06, rsr-template-repo#209): the `.a2ml` files named below no longer exist; their facts are clauses of the repo deed (`*_chora.deed`). @@ -58,7 +58,7 @@ New repositories are created by cloning the template: [source,bash] ---- -git clone https://github.com/{{OWNER}}/rsr-template-repo new-repo-name +git clone https://github.com/hyperpolymath/rsr-template-repo new-repo-name cd new-repo-name rm -rf .git && git init ---- diff --git a/build/docs-seed/decisions/0001-template.adoc b/docs/decisions/0001-template.adoc similarity index 94% rename from build/docs-seed/decisions/0001-template.adoc rename to docs/decisions/0001-template.adoc index eff8bb8..b4070a8 100644 --- a/build/docs-seed/decisions/0001-template.adoc +++ b/docs/decisions/0001-template.adoc @@ -1,8 +1,8 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) = ADR-0001 — Use Architecture Decision Records -:revdate: {{CURRENT_YEAR}}-MM-DD +:revdate: 2026-MM-DD :status: ACCEPTED == Context diff --git a/docs/developer/ABI-FFI-README.adoc b/docs/developer/ABI-FFI-README.adoc index 2a9ff08..1c9d668 100644 --- a/docs/developer/ABI-FFI-README.adoc +++ b/docs/developer/ABI-FFI-README.adoc @@ -3,7 +3,7 @@ = ABI/FFI Standards {{~ Aditionally delete this line and fill out the template below ~}} -== {{PROJECT}} ABI/FFI Documentation +== HERON_POLYFEED_READER ABI/FFI Documentation === Overview @@ -29,7 +29,7 @@ This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: ▼ ┌─────────────────────────────────────────────┐ │ C Headers (auto-generated) │ -│ generated/abi/{{project}}.h │ +│ generated/abi/heron_polyfeed_reader.h │ └─────────────────┬───────────────────────────┘ │ │ imported by @@ -42,7 +42,7 @@ This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: │ - Memory-safe by default │ └─────────────────┬───────────────────────────┘ │ - │ compiled to lib{{project}}.so/.a + │ compiled to libheron_polyfeed_reader.so/.a ▼ ┌─────────────────────────────────────────────┐ │ Any Language via C ABI │ @@ -53,7 +53,7 @@ This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: === Directory Structure ---- -{{project}}/ +heron_polyfeed_reader/ ├── src/ │ ├── abi/ # ABI definitions (Idris2) │ │ ├── Types.idr # Core type definitions with proofs @@ -70,11 +70,11 @@ This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: │ ├── test/ │ │ └── integration_test.zig │ └── include/ -│ └── {{project}}.h # C header (optional, can be generated) +│ └── heron_polyfeed_reader.h # C header (optional, can be generated) │ ├── generated/ # Auto-generated files │ └── abi/ -│ └── {{project}}.h # Generated from Idris2 ABI +│ └── heron_polyfeed_reader.h # Generated from Idris2 ABI │ └── bindings/ # Language-specific wrappers (optional) ├── rust/ @@ -212,7 +212,7 @@ zig build test # Run tests [source,bash] ---- cd src/abi -idris2 --cg c-header Types.idr -o ../../generated/abi/{{project}}.h +idris2 --cg c-header Types.idr -o ../../generated/abi/heron_polyfeed_reader.h ---- ==== Cross-Compile @@ -237,19 +237,19 @@ zig build -Dtarget=x86_64-windows [source,c] ---- -#include "{{project}}.h" +#include "heron_polyfeed_reader.h" int main() { - void* handle = {{project}}_init(); + void* handle = heron_polyfeed_reader_init(); if (!handle) return 1; - int result = {{project}}_process(handle, 42); + int result = heron_polyfeed_reader_process(handle, 42); if (result != 0) { - const char* err = {{project}}_last_error(); + const char* err = heron_polyfeed_reader_last_error(); fprintf(stderr, "Error: %s\n", err); } - {{project}}_free(handle); + heron_polyfeed_reader_free(handle); return 0; } ---- @@ -257,14 +257,14 @@ int main() { Compile with: [source,bash] ---- -gcc -o example example.c -l{{project}} -L./zig-out/lib +gcc -o example example.c -lheron_polyfeed_reader -L./zig-out/lib ---- ==== From Idris2 [source,idris] ---- -import {{PROJECT}}.ABI.Foreign +import HERON_POLYFEED_READER.ABI.Foreign main : IO () main = do @@ -282,22 +282,22 @@ main = do [source,rust] ---- -#[link(name = "{{project}}")] +#[link(name = "heron_polyfeed_reader")] extern "C" { - fn {{project}}_init() -> *mut std::ffi::c_void; - fn {{project}}_free(handle: *mut std::ffi::c_void); - fn {{project}}_process(handle: *mut std::ffi::c_void, input: u32) -> i32; + fn heron_polyfeed_reader_init() -> *mut std::ffi::c_void; + fn heron_polyfeed_reader_free(handle: *mut std::ffi::c_void); + fn heron_polyfeed_reader_process(handle: *mut std::ffi::c_void, input: u32) -> i32; } fn main() { unsafe { - let handle = {{project}}_init(); + let handle = heron_polyfeed_reader_init(); assert!(!handle.is_null()); - let result = {{project}}_process(handle, 42); + let result = heron_polyfeed_reader_process(handle, 42); assert_eq!(result, 0); - {{project}}_free(handle); + heron_polyfeed_reader_free(handle); } } ---- @@ -306,21 +306,21 @@ fn main() { [source,julia] ---- -const lib{{project}} = "lib{{project}}" +const libheron_polyfeed_reader = "libheron_polyfeed_reader" function init() - handle = ccall((:{{project}}_init, lib{{project}}), Ptr{Cvoid}, ()) + handle = ccall((:heron_polyfeed_reader_init, libheron_polyfeed_reader), Ptr{Cvoid}, ()) handle == C_NULL && error("Failed to initialize") handle end function process(handle, input) - result = ccall((:{{project}}_process, lib{{project}}), Cint, (Ptr{Cvoid}, UInt32), handle, input) + result = ccall((:heron_polyfeed_reader_process, libheron_polyfeed_reader), Cint, (Ptr{Cvoid}, UInt32), handle, input) result end function cleanup(handle) - ccall((:{{project}}_free, lib{{project}}), Cvoid, (Ptr{Cvoid},), handle) + ccall((:heron_polyfeed_reader_free, libheron_polyfeed_reader), Cvoid, (Ptr{Cvoid},), handle) end # Usage @@ -377,7 +377,7 @@ When modifying the ABI/FFI: 2. **Generate C header** ```bash - idris2 --cg c-header src/interface/Abi/Types.idr -o generated/abi/{{project}}.h + idris2 --cg c-header src/interface/Abi/Types.idr -o generated/abi/heron_polyfeed_reader.h ``` 3. **Update FFI implementation** (`ffi/zig/src/main.zig`) @@ -396,10 +396,10 @@ When modifying the ABI/FFI: === License -{{LICENSE}} +MPL-2.0 === See Also - https://idris2.readthedocs.io[Idris2 Documentation] - https://ziglang.org/documentation/master/[Zig Documentation] -- https://github.com/{{OWNER}}/rhodium-standard-repositories[Rhodium Standard Repositories] +- https://github.com/hyperpolymath/rhodium-standard-repositories[Rhodium Standard Repositories] diff --git a/docs/governance/CRG-AUDIT-TEMPLATE.adoc b/docs/governance/CRG-AUDIT-TEMPLATE.adoc index 192c5f9..ebd9332 100644 --- a/docs/governance/CRG-AUDIT-TEMPLATE.adoc +++ b/docs/governance/CRG-AUDIT-TEMPLATE.adoc @@ -1,8 +1,8 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= {{PROJECT_NAME}} — CRG Audit ({{CURRENT_DATE}}) -{{AUTHOR}} -{{CURRENT_DATE}} += heron-polyfeed-reader — CRG Audit (2026-10-27) +Jonathan D.A. Jewell +2026-10-27 :toc: :sectnums: @@ -18,17 +18,17 @@ == Scope and Standard -Evaluates the `{{REPO}}` repository against +Evaluates the `heron-polyfeed-reader` repository against *Component Readiness Grades v2.0 (STRICT)*, 2026-03-30 revision. - Standard: `standards/component-readiness-grades/COMPONENT-READINESS-GRADES.md` -- Template (criteria boilerplate): `rsr-template-repo/docs/governance/CRG-CRITERIA.adoc` +- Template (criteria boilerplate): `heron-polyfeed-reader/docs/governance/CRG-CRITERIA.adoc` - Self-declared grade (prior art): * `docs/status/ROADMAP.adoc` → `Declared CRG grade: ` line * `docs/READINESS.md` (if present) → per-component grades * Third-party badges (Glama, OpenSSF, etc.) if any — note date and scope -- Audit date: {{CURRENT_DATE}} -- Auditor: {{AUTHOR}} +- Audit date: 2026-10-27 +- Auditor: Jonathan D.A. Jewell The audit grades the repo *as-is today*, not aspirationally. @@ -49,7 +49,7 @@ The audit grades the repo *as-is today*, not aspirationally. Publication requires B+. Long alpha is discipline, not shame. -== Evidence Inventory (verified {{CURRENT_DATE}}) +== Evidence Inventory (verified 2026-10-27) === Structural compliance (Grade D floor) @@ -101,7 +101,7 @@ Do *not* mark PRESENT without citing the path. | Build-system policy (RSR R-020) | Remote -| `git@{{FORGE}}:{{OWNER}}/{{REPO}}.git` (origin only) / drift +| `git@github.com:hyperpolymath/heron-polyfeed-reader.git` (origin only) / drift | `git remote -v` |=== @@ -135,7 +135,7 @@ RSR compliance verdict: *met* / *partial* / *not met* — Grade D floor satisfie | Dangerous-pattern scan (`assert_total`, `Admitted`, `sorry`, `unsafeCoerce`, `Obj.magic`) | N -| Grepped {{CURRENT_DATE}}. +| Grepped 2026-10-27. |=== === Test matrix @@ -283,7 +283,7 @@ them. Examples: - `docs/READINESS.md` — per-component table. Note v1.0-vs-v2.0 schema alignment. - `*_chora.deed` (repo deed) — authoritative phase and maturity (`(status)`, `(maturity)`). - `docs/status/ROADMAP.adoc` — milestones, evidence and the declared CRG grade. -- `docs/governance/CRG-LIFT-PLAN-{{CURRENT_DATE}}.adoc` — companion plan for +- `docs/governance/CRG-LIFT-PLAN-2026-10-27.adoc` — companion plan for D→C (and medium-term C→B) lifts. - `docs/governance/CRG-CRITERIA.adoc` — boilerplate criteria doc. diff --git a/docs/governance/TEMPLATE-LINEAGE-AUDIT.adoc b/docs/governance/TEMPLATE-LINEAGE-AUDIT.adoc index 9c64d8a..7476242 100644 --- a/docs/governance/TEMPLATE-LINEAGE-AUDIT.adoc +++ b/docs/governance/TEMPLATE-LINEAGE-AUDIT.adoc @@ -51,7 +51,7 @@ ____ NOTE: 2026-10-02 — rsr-template-repo#209 retired STATE/META/ECOSYSTEM/AGENTIC/CLADE.a2ml; their content is now in the repo deed (`*_chora.deed`) and `docs/status/ROADMAP.adoc`. -The ordinary mint path rewrites `{{OWNER}}`, `{{REPO}}` and friends. Those +The ordinary mint path rewrites `hyperpolymath`, `heron-polyfeed-reader` and friends. Those tokens *are* consumed widely — 56 files in this repository, including the README badge block, `CITATION.cff`, `.github/CODEOWNERS`, `.github/settings.yml` and the three AI-assistant rule files. The problem is @@ -195,7 +195,7 @@ both files. NOTE: 2026-10-02 — in this template, rsr-template-repo#209 retired CLADE.a2ml; the canonical name now lives in the repo deed header (`:canonical-name`). *Severity is lower than it appears.* Across every `Justfile` and `*.just` in -that repository there are *zero* `{{OWNER}}` or `{{REPO}}` interpolations, so +that repository there are *zero* `hyperpolymath` or `heron-polyfeed-reader` interpolations, so neither variable is consumed by any recipe. This is a wrong self-description with no runtime effect — worth fixing because a template propagates its own mistakes, not because anything currently misbehaves. diff --git a/docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc b/docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc index 57348e6..e0b9ab6 100644 --- a/docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc +++ b/docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc @@ -30,7 +30,7 @@ Inventory snapshot at audit time: | Placeholder badge tokens in README | `README.adoc` -| `{{OPENSSF_PROJECT_ID}}`, `{{OWNER}}`, `{{REPO}}` +| OpenSSF Best Practices project id (not yet registered), `hyperpolymath`, `heron-polyfeed-reader` | placeholder | Template placeholders are intentional pre-bootstrap content. | Keep; document clearly as template tokens. diff --git a/docs/onboarding/QUICKSTART-DEV.adoc b/docs/onboarding/QUICKSTART-DEV.adoc index d2c4a5e..3f6798a 100644 --- a/docs/onboarding/QUICKSTART-DEV.adoc +++ b/docs/onboarding/QUICKSTART-DEV.adoc @@ -1,14 +1,14 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell // Template: QUICKSTART-DEV.adoc — clone → build → test → PR -// Replace rsr-template-repo, {{BUILD_CMD}}, {{TEST_CMD}}, {{LANG_STACK}} with actuals -= rsr-template-repo — Quick Start for Developers +// Replace heron-polyfeed-reader, UNASSIGNED, UNASSIGNED, UNASSIGNED with actuals += heron-polyfeed-reader — Quick Start for Developers :toc: :toclevels: 2 == Tech Stack -{{LANG_STACK}} +UNASSIGNED == Set Up Development Environment @@ -23,8 +23,8 @@ guix shell [source,bash] ---- -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader just setup-dev ---- @@ -32,21 +32,21 @@ just setup-dev [source,bash] ---- -{{BUILD_CMD}} +UNASSIGNED ---- == Test [source,bash] ---- -{{TEST_CMD}} +UNASSIGNED ---- == Project Structure [source] ---- -rsr-template-repo/ +heron-polyfeed-reader/ ├── src/ # Source code ├── src/interface/Abi/ # Idris2 ABI definitions (if applicable) ├── ffi/zig/ # Zig FFI bridge (if applicable) @@ -84,7 +84,7 @@ just panic-scan # No new security issues Read `.machine_readable/MUST.contractile` before making changes. Key invariants that must never be violated: -{{MUST_INVARIANTS}} +UNASSIGNED == LLM/AI Agent Development @@ -100,5 +100,5 @@ Or read the repo deed (`*_chora.deed` at root) and `.claude/CLAUDE.md` directly. == Get Help * **Architecture**: link:EXPLAINME.adoc[EXPLAINME.adoc] -* **Wiki**: https://github.com/hyperpolymath/rsr-template-repo/wiki +* **Wiki**: https://github.com/hyperpolymath/heron-polyfeed-reader/wiki * **Report issue**: `just help-me` diff --git a/docs/onboarding/QUICKSTART-MAINTAINER.adoc b/docs/onboarding/QUICKSTART-MAINTAINER.adoc index aaaa348..f1924b9 100644 --- a/docs/onboarding/QUICKSTART-MAINTAINER.adoc +++ b/docs/onboarding/QUICKSTART-MAINTAINER.adoc @@ -1,30 +1,30 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell // Template: QUICKSTART-MAINTAINER.adoc — packaging, deploying, and maintaining -// Replace rsr-template-repo, {{PACKAGE_NAME}}, {{DEPS}} with actuals -= rsr-template-repo — Quick Start for Platform Maintainers +// Replace heron-polyfeed-reader, UNASSIGNED, UNASSIGNED with actuals += heron-polyfeed-reader — Quick Start for Platform Maintainers :toc: :toclevels: 2 == Overview -This guide covers packaging, deploying, and maintaining rsr-template-repo for +This guide covers packaging, deploying, and maintaining heron-polyfeed-reader for distribution on your platform. == Runtime Dependencies -{{DEPS}} +UNASSIGNED == Build from Source [source,bash] ---- -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader just build-release ---- -Output: `{{BUILD_OUTPUT_PATH}}` +Output: `UNASSIGNED` == Packaging @@ -40,7 +40,7 @@ guix build -f build/guix.scm [source,bash] ---- just stapeln-export # Generates Containerfile -podman build -t rsr-template-repo . +podman build -t heron-polyfeed-reader . ---- === Manual Package @@ -59,10 +59,10 @@ Files installed: | `$PREFIX/bin/` | Executables -| `$PREFIX/share/{{PACKAGE_NAME}}/` +| `$PREFIX/share/UNASSIGNED/` | Data files, assets -| `$PREFIX/share/doc/{{PACKAGE_NAME}}/` +| `$PREFIX/share/doc/UNASSIGNED/` | Documentation | `$PREFIX/share/applications/` @@ -74,9 +74,9 @@ Files installed: == Configuration -Default config location: `$XDG_CONFIG_HOME/{{PACKAGE_NAME}}/config.toml` +Default config location: `$XDG_CONFIG_HOME/UNASSIGNED/config.toml` -Fallback: `$HOME/.config/{{PACKAGE_NAME}}/config.toml` +Fallback: `$HOME/.config/UNASSIGNED/config.toml` == Health Checks @@ -96,7 +96,7 @@ just build-release just install --prefix=/usr/local ---- -Or via OPSM: `opsm update {{PACKAGE_NAME}}` +Or via OPSM: `opsm update UNASSIGNED` == Security Notes @@ -111,13 +111,13 @@ For deploying multiple instances (e.g., different users or tenants): [source,bash] ---- -just install --prefix=/opt/{{PACKAGE_NAME}}-instance1 --config=/etc/{{PACKAGE_NAME}}/instance1.toml -just install --prefix=/opt/{{PACKAGE_NAME}}-instance2 --config=/etc/{{PACKAGE_NAME}}/instance2.toml +just install --prefix=/opt/UNASSIGNED-instance1 --config=/etc/UNASSIGNED/instance1.toml +just install --prefix=/opt/UNASSIGNED-instance2 --config=/etc/UNASSIGNED/instance2.toml ---- Each instance has isolated config, data, and logs. == Reporting Issues -* Upstream: https://github.com/hyperpolymath/rsr-template-repo/issues +* Upstream: https://github.com/hyperpolymath/heron-polyfeed-reader/issues * With diagnostic: `just help-me` (pre-fills context) diff --git a/docs/onboarding/QUICKSTART-USER.adoc b/docs/onboarding/QUICKSTART-USER.adoc index 0893e63..6f6beb2 100644 --- a/docs/onboarding/QUICKSTART-USER.adoc +++ b/docs/onboarding/QUICKSTART-USER.adoc @@ -1,12 +1,12 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell // Template: QUICKSTART-USER.adoc — 5-minute path to working software -// Replace rsr-template-repo, Rsr Template Repo — See README.adoc for details., just run, Rsr Template Repo started successfully. with actuals -= rsr-template-repo — Quick Start for Users +// Replace heron-polyfeed-reader, Rsr Template Repo — See README.adoc for details., just run, Rsr Template Repo started successfully. with actuals += heron-polyfeed-reader — Quick Start for Users :toc: :toclevels: 2 -== What is rsr-template-repo? +== What is heron-polyfeed-reader? Rsr Template Repo — See README.adoc for details. @@ -38,8 +38,8 @@ Before you begin, ensure you have: [source,bash] ---- # Clone and set up -git clone https://github.com/hyperpolymath/rsr-template-repo.git -cd rsr-template-repo +git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git +cd heron-polyfeed-reader just setup ---- @@ -62,7 +62,7 @@ just stapeln-run [source,bash] ---- -just install --portable --prefix=./rsr-template-repo-portable +just install --portable --prefix=./heron-polyfeed-reader-portable ---- == First Run @@ -103,7 +103,7 @@ just heal * **In-app**: `just run --help` * **Guided tour**: `just tour` * **Report a problem**: `just help-me` (pre-fills diagnostic context) -* **Wiki**: https://github.com/hyperpolymath/rsr-template-repo/wiki +* **Wiki**: https://github.com/hyperpolymath/heron-polyfeed-reader/wiki == Uninstall diff --git a/docs/onboarding/llm-warmup-dev.adoc b/docs/onboarding/llm-warmup-dev.adoc index 9078f77..df1069b 100644 --- a/docs/onboarding/llm-warmup-dev.adoc +++ b/docs/onboarding/llm-warmup-dev.adoc @@ -1,12 +1,12 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -= LLM warm-up — rsr-template-repo (developer) += LLM warm-up — heron-polyfeed-reader (developer) -Paste this into an AI assistant before changing code in rsr-template-repo. +Paste this into an AI assistant before changing code in heron-polyfeed-reader. == Orientation -rsr-template-repo (https://github.com/hyperpolymath/rsr-template-repo): a library in idris2, zig. +heron-polyfeed-reader (https://github.com/hyperpolymath/heron-polyfeed-reader): a library in idris2, zig. The template's own code is the ABI/FFI seam that every minted repo inherits: diff --git a/docs/onboarding/llm-warmup-maintainer.adoc b/docs/onboarding/llm-warmup-maintainer.adoc index 0ebbfac..1143312 100644 --- a/docs/onboarding/llm-warmup-maintainer.adoc +++ b/docs/onboarding/llm-warmup-maintainer.adoc @@ -1,9 +1,9 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -= LLM warm-up — rsr-template-repo (maintainer) += LLM warm-up — heron-polyfeed-reader (maintainer) Paste this into an AI assistant before release, CI or governance work on -rsr-template-repo. Read `llm-warmup-dev.adoc` first; this adds what a maintainer owns. +heron-polyfeed-reader. Read `llm-warmup-dev.adoc` first; this adds what a maintainer owns. == The provisioning set diff --git a/docs/onboarding/llm-warmup-user.adoc b/docs/onboarding/llm-warmup-user.adoc index 8da8ba4..f3b58c9 100644 --- a/docs/onboarding/llm-warmup-user.adoc +++ b/docs/onboarding/llm-warmup-user.adoc @@ -1,19 +1,19 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -= LLM warm-up — rsr-template-repo (user) += LLM warm-up — heron-polyfeed-reader (user) Paste this whole file into any AI assistant before asking it about -rsr-template-repo. It tells the assistant what the project is, how a person gets it +heron-polyfeed-reader. It tells the assistant what the project is, how a person gets it running, and where to look when something goes wrong. == What it is -rsr-template-repo (https://github.com/hyperpolymath/rsr-template-repo) is a library written in idris2, zig. +heron-polyfeed-reader (https://github.com/hyperpolymath/heron-polyfeed-reader) is a library written in idris2, zig. It is not a project in its own right: it is the template every new hyperpolymath repository is created from. It ships CI/CD, machine-readable metadata (`.machine_readable/descriptiles/`), an AI gatekeeper deed -(`rsr-template-repo_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and +(`heron-polyfeed-reader_chora.deed`), a typed ABI/FFI seam (Idris2 over Zig), Guix and mise toolchains, and governance files, all passing the RSR validators before you add a line of your own. @@ -22,7 +22,7 @@ add a line of your own. The supported routes, in order of ease: . *Ask an AI*: follow `docs/AI_INSTALLATION_GUIDE.adoc` in the repository. -. *The launcher*: `git clone https://github.com/hyperpolymath/rsr-template-repo.git && cd rsr-template-repo && ./launcher.sh --setup` +. *The launcher*: `git clone https://github.com/hyperpolymath/heron-polyfeed-reader.git && cd heron-polyfeed-reader && ./launcher.sh --setup` . *By hand*: `docs/SETUP.adoc`, every step explained. Prerequisites are git, a C toolchain and mise; everything else is installed by diff --git a/docs/practice/AI-CONVENTIONS.adoc b/docs/practice/AI-CONVENTIONS.adoc index a2e60d7..d087446 100644 --- a/docs/practice/AI-CONVENTIONS.adoc +++ b/docs/practice/AI-CONVENTIONS.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell = AI Conventions // SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) == AI Conventions (Authoritative Source) @@ -38,9 +38,9 @@ Per estate policy (hyperpolymath/standards `LICENCE-POLICY.adoc` Rule 1 + Addend === Author Attribution -- Name: **{{AUTHOR}}** -- Email: **{{AUTHOR_EMAIL}}** -- Copyright: `Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}>` +- Name: **Jonathan D.A. Jewell** +- Email: **j.d.a.jewell@open.ac.uk** +- Copyright: `Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ` === State Files diff --git a/docs/practice/STATE-VISUALIZER-GUIDE.adoc b/docs/practice/STATE-VISUALIZER-GUIDE.adoc index dc9a561..329082d 100644 --- a/docs/practice/STATE-VISUALIZER-GUIDE.adoc +++ b/docs/practice/STATE-VISUALIZER-GUIDE.adoc @@ -1,7 +1,7 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell = TOPOLOGY.md — Generation Guide -{{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Jonathan D.A. Jewell (hyperpolymath) :toc: :sectnums: @@ -62,7 +62,7 @@ The file must contain exactly three sections: Add a header comment with SPDX-License-Identifier and Last updated date. End with an "Update Protocol" section explaining how to maintain the file. -Use the template at TOPOLOGY.md in rsr-template-repo as a structural reference, +Use the template at TOPOLOGY.md in heron-polyfeed-reader as a structural reference, but make the content completely specific to THIS project. ---- @@ -70,7 +70,7 @@ but make the content completely specific to THIS project. [source,bash] ---- -cp /path/to/rsr-template-repo/TOPOLOGY.md ./TOPOLOGY.md +cp /path/to/heron-polyfeed-reader/TOPOLOGY.md ./TOPOLOGY.md # Then edit: replace placeholders, draw the real architecture, fill the dashboard ---- @@ -154,4 +154,4 @@ _structured data_ for tooling. Both should agree. == Copyright -Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) diff --git a/docs/practice/ci-cost-reduction.adoc b/docs/practice/ci-cost-reduction.adoc index 705c637..ec9024f 100644 --- a/docs/practice/ci-cost-reduction.adoc +++ b/docs/practice/ci-cost-reduction.adoc @@ -214,8 +214,8 @@ Recommendations: === 7. Reusable workflow (longer-term) Ship the above patterns in a single reusable workflow under -`rsr-template-repo/.github/workflows/rsr-ci-defaults.yml`, and adopt -it estate-wide via `uses: hyperpolymath/rsr-template-repo/.github/workflows/rsr-ci-defaults.yml@main` +`heron-polyfeed-reader/.github/workflows/rsr-ci-defaults.yml`, and adopt +it estate-wide via `uses: hyperpolymath/heron-polyfeed-reader/.github/workflows/rsr-ci-defaults.yml@main` in downstream repos. Single PR propagates improvements. === 8. Self-hosted runner for heavy work (long-term) @@ -274,5 +274,5 @@ frequency × job count). incident that motivated this spec. * `007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md` — separate defect stack also addressed in the same session. -* `rsr-template-repo/.github/workflows/dependabot-automerge.yml` — +* `heron-polyfeed-reader/.github/workflows/dependabot-automerge.yml` — canonical pattern for auto-merge pattern referenced above. diff --git a/docs/proposals/root-cleanup.adoc b/docs/proposals/root-cleanup.adoc index 891cb17..3bb10c9 100644 --- a/docs/proposals/root-cleanup.adoc +++ b/docs/proposals/root-cleanup.adoc @@ -165,7 +165,7 @@ across `Justfile`, `scripts/`, `.machine_readable/`). Resolved: the repo deed (`_chora.deed` at the root) is the single AI manifest. The family-7 allocation manifest folded into it (standards#837 pilot), and no separate manifest or pointer file remains: no `.a2ml` file -may exist in the template or its downstream repos (rsr-template-repo#209). +may exist in the template or its downstream repos (heron-polyfeed-reader#209). === Template-only relocations diff --git a/docs/reports/audit/pillar-audit-2026-04-15.adoc b/docs/reports/audit/pillar-audit-2026-04-15.adoc index d94b391..be154a8 100644 --- a/docs/reports/audit/pillar-audit-2026-04-15.adoc +++ b/docs/reports/audit/pillar-audit-2026-04-15.adoc @@ -3,10 +3,10 @@ = Gemini Audit Report (M2: Pillar Repo Audits) :date: 2026-04-15 -NOTE: Superseded in part (2026-10-06, rsr-template-repo#209): the `.a2ml` files named below no longer exist; their facts are clauses of the repo deed (`*_chora.deed`). +NOTE: Superseded in part (2026-10-06, heron-polyfeed-reader#209): the `.a2ml` files named below no longer exist; their facts are clauses of the repo deed (`*_chora.deed`). Date: 2026-04-15 + -Repository: `/var/mnt/eclipse/repos/rsr-template-repo` +Repository: `/var/mnt/eclipse/repos/heron-polyfeed-reader` == Audit Criteria diff --git a/docs/status/PROOF-NEEDS.adoc b/docs/status/PROOF-NEEDS.adoc index aabe3c2..8559a80 100644 --- a/docs/status/PROOF-NEEDS.adoc +++ b/docs/status/PROOF-NEEDS.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell // Template: rsr-template-repo/docs/status/PROOF-NEEDS.adoc // Authoritative master list: ~/Desktop/PROOF-REQUIREMENTS-MASTER.adoc -= Proof Requirements — {{PROJECT}} += Proof Requirements — HERON_POLYFEED_READER == Proof Tier @@ -26,7 +26,7 @@ == Mandatory Proofs (All RSR Repos) -These proofs come from the rsr-template-repo and MUST be present in every repo: +These proofs come from the heron-polyfeed-reader and MUST be present in every repo: === ABI/FFI Boundary Proofs (Idris2) diff --git a/docs/status/PROOF-STATUS.adoc b/docs/status/PROOF-STATUS.adoc index aca702c..fa0feb2 100644 --- a/docs/status/PROOF-STATUS.adoc +++ b/docs/status/PROOF-STATUS.adoc @@ -2,7 +2,7 @@ // Copyright (c) Jonathan D.A. Jewell // Template: rsr-template-repo/docs/status/PROOF-STATUS.adoc // Tracks proof completion. Requirements defined in PROOF-NEEDS.adoc -= Proof Status — {{PROJECT}} += Proof Status — HERON_POLYFEED_READER == Summary diff --git a/docs/status/READINESS.adoc b/docs/status/READINESS.adoc index b34e6a1..d6beb99 100644 --- a/docs/status/READINESS.adoc +++ b/docs/status/READINESS.adoc @@ -1,10 +1,10 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= {{PROJECT_NAME}} Component Readiness Assessment += heron-polyfeed-reader Component Readiness Assessment -*Standard:* link:https://{{FORGE}}/{{OWNER}}/standards/tree/main/component-readiness-grades[Component Readiness Grades (CRG) v2.0 STRICT] + -*Assessed:* {{CURRENT_DATE}} + -*Assessor:* {{AUTHOR}} + +*Standard:* link:https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades[Component Readiness Grades (CRG) v2.0 STRICT] + +*Assessed:* 2026-10-27 + +*Assessor:* Jonathan D.A. Jewell + *Previous assessment:* __ *Current Grade:* X @@ -59,7 +59,7 @@ Below B, any publication must be explicitly abstract or provisional. ''' -== 2. Headline Evidence (as of {{CURRENT_DATE}}) +== 2. Headline Evidence (as of 2026-10-27) [cols="2,3,3", options="header"] |=== @@ -67,7 +67,7 @@ Below B, any publication must be explicitly abstract or provisional. | Test count | __ | `docs/status/ROADMAP.adoc` or CI | Formal-verification posture | __ | grep of proof dirs -| Dangerous patterns (`assert_total`, `unsafeCoerce`, `Obj.magic`) | __ | grep {{CURRENT_DATE}} +| Dangerous patterns (`assert_total`, `unsafeCoerce`, `Obj.magic`) | __ | grep 2026-10-27 | Per-unit README coverage | __ | filesystem scan of unit dirs | CI status | __ | `.github/workflows/` | RSR mandatory workflows | __ | `.github/workflows/` @@ -87,9 +87,9 @@ unless noted. |=== | Component | Grade | Posture | Evidence Summary | Promotion blocker | Last Assessed -| __ | X/F/E/D/C/B/A | __ | __ | __ | {{CURRENT_DATE}} -| __ | … | … | … | … | {{CURRENT_DATE}} -| __ | … | … | … | … | {{CURRENT_DATE}} +| __ | X/F/E/D/C/B/A | __ | __ | __ | 2026-10-27 +| __ | … | … | … | … | 2026-10-27 +| __ | … | … | … | … | 2026-10-27 |=== *Rules of thumb for populating this table:* @@ -164,7 +164,7 @@ Real-world external feedback confirming value. Populate ''' -== 7. Summary ({{CURRENT_DATE}}) +== 7. Summary (2026-10-27) * Project grade: __. Why: _<1-2 line justification>_. * Delta since last assessment: __. @@ -177,7 +177,7 @@ Real-world external feedback confirming value. Populate * `docs/governance/CRG-CRITERIA.adoc` — grade definitions (boilerplate). * `docs/governance/CRG-AUDIT-.adoc` — formal audit (populate from - `rsr-template-repo/docs/governance/CRG-AUDIT-TEMPLATE.adoc`). + `heron-polyfeed-reader/docs/governance/CRG-AUDIT-TEMPLATE.adoc`). * `docs/practice/DOGFOOD-LOG.adoc` — dated dogfood evidence (required for C). * `docs/status/ROADMAP.adoc` — evidence journal (sections `== Dogfooding status`, `== External targets`, `== Issues fed back`, diff --git a/docs/status/ROADMAP.adoc b/docs/status/ROADMAP.adoc index 8597095..d7a5330 100644 --- a/docs/status/ROADMAP.adoc +++ b/docs/status/ROADMAP.adoc @@ -1,20 +1,28 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= rsr-template-repo Roadmap += heron-polyfeed-reader Roadmap -The journal of the template itself: milestones, blockers and next actions. -A repo minted from the template does not inherit it: `just repo-init` -replaces this file with `build/templates/ROADMAP.adoc.in`. - -Phase and maturity are not recorded here. They live in the repo deed, -`rsr-template-repo_chora.deed`, in the `(status …)` and `(maturity …)` +The STATE journal of heron-polyfeed-reader: milestones, blockers and next +actions. Phase and maturity are not recorded here; they live in the repo deed, +`heron-polyfeed-reader_chora.deed`, in the `(status …)` and `(maturity …)` clauses (`just state-phase` prints both). -This file took over the journal half of the retired STATE record -(rsr-template-repo#209), last -updated there on 2026-09-19. STATE also recorded version 0.3.0; the release -version is 0.1.0 (`Justfile`, `CITATION.cff`, tag `v0.1.0`, deed -`(meta :version …)`). +This file absorbs the handoff note (`HANDOFF.md`, written 2026-10-19) that +travelled with the M0–M6 import on 2026-10-27. That note is retired here; its +build and test instructions moved to `README.adoc` and `CONTRIBUTING.adoc`. + +== Current status + +Milestones M0–M6 are complete and imported with their signed commit history. +Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host): + +* `zig build test --summary all`: 85/85 tests pass. +* `zig build -Doptimize=ReleaseSafe` and the static musl build both succeed. +* `zig fmt --check` was NOT clean on seven files (Zig 0.17 renames + `@intFromEnum` to `@backingInt` and re-wraps two literals); the formatter was + applied at import so that `just lint` passes. +* Idle RSS ~8 MB (ReleaseSafe, 24 feeds + 6 monitors + web UI) was measured + before import over one ~45 s window; not re-measured since. == Milestones @@ -22,48 +30,88 @@ version is 0.1.0 (`Justfile`, `CITATION.cff`, tag `v0.1.0`, deed |=== | Milestone | Done -| Phase 0: Core scaffolding (justfile, CI/CD, machine-readable) | 100% -| Phase 1: ABI/FFI standard (Idris2/Zig templates) | 100% -| Phase 1b: AI Gatekeeper Protocol (AI manifest, now the repo deed) | 100% -| Phase 1c: TOPOLOGY.md standard and guide | 100% -| Phase 1d: Maintenance gate (axes, checklist, approach) | 100% -| Phase 1e: Trustfile / contractiles | 100% -| Phase 2: Container ecosystem templates (stapeln) | 100% -| Phase 2b: www/ site-operations bundle + .well-known migration (#53) | 100% -| Phase 2c: Forge & Sustain mechanics — provenance at mint, self-name passes, drift gate, update mechanism (ADR-0003, ratified 2026-09-19) | 100% -| Phase 2d: julia-library archetype overlay + e2e mint test (recon P1.2) | 100% -| Phase 3: Multi-forge sync hardening | 0% -| Phase 4: Guix reproducible shells | 50% -| Phase 4b: RSR oracle dogfood gate in CI (recon P0.2; hypatia M-3 SSOT-path fix is the precondition) | 0% +| M0: scaffold, vendored SQLite (FTS5), hand-written FFI, Containerfile | 100% +| M1: item model, adapter interface, XML/encoding/date parsers, feed adapter, fixtures | 100% +| M2: scheduler (fake clock): intervals, jitter, backoff, Retry-After, no busy loop | 100% +| M3: NDJSON log + SQLite state + FTS5 trigram index rebuilt from log, crash recovery | 100% +| M4: HTTP transport (timeouts, manual redirects), config, engine, daemon, CLI | 100% +| M5: monitor adapter (up/down transitions, change detection, TLS expiry, robots) | 100% +| M6: localhost-only web UI (list/search/item/read marks/status), daemon integration, e2e test | 100% +| M7: integrate into the RSR estate (template merge, mint, CI) | 90% +| M8: second adapter (NNTP or Gemini) to prove the adapter seam | 0% +| M9: live-site soak test and real-CA certificate-probe check | 0% +| M10: web UI `monitors` summary and search pagination | 0% +| M11: estate-integration pass (see Next actions) | 0% |=== == Blockers -. Hypatia M-3: `mix hypatia.rsr_score --standards` resolves the SSOT at the - pre-reorg path (`rhodium-standard-repositories/spec/`). A one-line fix is - prepared (recon P0.2/01), pending its PR. The dogfood job works around it - with an explicit `--ssot` until then. -. Estate-wide `actions.lock` sweep (D38/D39 ruling 2026-09-19): the grace - window closed on 2026-10-01. The sweep's estate-wide status is not recorded - here. This repo's own lockfile verifies (`gh actions-lock --no-fix`, rc=0, - 2026-10-02). +. *Clade is UNASSIGNED* in the deed. Choosing it is the owner's act (the + template says so); `just validate-rsr` fails until it is chosen. The + ecosystem `:position-type` is UNASSIGNED for the same reason. +. *Signing.* The M7 integration commits were prepared in a session with no + signing key. They must be committed with `id_ed25519_signing` locally + before push; the eleven imported milestone commits keep their own + signatures. +. *Idris2 ABI seam.* `just test` and `just build` run every detected + language. The template's `src/interface/abi.ipkg` (Idris2) is detected + alongside `build.zig`; without `idris2` on PATH the Zig steps pass but the + recipe exits non-zero. Decide whether the seam stays (then provision + `idris2` via pack, as the template expects) or whether this project opts + out (then `validate-rsr`, which hard-requires `src/interface/{abi,ffi,generated}`, + needs a ruling). +. *mise.lock was edited by hand* for the Zig 0.17.0 pin (no `mise` in the + import session). Checksums are the official ones from + `ziglang.org/download/index.json`; re-lock with `just toolchain-refresh` to + confirm. == Next actions -* Wire the RSR oracle dogfood job in dogfood-gate.yml (recon P0.2) once the hypatia M-3 fix lands -* Record where the estate-wide actions.lock sweep stands now that the 2026-10-01 window has closed (gatekeeper repos first) -* Rebase rsr-julia-library-template-repo onto this spine (recon P1.3 procedure; ADR-0003 now accepted) -* Validate container templates across wolfi-base and static Chainguard images -* Harden multi-forge sync for GitLab/Bitbucket mirroring edge cases -* Expand Guix development shell templates +* Push the branch, let `zig-ci.yml` and the estate gates run, triage every + review-app finding and CI anomaly, merge. +* Owner: choose the clade (`ap` Applications is the obvious candidate: an + end-user desktop daemon), set `:position-type`, register in gv-clade-index. +* M8: second adapter (NNTP/NNTPS or Gemini with TOFU) with no core changes; + that proves or disproves the adapter seam. Then ActivityPub / AT Protocol. +* M9: soak against live public feeds; probe a CA-signed, SNI-routed and a + TLS 1.2-only host (only a self-signed certificate has been tested). +* M10: `monitors`-style summary on the web UI index; search pagination. +* M11, deliberately, not by accident (carried from the handoff): Idris2 for + APIs (none beyond the local web UI yet), the unified-hexadeca-api / estate + Zig API conventions, DoQ/DoH + DNSSEC resolution for fetches (`zig-dnssec`, + `zig-boringtls`), SDP/firewall gating, VeriSim/LMDB storage choices, the + type work (echo/tropical/choreographic) and januskey/mma-framework for + security. +* Re-sync `src/vendor/tls_client.zig` whenever Zig is upgraded + (`src/vendor/NOTICE`); the `[sfr patch]` markers carry the old project + name and can be renamed then. +* Re-count the container `test` stage after the rename (host run was 85/85; + `zig-ci.yml` now builds that stage on every push). + +== Known gaps / untested + +* Never run against live public sites; every HTTP test uses a local fixture + server. +* Certificate probe verified only against a self-signed certificate. +* Web UI exercised via raw HTTP, not a browser; no search pagination. +* x86_64 Linux only; no soak test; RSS measured once. +* The adapter interface is meant to take NNTP, Gopher, Gemini, ActivityPub + and AT Protocol without core changes; unproven until M8. + +== Design decisions made without owner input (revisit freely) + +* JSON config, no YAML; web UI off unless configured; default bind + `127.0.0.1:8484`; a non-loopback bind needs `web.allow_non_loopback`. +* Web UI: Host-header allowlist (DNS rebinding), Origin check on POST, CSP, + no JavaScript, http(s)-only links, its own SQLite connection. +* Monitor alerts are stored as items of kind `alert`; the first success is a + silent baseline; DOWN after two failures; change alerts rate-limited to + one per hour. +* WebSub deliberately not implemented. +* Binary `heron`, environment `HERON_CONFIG` / `HERON_DATA`, lock file + `heron.lock`. == Maintenance runs No maintenance run has been recorded. Reports go to `docs/reports/maintenance/latest.json`. - -== Ecosystem - -Part of the RSR Framework and the stapeln ecosystem. The repos it depends on -(stapeln, selur-compose, cerro-torre, svalinn, vordr, k9-svc) are the -`(related …)` entries of the deed's `(ecosystem …)` clause. diff --git a/docs/status/TEST-NEEDS.adoc b/docs/status/TEST-NEEDS.adoc index c6d2e51..3f71ef1 100644 --- a/docs/status/TEST-NEEDS.adoc +++ b/docs/status/TEST-NEEDS.adoc @@ -1,6 +1,6 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell -= TEST-NEEDS: rsr-template-repo += TEST-NEEDS: heron-polyfeed-reader == CRG Grade: C — ACHIEVED 2026-04-04 @@ -65,7 +65,7 @@ output only. === Zig FFI Tests ✅ * [x] `src/interface/ffi/test/integration_test.zig` — template with examples -** Converted from `{{project}}` placeholders to "template" namespace +** Converted from `heron_polyfeed_reader` placeholders to "template" namespace ** Added comprehensive comments for how to instantiate ** Tests grouped by category (lifecycle, operations, strings, errors, version, memory safety, threading) ** Compiles and passes placeholder test diff --git a/docs/troubleshooting.adoc b/docs/troubleshooting.adoc new file mode 100644 index 0000000..9acd9d9 --- /dev/null +++ b/docs/troubleshooting.adoc @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Troubleshooting — heron-polyfeed-reader +:revdate: 2026-10-27 + +This file is a *living FAQ* — known failure modes and recovery paths. +Add a new entry every time you debug something that took more than 15 +minutes. + +== Known failure modes + +=== `zig build` fails with errors inside `std` (`std.Io`, `std.http`, TLS) + +**Symptom**: compile errors in standard-library code, or `@cImport` reported +as unknown. + +**Cause**: wrong Zig. The project needs 0.17.0 exactly; 0.15/0.16 changed +these APIs and `@cImport` no longer exists in 0.17. + +**Fix**: +[source,bash] +---- +zig version # must print 0.17.0 +mise install # installs the pinned version from mise.toml +---- + +**Avoidance**: let mise manage the toolchain; the Containerfile pins the same +version by SHA-256. + +--- + +=== `zig fmt --check` lists files after a Zig upgrade + +**Symptom**: `just lint` fails naming source files that were clean before. + +**Cause**: a new Zig renames builtins or re-wraps literals (0.17 renamed +`@intFromEnum` to `@backingInt`); the formatter applies the migration. + +**Fix**: `zig fmt build.zig src`, read the diff, commit it on its own. + +--- + +=== The web UI is unreachable from the host when running in a container + +**Symptom**: `curl http://127.0.0.1:8484/` on the host gets connection refused. + +**Cause**: the daemon binds `127.0.0.1` *inside* the container. + +**Fix**: in the config, `"web": {"bind": "0.0.0.0", "allow_non_loopback": true}`, +and publish the port loopback-only on the host: `-p 127.0.0.1:8484:8484`. + +**Avoidance**: never publish the UI on a non-loopback host address; it has no +authentication. + +--- + +=== `daemon` exits immediately: lock file held + +**Symptom**: the daemon refuses to start and names `heron.lock`. + +**Cause**: another daemon is running on the same data directory, or one died +without releasing the lock. + +**Fix**: stop the other instance; if none is running, remove `heron.lock` +from the data directory and start again. + +--- + +=== Items missing after an unclean shutdown + +**Symptom**: `list` shows fewer items than the log holds. + +**Cause**: the index fell behind the log. + +**Fix**: `heron rebuild` rebuilds the SQLite index and FTS5 tables from the +NDJSON log. The daemon also replays records past `meta.indexed_offset` on +start, so this is rarely needed by hand. + +--- + +=== `search` returns nothing for a short query + +**Symptom**: `heron search ab` finds nothing although items contain "ab". + +**Cause**: the trigram index needs at least three characters. + +**Fix**: use three or more characters. + +== Diagnostic toolkit + +[source,bash] +---- +heron check-config # validate the config file +heron sources # per-source status: last check, failures, next poll +heron monitors # per-monitor status: up/down, HTTP status, certificate expiry +zig build test --summary all # the unit suite +just doctor # estate self-checks +---- + +== When to escalate + +. File an issue with: version, OS, exact command, full error output, + and the symptom in plain English. +. Link the relevant ADR(s) and any related issues. +. For *security*-relevant findings, see + link:../.github/SECURITY.md[SECURITY.md] — do **not** file public issues. + +== Where to look for more help + +* `docs/architecture.adoc` — internals. +* `docs/decisions/` — historical record of why things are this shape. +* `https://github.com/hyperpolymath/heron-polyfeed-reader/issues?q=is%3Aissue+` — has anyone hit this before? +* `https://github.com/hyperpolymath/standards/issues` — for estate-wide problems. diff --git a/docs/usage.adoc b/docs/usage.adoc new file mode 100644 index 0000000..91ba585 --- /dev/null +++ b/docs/usage.adoc @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Usage — heron-polyfeed-reader +:revdate: 2026-10-27 + +== Audience + +This document is for people who *run* `heron`. For developers working *on* +it, see link:./contributing.adoc[contributing.adoc]. + +== Quickstart + +[source,bash] +---- +# 1. Build (Zig 0.17.0 exactly) +zig build -Doptimize=ReleaseSafe # -> zig-out/bin/heron + +# 2. Configure +cp examples/heron.json ~/.config/heron.json +$EDITOR ~/.config/heron.json # sources, monitors, web +heron --config ~/.config/heron.json check-config + +# 3. Run +heron --config ~/.config/heron.json --data ~/.local/share/heron daemon +---- + +`HERON_CONFIG` and `HERON_DATA` stand in for `--config` and `--data`. +With `"web": {"enabled": true}` the UI is at `http://127.0.0.1:8484/`. + +== Common use cases + +=== Read feeds from the terminal + +[source,bash] +---- +heron fetch # poll every source once, now +heron list --unread --limit 20 # newest unread items +heron show 123 # print item 123 and mark it read +heron search sqlite # substring search (3+ characters) over titles and text +heron mark-all-read lwn # one source, or all sources with no name +---- + +=== Watch a site + +Add a monitor and run the daemon; the first successful check is a silent +baseline. A DOWN alert is raised after two consecutive failures, an UP alert +when it recovers, a change alert (at most one per hour) when the content hash +changes, and an expiry alert when the leaf certificate is within +`cert_warn_days`. Alerts are items of kind `alert`, so `list` and `search` +show them; `heron monitors` prints the current state per monitor. + +=== Recover after a crash or a lost index + +The NDJSON log in the data directory is the source of truth; the SQLite +index is derived from it. `heron rebuild` rebuilds the index and the FTS5 +search tables from the log. The daemon also replays any log records newer than +`meta.indexed_offset` on start, so an unclean shutdown needs no manual step. + +== Configuration reference + +One JSON file (no YAML). `check-config` rejects unknown keys at every level and values of the wrong JSON type (a quoted number or boolean is an error, not a default). + +[cols="1,1,1,3", options="header"] +|=== +| Field | Type | Default | Meaning + +| `data_dir` | string | `--data` / `HERON_DATA` | Where the log, index and `heron.lock` live. +| `user_agent` | string | built-in | Sent on every request; put a contact address in it. +| `timeout_s` | integer | 30 | Wall-clock timeout per request. +| `web.enabled` | boolean | `false` | Serve the web UI from the daemon. +| `web.bind` | string | `127.0.0.1` | Bind address. Anything but loopback also needs `allow_non_loopback`. Requests must carry a Host header naming `127.0.0.1`, `localhost`, `[::1]` or this bound address (with the port); a wildcard bind (`0.0.0.0`, `::`) is not special-cased, so name the address you expose. +| `web.port` | integer | 8484 | Listening port. +| `web.allow_non_loopback` | boolean | `false` | Required to bind a non-loopback address; the UI has no authentication. +| `sources[].name` | string | required | Short unique name used by the CLI. +| `sources[].type` | string | `feed` | Adapter. Only `feed` (RSS 2.0, Atom, RDF/RSS 1.0) exists today. +| `sources[].url` | string | required | http(s) URL. +| `sources[].interval_s` | integer | per adapter | Poll interval; jitter, backoff and `Retry-After` are applied on top. +| `sources[].options` | object | `{}` | Adapter-specific options (reserved for future adapters). +| `monitors[].name` | string | required | Short unique name. +| `monitors[].url` | string | required | Page to check. +| `monitors[].interval_s` | integer | per adapter | Check interval. +| `monitors[].expect_status` | integer | 200 | HTTP status that counts as UP. +| `monitors[].detect_changes` | boolean | `true` | Fetch the body (GET), hash its visible text and alert on change (rate-limited to one alert per hour). `false` checks reachability with HEAD only. +| `monitors[].check_robots` | boolean | `true` | Fetch and honour `robots.txt` (RFC 9309, cached 24 h): a BLOCKED alert when it starts disallowing the monitored path, and redirects to another origin are refused. Changes to robots.txt that leave the path allowed raise no alert. +| `monitors[].cert_warn_days` | integer | 14 | Alert when the leaf certificate expires within this many days. +|=== + +`examples/heron.json` is a complete, valid example. + +== Stability guarantees + +* **Stable (pre-1.0, best effort)**: the CLI command names above, the JSON + config keys above, the NDJSON log format (it is the source of truth; the + index can always be rebuilt from it). +* **Unstable**: the web UI's URLs and markup; `sources[].options`. +* **Internal**: the SQLite schema. Rebuild it rather than reading it. + +== Limits & known constraints + +* Linux x86_64 only so far; no soak test against live sites has been run. +* `search` needs at least three characters (trigram index) and has no + pagination yet. +* The certificate probe has been verified only against a self-signed + certificate (not CA-signed, SNI-routed or TLS 1.2-only hosts). +* No WebSub; feeds are polled. +* Relative entry links are resolved against the feed URL; `xml:base` is not consulted. +* The web UI serves one request at a time with a 10 s per-connection deadline; it is a personal UI, not a public server. + +== See also + +* link:./architecture.adoc[Architecture] — how it works internally. +* link:./troubleshooting.adoc[Troubleshooting] — when things go wrong. +* link:./status/ROADMAP.adoc[Roadmap] — what is next and what is untested. diff --git a/examples/README.adoc b/examples/README.adoc index f0bf52e..6e9a3fb 100644 --- a/examples/README.adoc +++ b/examples/README.adoc @@ -1,3 +1,8 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell = examples Pillar + +`heron.json` is a complete, valid `heron` configuration: three feed sources, +one monitor with change detection, robots.txt checking and certificate-expiry +warning, and the web UI enabled on `127.0.0.1:8484`. `just test-smoke` +validates it with `heron check-config`; `docs/usage.adoc` documents every key. diff --git a/examples/heron.json b/examples/heron.json new file mode 100644 index 0000000..dccf8bc --- /dev/null +++ b/examples/heron.json @@ -0,0 +1,14 @@ +{ + "user_agent": "heron-polyfeed-reader/0.1 (+mailto:you@example.org)", + "timeout_s": 30, + "web": { "enabled": true, "bind": "127.0.0.1", "port": 8484 }, + "sources": [ + { "name": "lwn", "type": "feed", "url": "https://lwn.net/headlines/rss", "interval_s": 900 }, + { "name": "zig", "type": "feed", "url": "https://ziglang.org/news/index.xml", "interval_s": 3600 }, + { "name": "rust", "type": "feed", "url": "https://blog.rust-lang.org/feed.xml", "interval_s": 3600 } + ], + "monitors": [ + { "name": "example-site", "url": "https://example.org/", "interval_s": 300, + "expect_status": 200, "detect_changes": true, "check_robots": true, "cert_warn_days": 14 } + ] +} diff --git a/rsr-template-repo_chora.deed b/heron-polyfeed-reader_chora.deed similarity index 86% rename from rsr-template-repo_chora.deed rename to heron-polyfeed-reader_chora.deed index ab6d329..23aed20 100644 --- a/rsr-template-repo_chora.deed +++ b/heron-polyfeed-reader_chora.deed @@ -1,6 +1,6 @@ ;; SPDX-License-Identifier: MPL-2.0 ; -; The repo deed of rsr-template-repo: one-deed-per-repo doctrine (standards#837, +; The repo deed of heron-polyfeed-reader: one-deed-per-repo doctrine (standards#837, ; owner ruling 2026-09-19, Option A). Folds the former ply manifest tree ; (82 per-directory manifests) and the family-7 AI allocation manifest ; into deed clauses. @@ -16,9 +16,9 @@ ; field; scripts/deed-clause.sh is the one writer. (repo-deed :schema-version "1.0.0" - :canonical-name "rsr-template-repo" + :canonical-name "heron-polyfeed-reader" :beholding-chora #u5"estate/chora" - :repo-uuid #u5"github.com/hyperpolymath/rsr-template-repo" + :repo-uuid #u5"github.com/hyperpolymath/heron-polyfeed-reader" ; The canon pin: which release of hyperpolymath/standards canon.lock this ; repo implements. It is the ONLY canon pin in this deed (deed_canon ; refuses zero or two); the (rsr-profile) clause below declares @@ -30,45 +30,83 @@ ; equal canon.lock [canon.artifacts]; canon.lock [canon.lockstep].order is ; spine-adopts-then-canon-releases, so this clause moves FIRST. (canon - :version "2.1.2" + :version "2.1.3" :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" - :gates-sha256 "e70efd2f53c9445e30da4baf770366f04a4a84ffd844a01426e587e565b53e6a" + :gates-sha256 "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b" :lockstep-since "2026-09-17" ; A canon change this repo cannot satisfy is a change made in the WRONG ; ORDER, not a rejected change: land the spine's adoption first, then the ; canon change is a one-line bump. :order "spine-adopts-then-canon-releases") - ; >>> repo-identity (repo-init replaces this block at mint) - ; The template's OWN identity. `just repo-init` swaps everything between - ; these two markers for build/templates/identity.deed.in, filled for the - ; new repo. Before that swap existed every minted repo inherited this - ; block's uuid and canonical-name and told arriving agents it WAS the - ; template (corrected in chronicles-of-slavia and scaffoldia 2026-07-16). - ; The uuid is not stored: it is uuid5(URL namespace, the header - ; :repo-uuid name), a5ea1382-a34c-5334-8a46-a2ebe904c810 here. - ; Identity, clade, forges, lineage, status: mapping clade-to-repo-deed. + ; >>> repo-identity + ; Written by `just repo-init` from build/templates/identity.deed.in, in + ; place of the block the template repo ships (template issue #209). It replaces + ; descriptiles/CLADE.a2ml and ECOSYSTEM.a2ml; the mapping is standards + ; 1-formats/deed/mappings/clade-to-repo-deed.adoc and + ; meta-ecosystem-to-repo-deed.adoc. + ; + ; THE UUID IS DERIVED, NOT ALLOCATED, NOT INVENTED. It is not stored: it is + ; uuid5(namespace = URL, name = the header :repo-uuid string) + ; and anyone can recompute it (gv-clade-index: + ; docs/SPEC-clade-verisim-portal.adoc §Identity Model): + ; uuidgen --sha1 --namespace @url --name "github.com/hyperpolymath/heron-polyfeed-reader" + ; The owner is part of the name, so part of the identity: record the owner + ; that is true today, and re-derive if the repo ever moves. + ; + ; CHOOSE A CLADE. A CLOSED TAXONOMY OF 12 CATEGORIES, NOT AN ABBREVIATION + ; SCHEME: the two letters abbreviate the clade's name, never your repo's. + ; fv Formal Verification & Proofs provable correctness, dependent types, theorem proving + ; nl Nextgen Languages compilers, runtimes, language tooling + ; rm Repo Management & Tooling scaffolding, graph analysis, bots, templates + ; gv Governance & Standards licensing, compliance, policy enforcement + ; db Databases database engines, query languages, storage + ; ap Applications end-user apps, web, desktop, services + ; ix Infrastructure & Cloud containers, deployment, sysadmin + ; dx Developer Ecosystem dev tools, package managers, editors, bindings + ; pt Protocols & Interop internet standards, protocol implementations + ; ax AI & Neurosymbolic ML, neural proof synthesis, AI governance + ; gm Games & Interactive game engines, interactive experiences + ; sc Security scanning, vulnerability management, access control + ; paint-type once chose `pt` reading it as "PainT-type" (an image editor is + ; `ap`); gossamer chose `gv` reading it as "Graphical/Visual" (`gv` is + ; GoVernance). Both codes were valid, so every check passed. + ; HOW TO CHOOSE: the primary clade reflects the project's core value + ; proposition, not its implementation. A game written in Idris2 is `gm`. + ; Set BOTH :primary and :primary-name, and make them agree; then set + ; :prefixed-name to "-heron-polyfeed-reader". `just validate-rsr` fails while + ; :primary is UNASSIGNED. That is deliberate: an unchosen clade must never + ; look like a chosen one. + ; + ; REGISTRATION IS NOT DERIVATION. Registering means an entry in + ; gv-clade-index verisim/seed/repos.a2ml. It is outward-facing and it is the + ; OWNER's act; an agent must not do it. (identity :primary-forge github :owner "hyperpolymath" - :prefixed-name "rm-rsr-template-repo") + :prefixed-name "UNASSIGNED-heron-polyfeed-reader") (clade - :primary rm - :primary-name "Repo Management & Tooling" + :primary UNASSIGNED + :primary-name "UNASSIGNED" :secondary () - :assigned "2026-03-16" - :rationale "Repo Management & Tooling (`rm`): the core value proposition is scaffolding repositories — CI/CD, AI manifests, ABI/FFI seams, governance — that other projects are instantiated from. `gv` was previously claimed as a secondary and is dropped: the template SHIPS governance files, but shipping them is not being a governance project. That authority lives in `standards` and `metadatastician-governance`. The inherited [\"gv\"] propagated into every repo created from this template, where it was even less meant.") + :assigned "2026-10-27" + :rationale "") (forges - :github "hyperpolymath/rsr-template-repo" - :gitlab "hyperpolymath/rsr-template-repo" - :bitbucket "hyperpolymath/rsr-template-repo") + :github "hyperpolymath/heron-polyfeed-reader") + ; :type is one of standalone monorepo monorepo-child inflated deflated hub + ; satellite. A hub COORDINATES an ecosystem family; a satellite is + ; coordinated by one and keeps :parent "" (parent names a monorepo parent, + ; never the coordinating hub). (lineage :type standalone :parent "" - :born "2026-03-16" - :previous-names ()) + :born "2026-10-27" + :previous-names () + :instantiated-from "rsr-template-repo") + ; :phase is one of reserved incubating active dormant merged superseded + ; archived extinct. A repo created today has not proven anything yet. (status - :phase active - :since "2026-03-16" + :phase incubating + :since "2026-10-27" :present #t :aliases () :merged-into "" @@ -76,33 +114,23 @@ :successors () :ended "" (history - (entry :phase active :since "2026-03-16" :note "the estate's canonical repository template; in production use"))) - ; Maturity has its own clause (owner decision 2026-10-02; standards - ; 1-formats/deed/vocabulary/maturity.adoc). No :since: the date the - ; template reached production was never recorded. Milestones, next actions - ; and blockers live in docs/status/ROADMAP.adoc. + (entry :phase incubating :since "2026-10-27" :note "created from rsr-template-repo; clade not yet chosen; not yet registered"))) + ; :level is one of experimental alpha beta production lts (standards + ; 1-formats/deed/vocabulary/maturity.adoc). (maturity - :level production) - ; Ecosystem: mapping meta-ecosystem-to-repo-deed. + :level experimental + :since "2026-10-27") + ; Fill in what this repo IS and IS NOT, and the repos it relates to. + ; :relationship is one of standard-source build-tooling validation-tooling + ; signing-tooling verification-tooling minted-from registry. (ecosystem - :project "rsr-template-repo" + :project "heron-polyfeed-reader" :ecosystem "hyperpolymath" - :position-type "repository-template" - :purpose "Canonical RSR-compliant repository template: scaffolding (CI/CD, AI manifests, ABI/FFI standards, container ecosystem, governance) that new hyperpolymath projects are instantiated from." - :not ("a project in its own right" - "Scaffoldia (the full-featured repo designer)" - "standards (the canon source this template operationalises)") - :pipeline-position "foundation" - :chain "standards → rsr-template-repo → (every estate repo)" - :pipeline-notes "rsr-template-repo turns the RSR standard into runnable scaffolding. New repos are created from it via `just repo-init`, which substitutes the {{PLACEHOLDER}} tokens." + :position-type "UNASSIGNED" + :purpose "A small local feed reader and site monitor daemon (RSS/Atom/RDF polling, up/down and content-change and TLS-expiry monitoring, NDJSON log + SQLite FTS5 index, localhost-only web UI) in Zig, one user, one machine." + :not ("a hosted or multi-user service" "a WebSub subscriber" "a browser or page-content extractor" "the RSR template it was minted from") :coordination standards - (related :name "standards" :relationship standard-source :notes "Defines the RSR standard, contractile canon, and policies that this template operationalises.") - (related :name "stapeln" :relationship build-tooling :notes "Layer-based container build system; the template ships stapeln.toml scaffolding.") - (related :name "selur-compose" :relationship build-tooling :notes "Service composition; the template ships selur-compose.toml scaffolding.") - (related :name "k9-svc" :relationship validation-tooling :notes "Runs the self-validating k9.ncl checks (.machine_readable/self-validating/).") - (related :name "cerro-torre" :relationship signing-tooling :notes "Container/image signing provider referenced by the container scaffolding.") - (related :name "svalinn" :relationship verification-tooling :notes "Supply-chain verification referenced by the container scaffolding.") - (related :name "vordr" :relationship verification-tooling :notes "Build/artifact verification referenced by the container scaffolding.")) + (related :name "rsr-template-repo" :relationship minted-from :notes "the template this repo was minted from by just repo-init")) ; <<< repo-identity ; Meta: mapping meta-ecosystem-to-repo-deed. A minted repo keeps this ; clause (self-name pass applied) and edits it as its own. @@ -167,8 +195,8 @@ (methodology :instructions-clause "bot-directives" :default-mode hybrid)) (manifest :version "0.1.0" - :last-updated "{{CURRENT_DATE}}" - :project "[YOUR-REPO-NAME]" + :last-updated "2026-10-27" + :project "heron-polyfeed-reader" :purpose "Canonical RSR (Rhodium Standard Repository) template — governance, CI/CD, machine-readable metadata, ABI/FFI seam and formal-verification scaffolding that hyperpolymath projects are instantiated from." (agent :name CLAUDE :role "proofs, compilers, repo-local implementation, CI/CD, formal verification, Rust/Idris2/Zig") (agent :name CHATGPT :role "prose, papers, publication review, standards docs, outreach drafts, letters") @@ -183,8 +211,8 @@ (ply (directory :ply 1 :path ".github") (directory :id "machine-readable-pillar" :ply 1 :path ".machine_readable" :description "Registry for all machine-readable metadata, policies, and internal\nautomation scripts." - (canonical-locations :ai_configs "ai/" :anchor "rsr-template-repo_chora.deed (anchor)" :compliance "compliance/" :neurosym "rsr-template-repo_chora.deed (neurosym)" :playbook "rsr-template-repo_chora.deed (playbook)" :policies "rsr-template-repo_chora.deed (policies)" :scripts "scripts/") - :invariants ("Repo metadata MUST live in the repo deed (rsr-template-repo_chora.deed) and follow the DEED grammar" "Internal automation MUST live in scripts/ subfolder") + (canonical-locations :ai_configs "ai/" :anchor "heron-polyfeed-reader_chora.deed (anchor)" :compliance "compliance/" :neurosym "heron-polyfeed-reader_chora.deed (neurosym)" :playbook "heron-polyfeed-reader_chora.deed (playbook)" :policies "heron-polyfeed-reader_chora.deed (policies)" :scripts "scripts/") + :invariants ("Repo metadata MUST live in the repo deed (heron-polyfeed-reader_chora.deed) and follow the DEED grammar" "Internal automation MUST live in scripts/ subfolder") (directory :id "ai-registry" :ply 2 :path ".machine_readable/ai" :description "Sub-registry for ai metadata.") (directory :id "configs-registry" :ply 2 :path ".machine_readable/configs" :description "Sub-registry for configs metadata.") (directory :ply 0 :path ".machine_readable/descriptiles" :description "This manifest declares the AI-assistant context for the descriptiles machine-readable metadata directory." @@ -306,7 +334,7 @@ ; and the golden path. The CLAUDE.md arrival pack reads anchor/golden-path and hashes this clause (ANCHOR@…). (anchor :schema "hyperpolymath.anchor/1" - :repo "{{OWNER}}/{{REPO}}" + :repo "hyperpolymath/heron-polyfeed-reader" :authority "upstream-canonical" :purpose ( "Define canonical semantics and policy boundaries for this repository." @@ -315,13 +343,13 @@ ) (metadata :version "1.0.0" - :last-updated "{{CURRENT_DATE}}") + :last-updated "2026-10-27") (identity - :project "{{PROJECT_NAME}}" + :project "heron-polyfeed-reader" ; language | library | service | tool - :kind "{{PROJECT_KIND}}" - :one-sentence "{{PROJECT_PURPOSE}}" - :domain "{{PROJECT_DOMAIN}}") + :kind "UNASSIGNED" + :one-sentence "A small local feed reader and site monitor: polls RSS, Atom and RDF feeds and watches sites for downtime, content change and TLS certificate expiry. Zig, vendored SQLite with FTS5, localhost-only web UI." + :domain "UNASSIGNED") (semantic-authority :policy "canonical" :owns ( @@ -476,7 +504,7 @@ (methodology (metadata :version "1.0.0" - :last-updated "{{CURRENT_DATE}}" + :last-updated "2026-10-27" :spec "https://github.com/hyperpolymath/standards/blob/main/1-formats/a2ml/agentic/docs/ADR-002-methodology-layer.adoc") ; ============================================================================ ; MODE SELECTION @@ -520,7 +548,7 @@ ; What makes this project special. Agents should DEEPEN this, not broaden it. ; Customise this per project — the template default is generic. (unique-strength - :description "{{PROJECT_UNIQUE_STRENGTH}}" + :description "UNASSIGNED" :deepen-not-broaden #t) ; ============================================================================ ; DIVERGENT INVARIANTS @@ -559,14 +587,14 @@ ; These rules detect corrupt/template/stale state files. (state-validation ; A regex, not a literal list. The list form named the exact strings it was - ; meant to catch ("{{PROJECT}}"), so `just repo-init` substituted the detector along + ; meant to catch ("HERON_POLYFEED_READER"), so `just repo-init` substituted the detector along ; with everything else and shipped reject-if-contains = ["MY_PROJECT", ...] — ; a guard against unfilled tokens that had been eaten by the filling. The ; escaped pattern below contains no token for init to match, and covers every ; upper-snake token rather than the three anyone remembered to enumerate. ; Enforced by scripts/check-no-placeholders.sh. :reject-if-matches ("\\{\\{[A-Z][A-Z0-9_]*\\}\\}") - :reject-if-contains ("rsr-template-repo") + :reject-if-contains ("heron-polyfeed-reader") :reject-if-project-name-mismatch #t :staleness-threshold-days 90 :fallback-files ("TODO.md" "TODO.adoc" "ROADMAP.adoc" "README.adoc")))) @@ -578,7 +606,7 @@ (coverage (metadata :version "1.0.0" - :last-updated "{{CURRENT_DATE}}") + :last-updated "2026-10-27") ; ============================================================================ ; COVERAGE STATE ; ============================================================================ @@ -596,7 +624,7 @@ (debt (metadata :version "1.0.0" - :last-updated "{{CURRENT_DATE}}")) + :last-updated "2026-10-27")) ; rra — directives for Robot Repo Automaton (RRA) enforcing the Platonic Ideal root. (rra :target "robot-repo-automaton" @@ -611,7 +639,7 @@ ; The pattern ".*" allows hidden dotfiles implicitly; RRA checks specific ; dotfile rules below. (allow-list - :files ("README.adoc" "CHANGELOG.adoc" "Justfile" "LICENSE" "rsr-template-repo_chora.deed" "CITATION.cff" "CLAUDE.md") + :files ("README.adoc" "CHANGELOG.adoc" "Justfile" "LICENSE" "heron-polyfeed-reader_chora.deed" "CITATION.cff" "CLAUDE.md") :patterns (".*")) ; Relocation rules: move heavy prose out of the root (relocations @@ -709,8 +737,8 @@ ; See: https://github.com/hyperpolymath/standards/tree/main/groove-protocol (groove :version "1.0" - ; Service identity — replace {{REPO}} with your project name - :service "{{REPO}}" + ; Service identity — replace heron-polyfeed-reader with your project name + :service "heron-polyfeed-reader" :service-version "0.1.0" ; Primary port — MUST be unique across the ecosystem. Check PORT-REGISTRY.md ; in the standards repo before assigning: @@ -739,7 +767,7 @@ (maintenance-axes (metadata :version "1.0.0" - :last-updated "{{CURRENT_DATE}}" + :last-updated "2026-10-27" :scope "repo") (discovery :human-entrypoints ( @@ -1014,69 +1042,62 @@ "Axis 2 type governs commit categorisation" "Axis 3 focus governs audit depth"))) ; rsr-profile — this repo's declared RSR v2.0 capabilities. - ; It carries no canon pin: the (canon) clause above is the pin, exactly once. - ; - ; It declares which capabilities this repo has, - ; per 0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc; the RSR v2.0 conformance oracle - ; (hypatia Hypatia.Rules.RsrConformance) uses `capabilities` to compute the - ; APPLICABLE criteria set — a gated criterion is scored only when its gating - ; capability appears below; everything else is `na` and excluded from the - ; denominator. - ; - ; rsr-template-repo is deliberately MAXIMAL: it ships scaffolding for FFI/ABI - ; seams, in-tree formal proofs, a container, a reproducible build, benchmarks, - ; a docs site, and the full governance trio — so a repo scaffolded FROM it can - ; adopt any of them. Its declared set is therefore near-total, which is exactly - ; what makes it the dogfood target (the gate requires Gold on the applicable - ; set). Every capability below is grounded in a file/dir that actually exists - ; in this tree; capabilities it does NOT have (rust, affinescript, mobile, - ; web-ui, api-service, published-package) are omitted so the oracle does not - ; score this repo against modules it does not carry. + ; Written by just repo-init on 2026-10-27. The template's maximal + ; profile was replaced at mint: declare only what this tree carries. (rsr-profile :version "1.0.0" :spec "rsr-criteria-v2" :declares-against "2.1.1-draft" - ; Role in the estate pipeline: standards (canon) -> THIS (spine) -> scaffoldia - ; (composer) -> minted repos. A spine may carry capability-gated modules it - ; does not declare, for the repos minted from it; see [carrier] in the canon - ; gates table. A minted repo doing the same is still drift. - :role "spine" - ; Effective capability set (direct declaration; no preset shorthand used). - :capabilities ( - ; src/interface/ffi/build.zig + Zig FFI layer - "zig" - ; src/interface/abi.ipkg + src/interface/Abi/ (dependent-typed ABI) - "idris2" - ; scripts/*.sh automation - "bash" - ; src/ command-line entry scaffolding - "cli" - ; consumed/published as a library surface - "library" - ; C-ABI FFI seam (Zig), src/interface/ffi/ - "ffi" - ; formally specified ABI: src/interface/abi.ipkg + src/interface/Abi/ proofs - "abi" - ; verification/proofs/ (in-tree mechanised proofs) - "formal-proofs" - ; .github/workflows/pages.yml (casket/Pages) - "docs-site" - ; build/container/ + .devcontainer/Containerfile - "container" - ; build/guix.scm - "reproducible-build" - ; benches/ - "benchmarks" - ; full AUDIT/AFFIRMATION/GOVERNANCE/MAINTAINERS trio - "governance-tier" - ) - (notes - ; Capabilities intentionally NOT declared (no corresponding code in tree): - ; rust — no *.rs / Cargo.toml (the FFI/ABI layer is Zig + Idris2) - ; affinescript — no *.affine - ; mobile — no Tauri/Dioxus app - ; web-ui — no browser UI - ; api-service — not a network daemon - ; published-package — a scaffold template, not itself a registry artifact - :omitted-rationale "Declaring a capability this repo lacks would make the oracle score it against a module it does not carry — the over-scaffolding failure v2.0 fixes. See 0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc.")) + ; UNASSIGNED: declare capabilities (or a preset) — see standards + ; .machine_readable/template-capability-gates.toml + :capabilities ()) + ; variant — provenance contract (ADR-0002 shape; ADR-0003 use). + ; Records what this repo was minted from so the drift gate + ; (scripts/check-variant-drift.sh ) and future + ; re-templating tooling have a defined input. Placeholder rendering + ; means many files differ from the parent by design; folding rendered + ; answers into (normalise) is future work (ADR-0003), so the gate is + ; informational for minted repos until then. + (variant + :project "heron-polyfeed-reader" + :schema-version "0.1.0" + :last-updated "2026-10-27" + :parent "hyperpolymath/rsr-template-repo" + ; template tip at mint + :parent-pin "b012ce785437e158e0bd3a385d63f1fde69559d5" + :role "minted-repo" + :stack "unspecified" + :direction "generated-from-core" + (normalise :rules "action-pins self-name") + ; Files a minted repo owns from birth are (paths :diverged …). + (paths + :added () + :removed () + :diverged ("heron-polyfeed-reader_chora.deed" "docs/status/ROADMAP.adoc"))) + ; provenance — what this repo was minted FROM. Written once, at mint, + ; by build/just/repo-init.just. Tools that want "what is this repo" read + ; the rest of the deed; tools that want "where did it come from" read this. + (provenance + :minted-at "2026-10-27" + :template-repo "hyperpolymath/rsr-template-repo" + :template-branch "main" + :template-commit "b012ce785437e158e0bd3a385d63f1fde69559d5" + :template-tree "9e1af892e0058504eb7b9f811e810520a2a1bba4" + ; Contract: branches this repo is EXPECTED to carry beyond the default. + ; Empty means default-only. A mint must never inherit template work + ; branches (coderabbit/, chore/, bot-task or stale branches) — that is + ; how knot-knot received a byte-identical copy of a template work branch + ; with no common ancestor (#203). Enforced by + ; scripts/check-template-conformance.sh. + :extra-branches () + :canon-version "2.1.2" + :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" + :gates-sha256 "e70efd2f53c9445e30da4baf770366f04a4a84ffd844a01426e587e565b53e6a" + :archetype "" + ; Which minting path actually ran (#201/#203 needed this signal). + :minted-by "repo-init" + :mint-mode "interactive" + ; A non-empty :unassigned list means the mint did not fully render; + ; the validation step below already fails on a leftover token. + (substitutions :rendered 34 :unassigned ())) ) diff --git a/launcher.sh b/launcher.sh index 8fb0d7f..dab9a45 100755 --- a/launcher.sh +++ b/launcher.sh @@ -6,12 +6,12 @@ # ;; SPDX-License-Identifier: MPL-2.0 # (praxis-deed # :schema-version "1.0.0" -# :canonical-name "rsr-template-repo-launcher" +# :canonical-name "heron-polyfeed-reader-launcher" # :beholding-chora #u5"estate/chora" # (artefact :type "launcher" :version "0.1.0" # :generator "provision-set") -# (app :name "rsr-template-repo" :display "rsr-template-repo" -# :url "https://github.com/hyperpolymath/rsr-template-repo" :archetype "library") +# (app :name "heron-polyfeed-reader" :display "heron-polyfeed-reader" +# :url "https://github.com/hyperpolymath/heron-polyfeed-reader" :archetype "library") # (compliance :standard-version "0.6.0" # :standards ("launcher-standard.adoc" # "PROVISIONING-STANDARD.adoc")) diff --git a/mise.lock b/mise.lock index 504562b..32f7767 100644 --- a/mise.lock +++ b/mise.lock @@ -125,33 +125,33 @@ url_api = "https://api.github.com/repos/aquasecurity/trivy/releases/assets/51433 provenance = "cosign" [[tools.zig]] -version = "0.16.0" +version = "0.17.0" backend = "core:zig" [tools.zig."platforms.linux-arm64"] -checksum = "sha256:ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17" -url = "https://ziglang.org/download/0.16.0/zig-aarch64-linux-0.16.0.tar.xz" +checksum = "sha256:9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8" +url = "https://ziglang.org/download/0.17.0/zig-aarch64-linux-0.17.0.tar.xz" [tools.zig."platforms.linux-arm64-musl"] -checksum = "sha256:ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17" -url = "https://ziglang.org/download/0.16.0/zig-aarch64-linux-0.16.0.tar.xz" +checksum = "sha256:9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8" +url = "https://ziglang.org/download/0.17.0/zig-aarch64-linux-0.17.0.tar.xz" [tools.zig."platforms.linux-x64"] -checksum = "sha256:70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00" -url = "https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz" +checksum = "sha256:1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026" +url = "https://ziglang.org/download/0.17.0/zig-x86_64-linux-0.17.0.tar.xz" [tools.zig."platforms.linux-x64-musl"] -checksum = "sha256:70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00" -url = "https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz" +checksum = "sha256:1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026" +url = "https://ziglang.org/download/0.17.0/zig-x86_64-linux-0.17.0.tar.xz" [tools.zig."platforms.macos-arm64"] -checksum = "sha256:b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489" -url = "https://ziglang.org/download/0.16.0/zig-aarch64-macos-0.16.0.tar.xz" +checksum = "sha256:b607e9b9234790a008116ae5bdb71c6243b84b9fb42a53a9e70fde41c06c536a" +url = "https://ziglang.org/download/0.17.0/zig-aarch64-macos-0.17.0.tar.xz" [tools.zig."platforms.macos-x64"] -checksum = "sha256:0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7" -url = "https://ziglang.org/download/0.16.0/zig-x86_64-macos-0.16.0.tar.xz" +checksum = "sha256:4f9a1c5269aa17ebda5e6d3c2b89d6cbf36f7d2b22a0306e9ab98f25f95529c6" +url = "https://ziglang.org/download/0.17.0/zig-x86_64-macos-0.17.0.tar.xz" [tools.zig."platforms.windows-x64"] -checksum = "sha256:68659eb5f1e4eb1437a722f1dd889c5a322c9954607f5edcf337bc3684a75a7e" -url = "https://ziglang.org/download/0.16.0/zig-x86_64-windows-0.16.0.zip" +checksum = "sha256:b5663f69581dcf391293fbf16c06cb80d81d806545ce618b4d0bab7f0eb8c428" +url = "https://ziglang.org/download/0.17.0/zig-x86_64-windows-0.17.0.zip" diff --git a/mise.toml b/mise.toml index 7703058..8c101a4 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) # -# The toolchain for rsr-template-repo (idris2, zig). Specs are `latest`; mise.lock +# The toolchain for heron-polyfeed-reader (idris2, zig). Specs are `latest`; mise.lock # records the concrete version and per-platform checksum of each, so every # checkout installs the same bits. Refresh both with: just toolchain-refresh # Canon: hyperpolymath/standards 3-practice/provisioning (TRUST-DEFAULTS §toolchain). @@ -18,4 +18,4 @@ lockfile = true just = "latest" shellcheck = "latest" trivy = "latest" # just deps-audit -zig = "latest" +zig = "0.17.0" # heron needs this version EXACTLY (build.zig.zon minimum_zig_version; std.Io/std.http/TLS APIs changed from 0.16). The Containerfile and .github/workflows/zig-ci.yml pin the same release by SHA-256; move all three together. diff --git a/scripts/campaigns/www-bundle.campaign b/scripts/campaigns/www-bundle.campaign index c1424ce..56a1fb3 100644 --- a/scripts/campaigns/www-bundle.campaign +++ b/scripts/campaigns/www-bundle.campaign @@ -30,7 +30,7 @@ # # The rest of www/ is deliberately NOT propagated: # -# www/public/ per-repo placeholders ({{PROJECT_NAME}} and friends) +# www/public/ per-repo placeholders (heron-polyfeed-reader and friends) # www/policies/ per-repo placeholders # www/dns/ a DNS scaffold most repositories do not operate # www/tls/ as above diff --git a/scripts/check-no-placeholders.sh b/scripts/check-no-placeholders.sh index f33a4b8..4fcda7b 100755 --- a/scripts/check-no-placeholders.sh +++ b/scripts/check-no-placeholders.sh @@ -46,10 +46,10 @@ fi # # This check deliberately precedes the template exemption below. That exemption # is why the original incident went unseen: the gate skipped `*-template-repo` -# entirely, so nobody noticed that .github/settings.yml shipped `name: "{{REPO}}"` +# entirely, so nobody noticed that .github/settings.yml shipped `name: "heron-polyfeed-reader"` # — and .github/settings.yml is not inert content in a template. probot/settings # applies it on every push to the default branch, in the template as much as in -# an instantiation. The template submitted the literal `{{REPO}}` as its own +# an instantiation. The template submitted the literal `heron-polyfeed-reader` as its own # name; GitHub collapsed the illegal braces to dashes and renamed the repository # to `-REPO-`, which then read as a deleted repo. # @@ -96,11 +96,11 @@ fi # Identity comes from the git remote, not the directory name. # # This used to be `basename "$(pwd)"`, which is right in CI — GITHUB_REPOSITORY -# is set to `owner/rsr-template-repo` and matches — but wrong anywhere the +# is set to `owner/heron-polyfeed-reader` and matches — but wrong anywhere the # checkout is not literally named `*-template-repo`. A git worktree is the common # case: `git worktree add .claude/worktrees/defects` gives basename `defects`, # the exemption misses, and the gate reports every one of the template's ~85 -# deliberate placeholder files as a failure. A clone into `rsr-template-repo-2`, +# deliberate placeholder files as a failure. A clone into `heron-polyfeed-reader-2`, # or any renamed directory, does the same. # # The remote URL is the repo's actual identity and survives all of that. The diff --git a/scripts/check-no-vlang.sh b/scripts/check-no-vlang.sh index 39f3a53..c11ab83 100755 --- a/scripts/check-no-vlang.sh +++ b/scripts/check-no-vlang.sh @@ -4,6 +4,11 @@ # # Enforce the estate ban on the V programming language. Zig is the supported # systems/FFI language and must never be matched by this check. +# +# vendor/ is third-party code (here the SQLite amalgamation, whose FTS3 code +# has an identifier `iPrevLangid` that the case-insensitive `vlang` pattern +# matches). Estate policy makes third-party code untouchable, so it is +# excluded rather than edited; the pattern itself is canon (standards). set -euo pipefail @@ -23,6 +28,7 @@ if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then HITS=$(git -C "$REPO_ROOT" grep -n -i -E "$PATTERN" -- \ . \ ':(exclude)affinescript/**' \ + ':(exclude)vendor/**' \ ':(exclude)scripts/check-no-vlang.sh' \ ':(exclude)tests/workflows/check_no_vlang_test.sh' \ ':(exclude).github/workflows/estate-rules.yml' \ @@ -32,13 +38,14 @@ else HITS=$(grep -rni -E "$PATTERN" "$REPO_ROOT" \ --exclude-dir=.git \ --exclude-dir=affinescript \ + --exclude-dir=vendor \ --exclude-dir=node_modules \ --exclude=check-no-vlang.sh \ --exclude=check_no_vlang_test.sh \ --exclude=estate-rules.yml \ 2>/dev/null || true) V_MODS=$(find "$REPO_ROOT" -type f -name v.mod \ - -not -path '*/.git/*' -not -path '*/affinescript/*' \ + -not -path '*/.git/*' -not -path '*/affinescript/*' -not -path '*/vendor/*' \ -printf '%P\n' 2>/dev/null || true) fi diff --git a/scripts/deed-field.sh b/scripts/deed-field.sh index 57d27c1..3c9db19 100755 --- a/scripts/deed-field.sh +++ b/scripts/deed-field.sh @@ -6,7 +6,7 @@ # *_praxis.deed: the root is the first form with an ABNF doc-head). # # The repo deed holds what the descriptiles CLADE / META / ECOSYSTEM / STATE / -# AGENTIC .a2ml files used to hold (rsr-template-repo#209). This is the one +# AGENTIC .a2ml files used to hold (heron-polyfeed-reader#209). This is the one # shell reader for it, so that every gate, extractor and recipe asks the same # question the same way. It is a READER: it never writes the deed. # diff --git a/scripts/migrate-wellknown-to-www.sh b/scripts/migrate-wellknown-to-www.sh index 3135692..3b8340e 100755 --- a/scripts/migrate-wellknown-to-www.sh +++ b/scripts/migrate-wellknown-to-www.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MPL-2.0 # # migrate-wellknown-to-www.sh — conflict-safe migration of a repository-root -# .well-known/ tree into www/.well-known/ (rsr-template-repo#53). +# .well-known/ tree into www/.well-known/ (heron-polyfeed-reader#53). # # Stage 1 established the canonical location. Stage 5 (#119) sweeps it across # ~270 repositories, which changes what "conflict-safe" has to mean: a sweep diff --git a/scripts/sweep-wellknown.sh b/scripts/sweep-wellknown.sh index 1dc6db7..5536839 100755 --- a/scripts/sweep-wellknown.sh +++ b/scripts/sweep-wellknown.sh @@ -63,7 +63,7 @@ usage() { cat <<'EOF' Usage: scripts/sweep-wellknown.sh [options] [repo ...] -Classify, migrate and test repositories in batches (rsr-template-repo#119). +Classify, migrate and test repositories in batches (heron-polyfeed-reader#119). Selecting repositories: --repos-file FILE one repository name per line ('#' comments allowed) @@ -356,7 +356,7 @@ sweep_repo() { # repo class -> appends to RESULT_CSV if [ -n "$(cd "$dir" && git status --porcelain)" ]; then # A commit failure is recorded, never fatal: one repository with an # unusual hook or an unwritable ref must not abandon the batch. - if (cd "$dir" && git add -A && git commit -qm "chore(www): migrate root .well-known/ to www/.well-known/ (rsr-template-repo#119)"); then + if (cd "$dir" && git add -A && git commit -qm "chore(www): migrate root .well-known/ to www/.well-known/ (heron-polyfeed-reader#119)"); then if [ "$PUSH" -eq 1 ]; then (cd "$dir" && git push -q origin "HEAD:$BRANCH") \ || detail="${detail:+$detail; }push failed" diff --git a/scripts/validate-template.sh b/scripts/validate-template.sh index a269090..d57c850 100755 --- a/scripts/validate-template.sh +++ b/scripts/validate-template.sh @@ -187,7 +187,7 @@ log_info "Phase 2: Machine-readable metadata (.machine_readable/)" echo "" # Project state, architecture axes, ecosystem position, the anchor and the -# maintenance axes are clauses of the repo deed (rsr-template-repo#209). +# maintenance axes are clauses of the repo deed (heron-polyfeed-reader#209). DEED_FILE=$(bash "$REPO_ROOT/scripts/deed-field.sh" --find "$REPO_ROOT" || true) for clause in status maturity meta ecosystem anchor policies/maintenance-axes; do if [ -z "$DEED_FILE" ] || ! bash "$REPO_ROOT/scripts/deed-field.sh" --has "$DEED_FILE" "$clause"; then @@ -321,7 +321,7 @@ echo "" # Note: Template repo is allowed to have placeholders # For derived repos, we'd check that placeholders are replaced -if [ "$(basename "$REPO_ROOT")" = "rsr-template-repo" ]; then +if [ "$(basename "$REPO_ROOT")" = "heron-polyfeed-reader" ]; then log_pass "Skipping placeholder check for template repo" else # Check that key files don't have unresolved placeholders diff --git a/sonar-project.properties b/sonar-project.properties index 961e208..3e278d4 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 # SonarQube Cloud (SonarCloud) configuration. -# Project: https://sonarcloud.io/project/overview?id=hyperpolymath_rsr-template-repo +# Project: https://sonarcloud.io/project/overview?id=hyperpolymath_heron-polyfeed-reader # Requires the SONAR_TOKEN repository secret + a SonarCloud project (owner setup). sonar.organization=hyperpolymath -sonar.projectKey=hyperpolymath_rsr-template-repo +sonar.projectKey=hyperpolymath_heron-polyfeed-reader # Analysable surface = shell scripts + any JS/TS the template carries. Idris2, # Zig, Elixir and AffineScript have no SonarCloud analyser; vendored, generated, diff --git a/src/adapter.zig b/src/adapter.zig new file mode 100644 index 0000000..25a65e1 --- /dev/null +++ b/src/adapter.zig @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! The adapter interface: one `fetch(source)` per source *type*, returning normalised +//! items or a status result. The scheduler, storage, CLI and UI see only this file. +//! Adding NNTP/Gemini/Gopher/ActivityPub/AT Protocol/WebSub-fed sources means +//! implementing `Adapter` and calling `Registry.register`; nothing in the core changes. +const std = @import("std"); +const Item = @import("item.zig").Item; +const Transport = @import("transport.zig").Transport; +const dates = @import("dates.zig"); + +/// A configured source, as the adapter sees it. `options` is the adapter-private +/// remainder of the config entry (raw JSON object text, or "" if none), so new +/// adapter types can carry their own settings without core changes. +pub const Source = struct { + name: []const u8, + type_name: []const u8, + url: []const u8, + interval_s: u32, + options: []const u8 = "", +}; + +/// Opaque-to-the-core resume token. For HTTP adapters: conditional-GET validators. +/// Other adapters may reuse the fields (e.g. NNTP stores the last article number in `etag`). +pub const Cond = struct { + etag: ?[]const u8 = null, + last_modified: ?[]const u8 = null, + /// Adapter-private persisted state (opaque to the core): e.g. a monitor keeps its last + /// up/down verdict and body hash here; an NNTP adapter would keep the last article number. + state: ?[]const u8 = null, +}; + +pub const FailureClass = enum { + transient, // network error, 5xx: retry with backoff + rate_limited, // 429/503 with or without Retry-After + permanent, // 401/403/404/410: retry slowly (capped long backoff), keep source + malformed, // reachable but unparsable +}; + +pub const Failure = struct { + class: FailureClass, + http_status: ?u16 = null, + /// Seconds the server asked us to wait (already clamped), if any. + retry_after_s: ?u32 = null, + message: []const u8 = "", +}; + +pub const Batch = struct { + items: []Item, + cond: Cond = .{}, + /// Feed/channel title, if known. + title: ?[]const u8 = null, +}; + +pub const Outcome = union(enum) { + items: Batch, + not_modified: Cond, + failed: Failure, +}; + +/// Per-fetch context. `arena` owns everything the outcome points to; the caller +/// frees it after persisting. `now` is Unix seconds (injected so Retry-After +/// HTTP-dates are testable). +pub const Context = struct { + arena: std.mem.Allocator, + transport: Transport, + now: i64, +}; + +pub const Adapter = struct { + type_name: []const u8, + ptr: *anyopaque, + fetchFn: *const fn (ptr: *anyopaque, ctx: *Context, source: Source, cond: Cond) Outcome, + + pub fn fetch(self: Adapter, ctx: *Context, source: Source, cond: Cond) Outcome { + return self.fetchFn(self.ptr, ctx, source, cond); + } +}; + +pub const Registry = struct { + list: [16]Adapter = undefined, + len: usize = 0, + + pub fn register(self: *Registry, a: Adapter) error{RegistryFull}!void { + if (self.len == self.list.len) return error.RegistryFull; + self.list[self.len] = a; + self.len += 1; + } + + pub fn find(self: *const Registry, type_name: []const u8) ?Adapter { + for (self.list[0..self.len]) |a| if (std.mem.eql(u8, a.type_name, type_name)) return a; + return null; + } +}; + +pub const max_retry_after_s: u32 = 7 * 24 * 3600; + +/// Parse a Retry-After header (delta-seconds or HTTP-date), clamped to [0, 7 days]. +pub fn parseRetryAfter(value: []const u8, now: i64) ?u32 { + const v = std.mem.trim(u8, value, " \t"); + if (v.len == 0) return null; + if (std.fmt.parseInt(u64, v, 10)) |secs| return @intCast(@min(secs, max_retry_after_s)) else |_| {} + const t = dates.parse(v) orelse return null; + if (t <= now) return 0; + return @intCast(@min(t - now, max_retry_after_s)); +} + +test "retry-after delta and date" { + try std.testing.expectEqual(@as(?u32, 120), parseRetryAfter("120", 0)); + try std.testing.expectEqual(@as(?u32, max_retry_after_s), parseRetryAfter("99999999999", 0)); + try std.testing.expectEqual(@as(?u32, 30), parseRetryAfter("Sat, 01 Jan 2000 00:00:30 GMT", 946684800)); + try std.testing.expectEqual(@as(?u32, 0), parseRetryAfter("Sat, 01 Jan 2000 00:00:00 GMT", 946684900)); + try std.testing.expectEqual(@as(?u32, null), parseRetryAfter("soon", 0)); +} + +test "registry lookup" { + var reg: Registry = .{}; + var dummy: u8 = 0; + const f = struct { + fn f(_: *anyopaque, _: *Context, _: Source, _: Cond) Outcome { + return .{ .failed = .{ .class = .transient } }; + } + }.f; + try reg.register(.{ .type_name = "feed", .ptr = &dummy, .fetchFn = f }); + try std.testing.expect(reg.find("feed") != null); + try std.testing.expect(reg.find("nntp") == null); +} diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..52c9cec --- /dev/null +++ b/src/app.zig @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Process-level wiring shared by the CLI and the daemon: where config/data live, loading the +//! config, building the adapter registry and the list of jobs. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const config = @import("config.zig"); +const adapter = @import("adapter.zig"); +const feed_adapter = @import("feed_adapter.zig"); +const monitor = @import("monitor.zig"); +const http = @import("http.zig"); + +pub const Overrides = struct { + config_path: ?[]const u8 = null, + data_dir: ?[]const u8 = null, +}; + +pub const App = struct { + gpa: Allocator, + io: Io, + /// Owns config text, paths, job list. Freed in deinit. + arena: std.heap.ArenaAllocator, + config_path: []const u8, + data_dir: []const u8, + cfg: config.Config, + jobs: []adapter.Source = &.{}, + feed: feed_adapter.FeedAdapter = .{}, + mon: monitor.MonitorAdapter = .{}, + registry: adapter.Registry = .{}, + http: ?http.HttpTransport = null, + + pub const LoadError = error{ ConfigNotFound, InvalidConfig, NoHome } || Allocator.Error; + + /// Load configuration. The returned App must not be moved after `setup()`. + pub fn load(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, ov: Overrides, diag: *config.Diagnostic) LoadError!App { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const a = arena.allocator(); + + const home = env.get("HOME"); + const config_path = ov.config_path orelse env.get("HERON_CONFIG") orelse blk: { + if (env.get("XDG_CONFIG_HOME")) |x| break :blk try std.fmt.allocPrint(a, "{s}/heron-polyfeed-reader/config.json", .{x}); + const h = home orelse return error.NoHome; + break :blk try std.fmt.allocPrint(a, "{s}/.config/heron-polyfeed-reader/config.json", .{h}); + }; + const text = Io.Dir.cwd().readFileAlloc(io, config_path, a, .limited(1024 * 1024)) catch { + diag.message = try std.fmt.allocPrint(a, "cannot read config file {s}", .{config_path}); + return error.ConfigNotFound; + }; + const cfg = config.parse(a, text, diag) catch |e| switch (e) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidConfig => return error.InvalidConfig, + }; + const data_dir = ov.data_dir orelse env.get("HERON_DATA") orelse cfg.data_dir orelse blk: { + if (env.get("XDG_DATA_HOME")) |x| break :blk try std.fmt.allocPrint(a, "{s}/heron-polyfeed-reader", .{x}); + const h = home orelse return error.NoHome; + break :blk try std.fmt.allocPrint(a, "{s}/.local/share/heron-polyfeed-reader", .{h}); + }; + + var jobs: std.ArrayList(adapter.Source) = .empty; + for (cfg.sources) |s| try jobs.append(a, .{ .name = s.name, .type_name = s.type_name, .url = s.url, .interval_s = s.interval_s, .options = s.options }); + for (cfg.monitors) |m| { + const opts = try std.fmt.allocPrint(a, "{{\"expect_status\":{d},\"detect_changes\":{},\"check_robots\":{},\"cert_warn_days\":{d}}}", .{ m.expect_status, m.detect_changes, m.check_robots, m.cert_warn_days }); + try jobs.append(a, .{ .name = m.name, .type_name = "monitor", .url = m.url, .interval_s = m.interval_s, .options = opts }); + } + return .{ .gpa = gpa, .io = io, .arena = arena, .config_path = config_path, .data_dir = data_dir, .cfg = cfg, .jobs = jobs.items }; + } + + /// Register adapters. Call once, after the App has its final address. + pub fn setup(self: *App) !void { + try self.registry.register(self.feed.asAdapter()); + const ua = self.cfg.user_agent; + const end = std.mem.indexOfAny(u8, ua, "/ ") orelse ua.len; + if (end > 0) self.mon.ua_token = ua[0..end]; + try self.registry.register(self.mon.asAdapter()); + } + + pub fn transport(self: *App) @import("transport.zig").Transport { + if (self.http == null) self.http = http.HttpTransport.init(self.gpa, self.io, self.cfg.user_agent, self.cfg.timeout_s); + return self.http.?.asTransport(); + } + + pub fn deinit(self: *App) void { + if (self.http) |*h| h.deinit(); + self.arena.deinit(); + } +}; diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..419bc57 --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Command-line interface. +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const app_mod = @import("app.zig"); +const config = @import("config.zig"); +const store_mod = @import("store.zig"); +const engine_mod = @import("engine.zig"); +const daemon = @import("daemon.zig"); +const util = @import("util.zig"); +const monitor_mod = @import("monitor.zig"); + +const usage = + \\heron-polyfeed-reader — a small local feed reader and site monitor + \\ + \\usage: heron [--config FILE] [--data DIR] [args] + \\ + \\ daemon run in the foreground: poll sources, serve the web UI if enabled + \\ fetch [NAME...] poll the named sources (default: all) once, now + \\ list [--source NAME] [--unread] [--limit N] + \\ search TEXT substring search over titles and text (needs 3+ characters) + \\ show ID print an item and mark it read + \\ read ID | unread ID change an item's read state + \\ mark-all-read [NAME] + \\ sources per-source status: last check, failures, next poll + \\ monitors monitor status: up/down, HTTP status, TLS certificate expiry + \\ rebuild rebuild the index and search tables from the log + \\ check-config validate the config file + \\ help + \\ +; + +pub fn main(init: std.process.Init) u8 { + return run(init) catch |e| { + std.log.err("{s}", .{@errorName(e)}); + return 1; + }; +} + +fn run(init: std.process.Init) !u8 { + const gpa = init.gpa; + const io = init.io; + var out_buf: [4096]u8 = undefined; + var out_w = Io.File.stdout().writer(io, &out_buf); + const out = &out_w.interface; + defer out.flush() catch {}; + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const argv = try init.minimal.args.toSlice(arena); + var ov: app_mod.Overrides = .{}; + var pos: std.ArrayList([]const u8) = .empty; + var i: usize = 1; + while (i < argv.len) : (i += 1) { + const a = argv[i]; + if (std.mem.eql(u8, a, "--config") and i + 1 < argv.len) { + i += 1; + ov.config_path = argv[i]; + } else if (std.mem.eql(u8, a, "--data") and i + 1 < argv.len) { + i += 1; + ov.data_dir = argv[i]; + } else try pos.append(arena, a); + } + if (pos.items.len == 0 or std.mem.eql(u8, pos.items[0], "help") or std.mem.eql(u8, pos.items[0], "--help")) { + try out.writeAll(usage); + return if (pos.items.len == 0) 2 else 0; + } + const cmd = pos.items[0]; + const args = pos.items[1..]; + + // The daemon lowers its priority before any thread exists (Linux nice is per-thread). + if (std.mem.eql(u8, cmd, "daemon")) daemon.lowerPriority(); + + var diag: config.Diagnostic = .{}; + var app = app_mod.App.load(gpa, io, init.environ_map, ov, &diag) catch |e| { + try out.flush(); + std.log.err("{s}{s}{s}", .{ @errorName(e), if (diag.message.len > 0) ": " else "", diag.message }); + return 1; + }; + defer app.deinit(); + try app.setup(); + + if (std.mem.eql(u8, cmd, "check-config")) { + // A source type no registered adapter serves (a typo such as "fead") would otherwise pass + // here and then fail permanently on every scheduled poll. + for (app.jobs) |job| if (app.registry.find(job.type_name) == null) { + try out.print("error: source '{s}': no adapter for type '{s}' (available: feed)\n", .{ job.name, job.type_name }); + return 1; + }; + try out.print("ok: {d} source(s), {d} monitor(s); data dir {s}\n", .{ app.cfg.sources.len, app.cfg.monitors.len, app.data_dir }); + return 0; + } + + const exclusive = for ([_][]const u8{ "daemon", "fetch", "rebuild", "read", "unread", "mark-all-read", "show" }) |c| { + if (std.mem.eql(u8, cmd, c)) break !std.mem.eql(u8, cmd, "show") and !std.mem.eql(u8, cmd, "read") and !std.mem.eql(u8, cmd, "unread") and !std.mem.eql(u8, cmd, "mark-all-read"); + } else false; + var store = store_mod.Store.open(gpa, io, app.data_dir, exclusive) catch |e| switch (e) { + error.AlreadyRunning => { + try out.flush(); + std.log.err("another heron process (probably the daemon) is using {s}; this command needs the data lock", .{app.data_dir}); + return 1; + }, + else => return e, + }; + defer store.close(); + + const now = Io.Timestamp.now(io, .real).toSeconds(); + if (std.mem.eql(u8, cmd, "daemon")) { + try out.flush(); + try daemon.run(&app, &store); + return 0; + } else if (std.mem.eql(u8, cmd, "fetch")) { + return cmdFetch(&app, &store, out, args, now); + } else if (std.mem.eql(u8, cmd, "list")) { + var o: store_mod.ListOpts = .{}; + var j: usize = 0; + while (j < args.len) : (j += 1) { + if (std.mem.eql(u8, args[j], "--unread")) o.unread_only = true else if (std.mem.eql(u8, args[j], "--source") and j + 1 < args.len) { + j += 1; + o.source = args[j]; + } else if (std.mem.eql(u8, args[j], "--limit") and j + 1 < args.len) { + j += 1; + o.limit = std.fmt.parseInt(u32, args[j], 10) catch return usageErr(out); + } else return usageErr(out); + } + try printRows(out, arena, try store.list(arena, o)); + return 0; + } else if (std.mem.eql(u8, cmd, "search")) { + if (args.len != 1) return usageErr(out); + if (std.unicode.utf8CountCodepoints(args[0]) catch 0 < 3) { + try out.writeAll("search needs at least 3 characters (trigram index)\n"); + return 2; + } + try printRows(out, arena, try store.search(arena, args[0], 50)); + return 0; + } else if (std.mem.eql(u8, cmd, "show") or std.mem.eql(u8, cmd, "read") or std.mem.eql(u8, cmd, "unread")) { + if (args.len != 1) return usageErr(out); + const id = std.fmt.parseInt(i64, args[0], 10) catch return usageErr(out); + const rec = (try store.getRecord(arena, id)) orelse { + try out.print("no item {d}\n", .{id}); + return 1; + }; + if (std.mem.eql(u8, cmd, "show")) { + var db: [32]u8 = undefined; + // Everything below except the date and kind came off the network: neutralise + // terminal control sequences before it reaches the user's terminal. + try out.print("{s}\n{s}\n[{s}] {s} ({s})\n\n{s}\n", .{ + try util.termSafe(arena, rec.title), try util.termSafe(arena, rec.url), + try util.termSafe(arena, rec.source), if (rec.published) |p| util.fmtDate(&db, p) else "undated", + @tagName(rec.kind), try util.termSafe(arena, try store_mod.plainText(arena, rec.body, 64 * 1024)), + }); + } + try store.setRead(rec.source, rec.id, !std.mem.eql(u8, cmd, "unread"), now); + return 0; + } else if (std.mem.eql(u8, cmd, "mark-all-read")) { + const n = try store.markAllRead(if (args.len > 0) args[0] else null, now); + try out.print("{d} item(s) marked read\n", .{n}); + return 0; + } else if (std.mem.eql(u8, cmd, "sources")) { + return cmdSources(&app, &store, out, arena, now); + } else if (std.mem.eql(u8, cmd, "monitors")) { + return cmdMonitors(&app, &store, out, arena, now); + } else if (std.mem.eql(u8, cmd, "rebuild")) { + try store.rebuild(); + const c = try store.count(); + try out.print("rebuilt: {d} item(s), {d} unread\n", .{ c.total, c.unread }); + return 0; + } + return usageErr(out); +} + +fn usageErr(out: *Io.Writer) !u8 { + try out.writeAll(usage); + return 2; +} + +fn printRows(out: *Io.Writer, arena: Allocator, rows: []const store_mod.Row) !void { + for (rows) |r| { + var db: [32]u8 = undefined; + const when = if (r.published) |p| util.fmtDate(&db, p) else " "; + try out.print("{d:>6} {s} {s} [{s}] {s}\n", .{ @as(u64, @intCast(r.rowid)), if (r.read) " " else "*", when, r.source, try util.termSafe(arena, r.title) }); + } + if (rows.len == 0) try out.writeAll("(nothing)\n"); +} + +fn cmdFetch(app: *app_mod.App, store: *store_mod.Store, out: *Io.Writer, names: []const []const u8, now: i64) !u8 { + var eng: engine_mod.Engine = .{ .gpa = app.gpa, .store = store, .registry = &app.registry, .transport = app.transport() }; + // A misspelled or removed name must not look like a successful poll of nothing. + for (names) |n| { + var known = false; + for (app.jobs) |job| if (std.mem.eql(u8, n, job.name)) { + known = true; + }; + if (!known) { + try out.print("fetch: no source or monitor named '{s}' (see: heron sources)\n", .{n}); + return 2; + } + } + var failures: usize = 0; // only zero/non-zero matters; usize cannot overflow on a job count + for (app.jobs) |job| { + if (names.len > 0) { + var want = false; + for (names) |n| if (std.mem.eql(u8, n, job.name)) { + want = true; + }; + if (!want) continue; + } + const res = eng.poll(job, now); + if (res.ok) { + try out.print("{s}: {s}, {d} new\n", .{ job.name, if (res.unchanged) "unchanged" else "ok", res.new_items }); + } else { + failures += 1; + try out.print("{s}: FAILED {s}: {s}\n", .{ job.name, @tagName(res.class.?), res.messageSlice() }); + } + try out.flush(); + } + return if (failures > 0) 1 else 0; +} + +fn cmdSources(app: *app_mod.App, store: *store_mod.Store, out: *Io.Writer, arena: Allocator, now: i64) !u8 { + try out.print("{s:<20} {s:<8} {s:>9} {s:>6} {s:>9} {s}\n", .{ "name", "type", "last ok", "fails", "next", "last error" }); + for (app.jobs) |job| { + const st = try store.getSourceState(arena, job.name); + var b1: [16]u8 = undefined; + var b2: [16]u8 = undefined; + const ok_ago = if (st.last_ok) |t| util.fmtSpan(&b1, now - t) else "never"; + const next = if (st.next_poll) |t| (if (t <= now) "due" else util.fmtSpan(&b2, t - now)) else "-"; + try out.print("{s:<20} {s:<8} {s:>9} {d:>6} {s:>9} {s}\n", .{ job.name, job.type_name, ok_ago, st.failures, next, try util.termSafe(arena, st.last_error orelse "") }); + } + return 0; +} + +fn cmdMonitors(app: *app_mod.App, store: *store_mod.Store, out: *Io.Writer, arena: Allocator, now: i64) !u8 { + try out.print("{s:<20} {s:<8} {s:>9} {s:>6} {s:>10} {s}\n", .{ "name", "state", "since", "http", "cert", "note" }); + for (app.jobs) |job| { + if (!std.mem.eql(u8, job.type_name, "monitor")) continue; + const st = try store.getSourceState(arena, job.name); + const ms = monitor_mod.parseState(arena, st.adapter_state); + var b1: [16]u8 = undefined; + var b2: [16]u8 = undefined; + var b3: [16]u8 = undefined; + const state: []const u8 = if (ms.blocked) "blocked" else if (ms.up) |u| (if (u) "up" else "DOWN") else "unknown"; + const since = if (ms.since > 0) util.fmtSpan(&b1, now - ms.since) else "-"; + const http_s = if (ms.status > 0) try std.fmt.bufPrint(&b2, "{d}", .{ms.status}) else "-"; + const cert = if (ms.cert_not_after > 0) + (if (ms.cert_not_after <= now) "EXPIRED" else util.fmtSpan(&b3, ms.cert_not_after - now)) + else + "-"; + try out.print("{s:<20} {s:<8} {s:>9} {s:>6} {s:>10} {s}\n", .{ job.name, state, since, http_s, cert, try util.termSafe(arena, ms.err) }); + } + return 0; +} diff --git a/src/config.zig b/src/config.zig new file mode 100644 index 0000000..e665879 --- /dev/null +++ b/src/config.zig @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Configuration: a plain JSON file (no YAML). See `examples/heron.json`. +//! +//! { +//! "data_dir": "/var/lib/heron", // optional +//! "user_agent": "heron/0.1 (+contact)", // optional +//! "timeout_s": 30, // optional, per request +//! "web": { "enabled": true, "bind": "127.0.0.1", "port": 8484, "allow_non_loopback": false }, // optional +//! "sources": [ { "name": "lwn", "type": "feed", "url": "https://lwn.net/headlines/rss", "interval_s": 900 } ], +//! "monitors": [ { "name": "site", "url": "https://example.org/", "interval_s": 300, +//! "expect_status": 200, "detect_changes": true, "check_robots": true } ] +//! } +//! +//! Unknown keys inside a source are kept as that adapter's private `options` (raw JSON), so +//! new source types can add settings without touching this file. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const min_interval_s: u32 = 60; +pub const default_interval_s: u32 = 1800; + +pub const SourceCfg = struct { + name: []const u8, + type_name: []const u8, + url: []const u8, + interval_s: u32, + /// Raw JSON object text of adapter-specific settings ("" if none). + options: []const u8 = "", +}; + +pub const MonitorCfg = struct { + name: []const u8, + url: []const u8, + interval_s: u32, + expect_status: u16 = 200, + detect_changes: bool = true, + check_robots: bool = true, + /// Alert when the TLS certificate has fewer than this many days left (0 = off). + cert_warn_days: u32 = 14, +}; + +pub const WebCfg = struct { + enabled: bool = false, + bind: []const u8 = "127.0.0.1", + port: u16 = 8484, + /// Required to bind a non-loopback address (e.g. 0.0.0.0 inside a container whose port is published on host loopback only). + allow_non_loopback: bool = false, +}; + +pub const Config = struct { + data_dir: ?[]const u8 = null, + user_agent: []const u8 = "heron-polyfeed-reader/0.1 (+https://github.com/hyperpolymath/heron-polyfeed-reader)", + timeout_s: u32 = 30, + web: WebCfg = .{}, + sources: []SourceCfg = &.{}, + monitors: []MonitorCfg = &.{}, +}; + +pub const Diagnostic = struct { message: []const u8 = "" }; + +pub const Error = error{InvalidConfig} || Allocator.Error; + +fn bad(a: Allocator, diag: *Diagnostic, comptime fmt: []const u8, args: anytype) Error { + diag.message = std.fmt.allocPrint(a, fmt, args) catch "invalid configuration"; + return error.InvalidConfig; +} + +fn validName(n: []const u8) bool { + if (n.len == 0 or n.len > 64) return false; + for (n) |c| if (!(std.ascii.isAlphanumeric(c) or c == '.' or c == '_' or c == '-')) return false; + return true; +} + +fn validHttpUrl(u: []const u8) bool { + const uri = std.Uri.parse(u) catch return false; + if (!(std.ascii.eqlIgnoreCase(uri.scheme, "http") or std.ascii.eqlIgnoreCase(uri.scheme, "https"))) return false; + return uri.host != null; +} + +/// A string field: absent -> null; present but not a JSON string -> InvalidConfig, so +/// `"data_dir": false` cannot silently fall back to the default directory. +fn getStr(a: Allocator, diag: *Diagnostic, obj: std.json.ObjectMap, key: []const u8, what: []const u8) Error!?[]const u8 { + const v = obj.get(key) orelse return null; + if (v != .string) return bad(a, diag, "{s} must be a string", .{what}); + return v.string; +} + +/// An integer field: absent -> null; present but not a JSON integer -> InvalidConfig, so a quoted +/// `"port": "9000"` cannot silently fall back to the default. +fn getInt(a: Allocator, diag: *Diagnostic, obj: std.json.ObjectMap, key: []const u8, what: []const u8) Error!?i64 { + const v = obj.get(key) orelse return null; + if (v != .integer) return bad(a, diag, "{s} must be an integer (unquoted)", .{what}); + return v.integer; +} + +/// A typo such as "souces" must not parse as "no sources" and let the daemon run polling nothing. +fn rejectUnknown(a: Allocator, diag: *Diagnostic, obj: std.json.ObjectMap, comptime allowed: []const []const u8, what: []const u8) Error!void { + var it = obj.iterator(); + while (it.next()) |e| { + var known = false; + inline for (allowed) |k| { + if (std.mem.eql(u8, e.key_ptr.*, k)) known = true; + } + if (!known) return bad(a, diag, "{s}: unknown key '{s}'", .{ what, e.key_ptr.* }); + } +} + +/// A boolean field: absent -> null; present but not a JSON boolean -> InvalidConfig. A mistyped +/// `"enabled": "false"` must never silently take the default (which for web.enabled is true). +fn getBool(a: Allocator, diag: *Diagnostic, obj: std.json.ObjectMap, key: []const u8, what: []const u8) Error!?bool { + const v = obj.get(key) orelse return null; + if (v != .bool) return bad(a, diag, "{s} must be true or false (unquoted)", .{what}); + return v.bool; +} + +/// Parse and validate. All returned memory belongs to `a` (use an arena). +pub fn parse(a: Allocator, text: []const u8, diag: *Diagnostic) Error!Config { + const root = std.json.parseFromSliceLeaky(std.json.Value, a, text, .{}) catch |e| + return bad(a, diag, "not valid JSON: {s}", .{@errorName(e)}); + if (root != .object) return bad(a, diag, "top level must be an object", .{}); + const obj = root.object; + var cfg: Config = .{}; + try rejectUnknown(a, diag, obj, &.{ "data_dir", "user_agent", "timeout_s", "web", "sources", "monitors" }, "top level"); + + if (try getStr(a, diag, obj, "data_dir", "data_dir")) |d| cfg.data_dir = d; + if (try getStr(a, diag, obj, "user_agent", "user_agent")) |d| cfg.user_agent = d; + if (try getInt(a, diag, obj, "timeout_s", "timeout_s")) |t| { + if (t < 1 or t > 600) return bad(a, diag, "timeout_s must be 1..600", .{}); + cfg.timeout_s = @intCast(t); + } + if (obj.get("web")) |w| { + if (w != .object) return bad(a, diag, "web must be an object", .{}); + try rejectUnknown(a, diag, w.object, &.{ "enabled", "bind", "port", "allow_non_loopback" }, "web"); + cfg.web.enabled = (try getBool(a, diag, w.object, "enabled", "web.enabled")) orelse true; + if (try getStr(a, diag, w.object, "bind", "web.bind")) |b| cfg.web.bind = b; + cfg.web.allow_non_loopback = (try getBool(a, diag, w.object, "allow_non_loopback", "web.allow_non_loopback")) orelse false; + if (try getInt(a, diag, w.object, "port", "web.port")) |p| { + if (p < 1 or p > 65535) return bad(a, diag, "web.port out of range", .{}); + cfg.web.port = @intCast(p); + } + } + + var names: std.StringHashMapUnmanaged(void) = .empty; + var sources: std.ArrayList(SourceCfg) = .empty; + if (obj.get("sources")) |sv| { + if (sv != .array) return bad(a, diag, "sources must be an array", .{}); + for (sv.array.items, 0..) |item, idx| { + if (item != .object) return bad(a, diag, "sources[{d}] must be an object", .{idx}); + const o = item.object; + try rejectUnknown(a, diag, o, &.{ "name", "type", "url", "interval_s", "options" }, "source"); + const name = (try getStr(a, diag, o, "name", "source name")) orelse return bad(a, diag, "sources[{d}]: missing name", .{idx}); + if (!validName(name)) return bad(a, diag, "sources[{d}]: bad name '{s}' (use A-Z a-z 0-9 . _ -, max 64)", .{ idx, name }); + if ((try names.getOrPut(a, name)).found_existing) return bad(a, diag, "duplicate name '{s}'", .{name}); + const type_name = (try getStr(a, diag, o, "type", "source type")) orelse "feed"; + const url = (try getStr(a, diag, o, "url", "source url")) orelse return bad(a, diag, "source '{s}': missing url", .{name}); + const interval = (try getInt(a, diag, o, "interval_s", "source interval_s")) orelse default_interval_s; + if (interval < min_interval_s or interval > 30 * 86400) return bad(a, diag, "source '{s}': interval_s must be {d}..2592000", .{ name, min_interval_s }); + if (std.mem.eql(u8, type_name, "feed") and !validHttpUrl(url)) return bad(a, diag, "source '{s}': url must be http(s)", .{name}); + var options: []const u8 = ""; + if (o.get("options")) |opt| { + if (opt != .object) return bad(a, diag, "source '{s}': options must be an object", .{name}); + options = try std.json.Stringify.valueAlloc(a, opt, .{}); + } + try sources.append(a, .{ .name = name, .type_name = type_name, .url = url, .interval_s = @intCast(interval), .options = options }); + } + } + cfg.sources = sources.items; + + var monitors: std.ArrayList(MonitorCfg) = .empty; + if (obj.get("monitors")) |mv| { + if (mv != .array) return bad(a, diag, "monitors must be an array", .{}); + for (mv.array.items, 0..) |item, idx| { + if (item != .object) return bad(a, diag, "monitors[{d}] must be an object", .{idx}); + const o = item.object; + try rejectUnknown(a, diag, o, &.{ "name", "url", "interval_s", "expect_status", "detect_changes", "check_robots", "cert_warn_days" }, "monitor"); + const name = (try getStr(a, diag, o, "name", "monitor name")) orelse return bad(a, diag, "monitors[{d}]: missing name", .{idx}); + if (!validName(name)) return bad(a, diag, "monitors[{d}]: bad name '{s}'", .{ idx, name }); + if ((try names.getOrPut(a, name)).found_existing) return bad(a, diag, "duplicate name '{s}'", .{name}); + const url = (try getStr(a, diag, o, "url", "monitor url")) orelse return bad(a, diag, "monitor '{s}': missing url", .{name}); + if (!validHttpUrl(url)) return bad(a, diag, "monitor '{s}': url must be http(s)", .{name}); + const interval = (try getInt(a, diag, o, "interval_s", "monitor interval_s")) orelse 300; + if (interval < min_interval_s or interval > 30 * 86400) return bad(a, diag, "monitor '{s}': interval_s must be {d}..2592000", .{ name, min_interval_s }); + var m: MonitorCfg = .{ .name = name, .url = url, .interval_s = @intCast(interval) }; + if (try getInt(a, diag, o, "expect_status", "monitor expect_status")) |s| { + if (s < 100 or s > 599) return bad(a, diag, "monitor '{s}': expect_status out of range", .{name}); + m.expect_status = @intCast(s); + } + if (try getBool(a, diag, o, "detect_changes", "monitor detect_changes")) |b| m.detect_changes = b; + if (try getBool(a, diag, o, "check_robots", "monitor check_robots")) |b| m.check_robots = b; + if (try getInt(a, diag, o, "cert_warn_days", "monitor cert_warn_days")) |d| { + // Bound before the narrowing cast: an out-of-range integer is a diagnostic, not a trap. + if (d < 0 or d > 3650) return bad(a, diag, "monitor '{s}': cert_warn_days must be 0..3650", .{name}); + m.cert_warn_days = @intCast(d); + } + try monitors.append(a, m); + } + } + cfg.monitors = monitors.items; + return cfg; +} + +test "valid config with defaults, options passthrough, monitors" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var d: Diagnostic = .{}; + const c = try parse(arena.allocator(), + \\{"sources":[{"name":"lwn","url":"https://lwn.net/headlines/rss","interval_s":900}, + \\ {"name":"n","type":"nntp","url":"news://x/y","options":{"group":"a.b"}}], + \\ "monitors":[{"name":"site","url":"https://example.org/","expect_status":204,"check_robots":false}], + \\ "web":{"port":9000}} + , &d); + try std.testing.expectEqual(@as(usize, 2), c.sources.len); + try std.testing.expectEqualStrings("feed", c.sources[0].type_name); + try std.testing.expectEqual(@as(u32, 900), c.sources[0].interval_s); + try std.testing.expectEqual(default_interval_s, c.sources[1].interval_s); + try std.testing.expectEqualStrings("{\"group\":\"a.b\"}", c.sources[1].options); + try std.testing.expectEqual(@as(u16, 204), c.monitors[0].expect_status); + try std.testing.expect(!c.monitors[0].check_robots and c.monitors[0].detect_changes); + try std.testing.expectEqual(@as(u16, 9000), c.web.port); + try std.testing.expectEqualStrings("127.0.0.1", c.web.bind); +} + +test "rejections carry a message" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const cases = [_][]const u8{ + "[]", + "{not json", + "{\"sources\":[{\"url\":\"http://x/\"}]}", // no name + "{\"sources\":[{\"name\":\"a b\",\"url\":\"http://x/\"}]}", // bad name + "{\"sources\":[{\"name\":\"a\",\"url\":\"ftp://x/\"}]}", // bad scheme + "{\"sources\":[{\"name\":\"a\",\"url\":\"http://x/\",\"interval_s\":5}]}", // too fast + "{\"sources\":[{\"name\":\"a\",\"url\":\"http://x/\"},{\"name\":\"a\",\"url\":\"http://y/\"}]}", // dup + "{\"monitors\":[{\"name\":\"a\",\"url\":\"http://x/\"}],\"sources\":[{\"name\":\"a\",\"url\":\"http://y/\"}]}", // dup across kinds + "{\"web\":{\"port\":70000}}", + "{\"timeout_s\":0}", + "{\"web\":{\"enabled\":\"false\"}}", // a quoted boolean must not silently enable the UI + "{\"web\":{\"allow_non_loopback\":1}}", + "{\"monitors\":[{\"name\":\"m\",\"url\":\"http://x/\",\"check_robots\":\"no\"}]}", + "{\"monitors\":[{\"name\":\"m\",\"url\":\"http://x/\",\"cert_warn_days\":4294967296}]}", // would trap the u32 cast + "{\"monitors\":[{\"name\":\"m\",\"url\":\"http://x/\",\"cert_warn_days\":-1}]}", + "{\"web\":{\"port\":\"9000\"}}", // a quoted integer must not silently take the default port + "{\"timeout_s\":\"60\"}", + "{\"souces\":[]}", // a typo must not parse as "no sources" + "{\"web\":{\"enable\":true}}", + "{\"sources\":[{\"name\":\"a\",\"url\":\"http://x/\",\"interval\":60}]}", + "{\"monitors\":[{\"name\":\"m\",\"url\":\"http://x/\",\"expect\":200}]}", + "{\"data_dir\":false}", // a non-string must not silently take the default data directory + "{\"user_agent\":1}", + "{\"web\":{\"bind\":[]}}", + "{\"sources\":[{\"name\":\"a\",\"type\":7,\"url\":\"http://x/\"}]}", + }; + for (cases) |text| { + var d: Diagnostic = .{}; + try std.testing.expectError(error.InvalidConfig, parse(arena.allocator(), text, &d)); + try std.testing.expect(d.message.len > 0); + } +} diff --git a/src/daemon.zig b/src/daemon.zig new file mode 100644 index 0000000..f86e98a --- /dev/null +++ b/src/daemon.zig @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! The background daemon: scheduler loop + (optionally) the local web UI. +const std = @import("std"); +const Io = std.Io; +const app_mod = @import("app.zig"); +const engine_mod = @import("engine.zig"); +const scheduler = @import("scheduler.zig"); +const store_mod = @import("store.zig"); +const util = @import("util.zig"); +const web_mod = @import("web.zig"); +const localserver = @import("localserver.zig"); + +extern fn setpriority(which: c_int, who: c_uint, prio: c_int) c_int; + +/// Lower this process's CPU priority. On Linux nice values are per-thread and inherited by threads +/// created afterwards, so call this before any worker threads exist. +pub fn lowerPriority() void { + if (setpriority(0, 0, 19) != 0) std.log.warn("could not lower process priority", .{}); +} + +var g_stop: Io.Event = .unset; +var g_io: ?Io = null; + +fn onSignal(_: std.posix.SIG) callconv(.c) void { + if (g_io) |io| g_stop.set(io); +} + +pub fn installSignalHandlers(io: Io) void { + g_io = io; + const act: std.posix.Sigaction = .{ + .handler = .{ .handler = onSignal }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(.INT, &act, null); + std.posix.sigaction(.TERM, &act, null); +} + +const MonoClock = struct { + io: Io, + fn now(p: *anyopaque) i64 { + const self: *MonoClock = @ptrCast(@alignCast(p)); + return Io.Timestamp.now(self.io, .boot).toMilliseconds(); + } + fn clock(self: *MonoClock) scheduler.Clock { + return .{ .ptr = self, .nowMsFn = now }; + } +}; + +fn wallNow(io: Io) i64 { + return Io.Timestamp.now(io, .real).toSeconds(); +} + +pub fn run(app: *app_mod.App, store: *store_mod.Store) !void { + const io = app.io; + installSignalHandlers(io); + + var mono: MonoClock = .{ .io = io }; + var seed: [8]u8 = undefined; + io.random(&seed); + var prng = std.Random.DefaultPrng.init(std.mem.readInt(u64, &seed, .little)); + var sched = scheduler.Scheduler.init(app.gpa, mono.clock(), prng.random(), .{}); + defer sched.deinit(); + + var eng: engine_mod.Engine = .{ .gpa = app.gpa, .store = store, .registry = &app.registry, .transport = app.transport() }; + + // Restore each job's schedule from storage so restarts neither stampede nor skip polls. + var scratch = std.heap.ArenaAllocator.init(app.gpa); + defer scratch.deinit(); + const now0 = wallNow(io); + for (app.jobs) |job| { + const st = store.getSourceState(scratch.allocator(), job.name) catch store_mod.SourceState{}; + const due_in: ?u64 = if (st.next_poll) |np| @intCast(@max(0, np - now0) * 1000) else null; + _ = try sched.add(job.interval_s, due_in, st.failures); + } + // Optional web UI: its own task, its own read-mostly SQLite connection, loopback only. + var ui_store: ?store_mod.Store = null; + defer if (ui_store) |*s| s.close(); + var ui_server: Io.net.Server = undefined; + var ui_fut: ?Io.Future(void) = null; + var ui: web_mod.Web = .{ .gpa = app.gpa, .io = io, .app = app, .store = undefined, .bind = app.cfg.web.bind }; + if (app.cfg.web.enabled) ui_start: { + ui_server = web_mod.bind(io, app.cfg.web.bind, app.cfg.web.port, app.cfg.web.allow_non_loopback) catch |e| { + std.log.err("web UI disabled: cannot listen on {s}:{d}: {s}", .{ app.cfg.web.bind, app.cfg.web.port, @errorName(e) }); + break :ui_start; + }; + ui_store = store_mod.Store.open(app.gpa, io, app.data_dir, false) catch |e| { + std.log.err("web UI disabled: {s}", .{@errorName(e)}); + ui_server.deinit(io); + break :ui_start; + }; + ui.store = &ui_store.?; + ui.port = localserver.port(&ui_server); + ui_fut = io.concurrent(localserver.serve, .{ io, &ui_server, @as(*anyopaque, &ui), web_mod.handler }) catch |e| blk: { + std.log.err("web UI disabled: {s}", .{@errorName(e)}); + ui_server.deinit(io); + break :blk null; + }; + if (ui_fut != null) std.log.info("web UI on http://{s}:{d}/", .{ app.cfg.web.bind, ui.port }); + } + defer if (ui_fut) |*f| { + _ = f.cancel(io); + ui_server.deinit(io); + }; + + std.log.info("daemon started: {d} job(s), data in {s}", .{ app.jobs.len, app.data_dir }); + + while (!g_stop.isSet()) { + var polled = false; + while (sched.takeDue()) |id| { + polled = true; + const job = app.jobs[id]; + const t0 = Io.Timestamp.now(io, .boot); + const now = wallNow(io); + const res = eng.poll(job, now); + if (res.ok) sched.reportSuccess(id) else sched.reportFailure(id, res.class orelse .transient, res.retry_after_s); + const next = now + @as(i64, @intCast(sched.dueInMs(id) / 1000)); + store.setNextPoll(job.name, next) catch {}; + const took = t0.durationTo(Io.Timestamp.now(io, .boot)).toMilliseconds(); + if (res.ok) { + std.log.info("{s}: {s}, {d} new, {d} ms", .{ job.name, if (res.unchanged) "unchanged" else "ok", res.new_items, took }); + } else { + var b: [16]u8 = undefined; + std.log.warn("{s}: {s} ({s}); retry in {s}", .{ job.name, @tagName(res.class.?), res.messageSlice(), util.fmtSpan(&b, @intCast(sched.dueInMs(id) / 1000)) }); + } + if (g_stop.isSet()) break; + } + if (polled) if (app.http) |*h| h.releaseCaches(); + if (g_stop.isSet()) break; + const ms = sched.sleepMs(); + if (ms == 0) continue; + g_stop.waitTimeout(io, .{ .duration = .{ .raw = .fromMilliseconds(@intCast(ms)), .clock = .boot } }) catch |e| switch (e) { + error.Timeout => {}, + error.Canceled => break, + }; + } + std.log.info("daemon stopping", .{}); +} diff --git a/src/dates.zig b/src/dates.zig new file mode 100644 index 0000000..c6aab04 --- /dev/null +++ b/src/dates.zig @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Tolerant date parsing for feeds: RFC 822/2822 (incl. 2-digit years, named +//! zones, missing seconds, trailing comments), RFC 3339 / ISO 8601 (extended, +//! basic, partial dates), HTTP-date, and bare Unix epochs. Returns Unix seconds +//! (UTC) or null; never guesses on ambiguous numeric day/month forms. +const std = @import("std"); + +pub fn daysFromCivil(y_in: i64, m: i64, d: i64) i64 { + const y = if (m <= 2) y_in - 1 else y_in; + const era = @divFloor(y, 400); + const yoe = y - era * 400; + const mp = @mod(m + 9, 12); + const doy = @divFloor(153 * mp + 2, 5) + d - 1; + const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; + return era * 146097 + doe - 719468; +} + +fn isLeap(y: i64) bool { + return (@mod(y, 4) == 0 and @mod(y, 100) != 0) or @mod(y, 400) == 0; +} + +fn daysInMonth(y: i64, m: i64) i64 { + return switch (m) { + 1, 3, 5, 7, 8, 10, 12 => 31, + 4, 6, 9, 11 => 30, + 2 => if (isLeap(y)) 29 else 28, + else => 0, + }; +} + +fn build(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64, off_min: i64) ?i64 { + if (mo < 1 or mo > 12 or d < 1 or d > daysInMonth(y, mo)) return null; + if (h < 0 or h > 24 or mi < 0 or mi > 59 or s < 0 or s > 60) return null; + if (h == 24 and (mi != 0 or s != 0)) return null; // ISO 8601 allows 24:00:00 only (end of day) + if (y < 1 or y > 9999) return null; + return daysFromCivil(y, mo, d) * 86400 + h * 3600 + mi * 60 + s - off_min * 60; +} + +const months = [_][]const u8{ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec" }; + +fn monthFromName(s: []const u8) ?i64 { + if (s.len < 3) return null; + var buf: [3]u8 = undefined; + _ = std.ascii.lowerString(&buf, s[0..3]); + for (months, 0..) |m, i| if (std.mem.eql(u8, &buf, m)) return @intCast(i + 1); + return null; +} + +fn zoneOffsetMinutes(z: []const u8) ?i64 { + var buf: [8]u8 = undefined; + if (z.len == 0) return 0; + if (z.len > buf.len) return null; + const u = std.ascii.upperString(&buf, z); + const table = [_]struct { []const u8, i64 }{ + .{ "GMT", 0 }, .{ "UT", 0 }, .{ "UTC", 0 }, .{ "Z", 0 }, + .{ "EST", -300 }, .{ "EDT", -240 }, .{ "CST", -360 }, .{ "CDT", -300 }, + .{ "MST", -420 }, .{ "MDT", -360 }, .{ "PST", -480 }, .{ "PDT", -420 }, + .{ "BST", 60 }, .{ "CET", 60 }, .{ "CEST", 120 }, .{ "EET", 120 }, + .{ "EEST", 180 }, .{ "WET", 0 }, .{ "WEST", 60 }, .{ "IST", 330 }, + .{ "JST", 540 }, .{ "AEST", 600 }, .{ "AEDT", 660 }, + }; + for (table) |e| if (std.mem.eql(u8, u, e[0])) return e[1]; + if (u[0] == '+' or u[0] == '-') { + const sign: i64 = if (u[0] == '-') -1 else 1; + var digits: [4]u8 = undefined; + var n: usize = 0; + for (u[1..]) |c| { + if (c == ':') continue; + if (!std.ascii.isDigit(c) or n >= 4) return null; + digits[n] = c; + n += 1; + } + const hh: i64 = switch (n) { + 2 => std.fmt.parseInt(i64, digits[0..2], 10) catch return null, + 4 => std.fmt.parseInt(i64, digits[0..2], 10) catch return null, + else => return null, + }; + const mm: i64 = if (n == 4) (std.fmt.parseInt(i64, digits[2..4], 10) catch return null) else 0; + if (hh > 23 or mm > 59) return null; + return sign * (hh * 60 + mm); + } + return null; +} + +fn digitsAt(s: []const u8, i: usize, n: usize) ?i64 { + if (i + n > s.len) return null; + var v: i64 = 0; + for (s[i .. i + n]) |c| { + if (!std.ascii.isDigit(c)) return null; + v = v * 10 + (c - '0'); + } + return v; +} + +fn parseIso(s: []const u8) ?i64 { + // Basic form: YYYYMMDD[THHMMSS[Z]] + if (s.len >= 8 and digitsAt(s, 0, 8) != null and (s.len == 8 or s[8] == 'T' or s[8] == 't')) { + const y = digitsAt(s, 0, 4).?; + const mo = digitsAt(s, 4, 2).?; + const d = digitsAt(s, 6, 2).?; + if (s.len == 8) return build(y, mo, d, 0, 0, 0, 0); + const h = digitsAt(s, 9, 2) orelse return null; + const mi = digitsAt(s, 11, 2) orelse 0; + const sec = digitsAt(s, 13, 2) orelse 0; + const tail = if (s.len > 15) s[15..] else ""; + const off = zoneOffsetMinutes(std.mem.trim(u8, tail, " ")) orelse return null; + return build(y, mo, d, h, mi, sec, off); + } + const y = digitsAt(s, 0, 4) orelse return null; + if (s.len == 4) return build(y, 1, 1, 0, 0, 0, 0); + if (s[4] != '-') return null; + const mo = digitsAt(s, 5, 2) orelse return null; + if (s.len == 7) return build(y, mo, 1, 0, 0, 0, 0); + if (s[7] != '-') return null; + const d = digitsAt(s, 8, 2) orelse return null; + if (s.len == 10) return build(y, mo, d, 0, 0, 0, 0); + if (s[10] != 'T' and s[10] != 't' and s[10] != ' ') return null; + const h = digitsAt(s, 11, 2) orelse return null; + var i: usize = 13; + var mi: i64 = 0; + var sec: i64 = 0; + if (i < s.len and s[i] == ':') { + mi = digitsAt(s, i + 1, 2) orelse return null; + i += 3; + if (i < s.len and s[i] == ':') { + sec = digitsAt(s, i + 1, 2) orelse return null; + i += 3; + if (i < s.len and (s[i] == '.' or s[i] == ',')) { + i += 1; + while (i < s.len and std.ascii.isDigit(s[i])) i += 1; + } + } + } + const tail = std.mem.trim(u8, s[i..], " "); + const off = zoneOffsetMinutes(tail) orelse return null; + return build(y, mo, d, h, mi, sec, off); +} + +fn parseRfc822(s_in: []const u8) ?i64 { + // Strip trailing "(comment)". + var s = std.mem.trim(u8, s_in, " \t\r\n"); + if (std.mem.indexOfScalar(u8, s, '(')) |p| s = std.mem.trim(u8, s[0..p], " \t"); + var tok: [10][]const u8 = undefined; + var n: usize = 0; + var it = std.mem.tokenizeAny(u8, s, " \t,"); + while (it.next()) |t| { + if (n == tok.len) return null; + tok[n] = t; + n += 1; + } + var i: usize = 0; + // Optional weekday. + if (i < n and tok[i].len >= 3 and std.ascii.isAlphabetic(tok[i][0]) and monthFromName(tok[i]) == null) i += 1; + // Weekday abbreviations like "Mar" (Tuesday in some locales) collide with months; accept only if next token is numeric. + if (i == 0 and n >= 4 and std.ascii.isAlphabetic(tok[0][0]) and std.ascii.isDigit(tok[1][0])) i += 1; + if (i + 3 > n) return null; + const day = std.fmt.parseInt(i64, std.mem.trimEnd(u8, tok[i], ".stndrh"), 10) catch return null; + const mo = monthFromName(tok[i + 1]) orelse return null; + var yr = std.fmt.parseInt(i64, tok[i + 2], 10) catch return null; + if (tok[i + 2].len == 2) yr += if (yr < 50) 2000 else 1900 else if (tok[i + 2].len == 3) yr += 1900; + var h: i64 = 0; + var mi: i64 = 0; + var sec: i64 = 0; + var off: i64 = 0; + var j = i + 3; + if (j < n and std.mem.indexOfScalar(u8, tok[j], ':') != null) { + var p = std.mem.splitScalar(u8, tok[j], ':'); + h = std.fmt.parseInt(i64, p.next().?, 10) catch return null; + mi = std.fmt.parseInt(i64, p.next() orelse "0", 10) catch return null; + if (p.next()) |sx| { + const dot = std.mem.indexOfScalar(u8, sx, '.') orelse sx.len; + sec = std.fmt.parseInt(i64, sx[0..dot], 10) catch return null; + } + j += 1; + if (j < n) off = zoneOffsetMinutes(tok[j]) orelse 0; + } + return build(yr, mo, day, h, mi, sec, off); +} + +pub fn parse(input: []const u8) ?i64 { + const s = std.mem.trim(u8, input, " \t\r\n"); + if (s.len == 0) return null; + // Epoch seconds / milliseconds. + if (s.len >= 9 and s.len <= 13) { + var all = true; + for (s) |c| if (!std.ascii.isDigit(c)) { + all = false; + break; + }; + if (all) { + const v = std.fmt.parseInt(i64, s, 10) catch return null; + if (s.len >= 11) return @divTrunc(v, 1000); + if (s.len >= 9 and s.len <= 10) return v; + } + } + if (std.ascii.isDigit(s[0]) and s.len >= 4 and (digitsAt(s, 0, 4) != null) and + (s.len == 4 or s[4] == '-' or (s.len >= 8 and digitsAt(s, 0, 8) != null))) + return parseIso(s); + return parseRfc822(s); +} + +fn expectDate(expected: ?i64, s: []const u8) !void { + try std.testing.expectEqual(expected, parse(s)); +} + +test "rfc822 variants" { + try expectDate(946684800, "Sat, 01 Jan 2000 00:00:00 GMT"); + try expectDate(946684800, "Sat, 01 Jan 2000 00:00:00 +0000"); + try expectDate(946684800, "1 Jan 2000 00:00 UT"); + try expectDate(946684800 + 3600, "Sat, 1 Jan 2000 01:00 Z"); + try expectDate(946684800, "Sat, 01 Jan 2000 01:00:00 +0100"); + try expectDate(946684800 + 5 * 3600, "Sat, 01 Jan 2000 00:00:00 EST"); + try expectDate(946684800, "Sat, 01 Jan 00 00:00:00 GMT"); // 2-digit year + try expectDate(946684800, "SAT, 01 JAN 2000 00:00:00 GMT"); // upper case + try expectDate(946684800, "Saturday, 01 January 2000 00:00:00 GMT"); // long names + try expectDate(946684800, "Sat, 01 Jan 2000 00:00:00 GMT (Coordinated Universal Time)"); + try expectDate(946684800, "01 Jan 2000"); + try expectDate(946684800 + 3600, "Sat, 01 Jan 2000 00:00:00 -0100"); + try expectDate(946684800, "Sat, 01 Jan 2000 00:00:00.123 GMT"); +} + +test "iso 8601 variants" { + try expectDate(946684800, "2000-01-01T00:00:00Z"); + try expectDate(946684800, "2000-01-01T00:00:00+00:00"); + try expectDate(946684800, "2000-01-01T01:00:00+01:00"); + try expectDate(946684800, "2000-01-01T01:00:00+0100"); + try expectDate(946684800, "2000-01-01 00:00:00"); + try expectDate(946684800, "2000-01-01T00:00:00.123456Z"); + try expectDate(946684800, "2000-01-01"); + try expectDate(946684800, "2000-01"); + try expectDate(946684800, "2000"); + try expectDate(946684800, "20000101T000000Z"); + try expectDate(946684800, "2000-01-01t00:00z"); + try expectDate(946684800, "946684800"); + try expectDate(946684800, "946684800000"); +} + +test "invalid dates rejected" { + try expectDate(null, ""); + try expectDate(null, "yesterday"); + try expectDate(null, "2000-13-01T00:00:00Z"); + try expectDate(null, "2001-02-29"); + try expectDate(null, "Sat, 32 Jan 2000 00:00:00 GMT"); + try expectDate(null, "Sat, 01 Foo 2000 00:00:00 GMT"); + try expectDate(null, "01/02/2000"); // ambiguous: refuse to guess + try expectDate(null, "2000-01-01T25:00:00Z"); + try expectDate(null, "2000-01-01T24:59:00Z"); // 24 is valid only as 24:00:00 + try expectDate(null, "2000-01-01T24:00:01Z"); + try expectDate(946684800 + 86400, "2000-01-01T24:00:00Z"); // end of day = next midnight +} + +test "leap day and pre-epoch" { + try expectDate(951782400, "2000-02-29T00:00:00Z"); + try expectDate(-86400, "1969-12-31T00:00:00Z"); +} diff --git a/src/dedupe.zig b/src/dedupe.zig new file mode 100644 index 0000000..8348d45 --- /dev/null +++ b/src/dedupe.zig @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Cross-fetch dedupe: given the items of a fetch, keep only those whose +//! (source, id) has not been seen. The `Seen` interface is implemented by the +//! SQLite-backed store in production and by `MemSeen` in tests. +const std = @import("std"); +const Item = @import("item.zig").Item; + +pub const Seen = struct { + ptr: *anyopaque, + containsFn: *const fn (ptr: *anyopaque, source: []const u8, id: []const u8) bool, + + pub fn contains(self: Seen, source: []const u8, id: []const u8) bool { + return self.containsFn(self.ptr, source, id); + } +}; + +/// Returns the not-yet-seen subset in original order. Allocates from `a`. +pub fn filterNew(a: std.mem.Allocator, seen: Seen, items: []const Item) std.mem.Allocator.Error![]Item { + var out: std.ArrayList(Item) = .empty; + for (items) |it| if (!seen.contains(it.source, it.id)) try out.append(a, it); + return out.items; +} + +pub const MemSeen = struct { + set: std.StringHashMapUnmanaged(void) = .empty, + gpa: std.mem.Allocator, + + fn key(a: std.mem.Allocator, source: []const u8, id: []const u8) ![]u8 { + return std.fmt.allocPrint(a, "{s}\x00{s}", .{ source, id }); + } + pub fn add(self: *MemSeen, source: []const u8, id: []const u8) !void { + try self.set.put(self.gpa, try key(self.gpa, source, id), {}); + } + pub fn deinit(self: *MemSeen) void { + var it = self.set.keyIterator(); + while (it.next()) |k| self.gpa.free(k.*); + self.set.deinit(self.gpa); + } + fn containsImpl(ptr: *anyopaque, source: []const u8, id: []const u8) bool { + const self: *MemSeen = @ptrCast(@alignCast(ptr)); + var buf: [1024]u8 = undefined; + const k = std.fmt.bufPrint(&buf, "{s}\x00{s}", .{ source, id }) catch return false; + return self.set.contains(k); + } + pub fn seen(self: *MemSeen) Seen { + return .{ .ptr = self, .containsFn = containsImpl }; + } +}; + +test "filterNew drops seen ids, scoped by source" { + var ms: MemSeen = .{ .gpa = std.testing.allocator }; + defer ms.deinit(); + try ms.add("a", "1"); + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const mk = struct { + fn f(s: []const u8, id: []const u8) Item { + return .{ .source = s, .id = id, .title = "", .url = "", .published = null, .body = "", .kind = .article }; + } + }.f; + const items = [_]Item{ mk("a", "1"), mk("a", "2"), mk("b", "1") }; + const fresh = try filterNew(arena.allocator(), ms.seen(), &items); + try std.testing.expectEqual(@as(usize, 2), fresh.len); + try std.testing.expectEqualStrings("2", fresh[0].id); + try std.testing.expectEqualStrings("b", fresh[1].source); +} diff --git a/src/encoding.zig b/src/encoding.zig new file mode 100644 index 0000000..3c85aa2 --- /dev/null +++ b/src/encoding.zig @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Character-set detection and conversion to UTF-8 for feed bodies. +//! +//! Precedence (RFC 7303 style): byte-order mark, then HTTP charset, then the +//! XML declaration, then UTF-8. Latin-1 / US-ASCII labels are decoded as +//! windows-1252 (as browsers do), because that is what such feeds contain. +//! Bytes that are invalid in a stream declared as UTF-8 are repaired by +//! reading the offending byte as windows-1252: this recovers the common +//! "declared UTF-8, actually mixed" feed instead of emitting U+FFFD. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const Charset = enum { utf8, utf16le, utf16be, windows1252, iso8859_15 }; + +const cp1252_c1 = [32]u21{ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, + 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +}; + +fn latin1Cp(b: u8, cs: Charset) u21 { + if (b >= 0x80 and b <= 0x9F) return cp1252_c1[b - 0x80]; + if (cs == .iso8859_15) switch (b) { + 0xA4 => return 0x20AC, + 0xA6 => return 0x0160, + 0xA8 => return 0x0161, + 0xB4 => return 0x017D, + 0xB8 => return 0x017E, + 0xBC => return 0x0152, + 0xBD => return 0x0153, + 0xBE => return 0x0178, + else => {}, + }; + return b; +} + +/// Map a charset label (case-insensitive) to a supported charset. +pub fn fromLabel(label: []const u8) ?Charset { + var buf: [32]u8 = undefined; + const l = std.mem.trim(u8, label, " \t\"'"); + if (l.len == 0 or l.len > buf.len) return null; + const low = std.ascii.lowerString(&buf, l); + const eq = std.mem.eql; + if (eq(u8, low, "utf-8") or eq(u8, low, "utf8")) return .utf8; + if (eq(u8, low, "utf-16le") or eq(u8, low, "utf-16") or eq(u8, low, "ucs-2")) return .utf16le; + if (eq(u8, low, "utf-16be")) return .utf16be; + if (eq(u8, low, "iso-8859-15") or eq(u8, low, "latin9")) return .iso8859_15; + for ([_][]const u8{ "iso-8859-1", "iso8859-1", "latin1", "latin-1", "us-ascii", "ascii", "windows-1252", "cp1252", "iso_8859-1", "l1" }) |n| { + if (eq(u8, low, n)) return .windows1252; + } + return null; +} + +/// Extract `charset=` from a Content-Type header value. +pub fn charsetFromContentType(ct: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, ct, ';'); + _ = it.next(); + while (it.next()) |part| { + const p = std.mem.trim(u8, part, " \t"); + if (p.len > 8 and std.ascii.eqlIgnoreCase(p[0..8], "charset=")) return std.mem.trim(u8, p[8..], " \t\"'"); + } + return null; +} + +fn sniffDeclaration(bytes: []const u8) ?[]const u8 { + const head = bytes[0..@min(bytes.len, 256)]; + if (!std.mem.startsWith(u8, head, "") orelse head.len; + const decl = head[0..end]; + const k = std.ascii.findIgnoreCase(decl, "encoding") orelse return null; + var i = k + "encoding".len; + while (i < decl.len and (decl[i] == ' ' or decl[i] == '=')) i += 1; + if (i >= decl.len) return null; + const q = decl[i]; + if (q != '"' and q != '\'') return null; + const rest = decl[i + 1 ..]; + const e = std.mem.indexOfScalar(u8, rest, q) orelse return null; + return rest[0..e]; +} + +pub fn detect(bytes: []const u8, http_charset: ?[]const u8) struct { cs: Charset, skip: usize } { + if (std.mem.startsWith(u8, bytes, "\xEF\xBB\xBF")) return .{ .cs = .utf8, .skip = 3 }; + if (std.mem.startsWith(u8, bytes, "\xFF\xFE")) return .{ .cs = .utf16le, .skip = 2 }; + if (std.mem.startsWith(u8, bytes, "\xFE\xFF")) return .{ .cs = .utf16be, .skip = 2 }; + // BOM-less UTF-16 starting with "<\0" or "\0<" + if (bytes.len >= 4 and bytes[0] == '<' and bytes[1] == 0 and bytes[2] != 0 and bytes[3] == 0) return .{ .cs = .utf16le, .skip = 0 }; + if (bytes.len >= 4 and bytes[0] == 0 and bytes[1] == '<' and bytes[2] == 0 and bytes[3] != 0) return .{ .cs = .utf16be, .skip = 0 }; + if (http_charset) |h| if (fromLabel(h)) |cs| return .{ .cs = cs, .skip = 0 }; + if (sniffDeclaration(bytes)) |d| if (fromLabel(d)) |cs| return .{ .cs = cs, .skip = 0 }; + return .{ .cs = .utf8, .skip = 0 }; +} + +fn appendCp(out: *std.ArrayList(u8), a: Allocator, cp: u21) Allocator.Error!void { + var buf: [4]u8 = undefined; + const c: u21 = if (cp > 0x10FFFF or (cp >= 0xD800 and cp <= 0xDFFF)) 0xFFFD else cp; + const n = std.unicode.utf8Encode(c, &buf) catch unreachable; + try out.appendSlice(a, buf[0..n]); +} + +/// Convert `bytes` to valid UTF-8 allocated from `a` (an arena in practice). +pub fn toUtf8(a: Allocator, bytes: []const u8, http_charset: ?[]const u8) Allocator.Error![]u8 { + const d = detect(bytes, http_charset); + const src = bytes[d.skip..]; + var out: std.ArrayList(u8) = .empty; + try out.ensureTotalCapacity(a, src.len + src.len / 8); + switch (d.cs) { + .utf8 => { + var i: usize = 0; + while (i < src.len) { + const b = src[i]; + if (b < 0x80) { + try out.append(a, b); + i += 1; + continue; + } + const n = std.unicode.utf8ByteSequenceLength(b) catch 0; + if (n > 0 and i + n <= src.len and std.unicode.utf8ValidateSlice(src[i .. i + n])) { + try out.appendSlice(a, src[i .. i + n]); + i += n; + } else { + try appendCp(&out, a, latin1Cp(b, .windows1252)); + i += 1; + } + } + }, + .windows1252, .iso8859_15 => { + for (src) |b| { + if (b < 0x80) try out.append(a, b) else try appendCp(&out, a, latin1Cp(b, d.cs)); + } + }, + .utf16le, .utf16be => { + var i: usize = 0; + while (i + 1 < src.len) { + const u: u16 = if (d.cs == .utf16le) + @as(u16, src[i]) | (@as(u16, src[i + 1]) << 8) + else + (@as(u16, src[i]) << 8) | src[i + 1]; + i += 2; + if (u >= 0xD800 and u <= 0xDBFF and i + 1 < src.len) { + const lo: u16 = if (d.cs == .utf16le) + @as(u16, src[i]) | (@as(u16, src[i + 1]) << 8) + else + (@as(u16, src[i]) << 8) | src[i + 1]; + if (lo >= 0xDC00 and lo <= 0xDFFF) { + i += 2; + try appendCp(&out, a, 0x10000 + ((@as(u21, u) - 0xD800) << 10) + (lo - 0xDC00)); + continue; + } + } + try appendCp(&out, a, u); + } + }, + } + return out.items; +} + +test "utf8 passthrough and BOM strip" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const out = try toUtf8(arena.allocator(), "\xEF\xBB\xBFh\xC3\xA9", null); + try std.testing.expectEqualStrings("h\xC3\xA9", out); +} + +test "latin1 declared in xml decl" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const out = try toUtf8(arena.allocator(), "caf\xE9 \x93q\x94", null); + try std.testing.expectEqualStrings("caf\xC3\xA9 \xE2\x80\x9Cq\xE2\x80\x9D", out); +} + +test "http charset beats declaration; BOM beats http" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const o1 = try toUtf8(a, "\xE9", "iso-8859-1"); + try std.testing.expect(std.mem.endsWith(u8, o1, "\xC3\xA9")); + const o2 = try toUtf8(a, "\xEF\xBB\xBF\xC3\xA9", "iso-8859-1"); + try std.testing.expectEqualStrings("\xC3\xA9", o2); +} + +test "invalid utf8 bytes repaired as cp1252" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const out = try toUtf8(arena.allocator(), "ok \xC3\xA9 bad \x92 end", null); + try std.testing.expectEqualStrings("ok \xC3\xA9 bad \xE2\x80\x99 end", out); +} + +test "utf16 le with bom and surrogate pair" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const out = try toUtf8(arena.allocator(), "\xFF\xFE<\x00a\x00>\x00\x3D\xD8\x00\xDE", null); // U+1F600 + try std.testing.expectEqualStrings("\xF0\x9F\x98\x80", out); +} + +test "charset from content-type" { + try std.testing.expectEqualStrings("utf-8", charsetFromContentType("text/xml; charset=\"utf-8\"").?); + try std.testing.expect(charsetFromContentType("application/xml") == null); +} diff --git a/src/engine.zig b/src/engine.zig new file mode 100644 index 0000000..de289d4 --- /dev/null +++ b/src/engine.zig @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Engine: run one source through its adapter and persist the outcome. This is the only code +//! that knows both the adapter interface and the store; the scheduler only sees `PollResult`. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const adapter = @import("adapter.zig"); +const store_mod = @import("store.zig"); +const transport = @import("transport.zig"); +const dedupe = @import("dedupe.zig"); + +pub const PollResult = struct { + ok: bool, + new_items: usize = 0, + unchanged: bool = false, + class: ?adapter.FailureClass = null, + retry_after_s: ?u32 = null, + /// Static or arena-less text safe to print after the poll (copied into a small buffer). + message: [96]u8 = @splat(0), + + pub fn messageSlice(self: *const PollResult) []const u8 { + return std.mem.sliceTo(&self.message, 0); + } + fn setMessage(self: *PollResult, text: []const u8) void { + const n = @min(text.len, self.message.len - 1); + @memcpy(self.message[0..n], text[0..n]); + self.message[n] = 0; + } +}; + +pub const Engine = struct { + gpa: Allocator, + store: *store_mod.Store, + registry: *const adapter.Registry, + transport: transport.Transport, + + /// Poll `source` once. `now` is wall-clock Unix seconds. + pub fn poll(self: *Engine, source: adapter.Source, now: i64) PollResult { + var arena = std.heap.ArenaAllocator.init(self.gpa); + defer arena.deinit(); + const a = arena.allocator(); + + var res: PollResult = .{ .ok = false }; + const ad = self.registry.find(source.type_name) orelse { + res.class = .permanent; + res.setMessage("no adapter for this source type"); + self.recordFailure(a, source.name, now, res.messageSlice()); + return res; + }; + var st = self.store.getSourceState(a, source.name) catch store_mod.SourceState{}; + const cond: adapter.Cond = .{ .etag = st.etag, .last_modified = st.last_modified, .state = st.adapter_state }; + var ctx: adapter.Context = .{ .arena = a, .transport = self.transport, .now = now }; + + st.last_check = now; + switch (ad.fetch(&ctx, source, cond)) { + .items => |b| { + const n = self.store.ingest(b.items, now) catch |e| { + // Storage failure: do not advance validators, so the next poll refetches. + res.class = .transient; + res.setMessage(@errorName(e)); + self.recordFailure(a, source.name, now, @errorName(e)); + return res; + }; + st.etag = b.cond.etag; + st.last_modified = b.cond.last_modified; + st.adapter_state = b.cond.state orelse st.adapter_state; + st.failures = 0; + st.last_ok = now; + st.last_error = null; + res.ok = true; + res.new_items = n; + }, + .not_modified => |c| { + st.etag = c.etag; + st.last_modified = c.last_modified; + st.adapter_state = c.state orelse st.adapter_state; + st.failures = 0; + st.last_ok = now; + st.last_error = null; + res.ok = true; + res.unchanged = true; + }, + .failed => |f| { + st.failures +|= 1; + st.last_error = f.message; + res.class = f.class; + res.retry_after_s = f.retry_after_s; + res.setMessage(f.message); + }, + } + self.store.putSourceState(source.name, st) catch |e| std.log.warn("saving state for {s}: {s}", .{ source.name, @errorName(e) }); + return res; + } + + fn recordFailure(self: *Engine, a: Allocator, name: []const u8, now: i64, msg: []const u8) void { + var st = self.store.getSourceState(a, name) catch store_mod.SourceState{}; + st.failures +|= 1; + st.last_check = now; + st.last_error = msg; + self.store.putSourceState(name, st) catch {}; + } +}; + +// --------------------------------------------------------------------------- +const testing = std.testing; +const feed_adapter = @import("feed_adapter.zig"); + +const Fake = struct { + queue: []const transport.Response, + err: ?transport.Error = null, + i: usize = 0, + inm_buf: [32]u8 = undefined, + seen_inm: ?[]const u8 = null, + fn req(p: *anyopaque, _: Allocator, r: transport.Request) transport.Error!transport.Response { + const self: *Fake = @ptrCast(@alignCast(p)); + self.seen_inm = if (r.etag) |e| blk: { + @memcpy(self.inm_buf[0..e.len], e); // borrowed only for the duration of the call + break :blk self.inm_buf[0..e.len]; + } else null; + if (self.err) |e| return e; + defer self.i += 1; + return self.queue[@min(self.i, self.queue.len - 1)]; + } + fn tr(self: *Fake) transport.Transport { + return .{ .ptr = self, .requestFn = req }; + } +}; + +const feed_body = @embedFile("testdata/dedupe.rss"); + +test "poll: 200 ingests, validators persist, 304 is cheap, failure counts, success resets" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_arena = std.heap.ArenaAllocator.init(testing.allocator); + defer path_arena.deinit(); + const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", path_arena.allocator()); + var store = try store_mod.Store.open(testing.allocator, testing.io, dir, true); + defer store.close(); + + var fa: feed_adapter.FeedAdapter = .{}; + var reg: adapter.Registry = .{}; + try reg.register(fa.asAdapter()); + var fake: Fake = .{ .queue = &.{ + .{ .status = 200, .body = feed_body, .etag = "\"e1\"" }, + .{ .status = 304 }, + .{ .status = 500 }, + .{ .status = 429, .retry_after = "90" }, + .{ .status = 200, .body = feed_body, .etag = "\"e2\"" }, + } }; + var eng: Engine = .{ .gpa = testing.allocator, .store = &store, .registry = ®, .transport = fake.tr() }; + const src: adapter.Source = .{ .name = "s", .type_name = "feed", .url = "http://x/", .interval_s = 60 }; + + var r = eng.poll(src, 100); + try testing.expect(r.ok); + try testing.expectEqual(@as(usize, 5), r.new_items); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var st = try store.getSourceState(arena.allocator(), "s"); + try testing.expectEqualStrings("\"e1\"", st.etag.?); + try testing.expectEqual(@as(?i64, 100), st.last_ok); + + r = eng.poll(src, 200); + try testing.expect(r.ok and r.unchanged); + try testing.expectEqualStrings("\"e1\"", fake.seen_inm.?); // conditional GET used stored validator + try testing.expectEqual(@as(usize, 0), r.new_items); + + r = eng.poll(src, 300); + try testing.expect(!r.ok); + try testing.expectEqual(adapter.FailureClass.transient, r.class.?); + st = try store.getSourceState(arena.allocator(), "s"); + try testing.expectEqual(@as(u32, 1), st.failures); + try testing.expectEqualStrings("\"e1\"", st.etag.?); // validators survive failures + + r = eng.poll(src, 400); + try testing.expectEqual(adapter.FailureClass.rate_limited, r.class.?); + try testing.expectEqual(@as(?u32, 90), r.retry_after_s); + st = try store.getSourceState(arena.allocator(), "s"); + try testing.expectEqual(@as(u32, 2), st.failures); + + r = eng.poll(src, 500); // same items again: nothing new + try testing.expect(r.ok); + try testing.expectEqual(@as(usize, 0), r.new_items); + st = try store.getSourceState(arena.allocator(), "s"); + try testing.expectEqual(@as(u32, 0), st.failures); + try testing.expectEqualStrings("\"e2\"", st.etag.?); +} + +test "poll: unknown adapter type is a permanent failure, not a crash" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_arena = std.heap.ArenaAllocator.init(testing.allocator); + defer path_arena.deinit(); + const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", path_arena.allocator()); + var store = try store_mod.Store.open(testing.allocator, testing.io, dir, true); + defer store.close(); + var reg: adapter.Registry = .{}; + var fake: Fake = .{ .queue = &.{} }; + var eng: Engine = .{ .gpa = testing.allocator, .store = &store, .registry = ®, .transport = fake.tr() }; + const r = eng.poll(.{ .name = "n", .type_name = "nntp", .url = "news://x", .interval_s = 60 }, 1); + try testing.expectEqual(adapter.FailureClass.permanent, r.class.?); +} diff --git a/src/feed.zig b/src/feed.zig new file mode 100644 index 0000000..1b1da74 --- /dev/null +++ b/src/feed.zig @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! RSS 2.0 (and 0.9x), Atom 1.0 and RSS 1.0 (RDF) -> normalised items. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const xml = @import("xml.zig"); +const encoding = @import("encoding.zig"); +const dates = @import("dates.zig"); +const Item = @import("item.zig").Item; + +pub const Format = enum { rss2, atom, rdf }; + +pub const Parsed = struct { + format: Format, + title: []const u8, + items: []Item, +}; + +pub const ParseError = error{ NotAFeed, NoRoot, TooLarge } || Allocator.Error; + +/// `bytes` is the raw response body; `http_charset` the charset parameter of Content-Type. +/// Everything returned is allocated from `a` (use an arena). +pub fn parse(a: Allocator, source: []const u8, bytes: []const u8, http_charset: ?[]const u8) ParseError!Parsed { + const utf8 = try encoding.toUtf8(a, bytes, http_charset); + const doc = try xml.parse(a, utf8, .{}); + const root = xml.rootElement(doc).?; + + var fmt: Format = undefined; + if (root.is("rss") or root.is("channel")) { + fmt = .rss2; + } else if (root.is("feed")) { + fmt = .atom; + } else if (root.is("rdf")) { + fmt = .rdf; + } else return error.NotAFeed; + + var items: std.ArrayList(Item) = .empty; + var title: []const u8 = ""; + switch (fmt) { + .rss2, .rdf => { + const channel = if (root.is("channel")) root else root.child("channel"); + // A channel without a is malformed but must not take the daemon down: untrusted input. + if (channel) |ch| { + if (ch.child("title")) |t| title = try nodeText(a, t); + } + // Items are channel children in RSS 2.0 and root children in RSS 1.0; accept both. + for ([_]?*xml.Node{ channel, root }) |maybe| { + const container = maybe orelse continue; + var c = container.first_child; + while (c) |n| : (c = n.next) { + if (n.is("item")) if (try rssItem(a, source, n)) |it| try items.append(a, it); + } + if (items.items.len > 0) break; + } + }, + .atom => { + if (root.child("title")) |t| title = try nodeText(a, t); + var c = root.first_child; + while (c) |n| : (c = n.next) { + if (n.is("entry")) if (try atomEntry(a, source, n)) |it| try items.append(a, it); + } + }, + } + return .{ .format = fmt, .title = title, .items = try dedupeBatch(a, items.items) }; +} + +/// Text of a node; for xhtml/inner-markup content, flatten descendants. +fn nodeText(a: Allocator, n: *const xml.Node) Allocator.Error![]const u8 { + if (n.first_child == null) return n.text(); + return std.mem.trim(u8, try n.deepText(a), " \t\r\n"); +} + +fn firstNonEmpty(cands: []const []const u8) []const u8 { + for (cands) |c| if (c.len > 0) return c; + return ""; +} + +fn childText(a: Allocator, n: *const xml.Node, name: []const u8) Allocator.Error![]const u8 { + const c = n.child(name) orelse return ""; + return nodeText(a, c); +} + +fn rssItem(a: Allocator, source: []const u8, n: *const xml.Node) Allocator.Error!?Item { + const title = try childText(a, n, "title"); + var link = try childText(a, n, "link"); + var guid = try childText(a, n, "guid"); + if (guid.len == 0) if (n.attr("about")) |ab| { // RDF: rdf:about is the identifier + guid = ab; + }; + if (link.len == 0) { + // RSS where <link> is missing: a permalink guid or atom:link href stands in. + if (std.mem.startsWith(u8, guid, "http")) link = guid else if (n.child("link")) |l| link = l.attr("href") orelse ""; + } + const date_raw = firstNonEmpty(&.{ try childText(a, n, "pubDate"), try childText(a, n, "date"), try childText(a, n, "published"), try childText(a, n, "updated") }); + const body = firstNonEmpty(&.{ try childText(a, n, "encoded"), try childText(a, n, "description"), try childText(a, n, "summary") }); + if (title.len == 0 and link.len == 0 and guid.len == 0 and body.len == 0) return null; + const published = dates.parse(date_raw); + return .{ + .source = source, + .id = try deriveId(a, guid, link, published, date_raw, title, body), + .title = title, + .url = link, + .published = published, + .body = body, + .kind = .article, + }; +} + +fn atomEntry(a: Allocator, source: []const u8, n: *const xml.Node) Allocator.Error!?Item { + const title = try childText(a, n, "title"); + const guid = try childText(a, n, "id"); + // Prefer rel=alternate (or no rel); else the first link with an href. + var link: []const u8 = ""; + var first_any: []const u8 = ""; + var c = n.first_child; + while (c) |ln| : (c = ln.next) { + if (!ln.is("link")) continue; + const href = ln.attr("href") orelse continue; + if (first_any.len == 0) first_any = href; + const rel = ln.attr("rel") orelse "alternate"; + if (std.mem.eql(u8, rel, "alternate")) { + link = href; + break; + } + } + if (link.len == 0) link = first_any; + const date_raw = firstNonEmpty(&.{ try childText(a, n, "published"), try childText(a, n, "updated"), try childText(a, n, "issued"), try childText(a, n, "modified") }); + const body = firstNonEmpty(&.{ try childText(a, n, "content"), try childText(a, n, "summary") }); + if (title.len == 0 and link.len == 0 and guid.len == 0 and body.len == 0) return null; + const published = dates.parse(date_raw); + return .{ + .source = source, + .id = try deriveId(a, guid, link, published, date_raw, title, body), + .title = title, + .url = link, + .published = published, + .body = body, + .kind = .article, + }; +} + +/// Resolve `link` against the feed's own URL (RFC 3986 §5), so a relative Atom `href` or RSS +/// `<link>` such as `/posts/1` becomes an absolute URL the UI will render. Empty links, links +/// that already carry a scheme, and unparsable bases pass through unchanged. `xml:base` is not +/// consulted: the fetched feed URL is the right base for the feeds seen in practice. +pub fn resolveLink(a: Allocator, base_url: []const u8, link: []const u8) Allocator.Error![]const u8 { + if (link.len == 0 or hasScheme(link)) return link; + const base = std.Uri.parse(base_url) catch return link; + // resolveInPlace wants the reference at the start of a scratch buffer with room for a merged path. + const buf = try a.alloc(u8, link.len + base_url.len + 2); + @memcpy(buf[0..link.len], link); + var aux: []u8 = buf; + const resolved = std.Uri.resolveInPlace(base, link.len, &aux) catch return link; + return std.fmt.allocPrint(a, "{f}", .{resolved.fmt(.all)}); +} + +fn hasScheme(s: []const u8) bool { + if (s.len == 0 or !std.ascii.isAlphabetic(s[0])) return false; + for (s, 0..) |c, i| { + if (c == ':') return i > 0; + if (!(std.ascii.isAlphanumeric(c) or c == '+' or c == '-' or c == '.')) return false; + } + return false; +} + +/// Item identity: the feed's own guid / atom:id when present; otherwise link plus +/// date; otherwise (no link) a content hash of title + body. Prefixes keep the +/// three namespaces from colliding. +pub fn deriveId(a: Allocator, guid: []const u8, link: []const u8, published: ?i64, date_raw: []const u8, title: []const u8, body: []const u8) Allocator.Error![]const u8 { + if (guid.len > 0) return guid; + if (link.len > 0) { + if (published) |p| return std.fmt.allocPrint(a, "link:{s}#{d}", .{ link, p }); + return std.fmt.allocPrint(a, "link:{s}#{s}", .{ link, date_raw }); + } + var h = std.crypto.hash.sha2.Sha256.init(.{}); + h.update(title); + h.update("\x00"); + h.update(body); + var d: [32]u8 = undefined; + h.final(&d); + return std.fmt.allocPrint(a, "hash:{x}", .{d[0..16]}); +} + +/// Drop repeated ids inside one fetched batch (first occurrence wins, order kept). +pub fn dedupeBatch(a: Allocator, items: []Item) Allocator.Error![]Item { + var seen: std.StringHashMapUnmanaged(void) = .empty; + var out: std.ArrayList(Item) = .empty; + for (items) |it| { + const gop = try seen.getOrPut(a, it.id); + if (gop.found_existing) continue; + try out.append(a, it); + } + return out.items; +} + +// --------------------------------------------------------------------------- +// Tests: real recorded feeds + hand-made edge-case fixtures (src/testdata). +// --------------------------------------------------------------------------- +const testing = std.testing; + +fn parseFixture(a: Allocator, comptime path: []const u8, http_charset: ?[]const u8) !Parsed { + return parse(a, "t", @embedFile("testdata/" ++ path), http_charset); +} + +test "real: BBC RSS 2.0 with CDATA and dc:/media: namespaces" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "real/bbc.rss", "utf-8"); + try testing.expectEqual(Format.rss2, p.format); + try testing.expectEqualStrings("BBC News", p.title); + try testing.expect(p.items.len >= 20); + for (p.items) |it| { + try testing.expect(it.title.len > 0); + try testing.expect(std.mem.startsWith(u8, it.url, "http")); + try testing.expect(it.published != null); + try testing.expect(it.id.len > 0); + } +} + +test "real: LWN, Zig news, kernel.org RSS 2.0" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + inline for (.{ .{ "real/lwn.rss", 15 }, .{ "real/zig.rss", 30 }, .{ "real/kernel.rss", 9 } }) |fx| { + const p = try parseFixture(a, fx[0], null); + try testing.expectEqual(Format.rss2, p.format); + try testing.expectEqual(@as(usize, fx[1]), p.items.len); + for (p.items) |it| try testing.expect(it.published != null); + } +} + +test "real: Slashdot RSS 1.0 (RDF), ISO-8859-1 declared, items are root children" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "real/slashdot.rdf", null); + try testing.expectEqual(Format.rdf, p.format); + try testing.expectEqual(@as(usize, 15), p.items.len); + for (p.items) |it| { + try testing.expect(it.title.len > 0); + try testing.expect(it.published != null); // dc:date + try testing.expect(std.unicode.utf8ValidateSlice(it.title)); + try testing.expect(std.unicode.utf8ValidateSlice(it.body)); + } +} + +test "real: Atom (xkcd, Rust blog)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const x = try parseFixture(a, "real/xkcd.atom", null); + try testing.expectEqual(Format.atom, x.format); + try testing.expectEqual(@as(usize, 4), x.items.len); + try testing.expectEqualStrings("Rejected Space Mission Profiles", x.items[0].title); + const r = try parseFixture(a, "real/rustblog.atom", null); + try testing.expectEqual(@as(usize, 10), r.items.len); + for (r.items) |it| { + try testing.expect(it.id.len > 0 and it.url.len > 0 and it.published != null and it.body.len > 0); + } +} + +test "edge: malformed XML still yields items" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "malformed.rss", null); + try testing.expectEqual(@as(usize, 3), p.items.len); + try testing.expectEqualStrings("Fish & Chips < 5 quid", p.items[0].title); + try testing.expectEqualStrings("Unclosed description", p.items[1].body); + try testing.expectEqualStrings("Third", p.items[2].title); +} + +test "edge: declared utf-8 but contains cp1252 bytes (mixed encodings)" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "mixed-encoding.rss", null); + try testing.expectEqual(@as(usize, 2), p.items.len); + try testing.expectEqualStrings("Caf\u{e9} \u{2018}quoted\u{2019} \u{4e2d}\u{6587}", p.items[0].title); + try testing.expect(std.unicode.utf8ValidateSlice(p.items[1].title)); +} + +test "edge: UTF-16LE with BOM" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "utf16le.atom", null); + try testing.expectEqual(Format.atom, p.format); + try testing.expectEqualStrings("\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}", p.items[0].title); +} + +test "edge: HTTP charset overrides a lying declaration" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "latin1-no-declaration.rss", "iso-8859-1"); + try testing.expectEqualStrings("Z\u{fc}rich \u{e0} la carte", p.items[0].title); +} + +test "edge: unusual date formats" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "odd-dates.rss", null); + try testing.expectEqual(@as(usize, 9), p.items.len); + const expect = [_]?i64{ 946684800, 946684800, 946684800, 946684800, 946684800, 946684800 + 5 * 3600, 946684800, null, 946684800 }; + for (p.items, expect, 0..) |it, e, i| { + testing.expectEqual(e, it.published) catch |err| { + std.debug.print("item {d} ({s}): got {?d}\n", .{ i, it.title, it.published }); + return err; + }; + } +} + +test "dedupe: guid wins; link+date fallback; hash fallback; in-batch duplicates dropped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parseFixture(arena.allocator(), "dedupe.rss", null); + try testing.expectEqual(@as(usize, 5), p.items.len); // 7 items, 2 duplicate ids + try testing.expectEqualStrings("guid-1", p.items[0].id); + try testing.expectEqualStrings("link:http://example.com/a#946684800", p.items[1].id); + try testing.expectEqualStrings("link:http://example.com/a#946771200", p.items[2].id); // re-dated post is a new item + try testing.expect(std.mem.startsWith(u8, p.items[3].id, "hash:")); + try testing.expectEqualStrings("guid-2", p.items[4].id); +} + +test "edge: channel without a title yields an empty title, not a panic" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const p = try parse(arena.allocator(), "t", "<rss><channel><item><title>xhttp://x/1", null); + try testing.expectEqualStrings("", p.title); + try testing.expectEqual(@as(usize, 1), p.items.len); + const r = try parse(arena.allocator(), "t", "y", null); + try testing.expectEqualStrings("", r.title); + try testing.expectEqual(@as(usize, 1), r.items.len); +} + +test "resolveLink: relative references resolve against the feed URL; absolute and schemed pass through" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try testing.expectEqualStrings("http://x/posts/1", try resolveLink(a, "http://x/feed.xml", "/posts/1")); + try testing.expectEqualStrings("http://x/blog/posts/1", try resolveLink(a, "http://x/blog/feed.xml", "posts/1")); + try testing.expectEqualStrings("http://x/a/c?q=1", try resolveLink(a, "http://x/a/b/feed", "../c?q=1")); + try testing.expectEqualStrings("https://x:8443/p", try resolveLink(a, "https://x:8443/feed", "/p")); + try testing.expectEqualStrings("https://other/z", try resolveLink(a, "http://x/feed", "https://other/z")); + try testing.expectEqualStrings("mailto:a@b", try resolveLink(a, "http://x/feed", "mailto:a@b")); + try testing.expectEqualStrings("//cdn/x", try resolveLink(a, "not a url", "//cdn/x")); + try testing.expectEqualStrings("", try resolveLink(a, "http://x/feed", "")); +} + +test "not a feed" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectError(error.NotAFeed, parse(arena.allocator(), "t", "Rate limited", null)); + try testing.expectError(error.NoRoot, parse(arena.allocator(), "t", "plain text", null)); +} diff --git a/src/feed_adapter.zig b/src/feed_adapter.zig new file mode 100644 index 0000000..3d0be21 --- /dev/null +++ b/src/feed_adapter.zig @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! The "feed" adapter: RSS 2.0 / Atom / RSS 1.0 over HTTP(S) with conditional GET. +const std = @import("std"); +const adapter = @import("adapter.zig"); +const feed = @import("feed.zig"); +const transport = @import("transport.zig"); +const encoding = @import("encoding.zig"); + +pub const FeedAdapter = struct { + pub const type_name = "feed"; + + pub fn asAdapter(self: *FeedAdapter) adapter.Adapter { + return .{ .type_name = type_name, .ptr = self, .fetchFn = fetchImpl }; + } + + fn fetchImpl(_: *anyopaque, ctx: *adapter.Context, source: adapter.Source, cond: adapter.Cond) adapter.Outcome { + return fetch(ctx, source, cond); + } +}; + +fn fail(class: adapter.FailureClass, status: ?u16, retry: ?u32, msg: []const u8) adapter.Outcome { + return .{ .failed = .{ .class = class, .http_status = status, .retry_after_s = retry, .message = msg } }; +} + +pub fn fetch(ctx: *adapter.Context, source: adapter.Source, cond: adapter.Cond) adapter.Outcome { + const resp = ctx.transport.request(ctx.arena, .{ + .url = source.url, + .etag = cond.etag, + .last_modified = cond.last_modified, + }) catch |e| return fail(.transient, null, null, @errorName(e)); + + const retry = if (resp.retry_after) |h| adapter.parseRetryAfter(h, ctx.now) else null; + switch (resp.status) { + 304 => return .{ + .not_modified = .{ + // A 304 may omit validators; keep what we had. + .etag = resp.etag orelse cond.etag, + .last_modified = resp.last_modified orelse cond.last_modified, + }, + }, + 200...299 => {}, + 429 => return fail(.rate_limited, 429, retry, "429 Too Many Requests"), + 503 => return fail(.rate_limited, 503, retry, "503 Service Unavailable"), + 401, 403, 404, 410 => return fail(.permanent, resp.status, retry, "client error"), + else => return fail(.transient, resp.status, retry, "unexpected HTTP status"), + } + + const charset = if (resp.content_type) |ct| encoding.charsetFromContentType(ct) else null; + const parsed = feed.parse(ctx.arena, source.name, resp.body, charset) catch |e| switch (e) { + error.OutOfMemory => return fail(.transient, resp.status, null, "out of memory"), + error.TooLarge => return fail(.malformed, resp.status, null, "feed too large or too deeply nested"), + error.NotAFeed => return fail(.malformed, resp.status, null, "response is XML but not RSS, Atom or RDF"), + error.NoRoot => return fail(.malformed, resp.status, null, "response is not XML"), + }; + // Relative links (Atom permits them; some RSS carries them) are resolved against the URL the + // feed was actually retrieved from: after a redirect that is the final URL, not the configured one. + const base_url = resp.final_url orelse source.url; + for (parsed.items) |*it| it.url = feed.resolveLink(ctx.arena, base_url, it.url) catch it.url; + return .{ .items = .{ + .items = parsed.items, + .title = parsed.title, + .cond = .{ .etag = resp.etag, .last_modified = resp.last_modified }, + } }; +} + +// ---- tests with a fake transport ------------------------------------------- + +const FakeTransport = struct { + responses: []const transport.Response, + err: ?transport.Error = null, + calls: usize = 0, + last_req: ?transport.Request = null, + + fn requestImpl(ptr: *anyopaque, _: std.mem.Allocator, req: transport.Request) transport.Error!transport.Response { + const self: *FakeTransport = @ptrCast(@alignCast(ptr)); + self.last_req = req; + defer self.calls += 1; + if (self.err) |e| return e; + return self.responses[@min(self.calls, self.responses.len - 1)]; + } + fn t(self: *FakeTransport) transport.Transport { + return .{ .ptr = self, .requestFn = requestImpl }; + } +}; + +const src: adapter.Source = .{ .name = "t", .type_name = "feed", .url = "http://x/feed", .interval_s = 60 }; +const body = @embedFile("testdata/dedupe.rss"); + +fn run(ft: *FakeTransport, cond: adapter.Cond, now: i64, arena: std.mem.Allocator) adapter.Outcome { + var ctx: adapter.Context = .{ .arena = arena, .transport = ft.t(), .now = now }; + return fetch(&ctx, src, cond); +} + +test "200 returns items and validators; conditional headers are sent" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ft: FakeTransport = .{ .responses = &.{.{ .status = 200, .body = body, .etag = "\"v2\"", .last_modified = "Sat, 01 Jan 2000 00:00:00 GMT" }} }; + const o = run(&ft, .{ .etag = "\"v1\"", .last_modified = "old" }, 0, arena.allocator()); + try std.testing.expectEqual(@as(usize, 5), o.items.items.len); + try std.testing.expectEqualStrings("\"v2\"", o.items.cond.etag.?); + try std.testing.expectEqualStrings("\"v1\"", ft.last_req.?.etag.?); + try std.testing.expectEqualStrings("old", ft.last_req.?.last_modified.?); +} + +test "304 keeps previous validators" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ft: FakeTransport = .{ .responses = &.{.{ .status = 304 }} }; + const o = run(&ft, .{ .etag = "\"v1\"" }, 0, arena.allocator()); + try std.testing.expectEqualStrings("\"v1\"", o.not_modified.etag.?); +} + +test "429 honours Retry-After seconds and date; 503 without header" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ft: FakeTransport = .{ .responses = &.{.{ .status = 429, .retry_after = "120" }} }; + var o = run(&ft, .{}, 0, arena.allocator()); + try std.testing.expectEqual(adapter.FailureClass.rate_limited, o.failed.class); + try std.testing.expectEqual(@as(?u32, 120), o.failed.retry_after_s); + ft = .{ .responses = &.{.{ .status = 503, .retry_after = "Sat, 01 Jan 2000 00:01:00 GMT" }} }; + o = run(&ft, .{}, 946684800, arena.allocator()); + try std.testing.expectEqual(@as(?u32, 60), o.failed.retry_after_s); + ft = .{ .responses = &.{.{ .status = 503 }} }; + o = run(&ft, .{}, 0, arena.allocator()); + try std.testing.expectEqual(@as(?u32, null), o.failed.retry_after_s); +} + +test "status classes and transport errors" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const cases = [_]struct { u16, adapter.FailureClass }{ .{ 404, .permanent }, .{ 410, .permanent }, .{ 403, .permanent }, .{ 500, .transient }, .{ 502, .transient } }; + for (cases) |c| { + var ft: FakeTransport = .{ .responses = &.{.{ .status = c[0] }} }; + try std.testing.expectEqual(c[1], run(&ft, .{}, 0, arena.allocator()).failed.class); + } + var ft: FakeTransport = .{ .responses = &.{}, .err = error.Timeout }; + try std.testing.expectEqual(adapter.FailureClass.transient, run(&ft, .{}, 0, arena.allocator()).failed.class); +} + +test "relative entry links are resolved against the feed URL" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const atom = + \\t + \\e1one + \\e2two + \\ + ; + var ft: FakeTransport = .{ .responses = &.{.{ .status = 200, .body = atom }} }; + const o = run(&ft, .{}, 0, arena.allocator()); + try std.testing.expectEqual(@as(usize, 2), o.items.items.len); + try std.testing.expectEqualStrings("http://x/posts/1", o.items.items[0].url); + try std.testing.expectEqualStrings("https://other.example/2", o.items.items[1].url); + + // After a redirect the base is the final URL, not the configured one. + var ft2: FakeTransport = .{ .responses = &.{.{ .status = 200, .body = atom, .final_url = "https://moved.example/blog/feed.xml" }} }; + const o2 = run(&ft2, .{}, 0, arena.allocator()); + try std.testing.expectEqualStrings("https://moved.example/posts/1", o2.items.items[0].url); +} + +test "HTML and garbage bodies are malformed, not crashes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ft: FakeTransport = .{ .responses = &.{.{ .status = 200, .body = "captcha" }} }; + try std.testing.expectEqual(adapter.FailureClass.malformed, run(&ft, .{}, 0, arena.allocator()).failed.class); + ft = .{ .responses = &.{.{ .status = 200, .body = "\x00\x01\x02" }} }; + try std.testing.expectEqual(adapter.FailureClass.malformed, run(&ft, .{}, 0, arena.allocator()).failed.class); +} diff --git a/src/http.zig b/src/http.zig new file mode 100644 index 0000000..7b1f5aa --- /dev/null +++ b/src/http.zig @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! HTTP(S) transport over std.http.Client with a hard wall-clock timeout per request. +//! +//! std.http.Client has no read timeout, so each request races a timer using Io.Select; when the +//! timer wins the request task is cancelled (which interrupts the blocking syscall). +//! Connections are not kept alive: polls are minutes apart, so pooling would only hold memory. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const transport = @import("transport.zig"); +const tls_client = @import("vendor/tls_client.zig"); + +pub const HttpTransport = struct { + gpa: Allocator, + io: Io, + client: std.http.Client, + user_agent: []const u8, + timeout_s: u32, + + pub fn init(gpa: Allocator, io: Io, user_agent: []const u8, timeout_s: u32) HttpTransport { + return .{ .gpa = gpa, .io = io, .client = .{ .allocator = gpa, .io = io }, .user_agent = user_agent, .timeout_s = timeout_s }; + } + + pub fn deinit(self: *HttpTransport) void { + self.client.deinit(); + } + + pub fn asTransport(self: *HttpTransport) transport.Transport { + return .{ .ptr = self, .requestFn = requestImpl, .certNotAfterFn = certImpl }; + } + + const CertRace = union(enum) { + probe: ?i64, + timer: Io.Cancelable!void, + }; + + fn certImpl(ptr: *anyopaque, host: []const u8, port: u16) ?i64 { + const self: *HttpTransport = @ptrCast(@alignCast(ptr)); + var buf: [2]CertRace = undefined; + var sel = Io.Select(CertRace).init(self.io, &buf); + sel.concurrent(.probe, probeCert, .{ self, host, port }) catch return null; + sel.concurrent(.timer, timerTask, .{ self.io, self.timeout_s }) catch { + sel.cancelDiscard(); + return null; + }; + const first = sel.await() catch { + sel.cancelDiscard(); + return null; + }; + sel.cancelDiscard(); + return switch (first) { + .probe => |v| v, + .timer => null, + }; + } + + /// Handshake with certificate verification disabled and read only the leaf's notAfter. + /// Trust is established by the real request (std.http.Client verifies); this only records expiry. + fn probeCert(self: *HttpTransport, host: []const u8, port: u16) ?i64 { + const hn = Io.net.HostName.init(host) catch return null; + const stream = hn.connect(self.io, port, .{ .mode = .stream }) catch return null; + defer stream.close(self.io); + const n = tls_client.min_buffer_len; + const bufs = self.gpa.alloc(u8, n * 4) catch return null; + defer self.gpa.free(bufs); + var sr = stream.reader(self.io, bufs[0..n]); + var sw = stream.writer(self.io, bufs[n .. 2 * n]); + var entropy: [tls_client.Options.entropy_len]u8 = undefined; + self.io.random(&entropy); + var not_after: u64 = 0; + _ = tls_client.init(&sr.interface, &sw.interface, .{ + .host = .{ .sni_only = host }, + .ca = .no_verification, + .read_buffer = bufs[2 * n .. 3 * n], + .write_buffer = bufs[3 * n ..], + .entropy = &entropy, + .realtime_now = Io.Timestamp.now(self.io, .real), + .leaf_not_after = ¬_after, + }) catch {}; + return if (not_after != 0) @intCast(not_after) else null; + } + + /// Drop the parsed CA bundle (and force a rescan on the next HTTPS request) to give memory back + /// while idle. Costs a few milliseconds per scan; the daemon calls it between poll bursts. + pub fn releaseCaches(self: *HttpTransport) void { + if (!std.http.Client.disable_tls) { + self.client.ca_bundle.deinit(self.gpa); + self.client.ca_bundle = .empty; + self.client.now = null; + } + } + + const Race = union(enum) { + fetch: transport.Error!transport.Response, + timer: Io.Cancelable!void, + }; + + fn timerTask(io: Io, secs: u32) Io.Cancelable!void { + try io.sleep(.fromSeconds(secs), .awake); + } + + fn requestImpl(ptr: *anyopaque, arena: Allocator, req: transport.Request) transport.Error!transport.Response { + const self: *HttpTransport = @ptrCast(@alignCast(ptr)); + var buf: [2]Race = undefined; + var sel = Io.Select(Race).init(self.io, &buf); + sel.concurrent(.fetch, doRequest, .{ self, arena, req }) catch { + // No spare thread: run unguarded rather than not at all. + std.log.warn("no concurrency available; request to {s} runs without a timeout", .{req.url}); + return doRequest(self, arena, req); + }; + sel.concurrent(.timer, timerTask, .{ self.io, self.timeout_s }) catch { + const r = sel.await() catch { + sel.cancelDiscard(); + return error.Other; + }; + sel.cancelDiscard(); + return r.fetch; + }; + const first = sel.await() catch { + sel.cancelDiscard(); + return error.Other; + }; + sel.cancelDiscard(); + return switch (first) { + .fetch => |r| r, + .timer => error.Timeout, + }; + } + + fn dupeOpt(a: Allocator, s: ?[]const u8) Allocator.Error!?[]const u8 { + return if (s) |v| try a.dupe(u8, v) else null; + } + + fn doRequest(self: *HttpTransport, arena: Allocator, req: transport.Request) transport.Error!transport.Response { + return self.doRequestInner(arena, req) catch |e| return mapError(e); + } + + fn doRequestInner(self: *HttpTransport, arena: Allocator, req: transport.Request) !transport.Response { + var uri = try std.Uri.parse(req.url); + var hdrs: [3]std.http.Header = undefined; + var nh: usize = 0; + hdrs[nh] = .{ .name = "Accept", .value = "application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, */*;q=0.5" }; + nh += 1; + if (req.etag) |v| { + hdrs[nh] = .{ .name = "If-None-Match", .value = v }; + nh += 1; + } + if (req.last_modified) |v| { + hdrs[nh] = .{ .name = "If-Modified-Since", .value = v }; + nh += 1; + } + // Redirects are followed here, not by std.http.Client: its automatic path dereferences an + // uninitialised TLS clock when an http:// request redirects to https:// on a client that has + // not yet made an https request. Every hop below is a fresh `request()`, which initialises + // TLS state correctly. + var hops: u8 = 0; + while (true) : (hops += 1) { + var r = try self.client.request(if (req.method == .head) .HEAD else .GET, uri, .{ + .keep_alive = false, + .redirect_behavior = .unhandled, + .headers = .{ + .user_agent = .{ .override = self.user_agent }, + // gzip/deflate only: zstd decoding needs a multi-megabyte window. + .accept_encoding = .{ .override = "gzip, deflate" }, + }, + .extra_headers = hdrs[0..nh], + }); + defer r.deinit(); + try r.sendBodiless(); + + var resp = try r.receiveHead(&.{}); + const status = @backingInt(resp.head.status); + + switch (status) { + 301, 302, 303, 307, 308 => if (req.follow_redirects) { + if (hops >= 5) return error.TooManyRedirects; + const loc = resp.head.location orelse return error.BadRedirect; + // Resolve (possibly relative) Location against the current URI, in an arena buffer. + const aux = try arena.alloc(u8, loc.len + uri_scratch); + @memcpy(aux[0..loc.len], loc); + var rest: []u8 = aux; // resolveInPlace expects the location at the start of the buffer + uri = std.Uri.resolveInPlace(uri, loc.len, &rest) catch return error.BadRedirect; + continue; + }, + else => {}, + } + + var out: transport.Response = .{ .status = status }; + if (hops > 0) out.final_url = try std.fmt.allocPrint(arena, "{f}", .{uri.fmt(.all)}); + if (resp.head.location) |l| out.location = try arena.dupe(u8, l); + out.content_type = try dupeOpt(arena, resp.head.content_type); + var it = resp.head.iterateHeaders(); + while (it.next()) |h| { + if (std.ascii.eqlIgnoreCase(h.name, "etag")) out.etag = try arena.dupe(u8, h.value) else if (std.ascii.eqlIgnoreCase(h.name, "last-modified")) out.last_modified = try arena.dupe(u8, h.value) else if (std.ascii.eqlIgnoreCase(h.name, "retry-after")) out.retry_after = try arena.dupe(u8, h.value); + } + if (req.method == .head or status == 304 or status == 204) return out; + + var transfer_buf: [64]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const dbuf = try arena.alloc(u8, std.compress.flate.max_window_len); + defer arena.free(dbuf); + const reader = resp.readerDecompressing(&transfer_buf, &decompress, dbuf); + out.body = reader.allocRemaining(arena, .limited(req.max_body_bytes)) catch |e| switch (e) { + error.StreamTooLong => return error.StreamTooLong, + error.ReadFailed => return resp.bodyErr() orelse error.ReadFailed, + error.OutOfMemory => return error.OutOfMemory, + }; + return out; + } + } + + const uri_scratch = 2048; + + fn mapError(e: anyerror) transport.Error { + const n = @errorName(e); + const has = struct { + fn f(hay: []const u8, needle: []const u8) bool { + return std.mem.find(u8, hay, needle) != null; + } + }.f; + if (e == error.StreamTooLong) return error.TooLarge; + if (e == error.Canceled) return error.Timeout; + if (has(n, "Tls") or has(n, "Certificate")) return error.TlsFailure; + if (has(n, "Host") or has(n, "Name") or has(n, "Dns")) return error.DnsFailure; + if (has(n, "Timeout") or has(n, "TimedOut")) return error.Timeout; + if (has(n, "Connect") or has(n, "Network") or has(n, "Refused") or has(n, "Unreach")) return error.ConnectFailed; + if (has(n, "Redirect") or has(n, "Http") or has(n, "Invalid") or has(n, "Redirect") or has(n, "Uri") or has(n, "Url")) return error.BadResponse; + return error.Other; + } +}; + +// --------------------------------------------------------------------------- +// Tests against a local server (real sockets, real HTTP/1.1 on the wire) +// --------------------------------------------------------------------------- +const testing = std.testing; +const localserver = @import("localserver.zig"); + +const TestCtx = struct { + io: Io, + hits: std.atomic.Value(u32) = .init(0), + saw_inm: std.atomic.Value(bool) = .init(false), + saw_ua: std.atomic.Value(bool) = .init(false), + + fn handle(p: *anyopaque, req: *std.http.Server.Request) void { + const self: *TestCtx = @ptrCast(@alignCast(p)); + _ = self.hits.fetchAdd(1, .monotonic); + const t = req.head.target; + var it = req.iterateHeaders(); + var inm = false; + while (it.next()) |h| { + if (std.ascii.eqlIgnoreCase(h.name, "if-none-match") and std.mem.eql(u8, h.value, "\"v1\"")) { + inm = true; + self.saw_inm.store(true, .monotonic); + } + if (std.ascii.eqlIgnoreCase(h.name, "user-agent") and std.mem.eql(u8, h.value, "heron-test/1")) self.saw_ua.store(true, .monotonic); + } + if (std.mem.eql(u8, t, "/feed")) { + if (inm) return req.respond("", .{ .status = .not_modified, .keep_alive = false, .extra_headers = &.{.{ .name = "ETag", .value = "\"v1\"" }} }) catch {}; + req.respond("TOneg1", .{ + .keep_alive = false, + .extra_headers = &.{ .{ .name = "ETag", .value = "\"v1\"" }, .{ .name = "Last-Modified", .value = "Sat, 01 Jan 2000 00:00:00 GMT" }, .{ .name = "Content-Type", .value = "application/rss+xml; charset=utf-8" } }, + }) catch {}; + } else if (std.mem.eql(u8, t, "/redir")) { + req.respond("", .{ .status = .found, .keep_alive = false, .extra_headers = &.{.{ .name = "Location", .value = "/feed" }} }) catch {}; + } else if (std.mem.eql(u8, t, "/loop")) { + req.respond("", .{ .status = .found, .keep_alive = false, .extra_headers = &.{.{ .name = "Location", .value = "/loop" }} }) catch {}; + } else if (std.mem.eql(u8, t, "/limited")) { + req.respond("slow down", .{ .status = .too_many_requests, .keep_alive = false, .extra_headers = &.{.{ .name = "Retry-After", .value = "7" }} }) catch {}; + } else if (std.mem.eql(u8, t, "/slow")) { + self.io.sleep(.fromSeconds(5), .awake) catch { + self.io.recancel(); // the cancel request was consumed here; re-arm so the accept loop sees it too + return; + }; + req.respond("late", .{ .keep_alive = false }) catch {}; + } else if (std.mem.eql(u8, t, "/big")) { + var buf: [16384]u8 = undefined; + @memset(&buf, 'x'); + req.respond(&buf, .{ .keep_alive = false }) catch {}; + } else req.respond("nope", .{ .status = .not_found, .keep_alive = false }) catch {}; + } +}; + +fn startServer(io: Io, ctx: *TestCtx, server: *Io.net.Server) !Io.Future(void) { + server.* = try localserver.listen(io, "127.0.0.1", 0); + return io.concurrent(localserver.serve, .{ io, server, @as(*anyopaque, ctx), TestCtx.handle }); +} + +test "http transport: 200 with validators, conditional 304, 429 Retry-After, 404, size cap, timeout" { + const io = testing.io; + var ctx: TestCtx = .{ .io = io }; + var server: Io.net.Server = undefined; + var fut = try startServer(io, &ctx, &server); + var stopped = false; + defer if (!stopped) { + _ = fut.cancel(io); + server.deinit(io); + }; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var t = HttpTransport.init(testing.allocator, io, "heron-test/1", 1); + defer t.deinit(); + const tr = t.asTransport(); + const base = try std.fmt.allocPrint(a, "http://127.0.0.1:{d}", .{localserver.port(&server)}); + + const r1 = try tr.request(a, .{ .url = try std.fmt.allocPrint(a, "{s}/feed", .{base}) }); + try testing.expectEqual(@as(u16, 200), r1.status); + try testing.expectEqualStrings("\"v1\"", r1.etag.?); + try testing.expectEqualStrings("Sat, 01 Jan 2000 00:00:00 GMT", r1.last_modified.?); + try testing.expectEqualStrings("application/rss+xml; charset=utf-8", r1.content_type.?); + try testing.expect(std.mem.startsWith(u8, r1.body, "= 900 and took_ms < 3000); + + // Nothing listening: connection refused is a transport error, not a hang. + _ = fut.cancel(io); + server.deinit(io); + stopped = true; + try testing.expectError(error.ConnectFailed, tr.request(a, .{ .url = try std.fmt.allocPrint(a, "{s}/feed", .{base}) })); +} diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 129a44a..214be98 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -// {{PROJECT}} FFI Implementation +// HERON_POLYFEED_READER FFI Implementation // // This module implements the C-compatible FFI declared in src/abi/Foreign.idr // All types and layouts must match the Idris2 ABI definitions. @@ -10,7 +10,7 @@ const std = @import("std"); // Version information (keep in sync with project) const VERSION = "0.1.0"; -const BUILD_INFO = "{{PROJECT}} built with Zig " ++ @import("builtin").zig_version_string; +const BUILD_INFO = "HERON_POLYFEED_READER built with Zig " ++ @import("builtin").zig_version_string; /// Thread-local error storage threadlocal var last_error: ?[]const u8 = null; @@ -52,7 +52,7 @@ pub const Handle = struct { /// Initialize the library /// Returns a handle, or null on failure -export fn {{project}}_init() ?*Handle { +export fn heron_polyfeed_reader_init() ?*Handle { const allocator = std.heap.c_allocator; const handle = allocator.create(Handle) catch { @@ -71,7 +71,7 @@ export fn {{project}}_init() ?*Handle { } /// Free the library handle -export fn {{project}}_free(handle: ?*Handle) void { +export fn heron_polyfeed_reader_free(handle: ?*Handle) void { const h = handle orelse return; const allocator = h.allocator; @@ -87,7 +87,7 @@ export fn {{project}}_free(handle: ?*Handle) void { //============================================================================== /// Process data (example operation) -export fn {{project}}_process(handle: ?*Handle, input: u32) Result { +export fn heron_polyfeed_reader_process(handle: ?*Handle, input: u32) Result { const h = handle orelse { setError("Null handle"); return .null_pointer; @@ -111,7 +111,7 @@ export fn {{project}}_process(handle: ?*Handle, input: u32) Result { /// Get a string result (example) /// Caller must free the returned string -export fn {{project}}_get_string(handle: ?*Handle) ?[*:0]const u8 { +export fn heron_polyfeed_reader_get_string(handle: ?*Handle) ?[*:0]const u8 { const h = handle orelse { setError("Null handle"); return null; @@ -133,7 +133,7 @@ export fn {{project}}_get_string(handle: ?*Handle) ?[*:0]const u8 { } /// Free a string allocated by the library -export fn {{project}}_free_string(str: ?[*:0]const u8) void { +export fn heron_polyfeed_reader_free_string(str: ?[*:0]const u8) void { const s = str orelse return; const allocator = std.heap.c_allocator; @@ -146,7 +146,7 @@ export fn {{project}}_free_string(str: ?[*:0]const u8) void { //============================================================================== /// Process an array of data -export fn {{project}}_process_array( +export fn heron_polyfeed_reader_process_array( handle: ?*Handle, buffer: ?[*]const u8, len: u32, @@ -182,7 +182,7 @@ export fn {{project}}_process_array( /// Get the last error message /// Returns null if no error -export fn {{project}}_last_error() ?[*:0]const u8 { +export fn heron_polyfeed_reader_last_error() ?[*:0]const u8 { const err = last_error orelse return null; // Return C string (static storage, no need to free) @@ -196,12 +196,12 @@ export fn {{project}}_last_error() ?[*:0]const u8 { //============================================================================== /// Get the library version -export fn {{project}}_version() [*:0]const u8 { +export fn heron_polyfeed_reader_version() [*:0]const u8 { return VERSION.ptr; } /// Get build information -export fn {{project}}_build_info() [*:0]const u8 { +export fn heron_polyfeed_reader_build_info() [*:0]const u8 { return BUILD_INFO.ptr; } @@ -213,7 +213,7 @@ export fn {{project}}_build_info() [*:0]const u8 { pub const Callback = *const fn (u64, u32) callconv(.c) u32; /// Register a callback -export fn {{project}}_register_callback( +export fn heron_polyfeed_reader_register_callback( handle: ?*Handle, callback: ?Callback, ) Result { @@ -244,7 +244,7 @@ export fn {{project}}_register_callback( //============================================================================== /// Check if handle is initialized -export fn {{project}}_is_initialized(handle: ?*Handle) u32 { +export fn heron_polyfeed_reader_is_initialized(handle: ?*Handle) u32 { const h = handle orelse return 0; return if (h.initialized) 1 else 0; } @@ -254,22 +254,22 @@ export fn {{project}}_is_initialized(handle: ?*Handle) u32 { //============================================================================== test "lifecycle" { - const handle = {{project}}_init() orelse return error.InitFailed; - defer {{project}}_free(handle); + const handle = heron_polyfeed_reader_init() orelse return error.InitFailed; + defer heron_polyfeed_reader_free(handle); - try std.testing.expect({{project}}_is_initialized(handle) == 1); + try std.testing.expect(heron_polyfeed_reader_is_initialized(handle) == 1); } test "error handling" { - const result = {{project}}_process(null, 0); + const result = heron_polyfeed_reader_process(null, 0); try std.testing.expectEqual(Result.null_pointer, result); - const err = {{project}}_last_error(); + const err = heron_polyfeed_reader_last_error(); try std.testing.expect(err != null); } test "version" { - const ver = {{project}}_version(); + const ver = heron_polyfeed_reader_version(); const ver_str = std.mem.span(ver); try std.testing.expectEqualStrings(VERSION, ver_str); } diff --git a/src/item.zig b/src/item.zig new file mode 100644 index 0000000..3c0ec21 --- /dev/null +++ b/src/item.zig @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! The single item model every adapter produces and everything downstream consumes. +const std = @import("std"); + +/// What sort of thing an item is. New adapters add variants here; storage and UI +/// only treat the value as a label (stored as its tag name). +pub const Kind = enum { + article, // feed entry (RSS/Atom/RDF) + post, // social post (future: ActivityPub, AT Protocol) + message, // discussion message (future: NNTP) + document, // gopher/gemini resource (future) + alert, // monitor transition (up/down/changed/cert-expiry) +}; + +pub const Item = struct { + /// Source name from the config; with `id` forms the dedupe key. + source: []const u8, + /// Stable id within the source (guid / atom:id, or derived: link + date, or content hash). + id: []const u8, + title: []const u8, + url: []const u8, + /// Unix seconds UTC, if the feed gave a parsable date. + published: ?i64, + body: []const u8, + kind: Kind, +}; diff --git a/src/localserver.zig b/src/localserver.zig new file mode 100644 index 0000000..cbe555e --- /dev/null +++ b/src/localserver.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! A tiny sequential HTTP/1.1 server used by the local web UI and by tests. +//! One request per connection (`Connection: close`), so an idle client cannot hold the loop. +const std = @import("std"); +const Io = std.Io; +const net = Io.net; + +pub const Handler = *const fn (ctx: *anyopaque, req: *std.http.Server.Request) void; + +pub fn listen(io: Io, bind: []const u8, bind_port: u16) !net.Server { + const addr = try net.IpAddress.parse(bind, bind_port); + return addr.listen(io, .{ .reuse_address = true }); +} + +pub fn port(server: *const net.Server) u16 { + return server.socket.address.getPort(); +} + +/// Per-connection read/write deadline in seconds. Generous for a loopback UI; short enough that a +/// stuck client cannot park the accept loop. +const io_timeout_s: u32 = 10; + +fn setSocketTimeouts(handle: std.posix.socket_t, seconds: u32) !void { + const tv: std.posix.timeval = .{ .sec = @intCast(seconds), .usec = 0 }; + try std.posix.setsockopt(handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&tv)); + try std.posix.setsockopt(handle, std.posix.SOL.SOCKET, std.posix.SO.SNDTIMEO, std.mem.asBytes(&tv)); +} + +/// Accept loop. Returns when cancelled (or the listener is closed). +pub fn serve(io: Io, server: *net.Server, ctx: *anyopaque, handler: Handler) void { + while (true) { + const stream = server.accept(io) catch |e| switch (e) { + error.Canceled, error.SocketNotListening => return, + else => continue, + }; + defer stream.close(io); + // The loop is sequential, so a client that connects and never finishes its request (or never + // reads the response) would hold the UI for everyone. A socket deadline bounds both. + setSocketTimeouts(stream.socket.handle, io_timeout_s) catch {}; + var rbuf: [4096]u8 = undefined; + var wbuf: [4096]u8 = undefined; + var r = stream.reader(io, &rbuf); + var w = stream.writer(io, &wbuf); + var srv = std.http.Server.init(&r.interface, &w.interface); + var req = srv.receiveHead() catch continue; + handler(ctx, &req); + } +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..58731cf --- /dev/null +++ b/src/main.zig @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +const std = @import("std"); +const heron = @import("heron"); + +pub const std_options: std.Options = .{ .log_level = .info }; + +pub fn main(init: std.process.Init) u8 { + return heron.cli.main(init); +} diff --git a/src/monitor.zig b/src/monitor.zig new file mode 100644 index 0000000..ec24824 --- /dev/null +++ b/src/monitor.zig @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! The "monitor" adapter: HTTP(S) up/down checks, change detection and TLS certificate expiry. +//! +//! * Alerts are *transitions*, not check results: DOWN after `down_after` consecutive failed checks +//! (default 2), UP on the first success after that, CHANGED when the page content changes (at most +//! one alert per hour per monitor), CERT when the certificate is within `cert_warn_days` of expiry +//! or expired (once per certificate), BLOCKED when robots.txt forbids the request. +//! * Change detection: conditional GET with the stored ETag / Last-Modified first (304 = unchanged, +//! no body transferred). When the server sends a body, the SHA-256 of its *normalised text* +//! (scripts, styles, comments and markup removed, whitespace collapsed) decides: a changed +//! validator with an identical hash is not a change (many servers emit unstable ETags). +//! * robots.txt (RFC 9309) is consulted before each request, cached for 24 h. +//! The monitor reports every outcome as success to the scheduler (so a down site keeps being +//! checked at its normal interval instead of being backed off); its verdict lives in the alert items +//! and in the adapter state blob. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const adapter = @import("adapter.zig"); +const Item = @import("item.zig").Item; +const transport = @import("transport.zig"); +const robots_mod = @import("robots.zig"); +const util = @import("util.zig"); + +pub const day_s: i64 = 86400; +const robots_ttl_s: i64 = day_s; +const cert_recheck_s: i64 = 6 * 3600; +const change_alert_gap_s: i64 = 3600; +const max_body: usize = 2 * 1024 * 1024; +/// Must equal the robots.txt fetch cap below: a cached copy shorter than what was fetched would drop +/// rules past the cut and could allow a path the file disallows. (RFC 9309 §2.5 lets a crawler stop +/// parsing at a size limit; the limit is the fetch cap, applied once, not a second cut here.) +const max_robots_fetch: usize = 256 * 1024; +const max_robots_cache = max_robots_fetch; + +pub const Options = struct { + expect_status: u16 = 200, + detect_changes: bool = true, + check_robots: bool = true, + cert_warn_days: u32 = 14, + down_after: u32 = 2, +}; + +/// Persisted in `sources.adapter_state` as JSON. +pub const State = struct { + up: ?bool = null, + fails: u32 = 0, + since: i64 = 0, + checked: i64 = 0, + status: u16 = 0, + err: []const u8 = "", + hash: []const u8 = "", + last_change: i64 = 0, + cert_not_after: i64 = 0, + cert_checked: i64 = 0, + cert_alert: []const u8 = "", + robots_at: i64 = 0, + robots_txt: []const u8 = "", + blocked: bool = false, +}; + +pub const MonitorAdapter = struct { + pub const type_name = "monitor"; + /// Product token used to select robots.txt groups. + ua_token: []const u8 = "heron-polyfeed-reader", + + pub fn asAdapter(self: *MonitorAdapter) adapter.Adapter { + return .{ .type_name = type_name, .ptr = self, .fetchFn = fetchImpl }; + } + + fn fetchImpl(ptr: *anyopaque, ctx: *adapter.Context, source: adapter.Source, cond: adapter.Cond) adapter.Outcome { + const self: *MonitorAdapter = @ptrCast(@alignCast(ptr)); + return check(self, ctx, source, cond) catch |e| .{ .failed = .{ .class = .transient, .message = @errorName(e) } }; + } +}; + +/// Parse state JSON (all slices allocated from `a`); bad or empty input yields defaults. +pub fn parseState(a: Allocator, text: ?[]const u8) State { + const t = text orelse return .{}; + return std.json.parseFromSliceLeaky(State, a, t, .{ .ignore_unknown_fields = true }) catch .{}; +} + +pub fn parseOptions(a: Allocator, text: []const u8) Options { + if (text.len == 0) return .{}; + return std.json.parseFromSliceLeaky(Options, a, text, .{ .ignore_unknown_fields = true }) catch .{}; +} + +/// Visible-text normalisation for change hashing. +pub fn normalizeText(a: Allocator, html: []const u8) Allocator.Error![]u8 { + var out: std.ArrayList(u8) = .empty; + var i: usize = 0; + var space = true; + while (i < html.len) { + const c = html[i]; + if (c == '<') { + const rest = html[i..]; + if (std.mem.startsWith(u8, rest, "")) |e| e + 3 else rest.len; + } else if (startsWithIgnoreCase(rest, "') orelse rest.len - 1; + i += gt + 1; + } else { + const gt = std.mem.indexOfScalar(u8, rest, '>') orelse rest.len - 1; + i += gt + 1; + } + if (!space) { + try out.append(a, ' '); + space = true; + } + continue; + } + if (std.ascii.isWhitespace(c)) { + if (!space) try out.append(a, ' '); + space = true; + } else { + try out.append(a, c); + space = false; + } + i += 1; + } + while (out.items.len > 0 and out.items[out.items.len - 1] == ' ') out.items.len -= 1; + // Character references are presentation, not content: "A & B" and "A & B" are the same + // text and must hash the same, or an escaping change would raise a CHANGED alert. + var decoded: std.ArrayList(u8) = .empty; + try xml.appendDecoded(&decoded, a, out.items); + return decoded.items; +} + +const xml = @import("xml.zig"); +const feed = @import("feed.zig"); + +fn startsWithIgnoreCase(s: []const u8, prefix: []const u8) bool { + return s.len >= prefix.len and std.ascii.eqlIgnoreCase(s[0..prefix.len], prefix); +} + +fn hashHex(a: Allocator, text: []const u8) Allocator.Error![]const u8 { + var d: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(text, &d, .{}); + return std.fmt.allocPrint(a, "{x}", .{d[0..16]}); +} + +const Alerts = struct { + a: Allocator, + source: adapter.Source, + now: i64, + list: std.ArrayList(Item) = .empty, + + fn add(self: *Alerts, id: []const u8, title: []const u8, body: []const u8) Allocator.Error!void { + try self.list.append(self.a, .{ + .source = self.source.name, + .id = id, + .title = title, + .url = self.source.url, + .published = self.now, + .body = body, + .kind = .alert, + }); + } +}; + +const Target = struct { https: bool, host: []const u8, port: u16, path_query: []const u8, robots_url: []const u8 }; + +const max_redirect_hops = 5; + +fn isRedirect(status: u16) bool { + return status == 301 or status == 302 or status == 303 or status == 307 or status == 308; +} + +fn sameOrigin(x: Target, y: Target) bool { + return x.https == y.https and x.port == y.port and std.ascii.eqlIgnoreCase(x.host, y.host); +} + +fn parseTarget(a: Allocator, url: []const u8) !Target { + const uri = try std.Uri.parse(url); + const https = std.ascii.eqlIgnoreCase(uri.scheme, "https"); + const host_c = uri.host orelse return error.InvalidUrl; + const host = try host_c.toRawMaybeAlloc(a); + const port: u16 = uri.port orelse if (https) 443 else 80; + const path = try uri.path.toRawMaybeAlloc(a); + const q: ?[]const u8 = if (uri.query) |qc| try qc.toRawMaybeAlloc(a) else null; + const pq = try std.fmt.allocPrint(a, "{s}{s}{s}", .{ if (path.len == 0) "/" else path, if (q != null) "?" else "", q orelse "" }); + const default_port: u16 = if (https) 443 else 80; + const hostport = if (port == default_port) host else try std.fmt.allocPrint(a, "{s}:{d}", .{ host, port }); + const robots_url = try std.fmt.allocPrint(a, "{s}://{s}/robots.txt", .{ if (https) "https" else "http", hostport }); + return .{ .https = https, .host = host, .port = port, .path_query = pq, .robots_url = robots_url }; +} + +pub fn check(self: *MonitorAdapter, ctx: *adapter.Context, source: adapter.Source, cond: adapter.Cond) !adapter.Outcome { + const a = ctx.arena; + const now = ctx.now; + const opt = parseOptions(a, source.options); + var st = parseState(a, cond.state); + var alerts: Alerts = .{ .a = a, .source = source, .now = now }; + const target = parseTarget(a, source.url) catch return .{ .failed = .{ .class = .permanent, .message = "invalid monitor url" } }; + + var new_etag = cond.etag; + var new_lm = cond.last_modified; + st.checked = now; + + // ---- robots.txt ------------------------------------------------------------------------- + var robots_failed: ?[]const u8 = null; + var skip_request = false; + if (opt.check_robots) { + if (st.robots_at == 0 or now - st.robots_at >= robots_ttl_s) { + if (ctx.transport.request(a, .{ .url = target.robots_url, .max_body_bytes = max_robots_fetch })) |rr| { + switch (robots_mod.availabilityForStatus(rr.status)) { + .rules => { + st.robots_txt = rr.body[0..@min(rr.body.len, max_robots_cache)]; + st.robots_at = now; + }, + .allow_all => { + st.robots_txt = ""; + st.robots_at = now; + }, + .unreachable_ => robots_failed = "robots.txt unavailable (server error); not requesting the page", + } + } else |e| robots_failed = try std.fmt.allocPrint(a, "robots.txt unreachable: {s}", .{@errorName(e)}); + } + if (robots_failed == null and !robots_mod.allowed(st.robots_txt, self.ua_token, target.path_query)) { + skip_request = true; + if (!st.blocked) { + st.blocked = true; + try alerts.add( + try std.fmt.allocPrint(a, "blocked:{d}", .{now}), + try std.fmt.allocPrint(a, "BLOCKED: robots.txt forbids monitoring {s}", .{source.name}), + try std.fmt.allocPrint(a, "{s} disallows {s} for {s}. Monitoring of this URL is paused until robots.txt allows it (or set check_robots=false for a site you own).", .{ target.robots_url, target.path_query, self.ua_token }), + ); + } + } else if (robots_failed == null) st.blocked = false; + } else st.blocked = false; // check_robots turned off: a stale block must not outlive the rule that set it + + // ---- the request ------------------------------------------------------------------------ + var resp: ?transport.Response = null; + var err_text: []const u8 = ""; + if (robots_failed) |m| { + err_text = m; + } else if (!skip_request) { + const want_body = opt.detect_changes; + var req: transport.Request = .{ + .url = source.url, + .method = if (want_body) .get else .head, + .max_body_bytes = max_body, + .etag = if (want_body) cond.etag else null, + .last_modified = if (want_body) cond.last_modified else null, + // With robots checking on, redirects are stepped through here, one hop at a time: every + // hop's path is checked against the rules, and a hop to another origin is refused before + // that origin is fetched (RFC 9309 §2.3: robots.txt is per scheme and authority). + .follow_redirects = !opt.check_robots, + }; + var refused: ?[]const u8 = null; + var hops: u8 = 0; + var result = ctx.transport.request(a, req); + while (result) |r| { + if (req.method == .head and (r.status == 405 or r.status == 501)) { + req.method = .get; + result = ctx.transport.request(a, req); + continue; + } + if (!(opt.check_robots and isRedirect(r.status))) break; + hops += 1; + if (hops > max_redirect_hops) { + refused = "too many redirects"; + break; + } + const loc = r.location orelse { + refused = "redirect without a Location header"; + break; + }; + const next_url = try feed.resolveLink(a, req.url, loc); + const next = parseTarget(a, next_url) catch { + refused = try std.fmt.allocPrint(a, "redirect to an unusable URL: {s}", .{next_url}); + break; + }; + if (!sameOrigin(next, target)) { + refused = try std.fmt.allocPrint(a, "redirected to another origin ({s}); its robots.txt was not checked — point the monitor at the final URL", .{next_url}); + break; + } + if (!robots_mod.allowed(st.robots_txt, self.ua_token, next.path_query)) { + refused = try std.fmt.allocPrint(a, "robots.txt disallows the redirect target {s}", .{next.path_query}); + break; + } + req.url = next_url; + result = ctx.transport.request(a, req); + } else |_| {} + if (refused) |m| { + err_text = m; + } else if (result) |r| resp = r else |e| err_text = try std.fmt.allocPrint(a, "{s}", .{@errorName(e)}); + } + + if (!skip_request) { + const ok = if (resp) |r| (r.status == opt.expect_status or (r.status == 304 and opt.detect_changes)) else false; + if (resp) |r| { + st.status = r.status; + if (!ok) err_text = try std.fmt.allocPrint(a, "HTTP {d} (expected {d})", .{ r.status, opt.expect_status }); + } else st.status = 0; + st.err = if (ok) "" else err_text; + + if (ok) { + st.fails = 0; + if (st.up == false) { + try alerts.add( + try std.fmt.allocPrint(a, "up:{d}", .{now}), + try std.fmt.allocPrint(a, "UP: {s} is back", .{source.name}), + try std.fmt.allocPrint(a, "{s} answered HTTP {d} after being down for {s}.", .{ source.url, st.status, spanText(a, now - st.since) }), + ); + } + if (st.up != true) { + st.up = true; + st.since = now; + } + } else { + st.fails +|= 1; + if (st.fails >= opt.down_after and st.up != false) { + try alerts.add( + try std.fmt.allocPrint(a, "down:{d}", .{now}), + try std.fmt.allocPrint(a, "DOWN: {s} — {s}", .{ source.name, st.err }), + try std.fmt.allocPrint(a, "{s} failed {d} consecutive check(s): {s}.", .{ source.url, st.fails, st.err }), + ); + st.up = false; + st.since = now; + } + } + + // ---- change detection --------------------------------------------------------------- + if (ok and opt.detect_changes) if (resp) |r| if (r.status != 304) { + new_etag = r.etag; + new_lm = r.last_modified; + const h = try hashHex(a, try normalizeText(a, r.body)); + if (st.hash.len > 0 and !std.mem.eql(u8, st.hash, h)) { + if (st.last_change == 0 or now - st.last_change >= change_alert_gap_s) { + try alerts.add( + try std.fmt.allocPrint(a, "changed:{s}:{d}", .{ h[0..8], now }), + try std.fmt.allocPrint(a, "CHANGED: {s}", .{source.name}), + try std.fmt.allocPrint(a, "The text content of {s} changed (hash {s} → {s}).", .{ source.url, st.hash[0..8], h[0..8] }), + ); + st.last_change = now; + } + } + st.hash = h; + }; + } + + // ---- TLS certificate expiry ---------------------------------------------------------------- + if (target.https and !skip_request and (st.cert_checked == 0 or now - st.cert_checked >= cert_recheck_s)) { + if (ctx.transport.certNotAfter(target.host, target.port)) |na| { + st.cert_not_after = na; + st.cert_checked = now; + const days_left = @divFloor(na - now, day_s); + const kind: ?[]const u8 = if (na <= now) "expired" else if (opt.cert_warn_days > 0 and days_left <= opt.cert_warn_days) "warn" else null; + if (kind) |k| { + const id = try std.fmt.allocPrint(a, "cert:{d}:{s}", .{ na, k }); + if (!std.mem.eql(u8, id, st.cert_alert)) { + var db: [32]u8 = undefined; + try alerts.add( + id, + if (na <= now) + try std.fmt.allocPrint(a, "CERT EXPIRED: {s}", .{source.name}) + else + try std.fmt.allocPrint(a, "CERT: {s} expires in {d} day(s)", .{ source.name, days_left }), + try std.fmt.allocPrint(a, "The TLS certificate served by {s}:{d} {s} {s} UTC.", .{ target.host, target.port, if (na <= now) "expired on" else "expires on", util.fmtDate(&db, na) }), + ); + st.cert_alert = id; + } + } + } + } + + const state_json = try std.json.Stringify.valueAlloc(a, st, .{}); + const new_cond: adapter.Cond = .{ .etag = new_etag, .last_modified = new_lm, .state = state_json }; + if (alerts.list.items.len > 0) return .{ .items = .{ .items = alerts.list.items, .cond = new_cond } }; + return .{ .not_modified = new_cond }; +} + +fn spanText(a: Allocator, secs: i64) []const u8 { + const buf = a.alloc(u8, 16) catch return "a while"; + return util.fmtSpan(buf, secs); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +const testing = std.testing; + +const Fake = struct { + main: ?transport.Response = .{ .status = 200, .body = "hello" }, + main_err: ?transport.Error = null, + robots: ?transport.Response = .{ .status = 404 }, + robots_err: ?transport.Error = null, + cert: ?i64 = null, + main_calls: u32 = 0, + robots_calls: u32 = 0, + cert_calls: u32 = 0, + last_method: transport.Method = .get, + sent_etag: bool = false, + + fn req(p: *anyopaque, _: Allocator, r: transport.Request) transport.Error!transport.Response { + const self: *Fake = @ptrCast(@alignCast(p)); + if (std.mem.endsWith(u8, r.url, "/robots.txt")) { + self.robots_calls += 1; + if (self.robots_err) |e| return e; + return self.robots.?; + } + self.main_calls += 1; + self.last_method = r.method; + self.sent_etag = r.etag != null; + if (self.main_err) |e| return e; + if (r.method == .head and self.main.?.status == 405) return self.main.?; + return self.main.?; + } + fn certFn(p: *anyopaque, _: []const u8, _: u16) ?i64 { + const self: *Fake = @ptrCast(@alignCast(p)); + self.cert_calls += 1; + return self.cert; + } + fn tr(self: *Fake) transport.Transport { + return .{ .ptr = self, .requestFn = req, .certNotAfterFn = certFn }; + } +}; + +const Harness = struct { + arena: std.heap.ArenaAllocator, + fake: Fake = .{}, + mon: MonitorAdapter = .{}, + cond: adapter.Cond = .{}, + src: adapter.Source = .{ .name = "site", .type_name = "monitor", .url = "https://example.org/page?x=1", .interval_s = 60, .options = "{}" }, + + fn init() Harness { + return .{ .arena = std.heap.ArenaAllocator.init(testing.allocator) }; + } + fn deinit(h: *Harness) void { + h.arena.deinit(); + } + /// Run one check at `now`; returns alert titles (empty slice if none). Updates stored cond. + fn step(h: *Harness, now: i64) ![]const []const u8 { + const a = h.arena.allocator(); + var ctx: adapter.Context = .{ .arena = a, .transport = h.fake.tr(), .now = now }; + const out = try check(&h.mon, &ctx, h.src, h.cond); + var titles: std.ArrayList([]const u8) = .empty; + switch (out) { + .items => |b| { + h.cond = b.cond; + for (b.items) |it| { + try testing.expectEqual(@import("item.zig").Kind.alert, it.kind); + try titles.append(a, it.title); + } + }, + .not_modified => |c| h.cond = c, + .failed => return error.UnexpectedFailure, + } + return titles.items; + } + fn state(h: *Harness) State { + return parseState(h.arena.allocator(), h.cond.state); + } +}; + +fn expectTitle(titles: []const []const u8, prefix: []const u8) !void { + try testing.expectEqual(@as(usize, 1), titles.len); + try testing.expect(std.mem.startsWith(u8, titles[0], prefix)); +} + +test "up/down: baseline silent; DOWN only after 2 failures; no repeat alerts; UP once on recovery" { + var h = Harness.init(); + defer h.deinit(); + try testing.expectEqual(@as(usize, 0), (try h.step(1000)).len); // first success: baseline, no alert + try testing.expectEqual(@as(?bool, true), h.state().up); + + h.fake.main = .{ .status = 503 }; + try testing.expectEqual(@as(usize, 0), (try h.step(1060)).len); // 1st failure: not yet + try expectTitle(try h.step(1120), "DOWN: site"); // 2nd failure: transition + try testing.expectEqual(@as(usize, 0), (try h.step(1180)).len); // still down: silent + try testing.expectEqual(@as(usize, 0), (try h.step(1240)).len); + try testing.expectEqual(@as(?bool, false), h.state().up); + try testing.expectEqual(@as(u32, 4), h.state().fails); + + h.fake.main = .{ .status = 200, .body = "hello" }; + const t = try h.step(1300); + try expectTitle(t, "UP: site"); + try testing.expectEqual(@as(usize, 0), (try h.step(1360)).len); +} + +test "a single blip does not alert; transport errors count as failures" { + var h = Harness.init(); + defer h.deinit(); + _ = try h.step(1000); + h.fake.main_err = error.Timeout; + try testing.expectEqual(@as(usize, 0), (try h.step(1060)).len); + h.fake.main_err = null; + try testing.expectEqual(@as(usize, 0), (try h.step(1120)).len); // recovered before down_after + try testing.expectEqual(@as(?bool, true), h.state().up); + h.fake.main_err = error.ConnectFailed; + _ = try h.step(1180); + const t = try h.step(1240); + try expectTitle(t, "DOWN: site"); + try testing.expect(std.mem.indexOf(u8, t[0], "ConnectFailed") != null); +} + +test "down on the very first checks alerts after down_after failures; down_after=1 alerts at once" { + var h = Harness.init(); + defer h.deinit(); + h.fake.main = .{ .status = 500 }; + try testing.expectEqual(@as(usize, 0), (try h.step(1000)).len); + try expectTitle(try h.step(1060), "DOWN"); + var h2 = Harness.init(); + defer h2.deinit(); + h2.src.options = "{\"down_after\":1}"; + h2.fake.main = .{ .status = 500 }; + try expectTitle(try h2.step(1000), "DOWN"); +} + +test "expect_status: 204 expected, 200 is a failure" { + var h = Harness.init(); + defer h.deinit(); + h.src.options = "{\"expect_status\":204,\"down_after\":1}"; + h.fake.main = .{ .status = 204 }; + try testing.expectEqual(@as(usize, 0), (try h.step(1000)).len); + h.fake.main = .{ .status = 200, .body = "x" }; + try expectTitle(try h.step(1060), "DOWN"); +} + +test "change detection: hash of normalised text; markup/script noise ignored; validators first" { + var h = Harness.init(); + defer h.deinit(); + h.fake.main = .{ .status = 200, .body = "

Price: 10

", .etag = "\"a\"" }; + _ = try h.step(1000); + try testing.expectEqualStrings("\"a\"", h.cond.etag.?); + + // Same visible text, different markup/script/whitespace and a *different* ETag => not a change. + h.fake.main = .{ .status = 200, .body = "\n
Price: 10
", .etag = "\"b\"" }; + try testing.expectEqual(@as(usize, 0), (try h.step(1060)).len); + try testing.expect(h.fake.sent_etag); // conditional GET was attempted + try testing.expectEqualStrings("\"b\"", h.cond.etag.?); + + // 304 => unchanged, body never examined. + h.fake.main = .{ .status = 304 }; + try testing.expectEqual(@as(usize, 0), (try h.step(1120)).len); + try testing.expectEqual(@as(?bool, true), h.state().up); + + // Real text change => alert. + h.fake.main = .{ .status = 200, .body = "Price: 12", .etag = "\"c\"" }; + try expectTitle(try h.step(1180), "CHANGED: site"); +} + +test "change alerts are rate limited to one per hour; hash still tracks" { + var h = Harness.init(); + defer h.deinit(); + h.fake.main = .{ .status = 200, .body = "v0" }; + _ = try h.step(10_000); + h.fake.main = .{ .status = 200, .body = "v1" }; + try expectTitle(try h.step(10_060), "CHANGED"); + h.fake.main = .{ .status = 200, .body = "v2" }; + try testing.expectEqual(@as(usize, 0), (try h.step(10_120)).len); // within the hour: suppressed + h.fake.main = .{ .status = 200, .body = "v3" }; + try expectTitle(try h.step(10_060 + 3600), "CHANGED"); // after the gap +} + +test "detect_changes=false uses HEAD, falls back to GET on 405, never alerts on content" { + var h = Harness.init(); + defer h.deinit(); + h.src.options = "{\"detect_changes\":false}"; + _ = try h.step(1000); + try testing.expectEqual(transport.Method.head, h.fake.last_method); + h.fake.main = .{ .status = 405 }; + h.fake.main_calls = 0; + _ = try h.step(1060); // HEAD 405 -> GET also 405 here => failure counted, but exercised the retry + try testing.expectEqual(@as(u32, 2), h.fake.main_calls); +} + +test "robots.txt: disallow blocks the request and alerts once; allow resumes; cached 24h" { + var h = Harness.init(); + defer h.deinit(); + h.fake.robots = .{ .status = 200, .body = "User-agent: *\nDisallow: /page\n" }; + const t = try h.step(1000); + try expectTitle(t, "BLOCKED: robots.txt forbids"); + try testing.expectEqual(@as(u32, 0), h.fake.main_calls); // page never requested + try testing.expectEqual(@as(usize, 0), (try h.step(1060)).len); // no repeat alert + try testing.expectEqual(@as(u32, 1), h.fake.robots_calls); // cached + try testing.expectEqual(@as(?bool, null), h.state().up); // blocked is not "down" + + // After the TTL the file is re-read; now it allows us. + h.fake.robots = .{ .status = 200, .body = "User-agent: *\nDisallow: /admin\n" }; + try testing.expectEqual(@as(usize, 0), (try h.step(1000 + 86400 + 1)).len); + try testing.expectEqual(@as(u32, 2), h.fake.robots_calls); + try testing.expectEqual(@as(u32, 1), h.fake.main_calls); + try testing.expectEqual(@as(?bool, true), h.state().up); +} + +test "robots.txt: 404 allows; 5xx or network error fails the check without requesting the page" { + var h = Harness.init(); + defer h.deinit(); + _ = try h.step(1000); // 404 robots => allowed + try testing.expectEqual(@as(u32, 1), h.fake.main_calls); + + var h2 = Harness.init(); + defer h2.deinit(); + h2.src.options = "{\"down_after\":1}"; + h2.fake.robots = .{ .status = 503 }; + const t = try h2.step(1000); + try expectTitle(t, "DOWN"); + try testing.expectEqual(@as(u32, 0), h2.fake.main_calls); + + var h3 = Harness.init(); + defer h3.deinit(); + h3.src.options = "{\"down_after\":1}"; + h3.fake.robots_err = error.Timeout; + try expectTitle(try h3.step(1000), "DOWN"); + + var h4 = Harness.init(); + defer h4.deinit(); + h4.src.options = "{\"check_robots\":false}"; + h4.fake.robots = .{ .status = 200, .body = "User-agent: *\nDisallow: /\n" }; + _ = try h4.step(1000); + try testing.expectEqual(@as(u32, 0), h4.fake.robots_calls); // not even fetched + try testing.expectEqual(@as(u32, 1), h4.fake.main_calls); + + // A monitor blocked by robots.txt, then restarted with check_robots=false, is no longer "blocked". + var h5 = Harness.init(); + defer h5.deinit(); + h5.fake.robots = .{ .status = 200, .body = "User-agent: *\nDisallow: /\n" }; + try expectTitle(try h5.step(1000), "BLOCKED"); + try testing.expect(h5.state().blocked); + h5.src.options = "{\"check_robots\":false}"; + _ = try h5.step(1060); + try testing.expect(!h5.state().blocked); + try testing.expectEqual(@as(?bool, true), h5.state().up); +} + +test "robots.txt on: a redirect to another origin is refused before that origin is fetched" { + var h = Harness.init(); + defer h.deinit(); + h.src.options = "{\"down_after\":1}"; + h.fake.main = .{ .status = 302, .location = "https://other.example/x" }; + const t = try h.step(1000); + try expectTitle(t, "DOWN"); + try testing.expect(std.mem.indexOf(u8, t[0], "another origin") != null); + try testing.expectEqual(@as(u32, 1), h.fake.main_calls); // nothing was requested from the other origin + + // With robots checking off the transport follows redirects itself (the fake just answers 302). + var h2 = Harness.init(); + defer h2.deinit(); + h2.src.options = "{\"check_robots\":false,\"down_after\":1}"; + h2.fake.main = .{ .status = 302, .location = "https://other.example/x" }; + try expectTitle(try h2.step(1000), "DOWN"); // HTTP 302 (expected 200), no origin check involved +} + +test "tls certificate: warn once per cert, expired, renewal re-arms, http skipped, probe throttled" { + var h = Harness.init(); + defer h.deinit(); + const now: i64 = 1_800_000_000; + h.fake.cert = now + 5 * day_s; // inside the 14-day window + const t = try h.step(now); + try expectTitle(t, "CERT: site expires in 5 day(s)"); + try testing.expectEqual(now + 5 * day_s, h.state().cert_not_after); + + try testing.expectEqual(@as(usize, 0), (try h.step(now + 60)).len); // throttled: no probe + try testing.expectEqual(@as(u32, 1), h.fake.cert_calls); + try testing.expectEqual(@as(usize, 0), (try h.step(now + 7 * 3600)).len); // re-probed, same cert: no repeat + try testing.expectEqual(@as(u32, 2), h.fake.cert_calls); + + h.fake.cert = now + 90 * day_s; // renewed + try testing.expectEqual(@as(usize, 0), (try h.step(now + 14 * 3600)).len); + try testing.expectEqual(now + 90 * day_s, h.state().cert_not_after); + + h.fake.cert = now - 3600; // expired + try expectTitle(try h.step(now + 21 * 3600), "CERT EXPIRED"); + + var plain = Harness.init(); + defer plain.deinit(); + plain.src.url = "http://example.org/"; + plain.fake.cert = 1; + _ = try plain.step(now); + try testing.expectEqual(@as(u32, 0), plain.fake.cert_calls); +} + +test "normalizeText" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try testing.expectEqualStrings("a b c", try normalizeText(a, "

a

\n b c ")); + try testing.expectEqualStrings("A & B", try normalizeText(a, "A & B")); + try testing.expectEqualStrings(try normalizeText(a, "A & B"), try normalizeText(a, "A & B")); // same text, same hash + try testing.expectEqualStrings("x", try normalizeText(a, "x & Zig", .url = "javascript:alert(1)", .published = 1000, .body = "

Hello world

", .kind = .article }, + .{ .source = "s1", .id = "2", .title = "Second", .url = "https://example.org/2", .published = 2000, .body = "needle haystack", .kind = .article }, + }, 5000); + + var jobs = [_]@import("adapter.zig").Source{.{ .name = "s1", .type_name = "rss", .url = "https://example.org/feed", .interval_s = 600, .options = "" }}; + var app: app_mod.App = .{ .gpa = testing.allocator, .io = io, .arena = std.heap.ArenaAllocator.init(testing.allocator), .config_path = "", .data_dir = dir, .cfg = .{}, .jobs = &jobs }; + defer app.arena.deinit(); + + try testing.expectError(error.NotLoopback, bind(io, "0.0.0.0", 0, false)); + var server = try bind(io, "127.0.0.1", 0, false); + var w: Web = .{ .gpa = testing.allocator, .io = io, .app = &app, .store = &st, .port = localserver.port(&server), .fixed_now = 6000 }; + var fut = try io.concurrent(localserver.serve, .{ io, &server, @as(*anyopaque, &w), handler }); + defer { + _ = fut.cancel(io); + server.deinit(io); + } + + const hostline = try std.fmt.allocPrint(a, "Host: 127.0.0.1:{d}\r\n", .{w.port}); + const get = struct { + fn f(al: Allocator, i: Io, p: u16, method: []const u8, path: []const u8, extra: []const u8) ![]const u8 { + const req = try std.fmt.allocPrint(al, "{s} {s} HTTP/1.1\r\n{s}Connection: close\r\n\r\n", .{ method, path, extra }); + return rawRequest(al, i, p, req); + } + }.f; + + // Index: both unread, title escaped, never raw markup. + const idx = try get(a, io, w.port, "GET", "/", hostline); + try testing.expect(std.mem.startsWith(u8, idx, "HTTP/1.1 200")); + try testing.expect(std.mem.indexOf(u8, idx, "<script>alert(1)") != null); + try testing.expect(std.mem.indexOf(u8, idx, "