diff --git a/NCC-PLAN.md b/NCC-PLAN.md index a624b66..0494351 100644 --- a/NCC-PLAN.md +++ b/NCC-PLAN.md @@ -8,7 +8,7 @@ existing protocols) and the workflow-first rule in `ROADMAP.md` (no protocol breadth without a concrete workflow and conformance need). The NCC repository is pinned at commit -`adad77869d2a3be2ce8c5f673b88711e02df0bfb`. A release MUST record a new pin +`186c3ad50966a2319c354ace12e0a5a2267b75d9`. A release MUST record a new pin when NCC lowering behaviour changes, mirroring the NIP review-commit rule in [`spec/README.md`](spec/README.md). @@ -26,14 +26,12 @@ when NCC lowering behaviour changes, mirroring the NIP review-commit rule in | 09 | 30064 authority grant | 02 (service scope) | Scoped operator authority: `d=:`, `service`, `p`, `status` (`active`/`revoked`), repeatable `scope` tags, optional `expiration`/`valid_from` | | 10 | 30065 state | 02 (service id); optionally 09 | Operational state (`operational`/`degraded`/`maintenance`/`unavailable`/`retiring`), direct `d=` or operator `d=:` + three-column `operator_for` | | 11 | 30067 policy | 02 + 05 (rules over both); 09/10 rule namespaces | Portable trust policy: named `d`, three-column `rule ` and `trust certifier `, private rules NIP-44-encrypted in `content` | +| 13 | 30063 release (profiles NIP-51); 32267/30617/1063 referenced, not owned | composes 07 (advert), 08 (successor identity), 09 (`ncc:13:publish` operator scope) | Package-manager semantics over an existing NIP-51 Release Artifact Set: `version`/`version_scheme`/`channel`, two-column `requires`/`optional`/`conflicts`, `source`/`commit` provenance, and `os`/`arch`/`format` artefact selectors added to the NIP-94 artefacts it references | -The table covers every convention the pin contains — all nine reviewed and -all scheduled below. Upstream has added nothing beyond these; the only -commits after `fe5981f` create `ncc-09`, `ncc-10` and `ncc-11` (and their -reference implementations), so the pin was bumped to `adad778`, their merge -commit, to record the text these stages lower. Upstream has never contained -`ncc-01` or `ncc-04` — checked against the repository's full history — so -those gaps are upstream's own numbering, not conventions this plan skipped. +The table covers every convention the pin contains — ten reviewed and all +scheduled below. Upstream has never contained `ncc-01`, `ncc-04` or `ncc-12` +— checked against the repository's full history — so those gaps are +upstream's own numbering, not conventions this plan skipped. ## Upstream prerequisites @@ -58,7 +56,7 @@ off `71238583`. They are kept for the record because each one gated a stage: ## Pin history -The pin has moved twice, each move recorded here: +The pin has moved three times, each move recorded here: - `71238583` → `fe5981f` before stage 3: upstream `3a45e72` added the `d` tag NCC-02's addressable events were missing and made NCC-03's kinds @@ -69,6 +67,10 @@ The pin has moved twice, each move recorded here: `ncc-11` folders or the repository index `README.md` — so no convention lowered by stages 1–7 changed text, and every module and vector reference moved to the new commit. +- `adad778` → `186c3ad` before stage 11: upstream added NCC-13. Again + purely additive — every new file sits under `ncc-13/` or the repository + index `README.md` — so no convention lowered by earlier stages changed + text. Stages 8–10 also needed an infrastructure step the earlier stages did not: NCC-10's `operator_for` and NCC-11's `rule`/`trust certifier` are @@ -181,6 +183,7 @@ adoption (or an explicit non-adoption note). | 8 | NCC-09 | Delegated authority the service cluster builds on; needs the pin bump | Principals publish scoped, time-bounded operator grants | | 9 | NCC-10 | Current state over the same service identity; composes stage 8 | Services and their authorised operators publish operational state | | 10 | NCC-11 | Policy data over the whole service stack | Clients and agents read one portable trust policy | +| 11 | NCC-13 | Composes 07/08/09 and profiles NIP-51/94/34 rather than owning new kinds, so it lands after the service cluster it cites; needs the pin bump | A release monitor judges upgrade eligibility, dependency and conflict declarations, and platform-matching artefacts over a published Release Artifact Set | ## Stage 1 — NCC-07 capability manifest *(Done)* @@ -681,6 +684,82 @@ so it stands on the same infrastructure as stage 9. `ncc:05:stale-fallback` are the policy keys a client would enforce, while the host still performs neither. +## Stage 11 — NCC-13 software package release profile *(Done)* + +A profile with no event of its own: every rule adds tags to a NIP-51 +Release Artifact Set and the NIP-94 artefacts it references (§2), so this +stage's only new event is a minimal `ArtefactMetadata` declaration for +NIP-94's kind 1063 carrying NCC-13's own `os`/`arch`/`format` selectors — +file identity, hash and location stay NIP-94's, undeclared here. Software +Application (32267) and Git Repository (30617) stay address text a script +builds and compares; NCC-13 does not define their identity either (§3). + +- **Module** `modules/std/ncc13/0.1.0.nsm`, registered in `BUILTINS`, with + `use ncc09`, the pinned `186c3ad` reference, a `present` validator, the + addressable `ReleaseArtifactSet` event (kind 30063: `d`, `a`, `version`, + `version_scheme`, `channel`, `source`, `commit`, repeatable `requires`, + `optional`, `conflicts`, `e`, and `operator_for`), the regular + `ArtefactMetadata` event (kind 1063: NIP-94's `url`/`x`/`m`/`size` plus + NCC-13's `os`/`arch`/`format`), and twenty-two pure functions: + `application`/`version`/`source`/`commit` extract their tags directly; + `version_scheme` and `channel` apply §8.4/§9.1's defaults + (`"opaque"`/`"stable"`) when the tag is absent, so an unscoped version is + never assumed ordered; `requirement` builds the two-column + `requires`/`optional`/`conflicts` value (mirroring NCC-10's + `operator_for` shape) and `requirement_address`/`requirement_constraint` + split it back, while `requires`/`optional_dependencies`/`conflicts` read + every repeated entry, in order, for the caller to AND-fold (§13); + `operator_for_tag`/`operator_project`/`operator_application` build and + read the `ncc:13:publish` operator claim (§33); `semver_is_valid` and + `semver_compare` implement SemVer §11 precedence (major/minor/patch, + build metadata parsed and discarded, prerelease ranked below the release + it precedes, prerelease identifiers compared per §11.4); and + `version_satisfies` judges one `=`/`>`/`>=`/`<`/`<=` constraint — + equality under any scheme, a relational operator only under `semver`, + matching §8.2/§8.3's refusal to generically order CalVer or opaque + versions. `artefact_os`/`artefact_arch`/`artefact_format` extract the + NIP-94 selectors and `artefact_matches` judges §19–20's `os`/`arch` match + with `"any"` as the platform-independent wildcard on either side; package + format (§19 step 3) stays the caller's own policy membership check, not + an equality this function can decide. +- **Runtime** dispatch in `nscript-runtime`'s shared pure-function + registry, with a unit test covering extraction and the two defaults, the + two-column requirement and operator_for builders and readers, SemVer + validity, precedence (including the prerelease-before-release rule and + identifier comparison), constraint satisfaction under both semver and a + non-ordered scheme, and artefact matching including the `any` wildcard. +- **Fixtures**: `conformance/valid/ncc13-package-release.ns` (a handler + publishes a release with `requires`/`conflicts` built by + `ncc13.requirement` — a `.ns` literal cannot carry the tag column + separator — then judges delivered releases for upgrade eligibility and + reads a delivered artefact's platform match) and + `conformance/invalid/ncc13-version-satisfies-wrong-type.ns` + (`version_satisfies(scheme, version, event.created_at)` → `E1001`, since + a constraint is Text, never a timestamp). +- **Wire vector** `conformance/vectors/ncc13.json` (kind 30063; the checked + snapshot holds the literal `d`/`a`/`version`/`version_scheme`/`channel`/ + `source`/`commit`, since `requires`/`conflicts` are computed), asserted + against the `inspect --json` publication trace, plus two `nscript run` + tests exercising the `ReleaseArtifactSet` and `ArtefactMetadata` handlers + with real two-column tags. +- **Worked example** `examples/ncc13-release-monitor.ns`: publishes a + release for one Software Application, judges every Release Artifact Set + it receives against a tracked installed version and preferred channel + (§26–28), and selects a matching artefact by platform. Run end to end by + a CLI test covering the beta-declined, stable-accepted, mismatched- and + matched-artefact paths. +- **Docs**: spec subsection and matrix row. +- **Infrastructure adoption**: a package-distribution bot publishes its own + releases and a monitor decides upgrade eligibility, dependency and + conflict exposure, and artefact selection purely from published Nostr + events — exactly `examples/ncc13-release-monitor.ns`'s workflow. + +Scope notes: NCC-13 v0.1 deliberately excludes ranges, wildcards and +boolean dependency expressions (§13); `version_satisfies` matches that +scope rather than a general SemVer range library. Dependency resolution +(§14) and installation itself are explicitly out of NCC-13's scope and +are not implemented here. + ## Guardrails - No NCC-specific syntax: conventions arrive as typed modules with records, @@ -708,3 +787,8 @@ so it stands on the same infrastructure as stage 9. three implemented against the bumped `adad778` pin, with the multi-column tag infrastructure they required in place. The release now records `adad778`.)* +4. ~~NCC-13 (added upstream after the `adad778` pin): schedule as a further + stage with another pin bump, or defer.~~ *(resolved at stage 11: the pin + moved to `186c3ad` and NCC-13 shipped on the multi-column tag + infrastructure stages 8–10 already built — no further infrastructure + was needed.)* diff --git a/ROADMAP.md b/ROADMAP.md index ee418d1..e9b4da0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,8 +88,10 @@ protocol breadth. then NCC-00 (document lifecycle), then the service cluster NCC-02, NCC-05, NCC-06, then NCC-08 (identity handover), then the post-pin additions NCC-09 (scoped operator authority), NCC-10 (operational state) and NCC-11 - (portable trust policy) with the three-column tag support they need; - NCC-03 lands last (see Explicitly deferred below). + (portable trust policy) with the three-column tag support they need, then + NCC-13 (software package release profile), a further post-pin addition + that profiles NIP-51/94/34 rather than owning new kinds; NCC-03 lands + last (see Explicitly deferred below). - Build the real record-fields-to-wire-tags lowering path for declared events, so `publish ` statements emit correct kinds and tags on real relays. Module operation calls remain simulated until a host is wired in. diff --git a/conformance/MATRIX.md b/conformance/MATRIX.md index 41ff4f1..d325d5c 100644 --- a/conformance/MATRIX.md +++ b/conformance/MATRIX.md @@ -36,6 +36,7 @@ against deterministic host fakes. | NCC-09 scoped operator authority | `ncc09-authority-grant.ns` | `ncc09-grant-wrong-clock.ns` | `vectors/ncc09.json` publication trace + pure-function runtime tests and delegation bot run test | | NCC-10 service operational state | `ncc10-service-state.ns` | `ncc10-state-wrong-type.ns` | `vectors/ncc10.json` publication trace + pure-function runtime tests and status bot run test | | NCC-11 portable trust policy | `ncc11-trust-policy.ns` | `ncc11-rule-value-wrong-type.ns` | `vectors/ncc11.json` publication trace + pure-function runtime tests, policy client run test and three-column deploy e2e | +| NCC-13 software package release profile | `ncc13-package-release.ns` | `ncc13-version-satisfies-wrong-type.ns` | `vectors/ncc13.json` publication trace + pure-function runtime tests (SemVer precedence, constraint satisfaction, artefact matching) and release/artefact handler run tests | The Draft 0.1 release-blocker cells are covered by executable vectors or inspectable traces. New NIPs and broader runtime features are out of scope for diff --git a/conformance/invalid/ncc13-version-satisfies-wrong-type.ns b/conformance/invalid/ncc13-version-satisfies-wrong-type.ns new file mode 100644 index 0000000..77b81fa --- /dev/null +++ b/conformance/invalid/ncc13-version-satisfies-wrong-type.ns @@ -0,0 +1,15 @@ +// error: E1001 nominal-type-mismatch +// NCC-13's `version_satisfies` takes a scheme, a candidate version, and a +// constraint, all Text; the delivered event's `created_at` is an Int and +// cannot name a constraint. Constraints are opaque strings parsed for a +// leading operator, never a timestamp. +use ncc13 +use nip01 + +permissions { + log +} + +on ReleaseArtifactSet { + print(text(ncc13.version_satisfies(ncc13.version_scheme(event.tags), ncc13.version(event.tags), event.created_at))) +} diff --git a/conformance/valid/ncc13-package-release.ns b/conformance/valid/ncc13-package-release.ns new file mode 100644 index 0000000..81df4d2 --- /dev/null +++ b/conformance/valid/ncc13-package-release.ns @@ -0,0 +1,76 @@ +// NCC-13: a software release is a NIP-51 Release Artifact Set (kind +// 30063) profiled with package-manager metadata. NCC-13 does not define a +// new event — it only adds version, channel, dependency, compatibility, +// and provenance tags to an object NIP-51 already owns (§2), so this +// module declares no events for the Software Application (32267) or Git +// Repository (30617) it references by address, only the release itself and +// its NIP-94 artefact's `os`/`arch`/`format` selectors. +// +// The `requires`/`conflicts` two-column entries are built by the module's +// pure functions rather than typed as literal tag columns, because a `.ns` +// string literal cannot carry the tag column separator (the same reason +// NCC-10's `operator_for` and NCC-11's `rule`/`trust certifier` are built, +// not written literally) — a handler computes them from a script-visible +// dependency address and constraint. +use ncc13 +use nip01 +use nip46 + +signer account = nip46() +relayset public = configured + +permissions { + publish ReleaseArtifactSet to public + sign ReleaseArtifactSet with account + read ReleaseArtifactSet from public + read ArtefactMetadata from public + relay public + log +} + +on Note where tags.t contains "publish-release" { + let runtime_dependency = ncc13.requirement("32267:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:com.example.runtime", ">=2.0.0") + let legacy_conflict = ncc13.requirement("32267:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:com.example.legacy", "<2.0.0") + let published = publish ReleaseArtifactSet { + d: "com.example.app@1.4.2"; + a: "32267:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:com.example.app"; + version: "1.4.2"; + version_scheme: "semver"; + channel: "stable"; + source: "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example-app"; + commit: "7f83b1657ff1fc53b92dc18148a1d65dfa13514f"; + requires: [runtime_dependency]; + conflicts: [legacy_conflict]; + content: "Release notes for 1.4.2"; + } to public with account + print("release published: " + text(published.accepted) + "/1 relays") +} + +// A package manager already tracking `installed_version` for this +// application decides whether a delivered release is an eligible upgrade: +// the right channel, an ordered scheme, and a version this scheme judges +// greater than what is installed. +on ReleaseArtifactSet { + let installed_version = "1.4.1" + let scheme = ncc13.version_scheme(event.tags) + let channel = ncc13.channel(event.tags) + let is_upgrade = channel == "stable" and ncc13.version_satisfies(scheme, ncc13.version(event.tags), ">" + installed_version) + print("release " + ncc13.application(event.tags) + "@" + ncc13.version(event.tags) + " (" + channel + "): upgrade candidate " + text(is_upgrade)) + for entry in ncc13.requires(event.tags) { + print("requires " + ncc13.requirement_address(entry) + " " + ncc13.requirement_constraint(entry)) + } + for entry in ncc13.conflicts(event.tags) { + print("conflicts with " + ncc13.requirement_address(entry) + " " + ncc13.requirement_constraint(entry)) + } +} + +// Artefact selection: NCC-13 places `os`/`arch`/`format` on the NIP-94 +// File Metadata event the release's `e` tags reference, not on the release +// itself (§15) — a package manager reads them off each artefact it +// receives and picks the one that matches its own platform. +on ArtefactMetadata { + let wanted_os = "linux" + let wanted_arch = "arm64" + let matches = ncc13.artefact_matches(ncc13.artefact_os(event.tags), ncc13.artefact_arch(event.tags), wanted_os, wanted_arch) + print("artefact " + ncc13.artefact_format(event.tags) + " matches " + wanted_os + "/" + wanted_arch + ": " + text(matches)) +} diff --git a/conformance/vectors/ncc00.json b/conformance/vectors/ncc00.json index e2144ea..3436be8 100644 --- a/conformance/vectors/ncc00.json +++ b/conformance/vectors/ncc00.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-00/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-00/README.md", "vectors": [ { "type": "supporting-document", diff --git a/conformance/vectors/ncc02.json b/conformance/vectors/ncc02.json index 1cb4043..f5cb514 100644 --- a/conformance/vectors/ncc02.json +++ b/conformance/vectors/ncc02.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-02/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-02/README.md", "vectors": [ { "type": "service-record", diff --git a/conformance/vectors/ncc05.json b/conformance/vectors/ncc05.json index 4ca3ca1..4ad8275 100644 --- a/conformance/vectors/ncc05.json +++ b/conformance/vectors/ncc05.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-05/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-05/README.md", "vectors": [ { "type": "locator", diff --git a/conformance/vectors/ncc06.json b/conformance/vectors/ncc06.json index 6b6e57d..ccbb9d8 100644 --- a/conformance/vectors/ncc06.json +++ b/conformance/vectors/ncc06.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-06/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-06/README.md", "vectors": [ { "type": "service-record", diff --git a/conformance/vectors/ncc07.json b/conformance/vectors/ncc07.json index 1e5ea90..a19bf8a 100644 --- a/conformance/vectors/ncc07.json +++ b/conformance/vectors/ncc07.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-07/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-07/README.md", "vectors": [ { "type": "capability-manifest", diff --git a/conformance/vectors/ncc08.json b/conformance/vectors/ncc08.json index 1a587c2..75e2dcb 100644 --- a/conformance/vectors/ncc08.json +++ b/conformance/vectors/ncc08.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-08/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-08/README.md", "vectors": [ { "type": "handover-proposal", diff --git a/conformance/vectors/ncc09.json b/conformance/vectors/ncc09.json index 890c930..7500232 100644 --- a/conformance/vectors/ncc09.json +++ b/conformance/vectors/ncc09.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-09/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-09/README.md", "vectors": [ { "type": "authority-grant", diff --git a/conformance/vectors/ncc10.json b/conformance/vectors/ncc10.json index ac566a5..65d28d7 100644 --- a/conformance/vectors/ncc10.json +++ b/conformance/vectors/ncc10.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-10/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-10/README.md", "vectors": [ { "type": "service-state", diff --git a/conformance/vectors/ncc11.json b/conformance/vectors/ncc11.json index 90707ad..141613a 100644 --- a/conformance/vectors/ncc11.json +++ b/conformance/vectors/ncc11.json @@ -1,5 +1,5 @@ { - "source": "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-11/README.md", + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-11/README.md", "vectors": [ { "type": "trust-policy", diff --git a/conformance/vectors/ncc13.json b/conformance/vectors/ncc13.json new file mode 100644 index 0000000..9e77802 --- /dev/null +++ b/conformance/vectors/ncc13.json @@ -0,0 +1,19 @@ +{ + "source": "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-13/README.md", + "vectors": [ + { + "type": "release-artifact-set", + "kind": 30063, + "content": "Release notes for 1.4.2", + "tags": [ + ["d", "com.example.app@1.4.2"], + ["a", "32267:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:com.example.app"], + ["version", "1.4.2"], + ["version_scheme", "semver"], + ["channel", "stable"], + ["source", "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:example-app"], + ["commit", "7f83b1657ff1fc53b92dc18148a1d65dfa13514f"] + ] + } + ] +} diff --git a/crates/nscript-cli/tests/cli.rs b/crates/nscript-cli/tests/cli.rs index 6ccd13b..6d41009 100644 --- a/crates/nscript-cli/tests/cli.rs +++ b/crates/nscript-cli/tests/cli.rs @@ -342,6 +342,89 @@ fn ncc11_trust_policy_lowers_to_the_pinned_wire_vector() { assert_eq!(create["content"], expected["content"]); } +#[test] +fn ncc13_package_release_lowers_to_the_pinned_wire_vector() { + let vector: serde_json::Value = serde_json::from_slice( + &std::fs::read(repository_path("conformance/vectors/ncc13.json")).unwrap(), + ) + .unwrap(); + let expected = &vector["vectors"][0]; + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .args(["inspect", "--json"]) + .arg(repository_path( + "conformance/valid/ncc13-package-release.ns", + )) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let create = &value["publication_trace"][0]["steps"][0]; + assert_eq!(create["op"], "create_event"); + assert_eq!(create["event"], "ReleaseArtifactSet"); + assert_eq!(create["kind"], expected["kind"]); + assert_eq!(create["tags"], expected["tags"]); + // `requires` and `conflicts` are built by ncc13.requirement at publish + // time (a `.ns` literal cannot carry the tag column separator), so the + // checked snapshot holds only the literal fields. The two-column + // lowering that builder produces is asserted by the runtime's own tag + // round-trip test and by the run-with-event coverage below. + assert_eq!(create["content"], expected["content"]); +} + +#[test] +fn ncc13_release_artifact_set_handler_reads_dependencies_and_upgrade_eligibility() { + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("run") + .arg(repository_path("conformance/valid/ncc13-package-release.ns")) + .args([ + "--event", + r#"{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "notes", "tags": [["d", "com.example.app@1.4.2"], ["a", "32267:aaa:com.example.app"], ["version", "1.4.2"], ["version_scheme", "semver"], ["channel", "stable"], ["requires", "32267:bbb:com.example.runtime\u001f>=2.0.0"]]}"#, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("upgrade candidate true"), + "1.4.2 on the stable channel should outrank the fixture's installed 1.4.1: {stdout}" + ); + assert!( + stdout.contains("requires 32267:bbb:com.example.runtime >=2.0.0"), + "the requirement's address and constraint should read back from the two-column tag: {stdout}" + ); +} + +#[test] +fn ncc13_artefact_metadata_handler_selects_by_platform() { + let output = Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("run") + .arg(repository_path("conformance/valid/ncc13-package-release.ns")) + .args([ + "--event", + r#"{"event_type": "ArtefactMetadata", "kind": 1063, "created_at": 1790380000, "content": "", "tags": [["url", "https://example.com/app_arm64.deb"], ["os", "linux"], ["arch", "arm64"], ["format", "deb"]]}"#, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("matches linux/arm64: true"), + "the linux/arm64 artefact should match the fixture's wanted linux/arm64 platform: {stdout}" + ); +} + #[test] fn dry_run_reports_plan_without_external_effects() { let output = Command::new(env!("CARGO_BIN_EXE_nscript")) @@ -2012,6 +2095,92 @@ fn the_policy_client_publishes_and_evaluates_trust_policy() { ); } +#[test] +fn the_release_monitor_publishes_judges_upgrades_and_selects_artefacts() { + let path = repository_path("examples/ncc13-release-monitor.ns"); + let run = |extra: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_nscript")) + .arg("run") + .arg(&path) + .args(extra) + .output() + .unwrap() + }; + + let publish = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "cut a release", "tags": [["t", "publish-release"]]}"#; + let output = run(&["--event", publish]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("handler Note: ok"), "{stdout}"); + assert!( + stdout.contains("release published: true/1 relays"), + "{stdout}" + ); + + // A beta release is newer but on the wrong channel: not an upgrade. + let beta = r#"{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "", "tags": [["d", "com.example.app@1.5.0"], ["a", "32267:aaa:com.example.app"], ["version", "1.5.0"], ["version_scheme", "semver"], ["channel", "beta"]]}"#; + let output = run(&["--event", beta]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("not an upgrade: 32267:aaa:com.example.app@1.5.0 (beta) — right channel false, newer true"), + "{stdout}" + ); + + // A stable release newer than the tracked installed version is an + // upgrade candidate. + let stable = r#"{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "", "tags": [["d", "com.example.app@1.4.2"], ["a", "32267:aaa:com.example.app"], ["version", "1.4.2"], ["version_scheme", "semver"], ["channel", "stable"], ["requires", "32267:bbb:com.example.runtime\u001f>=2.0.0"]]}"#; + let output = run(&["--event", stable]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("upgrade candidate: 32267:aaa:com.example.app@1.4.2 (stable, semver)"), + "{stdout}" + ); + assert!( + stdout.contains("requires 32267:bbb:com.example.runtime >=2.0.0"), + "{stdout}" + ); + + // An artefact whose arch does not match the monitor's own platform is + // reported, not silently accepted. + let mismatched = r#"{"event_type": "ArtefactMetadata", "kind": 1063, "created_at": 1790380000, "content": "", "tags": [["url", "https://example.com/app_amd64.deb"], ["os", "linux"], ["arch", "amd64"], ["format", "deb"]]}"#; + let output = run(&["--event", mismatched]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("artefact linux/amd64 does not match linux/arm64"), + "{stdout}" + ); + + // A matching artefact is selected. + let matching = r#"{"event_type": "ArtefactMetadata", "kind": 1063, "created_at": 1790380000, "content": "", "tags": [["url", "https://example.com/app_arm64.deb"], ["os", "linux"], ["arch", "arm64"], ["format", "deb"]]}"#; + let output = run(&["--event", matching]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("selected artefact: deb"), "{stdout}"); +} + #[test] fn the_support_ticket_bot_labels_urgent_tickets_and_always_acknowledges() { let path = repository_path("examples/support-ticket-bot.ns"); diff --git a/crates/nscript-modules/src/resolver.rs b/crates/nscript-modules/src/resolver.rs index 02fa53b..2223d52 100644 --- a/crates/nscript-modules/src/resolver.rs +++ b/crates/nscript-modules/src/resolver.rs @@ -66,6 +66,10 @@ const BUILTINS: &[(&str, &str)] = &[ "ncc11", include_str!("../../../modules/std/ncc11/0.1.0.nsm"), ), + ( + "ncc13", + include_str!("../../../modules/std/ncc13/0.1.0.nsm"), + ), ( "nip02", include_str!("../../../modules/std/nip02/0.1.0.nsm"), @@ -858,7 +862,7 @@ mod tests { fn discovers_standard_layout() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../modules/std"); let mut registry = ModuleRegistry::default(); - assert_eq!(registry.load_root(&root).unwrap(), 92); + assert_eq!(registry.load_root(&root).unwrap(), 93); } #[test] diff --git a/crates/nscript-runtime/src/lib.rs b/crates/nscript-runtime/src/lib.rs index 7064e40..39f2b3f 100644 --- a/crates/nscript-runtime/src/lib.rs +++ b/crates/nscript-runtime/src/lib.rs @@ -14,6 +14,7 @@ pub mod stream; pub mod voice; pub mod wire; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::{fs, path::PathBuf}; @@ -3528,6 +3529,7 @@ type PureFunction = fn(&[OperationValue]) -> Result Option { match (module, function) { ("concord05", "invite_is_valid") => Some(invite_is_valid as PureFunction), @@ -3606,6 +3608,28 @@ fn pure_function(module: &str, function: &str) -> Option { ("ncc11", "rule_value_is_valid") => Some(ncc11_rule_value_is_valid as PureFunction), ("ncc11", "rule") => Some(ncc11_rule as PureFunction), ("ncc11", "certifier") => Some(ncc11_certifier as PureFunction), + ("ncc13", "application") => Some(ncc13_application as PureFunction), + ("ncc13", "version") => Some(ncc13_version as PureFunction), + ("ncc13", "version_scheme") => Some(ncc13_version_scheme as PureFunction), + ("ncc13", "channel") => Some(ncc13_channel as PureFunction), + ("ncc13", "source") => Some(ncc13_source as PureFunction), + ("ncc13", "commit") => Some(ncc13_commit as PureFunction), + ("ncc13", "requirement") => Some(ncc13_requirement as PureFunction), + ("ncc13", "requirement_address") => Some(ncc13_requirement_address as PureFunction), + ("ncc13", "requirement_constraint") => Some(ncc13_requirement_constraint as PureFunction), + ("ncc13", "requires") => Some(ncc13_requires as PureFunction), + ("ncc13", "optional_dependencies") => Some(ncc13_optional_dependencies as PureFunction), + ("ncc13", "conflicts") => Some(ncc13_conflicts as PureFunction), + ("ncc13", "operator_for_tag") => Some(ncc13_operator_for_tag as PureFunction), + ("ncc13", "operator_project") => Some(ncc13_operator_project as PureFunction), + ("ncc13", "operator_application") => Some(ncc13_operator_application as PureFunction), + ("ncc13", "semver_is_valid") => Some(ncc13_semver_is_valid as PureFunction), + ("ncc13", "semver_compare") => Some(ncc13_semver_compare as PureFunction), + ("ncc13", "version_satisfies") => Some(ncc13_version_satisfies as PureFunction), + ("ncc13", "artefact_os") => Some(ncc13_artefact_os as PureFunction), + ("ncc13", "artefact_arch") => Some(ncc13_artefact_arch as PureFunction), + ("ncc13", "artefact_format") => Some(ncc13_artefact_format as PureFunction), + ("ncc13", "artefact_matches") => Some(ncc13_artefact_matches as PureFunction), _ => None, } } @@ -5792,6 +5816,394 @@ fn ncc11_certifier(arguments: &[OperationValue]) -> Result Result { + tag_value(arguments, "a", "ncc13.application") +} + +/// NCC-13 §7: the version string a release represents, interpreted only +/// under its declared `version_scheme`. +fn ncc13_version(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "version", "ncc13.version") +} + +/// NCC-13 §8.4: the declared version scheme, defaulting to `"opaque"` when +/// absent so an unscoped version string is never assumed ordered. +fn ncc13_version_scheme(arguments: &[OperationValue]) -> Result { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid("ncc13.version_scheme")); + }; + let scheme = tag_lookup(tags, "version_scheme", "ncc13.version_scheme")?; + Ok(OperationValue::Text( + scheme + .filter(|value| !value.is_empty()) + .unwrap_or("opaque") + .to_owned(), + )) +} + +/// NCC-13 §9.1: the release channel, defaulting to `"stable"` when absent. +fn ncc13_channel(arguments: &[OperationValue]) -> Result { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid("ncc13.channel")); + }; + let channel = tag_lookup(tags, "channel", "ncc13.channel")?; + Ok(OperationValue::Text( + channel + .filter(|value| !value.is_empty()) + .unwrap_or("stable") + .to_owned(), + )) +} + +/// NCC-13 §21: the optional NIP-34 source repository address a release +/// claims to derive from. +fn ncc13_source(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "source", "ncc13.source") +} + +/// NCC-13 §22: the optional source commit a release claims to derive from. +fn ncc13_commit(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "commit", "ncc13.commit") +} + +/// NCC-13 §10-12: build a `requires`/`optional`/`conflicts` tag's two-column +/// value — a Software Application address and a version constraint. +fn ncc13_requirement(arguments: &[OperationValue]) -> Result { + let [ + OperationValue::Text(address), + OperationValue::Text(constraint), + ] = arguments + else { + return Err(invalid("ncc13.requirement")); + }; + Ok(OperationValue::Text(join_tag_columns(&[ + address, constraint, + ]))) +} + +fn ncc13_requirement_columns(entry: &str) -> (String, String) { + let columns = tag_value_columns(entry); + ( + columns.first().copied().unwrap_or_default().to_owned(), + columns.get(1).copied().unwrap_or_default().to_owned(), + ) +} + +/// NCC-13 §10-12: the Software Application address half of a requirement +/// entry built by [`ncc13_requirement`]. +fn ncc13_requirement_address(arguments: &[OperationValue]) -> Result { + let [OperationValue::Text(entry)] = arguments else { + return Err(invalid("ncc13.requirement_address")); + }; + Ok(OperationValue::Text(ncc13_requirement_columns(entry).0)) +} + +/// NCC-13 §10-12: the version-constraint half of a requirement entry built +/// by [`ncc13_requirement`]. +fn ncc13_requirement_constraint( + arguments: &[OperationValue], +) -> Result { + let [OperationValue::Text(entry)] = arguments else { + return Err(invalid("ncc13.requirement_constraint")); + }; + Ok(OperationValue::Text(ncc13_requirement_columns(entry).1)) +} + +/// NCC-13 §10: every `requires` entry, in order. §13's cumulative-AND rule +/// means a repeated address is deliberate — the caller folds every matching +/// entry's constraint together, not just the first. +fn ncc13_requires(arguments: &[OperationValue]) -> Result { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid("ncc13.requires")); + }; + Ok(OperationValue::List( + tag_values(tags, "requires", "ncc13.requires")? + .into_iter() + .map(|value| OperationValue::Text(value.to_owned())) + .collect(), + )) +} + +/// NCC-13 §11: every `optional` entry, in order. Failing one of these MUST +/// NOT, by itself, make the release uninstallable (§11) — that policy lives +/// in the calling script, not here. +fn ncc13_optional_dependencies( + arguments: &[OperationValue], +) -> Result { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid("ncc13.optional_dependencies")); + }; + Ok(OperationValue::List( + tag_values(tags, "optional", "ncc13.optional_dependencies")? + .into_iter() + .map(|value| OperationValue::Text(value.to_owned())) + .collect(), + )) +} + +/// NCC-13 §12: every `conflicts` entry, in order. +fn ncc13_conflicts(arguments: &[OperationValue]) -> Result { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid("ncc13.conflicts")); + }; + Ok(OperationValue::List( + tag_values(tags, "conflicts", "ncc13.conflicts")? + .into_iter() + .map(|value| OperationValue::Text(value.to_owned())) + .collect(), + )) +} + +/// NCC-13 §33: build the two-column `operator_for` tag's value — the +/// project's own pubkey and the application id it authorises release +/// automation to publish under, mirroring NCC-10's `operator_for` shape. +fn ncc13_operator_for_tag(arguments: &[OperationValue]) -> Result { + let [ + OperationValue::Text(project_pubkey), + OperationValue::Text(application_id), + ] = arguments + else { + return Err(invalid("ncc13.operator_for_tag")); + }; + Ok(OperationValue::Text(join_tag_columns(&[ + project_pubkey, + application_id, + ]))) +} + +fn ncc13_operator_for_columns( + arguments: &[OperationValue], + function: &'static str, +) -> Result<(String, String), RuntimeError> { + let [OperationValue::List(tags)] = arguments else { + return Err(invalid(function)); + }; + let value = tag_lookup(tags, "operator_for", function)?.unwrap_or_default(); + Ok(ncc13_requirement_columns(value)) +} + +/// NCC-13 §33: the principal a release names itself as publishing for, from +/// its `operator_for` tag's first column. +fn ncc13_operator_project(arguments: &[OperationValue]) -> Result { + Ok(OperationValue::Text( + ncc13_operator_for_columns(arguments, "ncc13.operator_project")?.0, + )) +} + +/// NCC-13 §33: the application id an `operator_for` tag names, from its +/// second column. +fn ncc13_operator_application( + arguments: &[OperationValue], +) -> Result { + Ok(OperationValue::Text( + ncc13_operator_for_columns(arguments, "ncc13.operator_application")?.1, + )) +} + +/// A version's `major.minor.patch` triple plus its dot-separated prerelease +/// identifiers, ignoring build metadata (`+...`) per `SemVer` §10 — build +/// metadata never affects precedence. +struct Semver<'a> { + major: u64, + minor: u64, + patch: u64, + prerelease: Vec<&'a str>, +} + +fn parse_semver(candidate: &str) -> Option> { + let core = candidate.split('+').next().unwrap_or(candidate); + let (numeric, prerelease) = match core.split_once('-') { + Some((numeric, prerelease)) => (numeric, prerelease), + None => (core, ""), + }; + let mut parts = numeric.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + let prerelease = if prerelease.is_empty() { + Vec::new() + } else { + prerelease.split('.').collect() + }; + Some(Semver { + major, + minor, + patch, + prerelease, + }) +} + +/// `SemVer` §11's prerelease identifier comparison: numeric identifiers +/// compare numerically and always sort lower than alphanumeric ones, equal +/// alphanumeric identifiers compare lexically, and a prefix that runs out of +/// identifiers first sorts lower. +fn compare_prerelease(a: &[&str], b: &[&str]) -> Ordering { + for pair in a.iter().zip(b.iter()) { + let (left, right) = pair; + let ordering = match (left.parse::(), right.parse::()) { + (Ok(left), Ok(right)) => left.cmp(&right), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => left.cmp(right), + }; + if ordering != Ordering::Equal { + return ordering; + } + } + a.len().cmp(&b.len()) +} + +fn compare_semver(a: &Semver<'_>, b: &Semver<'_>) -> Ordering { + a.major + .cmp(&b.major) + .then(a.minor.cmp(&b.minor)) + .then(a.patch.cmp(&b.patch)) + .then_with( + || match (a.prerelease.is_empty(), b.prerelease.is_empty()) { + (true, true) => Ordering::Equal, + // SemVer §11.3: a version without a prerelease has higher + // precedence than one with, once major.minor.patch agree. + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => compare_prerelease(&a.prerelease, &b.prerelease), + }, + ) +} + +/// NCC-13 §7: whether a string parses as `major.minor.patch` (each a +/// non-negative integer) with an optional dot-separated prerelease and an +/// optional build-metadata suffix. +fn ncc13_semver_is_valid(arguments: &[OperationValue]) -> Result { + let [OperationValue::Text(candidate)] = arguments else { + return Err(invalid("ncc13.semver_is_valid")); + }; + Ok(OperationValue::Bool(parse_semver(candidate).is_some())) +} + +/// NCC-13 §8.1: `SemVer` precedence between two version strings, `-1`/`0`/`1`. +/// Only meaningful when both parse as `SemVer`; an unparsable side is treated +/// as lower than any version that does parse, and equal to another +/// unparsable string only when the raw text matches, so an invalid +/// comparison never silently reports an order between two malformed inputs. +fn ncc13_semver_compare(arguments: &[OperationValue]) -> Result { + let [OperationValue::Text(a), OperationValue::Text(b)] = arguments else { + return Err(invalid("ncc13.semver_compare")); + }; + let ordering = match (parse_semver(a), parse_semver(b)) { + (Some(left), Some(right)) => compare_semver(&left, &right), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => a.cmp(b), + }; + Ok(OperationValue::Integer(match ordering { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + })) +} + +/// NCC-13 §13: whether `candidate` satisfies a single constraint (`=`, `>`, +/// `>=`, `<`, or `<=`, followed by a version literal) under `scheme`. +/// Non-`semver` schemes support equality only (§8.2's `CalVer` note and +/// §8.3's opaque-equality-only rule); a relational operator against a +/// non-semver scheme never claims satisfaction. Multiple constraints for +/// the same dependency are `ANDed` by the caller folding this over each one +/// (§13) — this function judges exactly one. +fn ncc13_version_satisfies(arguments: &[OperationValue]) -> Result { + let [ + OperationValue::Text(scheme), + OperationValue::Text(candidate), + OperationValue::Text(constraint), + ] = arguments + else { + return Err(invalid("ncc13.version_satisfies")); + }; + let (operator, literal) = if let Some(rest) = constraint.strip_prefix(">=") { + (">=", rest) + } else if let Some(rest) = constraint.strip_prefix("<=") { + ("<=", rest) + } else if let Some(rest) = constraint.strip_prefix('=') { + ("=", rest) + } else if let Some(rest) = constraint.strip_prefix('>') { + (">", rest) + } else if let Some(rest) = constraint.strip_prefix('<') { + ("<", rest) + } else { + return Ok(OperationValue::Bool(false)); + }; + let satisfied = if operator == "=" { + candidate == literal + } else if scheme != "semver" { + false + } else { + match (parse_semver(candidate), parse_semver(literal)) { + (Some(candidate), Some(literal)) => { + let ordering = compare_semver(&candidate, &literal); + match operator { + ">" => ordering == Ordering::Greater, + ">=" => ordering != Ordering::Less, + "<" => ordering == Ordering::Less, + "<=" => ordering != Ordering::Greater, + _ => unreachable!(), + } + } + _ => false, + } + }; + Ok(OperationValue::Bool(satisfied)) +} + +/// NCC-13 §15: an artefact's declared operating system, from its `os` tag. +fn ncc13_artefact_os(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "os", "ncc13.artefact_os") +} + +/// NCC-13 §15: an artefact's declared architecture, from its `arch` tag. +fn ncc13_artefact_arch(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "arch", "ncc13.artefact_arch") +} + +/// NCC-13 §18: an artefact's declared package format, from its `format` tag. +fn ncc13_artefact_format(arguments: &[OperationValue]) -> Result { + tag_value(arguments, "format", "ncc13.artefact_format") +} + +/// NCC-13 §19-20: whether an artefact's declared `os`/`arch` match what the +/// local system wants, where `"any"` on either side of the pair is the +/// platform-independent wildcard. Package format is deliberately excluded: +/// §19 step 3 (`supported package format`) is local policy membership, not +/// an equality the artefact itself can decide. +fn ncc13_artefact_matches(arguments: &[OperationValue]) -> Result { + let [ + OperationValue::Text(entry_os), + OperationValue::Text(entry_arch), + OperationValue::Text(wanted_os), + OperationValue::Text(wanted_arch), + ] = arguments + else { + return Err(invalid("ncc13.artefact_matches")); + }; + let os_matches = entry_os == "any" || entry_os == wanted_os; + let arch_matches = entry_arch == "any" || entry_arch == wanted_arch; + Ok(OperationValue::Bool(os_matches && arch_matches)) +} + fn checked_to_operation(argument: &CheckedArgument) -> OperationValue { match argument { CheckedArgument::Text(value) => OperationValue::Text(value.clone()), @@ -11385,6 +11797,224 @@ mod tests { ); } + #[allow(clippy::too_many_lines)] + #[test] + fn ncc13_release_functions_extract_compare_and_match() { + let mut host = FakeOperationHost::default(); + let text = |value: &str| OperationValue::Text(value.to_owned()); + let mut call = |function: &str, arguments: &[OperationValue]| { + host.call_pure_function("ncc13", function, arguments) + .expect("pure function succeeds") + }; + + // §7/§8.4/§9.1: extraction, and the opaque/stable defaults when the + // optional tags are absent. + let runtime_requirement = call( + "requirement", + &[text("32267:pub:com.example.runtime"), text(">=2.0.0")], + ); + let OperationValue::Text(runtime_requirement_text) = runtime_requirement.clone() else { + panic!("requirement builder returns text"); + }; + let tags = OperationValue::List(vec![ + text("d=com.example.app@1.4.2"), + text("a=32267:pub:com.example.app"), + text("version=1.4.2"), + text(&format!("requires={runtime_requirement_text}")), + ]); + assert_eq!( + call("application", std::slice::from_ref(&tags)), + text("32267:pub:com.example.app") + ); + assert_eq!(call("version", std::slice::from_ref(&tags)), text("1.4.2")); + assert_eq!( + call("version_scheme", std::slice::from_ref(&tags)), + text("opaque"), + "an absent version_scheme defaults to opaque, never an assumed order" + ); + assert_eq!( + call("channel", std::slice::from_ref(&tags)), + text("stable"), + "an absent channel defaults to stable" + ); + let scoped_tags = + OperationValue::List(vec![text("version_scheme=semver"), text("channel=beta")]); + assert_eq!( + call("version_scheme", std::slice::from_ref(&scoped_tags)), + text("semver") + ); + assert_eq!( + call("channel", std::slice::from_ref(&scoped_tags)), + text("beta") + ); + + // §10-12: the two-column requirement builder and its readers. + assert_eq!( + tag_value_columns(&runtime_requirement_text), + vec!["32267:pub:com.example.runtime", ">=2.0.0"] + ); + assert_eq!( + call( + "requirement_address", + std::slice::from_ref(&runtime_requirement) + ), + text("32267:pub:com.example.runtime") + ); + assert_eq!( + call( + "requirement_constraint", + std::slice::from_ref(&runtime_requirement) + ), + text(">=2.0.0") + ); + assert_eq!( + call("requires", std::slice::from_ref(&tags)), + OperationValue::List(vec![text(&runtime_requirement_text)]) + ); + assert_eq!( + call("optional_dependencies", std::slice::from_ref(&tags)), + OperationValue::List(vec![]) + ); + assert_eq!( + call("conflicts", std::slice::from_ref(&tags)), + OperationValue::List(vec![]) + ); + + // §33: the two-column operator_for tag NCC-09 operator publication + // uses, mirroring NCC-10's shape. + let operator_for = call( + "operator_for_tag", + &[text("projectpubkey"), text("com.example.app")], + ); + let OperationValue::Text(operator_for_text) = operator_for.clone() else { + panic!("operator_for_tag builder returns text"); + }; + let operator_tags = + OperationValue::List(vec![text(&format!("operator_for={operator_for_text}"))]); + assert_eq!( + call("operator_project", std::slice::from_ref(&operator_tags)), + text("projectpubkey") + ); + assert_eq!( + call("operator_application", std::slice::from_ref(&operator_tags)), + text("com.example.app") + ); + + // §7/§8.1: SemVer validity and precedence, including the + // prerelease-before-release rule (§11.3) and prerelease identifier + // comparison (§11.4). + let mut valid = |candidate: &str| call("semver_is_valid", &[text(candidate)]); + assert_eq!(valid("1.4.2"), OperationValue::Bool(true)); + assert_eq!(valid("1.4.2-beta.1"), OperationValue::Bool(true)); + assert_eq!(valid("1.4.2+build.5"), OperationValue::Bool(true)); + assert_eq!(valid("1.4"), OperationValue::Bool(false)); + assert_eq!(valid("phoenix-7"), OperationValue::Bool(false)); + let mut compare = |a: &str, b: &str| call("semver_compare", &[text(a), text(b)]); + assert_eq!(compare("1.4.2", "1.4.2"), OperationValue::Integer(0)); + assert_eq!(compare("1.4.1", "1.4.2"), OperationValue::Integer(-1)); + assert_eq!(compare("2.0.0", "1.4.2"), OperationValue::Integer(1)); + assert_eq!( + compare("1.0.0-alpha", "1.0.0"), + OperationValue::Integer(-1), + "a prerelease has lower precedence than the release it precedes" + ); + assert_eq!( + compare("1.0.0-alpha", "1.0.0-alpha.1"), + OperationValue::Integer(-1), + "a smaller set of prerelease identifiers sorts lower when the prefix agrees" + ); + assert_eq!( + compare("1.0.0-alpha.1", "1.0.0-alpha.beta"), + OperationValue::Integer(-1), + "numeric prerelease identifiers always sort below alphanumeric ones" + ); + assert_eq!( + compare("1.0.0", "not-a-version"), + OperationValue::Integer(1), + "an unparsable side never outranks a valid version" + ); + + // §13: constraint satisfaction, AND-folded by the caller over + // repeated tags; equality works for every scheme, relational + // operators only for semver. + let mut satisfies = |scheme: &str, candidate: &str, constraint: &str| { + call( + "version_satisfies", + &[text(scheme), text(candidate), text(constraint)], + ) + }; + assert_eq!( + satisfies("semver", "2.4.0", ">=2.0.0"), + OperationValue::Bool(true) + ); + assert_eq!( + satisfies("semver", "2.4.0", "<3.0.0"), + OperationValue::Bool(true) + ); + assert_eq!( + satisfies("semver", "3.0.0", "<3.0.0"), + OperationValue::Bool(false) + ); + assert_eq!( + satisfies("semver", "2.0.0", ">=2.0.0"), + OperationValue::Bool(true) + ); + assert_eq!( + satisfies("opaque", "phoenix-7", "=phoenix-7"), + OperationValue::Bool(true) + ); + assert_eq!( + satisfies("opaque", "phoenix-7", ">=phoenix-6"), + OperationValue::Bool(false), + "a relational operator never claims satisfaction outside semver" + ); + + // §15/§19-20: artefact selection over os/arch, "any" as the + // platform-independent wildcard on either side, format excluded. + let artefact_tags = OperationValue::List(vec![ + text("url=https://example.com/app.deb"), + text("os=linux"), + text("arch=arm64"), + text("format=deb"), + ]); + assert_eq!( + call("artefact_os", std::slice::from_ref(&artefact_tags)), + text("linux") + ); + assert_eq!( + call("artefact_arch", std::slice::from_ref(&artefact_tags)), + text("arm64") + ); + assert_eq!( + call("artefact_format", std::slice::from_ref(&artefact_tags)), + text("deb") + ); + let mut matches = |entry_os: &str, entry_arch: &str, wanted_os: &str, wanted_arch: &str| { + call( + "artefact_matches", + &[ + text(entry_os), + text(entry_arch), + text(wanted_os), + text(wanted_arch), + ], + ) + }; + assert_eq!( + matches("linux", "arm64", "linux", "arm64"), + OperationValue::Bool(true) + ); + assert_eq!( + matches("linux", "amd64", "linux", "arm64"), + OperationValue::Bool(false) + ); + assert_eq!( + matches("any", "any", "windows", "amd64"), + OperationValue::Bool(true), + "any/any is the platform-independent wildcard" + ); + } + #[allow(clippy::too_many_lines)] #[test] fn ncc00_lifecycle_functions_extract_validate_and_judge_effectiveness() { diff --git a/examples/ncc13-release-monitor.ns b/examples/ncc13-release-monitor.ns new file mode 100644 index 0000000..ec54cc2 --- /dev/null +++ b/examples/ncc13-release-monitor.ns @@ -0,0 +1,102 @@ +// An NCC-13 release monitor for a single Software Application address: it +// publishes its own release, then judges every Release Artifact Set it +// receives for that application against a fixed "installed" version and a +// preferred channel, and separately selects a matching NIP-94 artefact by +// platform. NCC-13 does not define automatic installation (§27) or a +// dependency solver (§14) — this bot reports what a package manager would +// decide, nothing more. +// +// Adoption note: package semantics stay entirely client-side (§46) — the +// Release Artifact Set, its NIP-94 artefacts, the Software Application and +// the Git repository it references remain ordinary NIP-51/94/34 events, so +// this bot interprets them without any relay or protocol change. +// +// Try it: +// +// nscript run examples/ncc13-release-monitor.ns +// (no --event: the bot idles, nothing published) +// +// nscript run examples/ncc13-release-monitor.ns \ +// --event '{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "cut a release", "tags": [["t", "publish-release"]]}' +// (publishes 1.4.2 on the stable channel) +// +// nscript run examples/ncc13-release-monitor.ns \ +// --event '{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "", "tags": [["d", "com.example.app@1.5.0"], ["a", "32267:aaa:com.example.app"], ["version", "1.5.0"], ["version_scheme", "semver"], ["channel", "beta"]]}' +// (a beta release the bot declines: wrong channel) +// +// nscript run examples/ncc13-release-monitor.ns \ +// --event '{"event_type": "ReleaseArtifactSet", "kind": 30063, "created_at": 1790380000, "content": "", "tags": [["d", "com.example.app@1.4.2"], ["a", "32267:aaa:com.example.app"], ["version", "1.4.2"], ["version_scheme", "semver"], ["channel", "stable"]]}' +// (a stable release the bot accepts as an upgrade) +// +// nscript run examples/ncc13-release-monitor.ns \ +// --event '{"event_type": "ArtefactMetadata", "kind": 1063, "created_at": 1790380000, "content": "", "tags": [["url", "https://example.com/app_amd64.deb"], ["os", "linux"], ["arch", "amd64"], ["format", "deb"]]}' +// (an artefact the bot's linux/arm64 platform does not match) +use ncc13 +use nip01 +use nip46 + +signer account = nip46() +relayset public = configured + +permissions { + publish ReleaseArtifactSet to public + sign ReleaseArtifactSet with account + read ReleaseArtifactSet from public + read ArtefactMetadata from public + relay public + log +} + +on Note where tags.t contains "publish-release" { + let runtime_dependency = ncc13.requirement("32267:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:com.example.runtime", ">=2.0.0") + let published = publish ReleaseArtifactSet { + d: "com.example.app@1.4.2"; + a: "32267:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:com.example.app"; + version: "1.4.2"; + version_scheme: "semver"; + channel: "stable"; + requires: [runtime_dependency]; + content: "1.4.2: dependency bump to runtime 2.x"; + } to public with account + print("release published: " + text(published.accepted) + "/1 relays") +} + +// §26-28: discovery and upgrade selection. The monitor tracks its own +// "installed" version and preferred channel; a release outside that +// channel, or that the scheme does not judge greater, is reported but not +// treated as an upgrade candidate. +on ReleaseArtifactSet { + let installed_version = "1.4.1" + let preferred_channel = "stable" + let application = ncc13.application(event.tags) + let candidate_version = ncc13.version(event.tags) + let scheme = ncc13.version_scheme(event.tags) + let channel = ncc13.channel(event.tags) + let right_channel = channel == preferred_channel + let newer = ncc13.version_satisfies(scheme, candidate_version, ">" + installed_version) + if right_channel and newer { + print("upgrade candidate: " + application + "@" + candidate_version + " (" + channel + ", " + scheme + ")") + } else { + print("not an upgrade: " + application + "@" + candidate_version + " (" + channel + ") — right channel " + text(right_channel) + ", newer " + text(newer)) + } + for entry in ncc13.requires(event.tags) { + print(" requires " + ncc13.requirement_address(entry) + " " + ncc13.requirement_constraint(entry)) + } + for entry in ncc13.conflicts(event.tags) { + print(" conflicts with " + ncc13.requirement_address(entry) + " " + ncc13.requirement_constraint(entry)) + } +} + +// §19-20: artefact selection against this monitor's own platform. §19 step +// 3 (supported package format) is local policy the script still owns — +// artefact_matches only judges os/arch. +on ArtefactMetadata { + let local_os = "linux" + let local_arch = "arm64" + let matches = ncc13.artefact_matches(ncc13.artefact_os(event.tags), ncc13.artefact_arch(event.tags), local_os, local_arch) + if matches { + print("selected artefact: " + ncc13.artefact_format(event.tags)) + } else { + print("artefact " + ncc13.artefact_os(event.tags) + "/" + ncc13.artefact_arch(event.tags) + " does not match " + local_os + "/" + local_arch) + } +} diff --git a/modules/std/ncc00/0.1.0.nsm b/modules/std/ncc00/0.1.0.nsm index 418355f..0e36f2c 100644 --- a/modules/std/ncc00/0.1.0.nsm +++ b/modules/std/ncc00/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc00 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-00/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-00/README.md" validator ncc_identifier(value: Text) { require contains(value, "ncc-") diff --git a/modules/std/ncc02/0.1.0.nsm b/modules/std/ncc02/0.1.0.nsm index 78c519a..441c83a 100644 --- a/modules/std/ncc02/0.1.0.nsm +++ b/modules/std/ncc02/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc02 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-02/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-02/README.md" validator present(value: Text) { require length(value) > 0 diff --git a/modules/std/ncc05/0.1.0.nsm b/modules/std/ncc05/0.1.0.nsm index d145067..644ace9 100644 --- a/modules/std/ncc05/0.1.0.nsm +++ b/modules/std/ncc05/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc05 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-05/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-05/README.md" use nip44 @ "^0.1" validator present(value: Text) { diff --git a/modules/std/ncc06/0.1.0.nsm b/modules/std/ncc06/0.1.0.nsm index fc6092f..2ca8a2e 100644 --- a/modules/std/ncc06/0.1.0.nsm +++ b/modules/std/ncc06/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc06 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-06/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-06/README.md" use ncc02 @ "^0.1" use ncc05 @ "^0.1" diff --git a/modules/std/ncc07/0.1.0.nsm b/modules/std/ncc07/0.1.0.nsm index 06b50b6..54529cc 100644 --- a/modules/std/ncc07/0.1.0.nsm +++ b/modules/std/ncc07/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc07 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-07/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-07/README.md" validator manifest_identifier(value: Text) { require value == "capabilities" diff --git a/modules/std/ncc08/0.1.0.nsm b/modules/std/ncc08/0.1.0.nsm index d99f7a1..a4d459a 100644 --- a/modules/std/ncc08/0.1.0.nsm +++ b/modules/std/ncc08/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc08 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-08/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-08/README.md" use ncc02 @ "^0.1" validator handover_role(value: Text) { diff --git a/modules/std/ncc09/0.1.0.nsm b/modules/std/ncc09/0.1.0.nsm index b4ab268..3e84f50 100644 --- a/modules/std/ncc09/0.1.0.nsm +++ b/modules/std/ncc09/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc09 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-09/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-09/README.md" use ncc02 @ "^0.1" validator grant_status(value: Text) { diff --git a/modules/std/ncc10/0.1.0.nsm b/modules/std/ncc10/0.1.0.nsm index 319deb4..48517b2 100644 --- a/modules/std/ncc10/0.1.0.nsm +++ b/modules/std/ncc10/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc10 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-10/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-10/README.md" use ncc02 @ "^0.1" use ncc09 @ "^0.1" diff --git a/modules/std/ncc11/0.1.0.nsm b/modules/std/ncc11/0.1.0.nsm index b440d4f..51d0446 100644 --- a/modules/std/ncc11/0.1.0.nsm +++ b/modules/std/ncc11/0.1.0.nsm @@ -1,6 +1,6 @@ module ncc11 @ 0.1.0 language ">=0.1.0 <0.2.0" -reference "https://github.com/imattau/nostr-community-conventions/blob/adad77869d2a3be2ce8c5f673b88711e02df0bfb/ncc-11/README.md" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-11/README.md" use ncc02 @ "^0.1" use ncc05 @ "^0.1" diff --git a/modules/std/ncc13/0.1.0.nsm b/modules/std/ncc13/0.1.0.nsm new file mode 100644 index 0000000..49e8ee9 --- /dev/null +++ b/modules/std/ncc13/0.1.0.nsm @@ -0,0 +1,71 @@ +module ncc13 @ 0.1.0 +language ">=0.1.0 <0.2.0" +reference "https://github.com/imattau/nostr-community-conventions/blob/186c3ad50966a2319c354ace12e0a5a2267b75d9/ncc-13/README.md" +use ncc09 @ "^0.1" + +validator present(value: Text) { + require length(value) > 0 +} + +validator version_scheme_value(value: Text) { + require value == "semver" or value == "calver" or value == "opaque" +} + +function application(tags: List) -> Text +function version(tags: List) -> Text +function version_scheme(tags: List) -> Text +function channel(tags: List) -> Text +function source(tags: List) -> Text +function commit(tags: List) -> Text +function requirement(address: Text, constraint: Text) -> Text +function requirement_address(entry: Text) -> Text +function requirement_constraint(entry: Text) -> Text +function requires(tags: List) -> List +function optional_dependencies(tags: List) -> List +function conflicts(tags: List) -> List +function operator_for_tag(project_pubkey: Text, application_id: Text) -> Text +function operator_project(tags: List) -> Text +function operator_application(tags: List) -> Text +function semver_is_valid(candidate: Text) -> Bool +function semver_compare(a: Text, b: Text) -> Int +function version_satisfies(scheme: Text, candidate: Text, constraint: Text) -> Bool +function artefact_os(tags: List) -> Text +function artefact_arch(tags: List) -> Text +function artefact_format(tags: List) -> Text +function artefact_matches(entry_os: Text, entry_arch: Text, wanted_os: Text, wanted_arch: Text) -> Bool + +event ReleaseArtifactSet { + kind: 30063 + mode: addressable + content: Text as text + tags: List = [] + field d: Text + field a: Text + field version: Text + field version_scheme: Text + field channel: Text + field source: Text + field commit: Text + field requires: List + field optional: List + field conflicts: List + field e: List + field operator_for: Text + require present(d) + require present(a) + require present(version) +} + +event ArtefactMetadata { + kind: 1063 + mode: regular + content: Text as text + tags: List = [] + field url: Text + field x: Text + field m: Text + field size: Int + field os: Text + field arch: Text + field format: Text +} diff --git a/spec/README.md b/spec/README.md index 75eb930..b2d8bbb 100644 --- a/spec/README.md +++ b/spec/README.md @@ -26,7 +26,7 @@ lowering behaviour. Nostr Community Conventions were reviewed against [`imattau/nostr-community-conventions`](https://github.com/imattau/nostr-community-conventions) -commit [`fe5981fd`](https://github.com/imattau/nostr-community-conventions/tree/adad77869d2a3be2ce8c5f673b88711e02df0bfb). +commit [`186c3ad5`](https://github.com/imattau/nostr-community-conventions/tree/186c3ad50966a2319c354ace12e0a5a2267b75d9). Community-convention modules follow the same rule: a release MUST record a new pin when updating their lowering behaviour. The integration plan lives in [`../NCC-PLAN.md`](../NCC-PLAN.md). diff --git a/spec/nostr.md b/spec/nostr.md index 590d680..4ab6bb6 100644 --- a/spec/nostr.md +++ b/spec/nostr.md @@ -464,3 +464,53 @@ for key pinning, `allow`/`deny` for the transport and operator rules, a non-negative integer for `max-stale-age`, and anything for an unknown key. Named profiles are independent (§19, §20), and which one is active is a local choice, as `examples/ncc11-policy-client.ns` shows. + +### NCC-13 software package release profile + +`ncc13` implements the package-release profile: it adds package-manager +metadata to a kind-30063 NIP-51 Release Artifact Set rather than defining a +new event (§2). The module declares that event (`d`, `a`, `version`, +`version_scheme`, `channel`, `source`, `commit`, repeatable `requires`, +`optional` and `conflicts`, and `e` references to NIP-94 artefacts) plus a +minimal `ArtefactMetadata` event for kind 1063, carrying only the `os`, +`arch` and `format` selectors NCC-13 adds to NIP-94 (§15) — the file's +identity, hash and location stay NIP-94's own concern and are not +redeclared. NCC-13 does not define Software Application (kind 32267) or Git +Repository (kind 30617) identity either (§3); scripts read and build those +as plain address text. + +`ncc13.version_scheme` and `ncc13.channel` apply the two documented +defaults — an absent scheme reads as `"opaque"` (§8.4), so an unscoped +version string is never assumed ordered, and an absent channel reads as +`"stable"` (§9.1). `ncc13.semver_is_valid` and `ncc13.semver_compare` +implement SemVer precedence over `major.minor.patch` plus an optional +dot-separated prerelease (build metadata is parsed and ignored, per SemVer +§10): equal releases compare equal, a prerelease has lower precedence than +the release it precedes (§11.3), and prerelease identifiers compare +per §11.4 (numeric identifiers compare numerically and always rank below +alphanumeric ones, equal-length ties compare lexically, and a shorter +identifier list with an equal prefix ranks lower). `ncc13.version_satisfies` +judges exactly one `=`/`>`/`>=`/`<`/`<=` constraint (§13); equality works +under any scheme, but a relational operator is scoped to `semver` only, +matching §8.2/§8.3's rule that CalVer and opaque versions are not generically +ordered. §13's multiple-constraints-are-ANDed rule is left to the calling +script folding this function over each constraint tag, the same shape as +NCC-11's rule extraction. + +`ncc13.requirement` builds the two-column `requires`/`optional`/`conflicts` +value (a Software Application address and a constraint); `ncc13.requires`, +`ncc13.optional_dependencies` and `ncc13.conflicts` read the repeated tags +back, and `ncc13.requirement_address`/`ncc13.requirement_constraint` split +one entry. `ncc13.operator_for_tag` builds NCC-13's own `operator_for` +two-column tag for the `ncc:13:publish` NCC-09 scope (§33), mirroring +NCC-10's `operator_for` shape rather than inventing a new one. +`ncc13.artefact_matches` judges §19-20's `os`/`arch` selection, with `"any"` +on either side as the platform-independent wildcard; package format +(§19 step 3) is left to the script, since "supported" is local policy +membership rather than an equality the artefact can decide on its own. + +`examples/ncc13-release-monitor.ns` publishes a release for one Software +Application, judges every Release Artifact Set it receives against a +tracked installed version and preferred channel, and selects a matching +artefact by platform — the read half is exactly §26-28's discovery and +upgrade-selection model, and NCC-13 itself never installs anything (§27).