Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/scripts/test-lifecycle-rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Negative test for the MIP-1 lifecycle constraints.

A control is not evidence until it has been seen to fail on the thing it is meant
to catch. When the registry gate was introduced this was done once by hand and
recorded in assurance/evidence/EVIDENCE.md; this script makes the same proof
re-run on every change, so the rules cannot be quietly loosened without a red
build. Recorded as EV-LIFECYCLE-001, backing INV-LIFECYCLE-001 / INV-LIFECYCLE-002.

Both directions are tested for each rule: the violation must be REJECTED and the
compliant form must be ACCEPTED, so a rule that simply rejects everything fails
here too.

The last case is the guard proof. JSON Schema's `if` is satisfied vacuously when
the named property is absent, so `{"properties": {"lifecycle": {"const": "core"}}}`
alone matches every entry that has no lifecycle. The pre-existing passportEligible
rules get away without a guard because `tier` is a required property; `lifecycle`
is optional by design (MIP-1 Art. 1 scopes the obligation to *.moss.land services),
so `required: ["lifecycle"]` is what keeps the policy off Upbit and sitemap.xml.
Removing it must break the unchanged registry.

Usage: python .github/scripts/test-lifecycle-rules.py
Exit code 1 if any case does not behave as specified. No network access.
"""

from __future__ import annotations

import copy
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

try:
from jsonschema import Draft202012Validator, FormatChecker
except ImportError:
print("ERROR: the 'jsonschema' package is required (pip install jsonschema)")
sys.exit(1)

ROOT = Path(__file__).resolve().parents[2]
REGISTRY = ROOT / "ecosystem-registry.json"
SCHEMA = ROOT / "ecosystem-registry.schema.json"
VALIDATOR = ROOT / ".github" / "scripts" / "validate-registry.py"

BASE = json.loads(REGISTRY.read_text(encoding="utf-8"))
HANDLE = "MosslandOpenDevs"
TEAM = "MosslandOpenDevs/registry-maintainers"


def entry(registry: dict, service_id: str) -> dict:
return next(s for s in registry["services"] if s.get("id") == service_id)


def run_validator(registry: dict) -> tuple[int, str]:
"""Run the real validator against a mutated copy in an isolated tree."""
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / ".github" / "scripts").mkdir(parents=True)
shutil.copy(SCHEMA, root / SCHEMA.name)
shutil.copy(VALIDATOR, root / ".github" / "scripts" / VALIDATOR.name)
(root / REGISTRY.name).write_text(
json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8"
)
proc = subprocess.run(
[sys.executable, str(root / ".github" / "scripts" / VALIDATOR.name)],
capture_output=True,
text=True,
)
return proc.returncode, proc.stdout


CASES = [
("core with only one maintainer", "REJECT", "MIP-1 Art. 2",
lambda r: entry(r, "agora").update(lifecycle="core", maintainer=HANDLE)),
("core with both maintainers", "ACCEPT", "MIP-1 Art. 2 satisfied",
lambda r: entry(r, "agora").update(lifecycle="core", maintainer=HANDLE, secondMaintainer=TEAM)),
("beta above the cap, unstaffed, no reason", "REJECT", "MIP-1 Art. 3",
lambda r: entry(r, "wa").update(lifecycle="beta", maintainer=HANDLE)),
("beta above the cap, unstaffed, reason recorded", "ACCEPT", "MIP-1 Art. 3 exception",
lambda r: entry(r, "wa").update(lifecycle="beta", maintainer=HANDLE,
lifecycleReason="Second maintainer search open; published as Beta per Annex A.")),
("archive without grounds", "REJECT", "MIP-1 Art. 4",
lambda r: entry(r, "media").update(lifecycle="archive")),
("lab needs no maintainer", "ACCEPT", "MIP-1 state table",
lambda r: entry(r, "bridge").update(lifecycle="lab")),
("maintainer recorded as an email address", "REJECT", "AGENTIC_ASSURANCE.md 9 — public repo",
lambda r: entry(r, "agora").update(lifecycle="core", maintainer="someone@example.com",
secondMaintainer=HANDLE)),
("unmodified registry", "ACCEPT", "MIP-1 Art. 1 scope — out-of-scope entries untouched",
lambda r: None),
]


def guard_is_load_bearing() -> tuple[bool, str]:
"""Removing `required: ["lifecycle"]` must break the unchanged registry."""
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
stripped = 0
for rule in schema["$defs"]["service"]["allOf"]:
guard = rule.get("if", {}).get("required") or []
if "lifecycle" in guard:
rule["if"].pop("required")
stripped += 1
if not stripped:
return False, "no lifecycle rule carries a required:[lifecycle] guard — the guard is gone"
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = list(validator.iter_errors(BASE))
dragged = sorted(
{BASE["services"][e.path[1]]["id"] for e in errors if len(e.path) > 1 and e.path[0] == "services"}
)
if not errors:
return False, f"guard removed from {stripped} rules, yet the registry still validates — the guard is not doing anything"
return True, f"guard removed from {stripped} rules -> {len(errors)} errors across {len(dragged)} entries (e.g. {', '.join(dragged[:4])})"


def main() -> int:
failures = 0
for name, expect, article, mutate in CASES:
registry = copy.deepcopy(BASE)
mutate(registry)
code, out = run_validator(registry)
got = "REJECT" if code else "ACCEPT"
ok = got == expect
failures += not ok
print(f"{'PASS' if ok else 'FAIL'} [{got:6}] {name} ({article})")
if not ok:
for line in out.splitlines():
print(f" {line}")

ok, detail = guard_is_load_bearing()
failures += not ok
print(f"{'PASS' if ok else 'FAIL'} [GUARD ] required:[lifecycle] is load-bearing")
print(f" {detail}")

if failures:
print(f"\n{failures} case(s) did not behave as specified.")
return 1
print(f"\nOK: {len(CASES)} lifecycle cases plus the guard proof behaved as specified.")
return 0


if __name__ == "__main__":
sys.exit(main())
141 changes: 140 additions & 1 deletion .github/scripts/validate-registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@
service is declared. The rubric is the registry's editorial contract
(RES-CURATION-002); an inconsistency means the page could show a chip whose
meaning is undeclared, or emit a stampClass Passport cannot interpret.
5. The MIP-1 lifecycle policy holds (INV-LIFECYCLE-001, INV-LIFECYCLE-002). The
rules are not hard-coded here: they are read from `rubric.lifecycle`, the same
arrangement the chip rules use, so the declared promise and the enforced check
are one declaration. Article 2 — a core service names a maintainer and a second
maintainer. Article 3 — without a second maintainer a service is capped at
`unstaffedCap`, and holding it above that cap requires a recorded reason.
Article 4 — an archive state requires its grounds. The schema's allOf carries
the same constraints for consumers validating against the published contract;
this restates them so a CI failure names the article it violated.
6. Maintainer handles carry no personal data. This is a public repository
(AGENTIC_ASSURANCE.md section 9), so a maintainer is a GitHub handle or an
org/team slug, never a name or an email address.

Non-fatal notes (printed, exit code unaffected): services in MIP-1 Article 1 scope
that carry no lifecycle yet, and a lifecycle review older than the monthly cadence
Article 4 sets. These are reported rather than enforced because a date-triggered
hard failure would break an unrelated pull request that changed nothing.

Usage: python .github/scripts/validate-registry.py
Exit code 1 on any error. No network access.
Expand All @@ -35,6 +52,7 @@
import json
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path

try:
Expand All @@ -47,6 +65,10 @@
REGISTRY = ROOT / "ecosystem-registry.json"
SCHEMA = ROOT / "ecosystem-registry.schema.json"

# MIP-1 Art. 4 reviews the lifecycle states monthly; a few days of slack keeps the
# note from firing on a review that landed a little late.
REVIEW_CADENCE_DAYS = 35


def load(path: Path) -> dict:
try:
Expand Down Expand Up @@ -143,20 +165,137 @@ def main() -> int:
"in rubric.stampClasses, so a consumer cannot tell what it signifies"
)

# 5. The MIP-1 lifecycle policy, enforced from its own declaration in the rubric.
# rubric.lifecycle states what each lifecycle promises AND which fields that
# promise requires, so the policy a reader sees and the rule CI applies are the
# same object — the arrangement that keeps the chip rules from drifting.
lifecycle_rubric = (rubric or {}).get("lifecycle") if isinstance(rubric, dict) else None
notes: list[str] = []
if isinstance(lifecycle_rubric, dict):
states = lifecycle_rubric.get("states") if isinstance(lifecycle_rubric.get("states"), dict) else {}
order = lifecycle_rubric.get("order") if isinstance(lifecycle_rubric.get("order"), list) else []
cap = lifecycle_rubric.get("unstaffedCap")
# MIP-1 articles, quoted in the failure so a red build names the policy it broke.
article = {
"maintainer": "MIP-1 Art. 2 / state table (a core or beta service names a maintainer)",
"secondMaintainer": "MIP-1 Art. 2 (core requires a second maintainer with deploy and recovery rights)",
"lifecycleReason": "MIP-1 Art. 4 (a lifecycle change, Archive in particular, is recorded with its grounds)",
}

for name in states:
if name not in order:
errors.append(
f"$.rubric.lifecycle: state {name!r} is defined but missing from `order`, "
"so its position relative to unstaffedCap is undefined"
)
if cap is not None and cap not in order:
errors.append(f"$.rubric.lifecycle.unstaffedCap: {cap!r} is not one of the states in `order`")

for service in services:
if not isinstance(service, dict):
continue
sid = service.get("id")
lifecycle = service.get("lifecycle")
if lifecycle is None:
continue
if lifecycle not in states:
errors.append(
f"$.services[id={sid!r}]: lifecycle {lifecycle!r} is not declared in "
"rubric.lifecycle.states, so its promise is undefined"
)
continue

for field in states[lifecycle].get("requires") or []:
if not service.get(field):
errors.append(
f"$.services[id={sid!r}]: lifecycle {lifecycle!r} requires {field!r} — "
f"{article.get(field, 'MIP-1')}"
)

# Article 3: without a second maintainer the lifecycle is capped, and
# anything above the cap is an exception that must record why.
if (
not service.get("secondMaintainer")
and cap in order
and lifecycle in order
and order.index(lifecycle) < order.index(cap)
and not service.get("lifecycleReason")
):
errors.append(
f"$.services[id={sid!r}]: lifecycle {lifecycle!r} is above {cap!r} with no "
"secondMaintainer, so it is an exception and requires `lifecycleReason` — "
"MIP-1 Art. 3 (a service without a second owner is shown at Lab or below; "
"an exception is recorded in the registry with its reason)"
)

# 6. A maintainer is a public handle, never personal data (public repository).
for service in services:
if not isinstance(service, dict):
continue
for field in ("maintainer", "secondMaintainer"):
value = service.get(field)
if isinstance(value, str) and ("@" in value or " " in value):
errors.append(
f"$.services[id={service.get('id')!r}]: {field} {value!r} looks like a name or an "
"email address; this is a public repository, so record a GitHub handle or an "
"org/team slug instead (AGENTIC_ASSURANCE.md section 9)"
)

# Non-fatal: MIP-1 Art. 1 scope not yet classified.
in_scope = [
s
for s in services
if isinstance(s, dict)
and s.get("owner") == "mossland"
and s.get("tier") not in ("third_party", "channel")
and s.get("artifact") is not True
and str(s.get("domain", "")).endswith("moss.land")
]
unclassified = [s.get("id") for s in in_scope if not s.get("lifecycle")]
if unclassified:
notes.append(
f"{len(unclassified)} of {len(in_scope)} services in MIP-1 Art. 1 scope carry no "
f"lifecycle yet ({', '.join(str(i) for i in unclassified)}). The schema and this "
"check are in place; the classification itself is an owner decision."
)

# Non-fatal: Art. 4 sets a monthly review cadence.
reviewed = registry.get("lifecycleReviewedAt")
if not reviewed:
notes.append(
"lifecycleReviewedAt is not set — MIP-1 Art. 4 reviews these states once a month "
"and records when that happened."
)
else:
try:
last = datetime.fromisoformat(str(reviewed).replace("Z", "+00:00"))
age = (datetime.now(timezone.utc) - last).days
if age > REVIEW_CADENCE_DAYS:
notes.append(
f"lifecycleReviewedAt is {age} days old, past the {REVIEW_CADENCE_DAYS}-day "
"monthly cadence in MIP-1 Art. 4."
)
except ValueError:
errors.append(f"$.lifecycleReviewedAt: {reviewed!r} is not a parseable date-time")

if errors:
for message in errors:
print(f"ERROR: {message}")
print(f"\n{len(errors)} error(s) — registry does not satisfy its contract.")
return 1

eligible = sum(1 for s in services if isinstance(s, dict) and s.get("passportEligible") is True)
classified = sum(1 for s in services if isinstance(s, dict) and s.get("lifecycle"))
rubric_version = registry.get("rubricVersion")
chip_count = len(((registry.get("rubric") or {}).get("chips") or {}).get("definitions") or {})
print(
f"OK: {len(services)} services validate against ecosystem-registry.schema.json; "
f"{eligible} Passport-eligible, all owned by Mossland; ids unique; "
f"rubric v{rubric_version} consistent ({chip_count} chips declared)."
f"rubric v{rubric_version} consistent ({chip_count} chips declared); "
f"{classified} services carry a MIP-1 lifecycle, all satisfying it."
)
for note in notes:
print(f"NOTE: {note}")
return 0


Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
# projection of the registry" property was checked on change — both relied on
# author discipline.
#
# Enforces INV-PASSPORT-001 (schema) and INV-REG-001 (projection).
# Enforces INV-PASSPORT-001 (schema), INV-REG-001 (projection), and
# INV-LIFECYCLE-001/002 (the MIP-1 lifecycle policy).
name: registry

on: [push, pull_request]
Expand Down Expand Up @@ -36,6 +37,13 @@ jobs:
- name: Registry validates against its JSON Schema
run: python .github/scripts/validate-registry.py

# INV-LIFECYCLE-001/002: the MIP-1 lifecycle rules must still reject what
# they exist to reject. Runs the constraints against deliberately violating
# copies of the registry, so the policy cannot be loosened without a red
# build. Evidence record: assurance/evidence/EVIDENCE.md EV-LIFECYCLE-001.
- name: MIP-1 lifecycle constraints reject violations
run: python .github/scripts/test-lifecycle-rules.py

# INV-REG-001: the committed pages must be byte-identical to a fresh
# generator run, so the page can never show a link the registry does not
# contain. Evidence record: assurance/evidence/EVIDENCE.md EV-PROJECTION-001.
Expand Down
Loading
Loading