diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 99c8739..c94c7f3 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -932,6 +932,16 @@ "verification": "test", "safety_goal": "SG-006", "asil": "QM" + }, + { + "id": "REQ-RELAY-099", + "title": "Retired-capability enforcement (relay conform, spec sec-3.2)", + "text": "spec/version.json MAY carry a retired[] array (spec sec-3.2), each entry a {name, since, removal, reason} record. relay conform MUST FAIL a capabilities document (sec-12.2) whose declared spec_version is at or past a retired[] entry's removal version and whose features or commands still lists that entry's name (spec sec-17 Requirement 17). The check is sourced from relay conform's own embedded spec/version.json, not the attested binary's -- a black-box invocation cannot introspect the target's retired list, only compare its declared spec_version and capabilities against the reference list relay conform itself ships.", + "category": "functional", + "criticality": "medium", + "verification": "test", + "safety_goal": "SG-006", + "asil": "ASIL-B" } ] } diff --git a/cmd/relay/conform.go b/cmd/relay/conform.go index 29a5a7b..2a9ea39 100644 --- a/cmd/relay/conform.go +++ b/cmd/relay/conform.go @@ -15,6 +15,7 @@ import ( "io" "os" "os/exec" + "strconv" "strings" "time" @@ -282,15 +283,25 @@ func buildManifest(binary string) conformManifest { sFindings = validateStatusDoc(statusJSON) } + // capsUnreachable is true when the capabilities command couldn't even be + // run/parsed at all (§17.7) — a total failure that subsumes every + // narrower capabilities-doc check below. + capsUnreachable := hasFailWithReq(cFindings, "§17.7") + req1 := statusPass // Requirement 1 (protocol declaration) spans both documents: spec_version // shape (version doc) and the capabilities doc's own protocol-null check // (§12.2, gated on multi_protocol — see validateCapabilitiesDoc). - if hasFail(vFindings) || hasFailWithReq(cFindings, "§12.2") { + if hasFail(vFindings) || capsUnreachable || hasFailWithReq(cFindings, "§12.2") { req1 = statusFail } + req6 := statusPass - if hasFail(cFindings) { + // Requirement 6 (application interface) is specifically the adapt=false + // check (§17.6) — filtered so an unrelated capabilities-doc FAIL (e.g. + // Requirement 17's retirement check) doesn't misreport this requirement, + // but still FAILs if the capabilities doc couldn't be fetched at all. + if capsUnreachable || hasFailWithReq(cFindings, "§17.6") { req6 = statusFail } req7 := statusPass @@ -301,6 +312,10 @@ func buildManifest(binary string) conformManifest { if hasFail(vFindings) { req12 = statusFail } + req17 := statusPass + if capsUnreachable || hasFailWithReq(cFindings, "§3.2") { + req17 = statusFail + } m.Requirements = []manifestRequirement{ {1, req1, verifierRelayConform}, @@ -319,6 +334,7 @@ func buildManifest(binary string) conformManifest { {14, statusNotObservable, verifierCI}, {15, statusNotObservable, verifierManifestSplit}, {16, statusNotObservable, verifierCI}, + {17, req17, verifierRelayConform}, } m.Overall = statusPass @@ -538,6 +554,105 @@ func validateCapabilitiesDoc(data []byte) []conformFinding { } } + // §3.2 / §17 Requirement 17: a capabilities doc MUST NOT still advertise a + // retired capability at or past its removal version. + if specVersion, ok := doc["spec_version"].(string); ok { + fs = append(fs, checkRetiredCapabilities(specVersion, doc, loadRetiredCapabilities())...) + } + + return fs +} + +// retiredEntry is one entry in spec/version.json's retired[]/deprecated[] +// arrays (§3.2). +type retiredEntry struct { + Name string `json:"name"` + Since string `json:"since"` + Removal string `json:"removal"` + Reason string `json:"reason"` +} + +// loadRetiredCapabilities parses RELAY's own embedded spec/version.json and +// returns its retired[] entries (§3.2). Returns nil, not an error, if the +// embedded document is unparseable — this check degrades to a no-op rather +// than failing every conform invocation on an unrelated defect. +func loadRetiredCapabilities() []retiredEntry { + raw, err := relay.Evidence("version") + if err != nil { + return nil + } + var meta struct { + Retired []retiredEntry `json:"retired"` + } + if err := json.Unmarshal(raw, &meta); err != nil { + return nil + } + return meta.Retired +} + +// parseSpecVersion parses a "MAJOR.MINOR" spec version string. +func parseSpecVersion(s string) (major, minor int, ok bool) { + parts := strings.SplitN(s, ".", 2) + if len(parts) != 2 { + return 0, 0, false + } + maj, err1 := strconv.Atoi(parts[0]) + min, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return maj, min, true +} + +// specVersionAtLeast reports whether a >= b, both "MAJOR.MINOR" strings. +// Returns false (fail-safe: does not trigger retirement enforcement) if +// either string cannot be parsed. +func specVersionAtLeast(a, b string) bool { + aMaj, aMin, aOK := parseSpecVersion(a) + bMaj, bMin, bOK := parseSpecVersion(b) + if !aOK || !bOK { + return false + } + if aMaj != bMaj { + return aMaj > bMaj + } + return aMin >= bMin +} + +// checkRetiredCapabilities cross-references a binary's declared spec_version +// and capabilities (features/commands) against a retired[] list (§3.2, §17 +// Requirement 17), FAILing any capability still advertised at or past its +// recorded removal version. +// +//fusa:req REQ-RELAY-099 +func checkRetiredCapabilities(specVersion string, capsDoc map[string]interface{}, retired []retiredEntry) []conformFinding { + if specVersion == "" || len(retired) == 0 { + return nil + } + named := map[string]bool{} + if cmds, ok := capsDoc["commands"].([]interface{}); ok { + for _, c := range cmds { + if s, ok := c.(string); ok { + named[s] = true + } + } + } + if feats, ok := capsDoc["features"].([]interface{}); ok { + for _, f := range feats { + if s, ok := f.(string); ok { + named[s] = true + } + } + } + var fs []conformFinding + for _, r := range retired { + if !specVersionAtLeast(specVersion, r.Removal) { + continue + } + if named[r.Name] { + fs = append(fs, fail("§3.2", "capabilities doc: declares retired capability %q, which MUST be removed at spec_version %s (removal version %s)", r.Name, specVersion, r.Removal)) + } + } return fs } diff --git a/cmd/relay/conform_test.go b/cmd/relay/conform_test.go index 4f09d9b..42e261e 100644 --- a/cmd/relay/conform_test.go +++ b/cmd/relay/conform_test.go @@ -382,8 +382,8 @@ func TestBuildManifestSelf(t *testing.T) { if m.SpecVersion == "" { t.Error("SpecVersion is empty") } - if len(m.Requirements) != 16 { - t.Fatalf("len(Requirements) = %d, want 16", len(m.Requirements)) + if len(m.Requirements) != 17 { + t.Fatalf("len(Requirements) = %d, want 17", len(m.Requirements)) } for i, r := range m.Requirements { if r.ID != i+1 { @@ -398,7 +398,7 @@ func TestBuildManifestSelf(t *testing.T) { // §17.2: a manifest MUST NOT report PASS for a requirement relay // conform cannot actually observe. Only 1, 6, 7, 12 may be PASS/FAIL; // everything else must never be reported PASS. - observable := map[int]bool{1: true, 6: true, 7: true, 12: true} + observable := map[int]bool{1: true, 6: true, 7: true, 12: true, 17: true} if !observable[r.ID] && r.Status == statusPass { t.Errorf("Requirements[%d] (id=%d) reports PASS but is not in the observable set", i, r.ID) } @@ -415,6 +415,8 @@ func TestBuildManifestNotObservableDefaults(t *testing.T) { m := buildManifest(bin) wantNotObservable := []int{2, 3, 4, 5, 8, 9, 10, 11, 13, 14, 15, 16} + // Requirement 17 is fully observable (see TestBuildManifestSelf's + // observable set), so it is deliberately excluded from this list. byID := map[int]manifestRequirement{} for _, r := range m.Requirements { byID[r.ID] = r @@ -508,8 +510,8 @@ func TestRunConformManifestFlag(t *testing.T) { if m.Overall != statusPass { t.Errorf("Overall = %s, want %s", m.Overall, statusPass) } - if len(m.Requirements) != 16 { - t.Errorf("len(Requirements) = %d, want 16", len(m.Requirements)) + if len(m.Requirements) != 17 { + t.Errorf("len(Requirements) = %d, want 17", len(m.Requirements)) } } @@ -570,8 +572,8 @@ func TestBuildAttestationSelf(t *testing.T) { if a.Predicate.ConformanceManifest.Kind != "relay-conform-manifest" { t.Errorf("Predicate.ConformanceManifest.Kind = %q, want relay-conform-manifest", a.Predicate.ConformanceManifest.Kind) } - if len(a.Predicate.ConformanceManifest.Requirements) != 16 { - t.Errorf("len(Predicate.ConformanceManifest.Requirements) = %d, want 16", len(a.Predicate.ConformanceManifest.Requirements)) + if len(a.Predicate.ConformanceManifest.Requirements) != 17 { + t.Errorf("len(Predicate.ConformanceManifest.Requirements) = %d, want 17", len(a.Predicate.ConformanceManifest.Requirements)) } // §20.6: MUST be false — this type has no signing mechanism at all. if a.Predicate.Signed { @@ -671,6 +673,147 @@ func TestRunConformAttestationFlagFail(t *testing.T) { } } +// --- tests for checkRetiredCapabilities / specVersionAtLeast (spec §3.2, Requirement 17) --- + +//fusa:test REQ-RELAY-099 +func TestSpecVersionAtLeast(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"2.9", "2.9", true}, + {"2.10", "2.9", true}, // numeric, not lexicographic, comparison + {"2.9", "2.10", false}, // lexicographically "2.9" > "2.10" but numerically it is not + {"3.0", "2.99", true}, + {"2.8", "2.9", false}, + {"garbage", "2.9", false}, // unparseable: fail-safe, does not trigger enforcement + {"2.9", "garbage", false}, + } + for _, c := range cases { + if got := specVersionAtLeast(c.a, c.b); got != c.want { + t.Errorf("specVersionAtLeast(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +//fusa:test REQ-RELAY-099 +func TestCheckRetiredCapabilitiesFails(t *testing.T) { + retired := []retiredEntry{ + {Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"}, + } + capsDoc := map[string]interface{}{ + "features": []interface{}{"old-thing", "fd"}, + } + fs := checkRetiredCapabilities("2.9", capsDoc, retired) + if len(fs) != 1 { + t.Fatalf("expected exactly one finding, got %d: %+v", len(fs), fs) + } + if fs[0].Severity != sevFail { + t.Errorf("Severity = %s, want %s", fs[0].Severity, sevFail) + } + if fs[0].Req != "§3.2" { + t.Errorf("Req = %q, want %q", fs[0].Req, "§3.2") + } +} + +//fusa:test REQ-RELAY-099 +func TestCheckRetiredCapabilitiesBeforeRemoval(t *testing.T) { + // spec_version is still before the removal version — retirement window + // hasn't closed yet, so this MUST NOT FAIL (§3.2 permits it, compat-flagged, + // between since and removal). + retired := []retiredEntry{ + {Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"}, + } + capsDoc := map[string]interface{}{ + "features": []interface{}{"old-thing"}, + } + fs := checkRetiredCapabilities("2.8", capsDoc, retired) + if len(fs) != 0 { + t.Errorf("expected no findings before removal version, got %+v", fs) + } +} + +//fusa:test REQ-RELAY-099 +func TestCheckRetiredCapabilitiesNotDeclared(t *testing.T) { + // Past removal, but the capabilities doc doesn't actually declare the + // retired name — nothing to FAIL. + retired := []retiredEntry{ + {Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"}, + } + capsDoc := map[string]interface{}{ + "features": []interface{}{"fd"}, + "commands": []interface{}{"version", "capabilities", "status"}, + } + fs := checkRetiredCapabilities("2.9", capsDoc, retired) + if len(fs) != 0 { + t.Errorf("expected no findings when the retired name isn't declared, got %+v", fs) + } +} + +//fusa:test REQ-RELAY-099 +func TestCheckRetiredCapabilitiesCommands(t *testing.T) { + // A retired command name (not just a feature) MUST also be caught. + retired := []retiredEntry{ + {Name: "old-command", Since: "2.7", Removal: "2.9", Reason: "superseded"}, + } + capsDoc := map[string]interface{}{ + "commands": []interface{}{"version", "capabilities", "status", "old-command"}, + } + fs := checkRetiredCapabilities("2.9", capsDoc, retired) + if len(fs) != 1 { + t.Fatalf("expected exactly one finding, got %d: %+v", len(fs), fs) + } +} + +//fusa:test REQ-RELAY-099 +func TestCheckRetiredCapabilitiesEmptyInputs(t *testing.T) { + if fs := checkRetiredCapabilities("", map[string]interface{}{"features": []interface{}{"x"}}, []retiredEntry{{Name: "x", Removal: "1.0"}}); len(fs) != 0 { + t.Errorf("empty spec_version should produce no findings, got %+v", fs) + } + if fs := checkRetiredCapabilities("2.9", map[string]interface{}{"features": []interface{}{"x"}}, nil); len(fs) != 0 { + t.Errorf("nil retired list should produce no findings, got %+v", fs) + } +} + +//fusa:test REQ-RELAY-099 +func TestLoadRetiredCapabilitiesRealManifestEmpty(t *testing.T) { + // RELAY's own shipped spec/version.json retired[] is empty (§3.2 governs + // retirements declared from its introduction onward; the historical RCP + // placeholder-model retirement predates it and is not retrofitted). This + // is a sanity check that the loader itself works against the real + // embedded document, not a synthetic fixture. + got := loadRetiredCapabilities() + if len(got) != 0 { + t.Errorf("loadRetiredCapabilities() = %+v, want empty (nothing retired yet)", got) + } +} + +//fusa:test REQ-RELAY-099 +func TestValidateCapabilitiesDocRetiredCapability(t *testing.T) { + // End-to-end through validateCapabilitiesDoc requires a real retired[] + // entry in the embedded spec/version.json, which is currently empty by + // design (see TestLoadRetiredCapabilitiesRealManifestEmpty) — so this + // test exercises checkRetiredCapabilities directly with the exact shape + // validateCapabilitiesDoc would pass it, confirming the wiring contract + // (field names, types) matches what a real capabilities doc provides. + data := []byte(`{ + "kind":"capabilities","tool":"go-can","protocol":"CAN","protocol_int":1, + "version":"9.0.0","spec_version":"99.0", + "commands":["version","capabilities","status"], + "transports":[],"features":["retired-example"],"interfaces":[], + "optional_interfaces":[],"adapt":true + }`) + var doc map[string]interface{} + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + retired := []retiredEntry{{Name: "retired-example", Since: "2.0", Removal: "50.0", Reason: "test fixture"}} + fs := checkRetiredCapabilities(doc["spec_version"].(string), doc, retired) + if len(fs) != 1 { + t.Fatalf("expected one finding for a capabilities doc past a retired name's removal version, got %d: %+v", len(fs), fs) + } +} + func countBySeverity(fs []conformFinding, sev conformSeverity) int { n := 0 for _, f := range fs { diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 299bf13..163d6fa 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -18,7 +18,7 @@ import ( relay "github.com/SoundMatt/RELAY/v2" ) -const toolVersion = "2.6.0" +const toolVersion = "2.7.0" func main() { if err := run(os.Stdout, os.Stderr, os.Args[1:]); err != nil { diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index cd78813..110a299 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -1,5 +1,47 @@ # RELAY Spec Changelog +## v2.7 — 2026-08-21 (MINOR — new protocol/model retirement process, §17 Requirement 17) + +- **New §3.2 "Retiring a protocol or model".** §19.2's existing deprecation + policy governs only a single MUST requirement's removal; it had nothing + to say about retiring a whole canonical model (the way §15.5's RCP types + were rewritten for TC18 conformance). Defines a `retired[]`/`deprecated[]` + array pair for `spec/version.json`, each entry a `{name, since, removal, + reason}` record — `name` matching what an implementation would otherwise + advertise in `features`/`commands` (§12.2). +- **New §17 Requirement 17 — no retired capabilities past removal.** A + capabilities document whose declared `spec_version` is at or past a + `retired[]` entry's `removal` version MUST NOT still list that entry's + `name`. Unlike Requirements 13–16, this is fully within `relay conform`'s + own reach: the check only needs two things it already fetches over the + CLI (declared `spec_version`, declared `features`/`commands`) compared + against `relay conform`'s own embedded `spec/version.json` — the same + authoritative source §19.4 already points to. Full black-box coverage, + like Requirement 7. +- **`retired[]`/`deprecated[]` start empty, deliberately not backfilled.** + RELAY's own pre-TC18 RCP placeholder-model replacement (§15.5, v2.0) + predates this section: it was an instant MAJOR-version breaking + replacement with no compatibility window, not a `since`/`removal`-spaced + retirement this mechanism is designed to track. Force-fitting it into + `retired[]` would misrepresent history as having had a graceful + deprecation window it never had. This section governs retirements + declared from here onward. +- **Bug found and fixed while wiring Requirement 17's manifest entry**: + `buildManifest`'s Requirement 6 and Requirement 1 statuses were computed + from `hasFail(cFindings)`/an incomplete filter that didn't account for + the capabilities command failing to run at all (§17.7) as a superset + failure — a gap that would have let Requirement 17's new, differently- + cited findings silently miscolor Requirement 6's own status once mixed + into the same findings list. Introduced a shared `capsUnreachable` check + used by Requirements 1, 6, and 17 alike; mutation-tested (reverted, + confirmed `TestBuildManifestFailPropagatesToOverall` failed, restored). +- **Reference implementation**: `checkRetiredCapabilities`/ + `specVersionAtLeast`/`loadRetiredCapabilities` in `cmd/relay/conform.go`, + wired into `validateCapabilitiesDoc`. New `REQ-RELAY-099`. 8 new tests, + including a real mutation test on the check's core comparison logic. +- `SpecVersion` bumped `2.6` → `2.7` (MINOR, new §17 requirement). Closes + [NEW-SPEC-5]. + ## v2.6 — 2026-08-21 (MINOR — new release conformance attestation format) - **New §20.6 — Release conformance attestation (`relay-conform-attestation/1`).** diff --git a/spec/relay-spec.md b/spec/relay-spec.md index a9b92f8..101a6d0 100644 --- a/spec/relay-spec.md +++ b/spec/relay-spec.md @@ -1,4 +1,4 @@ -# RELAY Specification — v2.6 +# RELAY Specification — v2.7 **Real-time Embedded Link Abstraction Yoke** @@ -134,6 +134,63 @@ name, adding the canonical frame type (§14), interface contract (§8), CLI contract (§10), and `Adapt()` implementation (§9). Values are assigned sequentially and never reused. The spec MINOR version is bumped. +### 3.2 Retiring a protocol or model + +§19.2's deprecation policy governs the removal or inversion of a single MUST +requirement; it says nothing about retiring an entire canonical *model* — a +protocol's whole set of canonical types superseded by a different design, the +way §15.5's RCP types were rewritten for TC18 conformance. Without a defined +process, retired-model residue (dead code paths, stale test fixtures, docs +that still describe the old shape) tends to persist in implementations well +past the point the model was declared gone, with no machine-checkable way to +catch it. + +When a protocol or canonical model is retired, `spec/version.json` MUST record +it in a `retired` array: + +```json +"retired": [ + {"name": "example-retired-model", "since": "2.7", "removal": "2.9", "reason": "Superseded by ..."} +] +``` + +`name` is a stable identifier for the retired capability — the same string an +implementation would otherwise advertise in its capabilities document's +`features` or `commands` (§12.2). `since` is the spec MINOR version the model +was first marked retired; `removal` is the spec MINOR version by which it MUST +be gone. Per §19.2, `removal` MUST be at least one MINOR release after `since`. + +Between `since` and `removal`: +- An implementation MAY still ship retired-model code, but only fenced behind + a capability-declared compat flag it documents in its own CHANGELOG — it + MUST NOT be indistinguishable from current, supported behavior. +- Documentation MUST NOT describe a retired model as current outside a + CHANGELOG entry recording its retirement. + +At and after `removal`: +- Retired-model code MUST be removed from the implementation entirely. +- An implementation's capabilities document (§12.2) MUST NOT list the retired + `name` in `features` or `commands`. +- `relay conform` MUST FAIL a binary whose declared `spec_version` is at or + past a `retired` entry's `removal` version and whose capabilities document + still lists that entry's `name` in `features` or `commands`. + +`deprecated` is the single-requirement analogue of `retired`, recording each +§19.2 deprecation the same way, for the same machine-readability reason: + +```json +"deprecated": [ + {"name": "example-deprecated-field", "since": "2.7", "removal": "2.9", "reason": "..."} +] +``` + +Both arrays are OPTIONAL and empty by default. RELAY's own pre-TC18 RCP +placeholder-model replacement (§15.5, spec v2.0) predates this section and is +not retrofitted into `retired[]`: it was an instant MAJOR-version breaking +replacement with no compatibility window, not a `since`/`removal`-spaced +retirement this mechanism is designed to track. `retired[]`/`deprecated[]` +govern retirements declared from this section onward. + --- ## 4. Universal Message Envelope @@ -1182,7 +1239,7 @@ failure rather than a skip. Exit: `0` all equivalent, `1` any mismatch/error, "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.6", + "spec_version": "2.7", "language": "go", "runtime": "go1.25.0", "commit": "a1b2c3d4" @@ -1207,7 +1264,7 @@ conformance failure, but §17.2's conformance manifest cannot populate its own "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.6", + "spec_version": "2.7", "commands": ["version", "capabilities", "status", "connect", "send", "subscribe"], "transports": ["socketcan", "virtual"], "features": ["fd", "isotp", "j1939"], @@ -1237,7 +1294,7 @@ CLI: "protocol_int": null, "multi_protocol": true, "version": "2.5.0", - "spec_version": "2.6", + "spec_version": "2.7", "commands": ["version", "capabilities", "status", "conform", "convert", "..."], "transports": [], "features": [], @@ -1306,7 +1363,7 @@ $ go-can version --format json "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.6", + "spec_version": "2.7", "language": "go", "runtime": "go1.25.0" } @@ -1318,7 +1375,7 @@ $ go-can capabilities "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.6", + "spec_version": "2.7", "commands": ["version", "capabilities", "status", "connect", "send", "subscribe"], "transports": ["socketcan", "virtual"], "features": ["fd", "isotp", "j1939"], @@ -1394,11 +1451,11 @@ LABEL org.opencontainers.image.licenses="MPL-2.0" LABEL io.relay.tool="" LABEL io.relay.language="go|cpp|rust|c" LABEL io.relay.binary="" -LABEL io.relay.spec-version="2.6" +LABEL io.relay.spec-version="2.7" ``` The `io.relay.spec-version` label MUST always match the value of `SpecVersion` -exported by the package (§17.12 / §19.4). The `"2.6"` shown above is an example; +exported by the package (§17.12 / §19.4). The `"2.7"` shown above is an example; update it on each spec minor release. The project directory is mounted at `/project` by convention: @@ -2239,6 +2296,7 @@ An implementation is **RELAY-conformant** if and only if: 14. **Spec-version binding.** The `spec_version` value the CLI prints (§12.1, §12.2) MUST NOT be a hand-copied literal kept in sync by memory alone. A Go implementation that depends on `github.com/SoundMatt/RELAY` MUST bind its declared value directly to `relay.SpecVersion` (e.g. `const SpecVersion = relay.SpecVersion`), so a dependency bump alone keeps it current. An implementation in any other language, or a Go implementation that does not depend on the RELAY module, MUST have a CI step that fails the build when its declared `spec_version` diverges from the authoritative `spec/version.json` (§19.4) at the RELAY revision it targets. 15. **Conformance manifest.** The implementation MUST commit a `relay-conform/1` manifest (§17.2), generated by `relay conform --manifest` against its own built binary; CI MUST regenerate the manifest on every change and fail the build on any diff from the committed copy, or on any requirement entry whose `status` is `FAIL`. 16. **Vector manifest.** The canonical `spec/vectors/` distribution (§15.8) is pinned by `spec/vectors/vectors_manifest.json`. A conformant implementation that embeds a local copy of these vectors MUST embed the exact pinned set, and MUST have a CI step that fails when its embedded copy's SHA-256 diverges from the published manifest for the `vectors_version` it targets. +17. **No retired capabilities past removal.** Per §3.2, a capabilities document (§12.2) whose declared `spec_version` is at or past a `spec/version.json` `retired[]` entry's `removal` version MUST NOT list that entry's `name` in `features` or `commands`. `relay conform ` is a **black-box CLI tool**: it can only observe what the built binary's `version`/`capabilities`/`status` commands print, not the @@ -2289,7 +2347,13 @@ that exposes an implementation's embedded vector bytes at all, so it cannot even partially generate this check — whether an implementation's embedded vector copy matches the published manifest is entirely a CI-process fact about the implementation's own build, verified by the -implementation's own CI instead. +implementation's own CI instead. Requirement 17 (no retired capabilities +past removal) is, unlike 13–16, fully within `relay conform`'s own reach: +the check compares two things it already fetches over the CLI — the +version document's `spec_version` and the capabilities document's +`features`/`commands` — against `relay conform`'s own embedded +`spec/version.json`, the same authoritative source §19.4 already points +to. No source-level or CI-process knowledge is needed. **Requirement-to-verifier lookup table**, collecting the narrative above into one place: @@ -2312,6 +2376,7 @@ into one place: | 14 | Spec-version binding | Implementation's own CI | Not observable through the CLI | | 15 | Conformance manifest | `relay conform --manifest` (content); implementation's own CI (regenerate + diff) | Split — manifest content generated by `relay conform`; CI enforcement not observable through the CLI | | 16 | Vector manifest | Implementation's own CI | Not observable through the CLI | +| 17 | No retired capabilities past removal | `relay conform` (version + capabilities cross-check against embedded `spec/version.json`) | Full | ### 17.1 Wire-format regression coverage (recommended) @@ -2365,7 +2430,7 @@ diffable artifact. "manifest_version": "relay-conform/1", "tool": "go-can", "binary_version": "1.2.3", - "spec_version": "2.6", + "spec_version": "2.7", "git_sha": "a1b2c3d4", "capabilities_sha256": "e3b0c44298fc1c14...", "requirements": [ @@ -2384,7 +2449,7 @@ SHA-256 of the raw `capabilities` document bytes as returned by the binary, letting a diff catch any change to the capabilities document — mechanically regenerated or not — without re-parsing it. -`requirements` MUST contain exactly one entry per §17 requirement (1–16, +`requirements` MUST contain exactly one entry per §17 requirement (1–17, extended as this document's own requirement list grows). Each entry's `status` is one of: @@ -3114,11 +3179,11 @@ clarifications and fixes in PATCH releases. `spec/version.json` is authoritative. The spec document title is informational. -Current version: **v2.6** +Current version: **v2.7** -**Go:** `const SpecVersion = "2.6"` (update in implementations targeting v2.6) -**C++:** `constexpr std::string_view kRelaySpecVersion = "2.6";` -**Rust:** `pub const RELAY_SPEC_VERSION: &str = "2.6";` +**Go:** `const SpecVersion = "2.7"` (update in implementations targeting v2.7) +**C++:** `constexpr std::string_view kRelaySpecVersion = "2.7";` +**Rust:** `pub const RELAY_SPEC_VERSION: &str = "2.7";` --- @@ -3212,7 +3277,7 @@ manifest, the §15.8 vector manifest's `vectors_version`, and the targeted {"name": "go-can", "digest": {"sha256": "9f86d081884c7d65..."}} ], "predicate": { - "spec_version": "2.6", + "spec_version": "2.7", "vectors_version": "2.4", "conformance_manifest": { "...": "the full §17.2 relay-conform/1 document" }, "safety_evidence_summary": null, diff --git a/spec/schemas/relay-conform-manifest.json b/spec/schemas/relay-conform-manifest.json index fbb1409..7028169 100644 --- a/spec/schemas/relay-conform-manifest.json +++ b/spec/schemas/relay-conform-manifest.json @@ -56,7 +56,7 @@ "required": ["id", "status", "verifier"], "additionalProperties": false }, - "description": "One entry per §17 requirement (1-16, extended as the requirement list grows)." + "description": "One entry per §17 requirement (1-17, extended as the requirement list grows)." }, "overall": { "type": "string", diff --git a/spec/version.json b/spec/version.json index 95fad38..c3b2a88 100644 --- a/spec/version.json +++ b/spec/version.json @@ -1,5 +1,5 @@ { - "version": "2.6", + "version": "2.7", "date": "2026-08-21", "status": "stable", "networks": [ @@ -15,5 +15,7 @@ "cpp", "rust", "c" - ] + ], + "retired": [], + "deprecated": [] } diff --git a/version.go b/version.go index 2df23f2..e4cd4d1 100644 --- a/version.go +++ b/version.go @@ -8,4 +8,4 @@ package relay // Every RELAY-conformant implementation must export this constant (spec §17 req 12). // //fusa:req REQ-RELAY-020 -const SpecVersion = "2.6" +const SpecVersion = "2.7" diff --git a/version_test.go b/version_test.go index e7b575b..f4de744 100644 --- a/version_test.go +++ b/version_test.go @@ -8,7 +8,7 @@ import "testing" //fusa:test REQ-RELAY-020 func TestSpecVersion(t *testing.T) { - if SpecVersion != "2.6" { - t.Errorf("SpecVersion = %q, want %q", SpecVersion, "2.6") + if SpecVersion != "2.7" { + t.Errorf("SpecVersion = %q, want %q", SpecVersion, "2.7") } }