diff --git a/.github/scripts/test-lifecycle-rules.py b/.github/scripts/test-lifecycle-rules.py new file mode 100644 index 0000000..e1f9234 --- /dev/null +++ b/.github/scripts/test-lifecycle-rules.py @@ -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()) diff --git a/.github/scripts/validate-registry.py b/.github/scripts/validate-registry.py index b375ffe..06e5fda 100644 --- a/.github/scripts/validate-registry.py +++ b/.github/scripts/validate-registry.py @@ -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. @@ -35,6 +52,7 @@ import json import sys from collections import Counter +from datetime import datetime, timezone from pathlib import Path try: @@ -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: @@ -143,6 +165,119 @@ 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}") @@ -150,13 +285,17 @@ def main() -> int: 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 diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index cfa9c42..e6fa479 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -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] @@ -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. diff --git a/README.md b/README.md index f126737..9d63142 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ job is anti-phishing: telling a visitor which domains are genuinely Mossland's. - [Files](#files) - [Sections](#sections) - [Chips (trust signal)](#chips-trust-signal) +- [Lifecycle (MIP-1)](#lifecycle-mip-1) - [Conventions](#conventions) - [Adding or changing a service](#adding-or-changing-a-service) - [Security](#security) @@ -31,7 +32,8 @@ job is anti-phishing: telling a visitor which domains are genuinely Mossland's. [`ecosystem-registry.json`](ecosystem-registry.json) is the single source of truth. Each service is keyed by a stable `id` and carries `name`, `domain`, `url`, `tier`, `status` and `passportEligible` (required), plus optional -`section`, `stampType`/`stampClass`, `labelKo`/`label`, `chip` and more (see +`section`, `stampType`/`stampClass`, `labelKo`/`label`, `chip`, +`lifecycle`/`maintainer`/`secondMaintainer`/`lifecycleReason` and more (see [`ecosystem-registry.schema.json`](ecosystem-registry.schema.json) for the contract). The same file also holds the classification `rubric` and its `rubricVersion`. Other Mossland properties (moss.land, Passport, City, WA) can @@ -144,7 +146,45 @@ not the chip. There are seven chips: Key rule: **the amber `실험` stage is decoupled from operational `status`.** `offline`/`paused` are uptime, not a stage — a service is `실험` because it *is* experimental, set via `tier: labs` (auto) or an explicit `chip: "실험"` on a -non-labs service (e.g. `media`, which is live but still data-seeding). +non-labs service (e.g. `signal`, live but still data-seeding on its home view). + +## Lifecycle (MIP-1) + +`lifecycle` records **the maintenance promise a service carries** — separate from +`status`, which records observed availability at a point in time. An archived +service can still be reachable; a core service can be degraded. The states come +from **MIP-1** (*공개 서비스·저장소 생명주기 정책*), ratified through +[Agora](https://agora.moss.land/), which names this registry the single source of +truth for them and sets a monthly review (`lifecycleReviewedAt`). + +| State | 의미 | Promise | Required fields | +|---|---|---|---| +| `core` | 상시 운영 | Maintainer + second maintainer, incident response, changes pre-announced | `maintainer`, `secondMaintainer` | +| `beta` | 운영 중, 변동 가능 | Maintainer assigned; features and data may change | `maintainer` | +| `lab` | 실험, best-effort | May change or stop without notice | — | +| `archive` | 종료·보존 | Development ended; the record is preserved read-only | `lifecycleReason` | + +Two rules are enforced, not just documented — the schema's `allOf` carries them +for any consumer validating against the published contract, and CI fails the +build with a message naming the article violated: + +- **No unstaffed Core.** `core` without a `secondMaintainer` is rejected. +- **No silent exception, no silent ending.** A `core`/`beta` entry without a + `secondMaintainer` is an exception and needs `lifecycleReason`; `archive` + always needs one. Archive is not deletion — the reason is the record of *why*. + +`lifecycle` is **optional by design.** MIP-1 scopes the obligation to public +`*.moss.land` services and deployment-linked public repositories, so third-party +market entries, off-domain channels and data artifacts carry no lifecycle at all. + +> **Maintainers are public handles.** `maintainer` and `secondMaintainer` take a +> GitHub handle or an `org/team` slug — never a real name or an email address. +> This is a public repository; the schema pattern and CI both reject +> address-like values. + +**Status of this repository:** `links` is proposed as **Core** in MIP-1's Annex A. +The classification is not applied yet — the schema and checks landed first, and the +values follow once the mapping is approved. Tracked as `RES-LIFECYCLE-001`. ## Conventions @@ -162,11 +202,17 @@ non-labs service (e.g. `media`, which is live but still data-seeding). contract. Both are pinned by the schema's `allOf`, and CI rejects a violation (`INV-PASSPORT-001`): exchange and price links *and* off-domain channels (Medium, X) never back a Passport stamp. -- **Empty-but-live services** (e.g. Media: live but 0 data) get an explicit - `chip: "실험"` while empty; drop the chip once real data appears and it falls - through to its **tier** chip — for `media` (`tier: intelligence`) that is - `인텔리전스`, not `베타`, because `인텔리전스` outranks `베타` in the precedence - order (see [`assurance/RUBRIC.md`](assurance/RUBRIC.md) §4.1). +- **Empty-but-live services** (currently `signal`: live, but the home view still + renders `00` counters) get an explicit `chip: "실험"` while empty; drop the chip + once real data appears and it falls through to its **tier** chip — for `signal` + (`tier: intelligence`) that is `인텔리전스`, not `베타`, because `인텔리전스` + outranks `베타` in the precedence order (see + [`assurance/RUBRIC.md`](assurance/RUBRIC.md) §4.1). +- **`lifecycle` is a governance decision, not curation.** `tier`, `chip` and + `status` are Mossland's own editorial judgements; `lifecycle` is a Mossland DAO + decision recorded here because MIP-1 makes this registry its source of truth. + Changing one needs `lifecycleReason` and belongs in the monthly review, not in a + routine registry edit. - **Always ground descriptions in the live site.** Several labels were corrected by actually rendering each service (many are client-side SPAs whose homepage is an empty shell — check `/about` or render with JS), not by guessing. @@ -177,7 +223,8 @@ non-labs service (e.g. `media`, which is live but still data-seeding). > the OpenDevs Agentic Assurance Profile > ([`AGENTIC_ASSURANCE.md`](AGENTIC_ASSURANCE.md)). Adding a service is routine. > Changing what the registry **means** — a tier or chip's semantics, -> `passportEligible`, `stampClass`, the rubric, or the `customHttp.yml` headers — +> `passportEligible`, `stampClass`, a `lifecycle` state, the rubric, or the +> `customHttp.yml` headers — > is a *material change* under §7, which means reading and updating the affected > artifacts in [`assurance/`](assurance/) as part of the same change. @@ -189,11 +236,14 @@ non-labs service (e.g. `media`, which is live but still data-seeding). 2. If the entry needs a `stampClass` or chip the registry's `rubric` does not already declare, add it there too and bump `rubricVersion` per [`assurance/RUBRIC.md`](assurance/RUBRIC.md) §3 — CI fails on an undeclared - `stampClass` or an orphan chip. + `stampClass` or an orphan chip. Setting or changing a `lifecycle` also needs + its `maintainer`/`secondMaintainer`/`lifecycleReason` companions — CI names the + MIP-1 article if one is missing. 3. Run `node build/generate.mjs`. -4. Validate locally: `git diff --exit-code` (after regenerating) and - `python .github/scripts/validate-registry.py` (needs the validator - prerequisites above). +4. Validate locally: `git diff --exit-code` (after regenerating), + `python .github/scripts/validate-registry.py`, and — if you touched a + `lifecycle` rule — `python .github/scripts/test-lifecycle-rules.py` (all need + the validator prerequisites above). 5. Commit the registry change **and** the regenerated `index.html`, `embed.html`, `llms.txt`, and `sitemap.xml`. CI re-runs the generator and fails the build if they differ. diff --git a/assurance/CLAIMS.yaml b/assurance/CLAIMS.yaml index c1fdc56..cbe4c0d 100644 --- a/assurance/CLAIMS.yaml +++ b/assurance/CLAIMS.yaml @@ -66,7 +66,7 @@ claims: invariants: - INV-PASSPORT-001 evidence: - - "ecosystem-registry.schema.json:102-111 (third_party and channel forced to passportEligible false)" + - "ecosystem-registry.schema.json $defs.service.allOf (third_party and channel forced to passportEligible false)" - "ecosystem-registry.json (public data: no third_party or channel entry is passportEligible)" limitations: - The schema constraint is publicly checkable by any consumer and, since 2026-07-18, is also enforced in CI on every change (RES-CI-VALIDATION-001 RESOLVED). diff --git a/assurance/INVARIANTS.yaml b/assurance/INVARIANTS.yaml index f124f5c..8dc8cb5 100644 --- a/assurance/INVARIANTS.yaml +++ b/assurance/INVARIANTS.yaml @@ -92,14 +92,14 @@ invariants: limitations: - "The schema itself encodes only this negative direction (third_party/channel ⇒ not eligible); it still does not bind passportEligible:true to owner 'mossland'. Since 2026-07-18 that positive direction is asserted by the CI validator rather than the schema, so a consumer validating against the published schema alone does not get it. See CLAIM-PASSPORT-002." enforcement: - - "ecosystem-registry.schema.json allOf lines 102-111: if tier is third_party then passportEligible const false; if tier is channel then passportEligible const false." + - "ecosystem-registry.schema.json $defs.service.allOf: if tier is third_party then passportEligible const false; if tier is channel then passportEligible const false." - ".github/scripts/validate-registry.py, run by .github/workflows/registry.yml on every push and pull request: validates the registry against the schema and additionally rejects any passportEligible entry whose owner is not 'mossland'." - "Registry data: every third_party (markets) and channel (Medium, X) entry has passportEligible false." verification: - "JSON Schema (draft 2020-12) validation of ecosystem-registry.json against ecosystem-registry.schema.json — reproducible by any consumer, and enforced in CI since 2026-07-18." - "Negative test (2026-07-18): marking a third_party entry passportEligible made the validator emit two errors and exit 1; the unmodified registry exits 0 with 29 services and 11 Passport-eligible entries." evidence: - - "ecosystem-registry.schema.json:102-111 (allOf if/then constraints for third_party and channel)" + - "ecosystem-registry.schema.json $defs.service.allOf (if/then constraints for third_party and channel)" - "ecosystem-registry.json (all owner 'third-party' and channel entries: passportEligible false)" defeaters: - DEF-PASSPORT-001 @@ -235,3 +235,69 @@ invariants: status: VERIFIED disclosure: PUBLIC owner: MosslandOpenDevs maintainers (Mossland) + + - id: INV-LIFECYCLE-001 + title: A core service names two maintainers + statement: >- + No service with lifecycle "core" lacks a maintainer or a secondMaintainer. + MIP-1 Article 2 states no exception to this, so a Core entry that names only + one accountable party cannot exist in the registry. + severity: high + intent: + classification: INTENDED + authority: "Mossland DAO via Agora — MIP-1 (공개 서비스·저장소 생명주기 정책), contentHash sha256:7d3cf4a4…; voting open until 2026-09-02T02:19:25Z at the time of writing. The enforcement was built ahead of ratification so it is ready inside the 14-day implementation window MIP-1 Article 4 sets; no classification value is applied until the owner approves the Annex A mapping. Registry governance authority: assurance/SYSTEM.md §5." + scope: ecosystem-registry.json lifecycle / maintainer / secondMaintainer fields. + assumptions: + - "MIP-1 passes substantially as proposed. If the ratified text differs, the rule is re-derived from the ratified text, not from this entry." + - "A recorded handle corresponds to a person who actually holds deploy and recovery rights; the registry records the assignment, not the permission grant." + limitations: + - "Holds vacuously today: no entry carries a lifecycle yet, because the classification is an owner decision pending approval of the MIP-1 Annex A mapping (RES-LIFECYCLE-001). The control is nonetheless demonstrated to reject violations (EV-LIFECYCLE-001)." + - "The registry cannot verify that a named maintainer holds deploy or recovery rights — that lives in GitHub and AWS permissions, not here. This checks that an accountable party is named, which is the part MIP-1 makes this registry the source of truth for." + enforcement: + - "ecosystem-registry.schema.json $defs.service.allOf: if lifecycle is core then maintainer and secondMaintainer are required. The if is guarded by required:[lifecycle] so an entry with no lifecycle is not matched vacuously." + - ".github/scripts/validate-registry.py, run by .github/workflows/registry.yml on every push and pull request: reads rubric.lifecycle.states[*].requires and enforces it, so the declared promise and the applied rule are one object rather than two copies that can drift." + verification: + - ".github/scripts/test-lifecycle-rules.py, run by .github/workflows/registry.yml on every push and pull request: a core entry with only a maintainer is rejected by both the schema and the validator; the same entry with both handles is accepted. First captured 2026-08-23 (EV-LIFECYCLE-001)." + evidence: + - "assurance/evidence/EVIDENCE.md EV-LIFECYCLE-001 (negative test, 8 cases plus the guard proof)" + - "ecosystem-registry.json rubric.lifecycle.states.core.requires = [maintainer, secondMaintainer]" + defeaters: [] + residuals: + - RES-LIFECYCLE-001 + status: VERIFIED + disclosure: PUBLIC + owner: MosslandOpenDevs maintainers (Mossland) + + - id: INV-LIFECYCLE-002 + title: An exception or an ending is recorded with its reason + statement: >- + Two cases in the registry always carry a lifecycleReason: a service held above + the unstaffed cap (lifecycle core or beta) without a secondMaintainer, which + MIP-1 Article 3 treats as an exception to be recorded; and any service with + lifecycle "archive", which MIP-1 Article 4 requires to be recorded with its + grounds. A state cannot be reached silently in either direction. + severity: medium + intent: + classification: INTENDED + authority: "Mossland DAO via Agora — MIP-1 (공개 서비스·저장소 생명주기 정책), contentHash sha256:7d3cf4a4…; voting open until 2026-09-02T02:19:25Z at the time of writing. The enforcement was built ahead of ratification so it is ready inside the 14-day implementation window MIP-1 Article 4 sets; no classification value is applied until the owner approves the Annex A mapping. Registry governance authority: assurance/SYSTEM.md §5." + scope: ecosystem-registry.json lifecycle / secondMaintainer / lifecycleReason fields. + assumptions: + - "MIP-1 passes substantially as proposed." + limitations: + - "Holds vacuously today (RES-LIFECYCLE-001); the control itself is demonstrated (EV-LIFECYCLE-001)." + - "The check requires that a reason exists, not that it is a good one. Whether a recorded exception is justified stays a review judgement." + - "MIP-1's own text is not fully consistent here: Article 3 caps an unstaffed service at Lab or below, while Annex A publishes an unstaffed Core candidate as Beta. This invariant takes the reading that reconciles them — Annex A is exercising Article 3's exception clause, which is exactly why the reason must be recorded. The stricter reading (unstaffed ⇒ Lab, full stop) would also be satisfiable under this schema by simply not using the exception. Flagged for the owner rather than resolved by the drafting agent." + enforcement: + - "ecosystem-registry.schema.json $defs.service.allOf: (a) lifecycle in [core, beta] with no secondMaintainer requires lifecycleReason; (b) lifecycle archive requires lifecycleReason." + - ".github/scripts/validate-registry.py: the same two rules, derived from rubric.lifecycle.unstaffedCap and rubric.lifecycle.states[*].requires, with a failure message naming the MIP-1 article violated." + verification: + - ".github/scripts/test-lifecycle-rules.py, run on every push and pull request: an unstaffed beta with no reason is rejected and the same entry with a reason is accepted; an archive with no reason is rejected. First captured 2026-08-23 (EV-LIFECYCLE-001)." + evidence: + - "assurance/evidence/EVIDENCE.md EV-LIFECYCLE-001" + - "ecosystem-registry.json rubric.lifecycle.unstaffedCap = \"lab\"; rubric.lifecycle.states.archive.requires = [lifecycleReason]" + defeaters: [] + residuals: + - RES-LIFECYCLE-001 + status: VERIFIED + disclosure: PUBLIC + owner: MosslandOpenDevs maintainers (Mossland) diff --git a/assurance/RESIDUALS.yaml b/assurance/RESIDUALS.yaml index c19d167..0f45760 100644 --- a/assurance/RESIDUALS.yaml +++ b/assurance/RESIDUALS.yaml @@ -134,28 +134,52 @@ residuals: - id: RES-STATUS-DRIFT-001 summary: >- - The registry records point-in-time operational facts for ecosystem services - (for example alpha 'paused', ao 'degraded', media empty/'실험' as of 2026-07-06). - These states drift, so the status and notes fields can lag the live services. + The registry records point-in-time operational facts for ecosystem services in + `status` and `note`. These states drift, so both can lag the live services. This + is not hypothetical: the 2026-07-06 values for alpha ('paused') and ao ('degraded') + were still in the registry on 2026-08-23, 48 days later, and both were wrong by + then — see review_note. private_detail_location: null affected_claims: [] affected_invariants: - INV-MARKET-001 impact: low - uncertainty: medium + uncertainty: high mitigation: - - Statuses are re-verified against the live services when the registry is updated. - "The ecosystem section carries a note that '실험' services are not finished official products (build/generate.mjs:23)." + - "Since 2026-08-23, statusVerifiedAt records per entry when its status was last checked, so the age of an observation is visible to a consumer instead of inferred from the document-level generatedAt. This makes drift detectable; it does not prevent it." status: ACCEPTED acceptance_rationale: >- Accepted as an inherent property of a manually curated directory and as low impact: a stale operational status does not weaken the anti-phishing guarantee, which is about which domains are genuinely Mossland's, not about uptime. The page is explicit that it is not a live uptime monitor (assurance/SYSTEM.md section 2). + review_note: >- + Re-reviewed 2026-08-23. The acceptance stands — the reasoning above is unchanged + and the impact is still low — but one recorded mitigation did not survive contact + with practice and has been withdrawn. It read "Statuses are re-verified against the + live services when the registry is updated." On 2026-08-21 the registry WAS updated + (commit 9c7b186: media retired, Mossland Signal registered) and neither alpha nor + ao was re-verified. Both values, first recorded 2026-07-06, were still wrong 48 days + later: alpha stood at "paused" while /api/health?strict=1 returned status ok, db ok, + worst_status ok over 1642 indexed pages, and ao stood at "degraded" while /api/status + returned operational with every component healthy (both captured 2026-08-23, + EV-STATUS-001). That mitigation was author discipline described as a control, with + nothing enforcing it; the honest position is that it was not operating, so it is + removed rather than restated. Uncertainty is raised medium -> high accordingly: the + registry has now demonstrated, not merely risked, carrying a wrong operational + status across an edit. Impact stays low for the reason already recorded. + What replaces it is weaker than a control and is described as such: statusVerifiedAt + makes staleness visible. Anything stronger — a scheduled probe that compares the + registry against live health endpoints — would reverse the owner-approved non-goal + in assurance/SYSTEM.md section 2 ("not a live uptime monitor"), which is the very + basis on which this residual was accepted. That is a separate owner decision and is + deliberately not taken here. disclosure: PUBLIC owner: MosslandOpenDevs maintainers (Mossland) accepted_by: MosslandOpenDevs maintainers (Mossland) accepted_at: 2026-07-18 + reviewed_at: 2026-08-23 review_after: 2026-10-18 # --- data-curation profile (PROFILE.md 6.4) gaps, recorded on adoption 2026-07-18 --- @@ -276,3 +300,38 @@ residuals: accepted_by: null accepted_at: null review_after: 2026-10-18 + + # --- Recorded 2026-08-23, adopting the MIP-1 lifecycle policy into the schema. + + - id: RES-LIFECYCLE-001 + summary: >- + The MIP-1 lifecycle policy is enforceable but not yet in force. The schema, the + rubric declaration and the CI check are in place and demonstrated (EV-LIFECYCLE-001), + but no service carries a lifecycle value: 17 of 17 entries in MIP-1 Article 1 scope + are unclassified, because the classification is an owner decision that this change + deliberately did not make. Two consequences follow. First, INV-LIFECYCLE-001 and + INV-LIFECYCLE-002 hold vacuously — they constrain a set that is currently empty. + Second, MIP-1 Article 1 requires the state to be *displayed* on links.moss.land, and + build/generate.mjs does not render lifecycle at all; today `status` is not rendered + either, so no lifecycle information reaches a visitor. The display obligation is + therefore unmet until a follow-up change both populates the values and renders them. + private_detail_location: null + affected_claims: + - CLAIM-REG-001 + affected_invariants: + - INV-LIFECYCLE-001 + - INV-LIFECYCLE-002 + impact: medium + uncertainty: low + mitigation: + - "The validator reports the gap explicitly on every run rather than passing silently: it names the unclassified services and the count in MIP-1 Article 1 scope." + - "The mapping proposed by MIP-1 Annex A is drafted and awaiting owner approval, so the remaining work is a decision, not a design." + - "MIP-1 allows 14 days after ratification for exactly this step; the mechanism landing first is what makes that window achievable." + status: OPEN + disclosure: PUBLIC + owner: MosslandOpenDevs maintainers (Mossland) + accepted_by: null + accepted_at: null + review_after: 2026-09-16 + # review_after is the end of MIP-1's 14-day implementation window measured from the + # close of voting (2026-09-02), not the register's usual quarterly cadence. diff --git a/assurance/RUBRIC.md b/assurance/RUBRIC.md index 456d33f..a0d91d4 100644 --- a/assurance/RUBRIC.md +++ b/assurance/RUBRIC.md @@ -1,6 +1,6 @@ # Classification rubric — links.moss.land -The **rubric** is how this registry turns a service's raw fields into an editorial judgement: which trust chip a visitor sees, and what a Passport `stampClass` signifies. +The **rubric** is how this registry turns a service's raw fields into an editorial judgement: which trust chip a visitor sees, what a Passport `stampClass` signifies, and — since `1.1.0` — what maintenance promise a `lifecycle` state carries. > **Authoritative source:** the `rubric` object in [`ecosystem-registry.json`](../ecosystem-registry.json), versioned by `rubricVersion`. > This file is the changelog and the rules for changing it. Where the two disagree, the registry is the rubric; this file is stale and should be fixed. @@ -26,6 +26,7 @@ The rubric is data, not prose, so the code cannot drift from it: - `build/generate.mjs` derives **both** the chip assignment (`chipFor`) and the on-page legend from `rubric.chips`. A chip's rendered meaning cannot disagree with the rule that assigns it, because both read the same object. - `.github/scripts/validate-registry.py` (run by `.github/workflows/registry.yml` on every push and pull request) checks that the rubric is internally consistent and covers the data: the precedence list ends with a catch-all, every chip a rule assigns has a definition and a legend row, no chip is defined that no rule can assign, and every `stampClass` used by a service is declared. +- The same validator enforces the **lifecycle** rules *from their own declaration* — it reads `rubric.lifecycle.states[*].requires` and `rubric.lifecycle.unstaffedCap` rather than hard-coding MIP-1, so the promise a reader sees and the rule CI applies are one object. `ecosystem-registry.schema.json` carries the same constraints in its `allOf` so a consumer validating against the published schema gets them too. Not enforced: the summary table in [`README.md`](../README.md) is a human convenience copy and is **not** checked against the rubric. It can drift; the registry governs. This is an instance of the general problem tracked as `RES-PROVENANCE-001`. @@ -35,15 +36,17 @@ The test that decides the bump is: > **Would an unchanged service entry now render differently, or mean something different to a consumer?** +The test covers all three axes the rubric declares: chips, `stampClass`, and `lifecycle`. + | Bump | When | Consumer obligation | |---|---|---| -| **MAJOR** | The meaning of an existing chip or `stampClass` changes; one is removed; or precedence changes such that an unchanged entry gets a different chip. Anything that silently re-interprets existing entries. | Consumers **must** re-check their assumptions. Passport must re-examine its stamp weighting before accepting the new registry. | -| **MINOR** | Purely additive: a new chip or `stampClass` is introduced, or a new rule is added that only classifies entries no existing rule claimed. Existing entries keep their meaning. | Consumers may ignore, but should confirm they handle the new value. | +| **MAJOR** | The meaning of an existing chip, `stampClass`, or `lifecycle` state changes; one is removed; or precedence changes such that an unchanged entry gets a different chip. Anything that silently re-interprets existing entries. | Consumers **must** re-check their assumptions. Passport must re-examine its stamp weighting before accepting the new registry. | +| **MINOR** | Purely additive: a new chip, `stampClass`, or classification axis is introduced, or a new rule is added that only classifies entries no existing rule claimed. Existing entries keep their meaning. | Consumers may ignore, but should confirm they handle the new value. | | **PATCH** | Presentation only: legend wording, translation, `aria` text, CSS class. No classification semantics change. | None. | A rubric change is a **material change** under `AGENTIC_ASSURANCE.md` §7 — it affects public claims and a downstream consumer's behaviour — so it needs a change specification, not just a commit. -## 4. Current rubric — `1.0.0` +## 4. Current rubric — `1.1.0` ### 4.1 Chip precedence (first match wins) @@ -84,8 +87,36 @@ Order matters. The `실험` override deliberately outranks the tier chips, so an **Numeric weights are not defined here, deliberately.** This registry declares what a class *signifies*; how heavily to weight it is Passport's policy, in Passport's repository. Recording a weight here would assert authority over another system's behaviour that this project does not have. The registry's contribution is the meaning and the version — enough for Passport to know when to re-check. +### 4.4 `lifecycle` — MIP-1 states + +Adopted from **MIP-1** (*공개 서비스·저장소 생명주기 정책*, Mossland DAO / Agora). `lifecycle` is the maintenance promise a service carries. It is **orthogonal to `status`**, which reports observed availability at a point in time: an archived service can still be reachable, and a core service can be degraded. + +| State | 의미 | Promise | Requires | +|---|---|---|---| +| `core` | 상시 운영 | Maintainer + second maintainer (both with deploy and recovery rights), incident response, changes pre-announced | `maintainer`, `secondMaintainer` | +| `beta` | 운영 중, 변동 가능 | Maintainer assigned; features and data may change | `maintainer` | +| `lab` | 실험, best-effort | May change or stop without notice | — | +| `archive` | 종료·보존 | Development ended; the record is preserved read-only | `lifecycleReason` | + +Two rules sit on top of the table: + +- **Article 3 — `unstaffedCap: "lab"`.** Without a `secondMaintainer` a service is capped at `lab`. Holding it above the cap is an exception and requires `lifecycleReason`. +- **Article 4 — recorded change.** `archive` always requires `lifecycleReason`; Archive is not deletion, and the reason is what preserves why the service ended. + +`lifecycle` is **optional**. MIP-1 Article 1 scopes the obligation to public `*.moss.land` services and deployment-linked public repositories, so third-party market entries, off-domain channels, and data artifacts carry no lifecycle at all. In the schema this is why every lifecycle `allOf` guards its `if` with `required: ["lifecycle"]` — without that guard an absent lifecycle satisfies the condition vacuously and every market entry would be asked for a maintainer. + ## 5. Changelog +### `1.1.0` — 2026-08-23 + +Added the `lifecycle` axis (`rubric.lifecycle`), adopting the four states MIP-1 ratifies — `core` / `beta` / `lab` / `archive` — together with the fields each state requires and the Article 3 `unstaffedCap`. + +**MINOR, not MAJOR.** The governing test is whether an unchanged entry now renders differently or means something different. It does not: `lifecycle` is a new, optional, orthogonal axis; no chip rule reads it; no `stampClass` meaning moved; every existing entry is unchanged in both value and meaning. That is the "purely additive" row of the table in §3. + +The alternative considered and **not** taken was splitting `status` into a `lifecycle` + `availability` pair with `status` retained as a derived projection. It is a tidier end state — today's `status` enum genuinely mixes availability (`operational`, `degraded`, `paused`, `offline`) with lifecycle-flavoured values (`beta`, `deprecated`) — but it re-interprets a field that moss.land, Passport, City and WA already fetch, which is a MAJOR rubric change and a consumer-coordination problem. Adding an orthogonal axis gets MIP-1 what it ratifies at MINOR cost. The split stays available as a later, separately-reviewed change. + +`beta` and `deprecated` therefore remain in the `status` enum, and `status: "beta"` still drives the 베타 chip (§4.1 rule 6). Where the two axes could disagree, **`lifecycle` is the authority for MIP-1 purposes** and `status` remains a point-in-time availability observation. + ### `1.0.0` — 2026-07-18 Initial declaration. The rubric was **reconstructed from the existing implementation, not redesigned** — `build/generate.mjs` `chipFor()` and the hard-coded legend were the de-facto rubric, and this version writes down exactly what they already did. diff --git a/assurance/SYSTEM.md b/assurance/SYSTEM.md index 203d4c3..fd13882 100644 --- a/assurance/SYSTEM.md +++ b/assurance/SYSTEM.md @@ -20,13 +20,19 @@ Reconstructed from the code and copy, and **confirmed by the owner on 2026-07-18 - **Not a general CMS** — the rendered files are generated, never hand-edited (`README.md:23-32`). Machine-enforced, not merely documented: the generator renders solely from `reg.services`, and `.github/workflows/registry.yml` fails the build when committed output diverges from a fresh run. This is the structural precondition for `INV-REG-001`, and therefore for the anti-phishing claim `CLAIM-PHISH-001` — a hand-editable page could show links the registry does not contain, and the registry would stop being the source of truth that Passport and other properties verify against. - **Not a price feed, trading venue, or investment advice** — third-party market links are "reference only and are not trading recommendations" (`build/generate.mjs:31`, visible in the rendered output). Adding a price widget or chart would require revisiting this non-goal first. -- **Not a Passport-eligibility grantor for third parties** — market and off-domain channel links are `passportEligible: false` by contract (`ecosystem-registry.schema.json:102-111`), enforced in CI since 2026-07-18. No exchange or off-domain channel can ever back an ecosystem stamp. +- **Not a Passport-eligibility grantor for third parties** — market and off-domain channel links are `passportEligible: false` by contract (`ecosystem-registry.schema.json` `$defs.service.allOf`), enforced in CI since 2026-07-18. No exchange or off-domain channel can ever back an ecosystem stamp. - **Not a live uptime monitor** — service `status` reflects point-in-time manual verification, not continuous monitoring. Consumers must not read `status` as a real-time signal. This non-goal is the basis on which `RES-STATUS-DRIFT-001` was accepted. Evidence note: unlike the other three, this one is inferred from an absence (no monitoring exists) rather than from a positive artifact, so it rests on the owner's confirmation rather than on a machine-checkable control. ## 3. Domain entities and identifiers -- **Registry document** — `ecosystem-registry.json`, the single source of truth (`version` 1.0.0, `rubricVersion` 1.0.0, `generatedAt` 2026-07-06). Authoritative at `https://links.moss.land/ecosystem-registry.json`. `version` versions the document's contents; `rubricVersion` versions the *meaning* its classifications carry, and the two move independently (see [`RUBRIC.md`](RUBRIC.md)). -- **Service** — one entry in `services[]`. Stable identifier: `id` (pattern `^[a-z0-9-]+$`, `ecosystem-registry.schema.json:50`). Key fields: `domain`, `url`, `tier`, `status`, `section`, `owner`, `passportEligible`, `stampType`, `stampClass`, `labelKo`/`label`, `chip`, `artifact`, `hidden` (`ecosystem-registry.schema.json:45-101`). +- **Registry document** — `ecosystem-registry.json`, the single source of truth (`version` 1.0.0, `rubricVersion` 1.1.0, `generatedAt` 2026-08-21). Authoritative at `https://links.moss.land/ecosystem-registry.json`. `version` versions the document's contents; `rubricVersion` versions the *meaning* its classifications carry, and the two move independently (see [`RUBRIC.md`](RUBRIC.md)). +- **Service** — one entry in `services[]`. Stable identifier: `id` (pattern `^[a-z0-9-]+$`). Key fields: `domain`, `url`, `tier`, `status`, `section`, `owner`, `passportEligible`, `stampType`, `stampClass`, `labelKo`/`label`, `chip`, `artifact`, `hidden`. +- **Lifecycle** — `lifecycle` (`core`/`beta`/`lab`/`archive`), with `maintainer`, `secondMaintainer`, and `lifecycleReason`. Adopted from **MIP-1** (Mossland DAO / Agora), which makes this registry the single source of truth for the maintenance promise each public service carries and requires a monthly review (`lifecycleReviewedAt`). Optional by design: MIP-1 Article 1 scopes the obligation to public `*.moss.land` services and deployment-linked public repositories, so third-party, off-domain channel, and data-artifact entries carry no lifecycle. Meanings and per-state requirements are declared in `rubric.lifecycle` and enforced from that declaration ([`RUBRIC.md`](RUBRIC.md) §4.4). + + **`maintainer` is deliberately not `owner`.** The pre-existing `owner` field names the *domain-owning party* — `mossland` or `third-party` — and `INV-PASSPORT-001` and `CLAIM-PASSPORT-002` both depend on that meaning (the CI validator asserts every Passport-eligible entry has `owner: "mossland"`). MIP-1's "owner" is a *person accountable for the service*. Overloading one field with two unrelated meanings would have made the Passport check ambiguous, so the new concept got new names. + + Both handle fields are constrained to a GitHub handle or an `org/team` slug and are checked against address-like values, because this repository is public and must not publish personal data (`AGENTIC_ASSURANCE.md` §9). +- **Observation time** — `statusVerifiedAt` records when an entry's `status` was last checked against the live service, distinct from `lastDataAt` (when the service's own data last moved). It changes nothing about *how* verification happens; it records *when* it happened. - **Schema contract** — `ecosystem-registry.schema.json` (JSON Schema draft 2020-12), authoritative at `https://links.moss.land/ecosystem-registry.schema.json`. - **Passport stamp identity** — `stampType` (namespaced id, e.g. `identity_holder`) and `stampClass` (significance tier: core/governance/participation/connect/lab/showcase), consumed by Passport. @@ -38,18 +44,21 @@ The `data-curation` profile requires separating externally sourced facts from lo |---|---| | **Externally observed fact** | `domain`, `url`, `ticker`, `runtime.domain`/`runtime.url` | | **Mossland editorial judgement** | `tier`, `chip`, `status`, `label`/`labelKo`, `section`, `featured`, `note`, `passportEligible`, `stampType`, `stampClass` | -| **Provenance / bookkeeping** | `id`, `owner`, `hidden`, `artifact`, `sourceOfTruth`, `lastDataAt`, document-level `version` / `generatedAt` | +| **Governance decision (MIP-1)** | `lifecycle`, `lifecycleReason` — not editorial judgement and not external fact: a state ratified by Mossland DAO and recorded here because MIP-1 Article 4 names this registry the source of truth | +| **Assignment of record** | `maintainer`, `secondMaintainer` — who is accountable, as public handles | +| **Provenance / bookkeeping** | `id`, `owner`, `hidden`, `artifact`, `sourceOfTruth`, `lastDataAt`, `statusVerifiedAt`, document-level `version` / `generatedAt` / `lifecycleReviewedAt` | Two properties of this table matter for consumers: - **`stampClass` is a scoring rubric, not a fact.** It weights Passport stamps ("so stamps can be weighted rather than treated as a flat attendance log"), and Passport consumes it. Since 2026-07-18 the rubric is declared as data in the registry's `rubric` object and versioned by `rubricVersion`, with the changelog and bump rules in [`RUBRIC.md`](RUBRIC.md) — `RES-CURATION-002` RESOLVED. Numeric weights remain Passport's policy, deliberately undefined here. -- **Provenance is thin on the fact side.** `sourceOfTruth` is set on only a few entries and `lastDataAt` is `null` on every entry, so an observed fact carries no capture time. Also `RES-CURATION-001`. +- **Provenance is thin on the fact side.** `sourceOfTruth` is set on only a few entries and `lastDataAt` is `null` on every entry, so an observed fact carries no capture time. Also `RES-CURATION-001`. `statusVerifiedAt` (added 2026-08-23) closes part of this for the `status` field specifically — it is set on the two entries verified in that change and absent elsewhere, which is itself the honest reading: absence means "not checked in this pass". ## 4. State transitions - **Registry → generated artifacts.** `node build/generate.mjs` reads the registry and writes `index.html`, `embed.html`, `llms.txt`, `sitemap.xml` (`build/generate.mjs:347-350`). Idempotent; committed output equals a fresh run (`amplify.yml:7-8`). Trigger: a human editing the registry and running the generator, then committing both (`README.md:103-108`). - **Commit → deploy.** Push to `main` triggers AWS Amplify, which runs the generator (`node build/generate.mjs || true`) and publishes the artifact list (`amplify.yml:5-22`). -- **Service `status`** — one of `operational|degraded|beta|paused|offline|deprecated` (`ecosystem-registry.schema.json:41-44`), changed by human curation after live verification. +- **Service `status`** — one of `operational|degraded|beta|paused|offline|deprecated` (`ecosystem-registry.schema.json` `$defs.status`), changed by human curation after live verification. Since 2026-08-23 the verification time is recorded per entry in `statusVerifiedAt`. That is a record of *when*, not a change to *how*: verification remains a human checking the live service, so §2's "not a live uptime monitor" non-goal is untouched. If anything the field makes it harder to mistake `status` for a real-time signal, because a consumer can now see how old the observation is instead of inferring it from the document-level `generatedAt`. +- **Service `lifecycle`** — one of `core|beta|lab|archive`, changed by a Mossland DAO decision (MIP-1), not by curation. A change requires `lifecycleReason`; Archive always does. MIP-1 Article 4 sets a monthly review of the whole set, recorded in the document-level `lifecycleReviewedAt`. `lifecycle` and `status` move independently and never derive from each other: an `archive` service can be `operational` (preserved read-only and still reachable) and a `core` service can be `degraded`. - **`chip` lifecycle** — an empty-but-live service carries an explicit `chip: "실험"` until it has real data, then the field is removed and it falls through to its tier chip (`README.md:95-97`, `ecosystem-registry.json` `media`). ## 5. Trust boundaries and external dependencies @@ -73,8 +82,11 @@ Material public claims are registered with stable IDs in `assurance/CLAIMS.yaml` ## 7. Enforcement inventory - **Chip derivation** — `build/generate.mjs chipFor()` drives the 제3자 vs verified-Mossland trust signal from `owner`/`tier`, applying the ordered rules declared in the registry's `rubric.chips.precedence`. The on-page legend renders from the same definitions, so a chip's stated meaning cannot disagree with the rule that assigns it. Backs `INV-PHISH-001`, `INV-MARKET-001`. -- **Registry schema `allOf`** — forces `passportEligible: false` for `third_party` and `channel` tiers (`ecosystem-registry.schema.json:102-111`). Backs `INV-PASSPORT-001`. -- **CI registry gate** — `.github/workflows/registry.yml` runs `.github/scripts/validate-registry.py` (schema validation + "every Passport-eligible entry is owner `mossland`" + unique ids) and a regenerate-and-diff step, on every push and pull request. Backs `INV-PASSPORT-001` and `INV-REG-001`; added 2026-07-18, closing `RES-CI-VALIDATION-001`. +- **Registry schema `allOf`** — forces `passportEligible: false` for `third_party` and `channel` tiers (`ecosystem-registry.schema.json` `$defs.service.allOf`). Backs `INV-PASSPORT-001`. +- **Registry schema `allOf` (lifecycle)** — `ecosystem-registry.schema.json` `$defs.service.allOf` encodes MIP-1 as a published contract: `core` requires `maintainer` + `secondMaintainer` (Art. 2); a `core`/`beta` entry with no `secondMaintainer` requires `lifecycleReason` (Art. 3); `archive` requires `lifecycleReason` (Art. 4). Each `if` is guarded by `required: ["lifecycle"]`, without which an absent lifecycle would satisfy the condition vacuously and pull every out-of-scope entry into the requirement. Backs `INV-LIFECYCLE-001`, `INV-LIFECYCLE-002`. +- **CI registry gate** — `.github/workflows/registry.yml` runs `.github/scripts/validate-registry.py` (schema validation + "every Passport-eligible entry is owner `mossland`" + unique ids + rubric consistency + the MIP-1 lifecycle rules) and a regenerate-and-diff step, on every push and pull request. Backs `INV-PASSPORT-001`, `INV-REG-001`, `INV-LIFECYCLE-001`, `INV-LIFECYCLE-002`; added 2026-07-18, closing `RES-CI-VALIDATION-001`. +- **Lifecycle rules read from their own declaration** — the validator does not hard-code MIP-1; it reads `rubric.lifecycle.states[*].requires` and `rubric.lifecycle.unstaffedCap`, so the promise a reader sees and the rule CI applies are the same object. This is the arrangement already used for chips (`RES-CURATION-002`), applied to a second axis. +- **Maintainer handles carry no personal data** — the schema constrains `maintainer`/`secondMaintainer` to a GitHub handle or `org/team` slug, and the validator additionally rejects address-like values. Backs `AGENTIC_ASSURANCE.md` §9 on a public repository. - **HTML escaping** — `esc()` on every `card`/`domLine`/section-title interpolation (`build/generate.mjs:12-13`); the schema-constrained `generatedAt` date in the footer/sitemap is rendered directly (bounded exception). Backs `INV-RENDER-001`. - **CSP / HSTS / CORS scoping / frame-ancestors** — `customHttp.yml:1-50`; the overall HTTP-header posture. Backs `INV-CORS-001`, `INV-FRAME-001`. - **Generator as single writer** — pages rendered only from `reg.services`; README forbids hand edits. Backs `INV-REG-001`. @@ -86,7 +98,8 @@ Material public claims are registered with stable IDs in `assurance/CLAIMS.yaml` - **Manual live-site verification** — descriptions/statuses grounded in the live services, recorded via the footer "최종 확인" date (`build/generate.mjs:180`). - **Bound evidence records** — `assurance/evidence/EVIDENCE.md`: `EV-PROJECTION-001` (regenerate-and-diff proves the committed pages are a pure projection; re-captured on each generator/registry change, latest `7dbfc80`) and `EV-HEADERS-001` (live capture proves the deployed CSP/HSTS/CORS/frame-ancestors match `customHttp.yml`). - **Continuous validation** — two workflows run on every push and pull request: `.github/workflows/assurance.yml` (the pinned upstream profile validator) and `.github/workflows/registry.yml` (registry schema contract + projection diff). The registry gate was verified by a negative test before merge: marking a `third_party` entry `passportEligible` failed the build with two errors. -- **Remaining gap:** there is still no unit-test suite, and no *continuous* deployed-header assertion — the headers were confirmed by a one-time live capture and that residual was accepted (`RES-HEADERS-001`, ACCEPTED 2026-07-18) rather than remediated, because a live-site check in CI would make the build network-dependent. +- **Lifecycle negative test** — `.github/scripts/test-lifecycle-rules.py` checks eight cases covering each MIP-1 article in both directions (violation rejected, compliant form accepted) plus a ninth that removes the `required: ["lifecycle"]` guard to show it is load-bearing. Unlike the 2026-07-18 registry-gate negative test, it is committed and wired into `.github/workflows/registry.yml`, so the proof re-runs on every push and pull request instead of standing as a one-time capture. Recorded as `EV-LIFECYCLE-001`. +- **Remaining gap:** there is still no general unit-test suite (`test-lifecycle-rules.py` covers one control, not the codebase), and no *continuous* deployed-header assertion — the headers were confirmed by a one-time live capture and that residual was accepted (`RES-HEADERS-001`, ACCEPTED 2026-07-18) rather than remediated, because a live-site check in CI would make the build network-dependent. ## 9. Behavior classification @@ -98,9 +111,10 @@ Material public claims are registered with stable IDs in `assurance/CLAIMS.yaml` | Official off-domain channels/accounts (Medium, X, GitHub; owner mossland) render the 공식 chip, not 제3자 | INTENDED | `build/generate.mjs:45-53`; README chips table ('Verified Mossland domain or official channel') | VERIFIED | | Rendered pages are a pure projection of the registry | INTENDED | `EV-PROJECTION-001`; `build/generate.mjs:76-97` | VERIFIED (at latest capture) | | Registry JSON served cross-origin with `ACAO: *` (public, read-only) | INTENDED | `customHttp.yml:35-42` | VERIFIED | -| `passportEligible:false` enforced for third_party/channel in schema | INTENDED | `ecosystem-registry.schema.json:102-111` | VERIFIED | +| `passportEligible:false` enforced for third_party/channel in schema | INTENDED | `ecosystem-registry.schema.json` `$defs.service.allOf` | VERIFIED | | `media` shows explicit 실험 chip while empty | INTENDED | `ecosystem-registry.json` media `chip:"실험"`; `README.md:95-97` | VERIFIED | -| `alpha` paused / `ao` degraded recorded as of 2026-07-06 | INTENDED (point-in-time data) | `ecosystem-registry.json` notes | VERIFIED (as of that date) | +| `alpha` and `ao` recorded operational as of 2026-08-23 | INTENDED (point-in-time data) | Live capture, `assurance/evidence/EVIDENCE.md` `EV-STATUS-001`; `statusVerifiedAt` on both entries | VERIFIED (as of that date) | +| The 2026-07-06 `alpha` paused / `ao` degraded values stood unrevised for 48 days, across a registry edit that did not re-verify them | ACCIDENTAL | `git log` — the 2026-08-21 registry change (`9c7b186`) touched `media`/`signal` and left both stale values in place | VERIFIED — see `RES-STATUS-DRIFT-001` | | Amplify runs generator with `\|\| true`, shipping committed files even on generator failure | INTENDED (resilience) — owner-confirmed 2026-07-18 [^amplify] | Owner confirmation recorded in `.agentic-assurance/adoption.yaml` `human_review` | VERIFIED | | JSON-LD blocks not escaped against script-element termination | ACCIDENTAL — **remediated 2026-07-18** | `build/generate.mjs` jsonLd() now escapes `<`; `RES-RENDER-JSONLD-001` RESOLVED | VERIFIED | | No CI validation of registry against its schema | ACCIDENTAL — **remediated 2026-07-18** | `.github/workflows/registry.yml`; `RES-CI-VALIDATION-001` RESOLVED | VERIFIED | diff --git a/assurance/evidence/EVIDENCE.md b/assurance/evidence/EVIDENCE.md index e9d16a2..367059d 100644 --- a/assurance/evidence/EVIDENCE.md +++ b/assurance/evidence/EVIDENCE.md @@ -105,3 +105,68 @@ Captured 2026-07-18 against `.github/scripts/validate-registry.py`. Each mutatio | Drop a chip from `legendOrder` | `ERROR: ... '베타' is defined but is missing from legendOrder, so it would render on the page without an explanation` — exit 1 | The unmodified registry exits 0. These confirm the checks fail on real drift rather than merely existing. + +--- + +## EV-STATUS-001 — alpha and ao verified against their live health endpoints + +- **Command:** + ```sh + curl -s "https://alpha.moss.land/api/health?strict=1" + curl -s "https://ao.moss.land/api/status" + curl -s "https://alpha.moss.land/sitemap.xml" | head + ``` +- **Captured:** 2026-08-23T01:01:52Z (the `ts` / `timestamp` the services themselves returned; recorded per entry in `statusVerifiedAt`). +- **Observed:** + - `alpha` → `{"status":"ok","service":"alpha","db":"ok","seo_pages":1642,"ts":"2026-08-23T01:01:52.092Z","worst_status":"ok"}`; `sitemap.xml` lists same-day entries (`lastmod` 2026-08-22/23), and `GET /` returns 200. + - `ao` → `{"status":"operational",...}` with `api`, `database` and `llm_router` all `healthy`, `signal_feed` healthy with 12005 records emitted; sampled data endpoints `/api/signals`, `/api/ideas`, `/api/agents` each return HTTP 200. +- **Registry before this capture:** `alpha: "paused"`, `ao: "degraded"` — both first recorded 2026-07-06 and unchanged for 48 days, including across the 2026-08-21 registry edit (`9c7b186`). +- **Result:** both values were wrong and are corrected to `operational`, with the observation time recorded in `statusVerifiedAt` and the `note` fields re-grounded on this capture. +- **Supports:** the `RES-STATUS-DRIFT-001` re-review (this is the evidence that its "re-verified when the registry is updated" mitigation was not operating); the 2026-08-23 rows in `assurance/SYSTEM.md` §9. +- **Does not close:** `RES-STATUS-DRIFT-001` — this is a point-in-time capture, and nothing continuous replaces it. Per `assurance/SYSTEM.md` §2 that is deliberate. + +--- + +## EV-LIFECYCLE-001 — the MIP-1 lifecycle constraints reject what they are meant to reject + +Follows the precedent set for the registry gate (`assurance/SYSTEM.md` §8): a control is not evidence until it has been seen to fail on the thing it is supposed to catch. + +- **Command:** + ```sh + python .github/scripts/test-lifecycle-rules.py + ``` +- **Method:** each case mutates a copy of `ecosystem-registry.json` in a temporary tree and runs the real `.github/scripts/validate-registry.py` against it. Both directions are tested — the violation must be rejected *and* the compliant form must be accepted, so a rule that rejected everything would fail here too. +- **Captured:** 2026-08-23, Python 3.9.6 with `jsonschema[format-nongpl]`. +- **Continuous, not a snapshot.** Unlike the 2026-07-18 registry-gate negative test, which was performed once by hand and recorded here, this one is a committed script wired into `.github/workflows/registry.yml` and re-runs on every push and pull request. Loosening a lifecycle rule turns the build red rather than going unnoticed. + +| Case | MIP-1 | Expected | Result | +|---|---|---|---| +| `core` with `maintainer` only | Art. 2 | REJECT | REJECT — schema `'secondMaintainer' is a required property` + validator `lifecycle 'core' requires 'secondMaintainer'` | +| `core` with both handles | Art. 2 | ACCEPT | ACCEPT | +| `beta`, no `secondMaintainer`, no reason | Art. 3 | REJECT | REJECT — `lifecycle 'beta' is above 'lab' with no secondMaintainer, so it is an exception and requires lifecycleReason` | +| `beta`, no `secondMaintainer`, reason recorded | Art. 3 (exception) | ACCEPT | ACCEPT | +| `archive` with no reason | Art. 4 | REJECT | REJECT — `lifecycle 'archive' requires 'lifecycleReason'` | +| `lab` with no maintainer | state table | ACCEPT | ACCEPT | +| `maintainer` set to an email address | `AGENTIC_ASSURANCE.md` §9 | REJECT | REJECT — schema pattern + validator `looks like a name or an email address` | +| Unmodified registry (out-of-scope entries present) | Art. 1 scope | ACCEPT | ACCEPT | + +**8/8 behaved as specified.** Every rejection was caught twice — once by the published schema, once by the validator with a message naming the article violated. + +### The `required: ["lifecycle"]` guard is load-bearing + +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), so the guard is what keeps the policy off out-of-scope entries. + +Removing `required: ["lifecycle"]` from all four lifecycle rules and validating the **unchanged** registry: + +```text +150 schema errors across 30 entries +entries dragged in: agora, algora, alpha, ao, bithumb, bridge, city, coingecko, +coinmarketcap, coinone, disclosure, github-mossland, github-opendevs, gopax, links, +llms-txt, media, medium, monitor, moss, npc, passport, recipe, registry-json, signal, +signalmap, sitemap, upbit, x, wa +``` + +Upbit, Bithumb and `sitemap.xml` would each be required to name a maintainer. With the guard in place the same registry produces **0 errors**. + +- **Supports:** `INV-LIFECYCLE-001`, `INV-LIFECYCLE-002`. +- **Does not close:** `RES-LIFECYCLE-001` — this proves the control works, not that any service is classified. No entry carries a lifecycle yet. diff --git a/ecosystem-registry.json b/ecosystem-registry.json index 42a5786..260f088 100644 --- a/ecosystem-registry.json +++ b/ecosystem-registry.json @@ -26,7 +26,7 @@ "offline": "오프라인", "deprecated": "지원 종료" }, - "rubricVersion": "1.0.0", + "rubricVersion": "1.1.0", "rubric": { "note": "The editorial rubric of record: how a service's fields map to the trust chip shown on the page, and what each Passport stampClass signifies. Versioned separately from `version` (which versions this document's contents) so that a change in classification *meaning* is visible to consumers even when no entry changed. build/generate.mjs derives both the chip and the on-page legend from this object, so the rendered page cannot drift from the declared rubric. See assurance/RUBRIC.md for the changelog and the version-bump rules.", "chips": { @@ -50,6 +50,42 @@ "자료": { "class": "chip muted", "aria": "자료 · 개발자 데이터 파일", "ko": "개발자·데이터 파일", "en": "Developer & data files" } } }, + "lifecycle": { + "note": "MIP-1 (Mossland DAO, agora.moss.land) — 공개 서비스·저장소 생명주기 정책. The lifecycle axis records the maintenance promise a service carries; `status` separately records observed availability at a point in time. Article 1 scopes the obligation to public *.moss.land services and deployment-linked public repositories, so out-of-scope entries (third-party markets, off-domain channels, data artifacts) carry no lifecycle. Article 4 makes this registry the single source of truth for these states, reviewed monthly.", + "order": ["core", "beta", "lab", "archive"], + "unstaffedCap": "lab", + "unstaffedNote": "MIP-1 Article 3 — a service without a secondMaintainer is capped at `unstaffedCap`. Holding it above that cap is an exception and requires `lifecycleReason`.", + "states": { + "core": { + "ko": "상시 운영", + "en": "Always-on", + "promiseKo": "담당자·부담당자 지정 · 장애 대응 · 변경은 예고 후", + "promise": "Maintainer and second maintainer, both with deploy and recovery rights; incident response; changes pre-announced", + "requires": ["maintainer", "secondMaintainer"] + }, + "beta": { + "ko": "운영 중, 변동 가능", + "en": "Running, subject to change", + "promiseKo": "담당자 지정 · 기능·데이터가 바뀔 수 있음", + "promise": "Maintainer assigned; features and data may change", + "requires": ["maintainer"] + }, + "lab": { + "ko": "실험, best-effort", + "en": "Experimental, best-effort", + "promiseKo": "예고 없이 변경·중단될 수 있음", + "promise": "May change or stop without notice", + "requires": [] + }, + "archive": { + "ko": "종료·보존", + "en": "Ended, preserved", + "promiseKo": "신규 개발 중단 · 기록은 읽기 전용으로 보존", + "promise": "Development ended; the record is preserved read-only. Archive is not deletion — removing a domain, its data, or its repository requires its own proposal.", + "requires": ["lifecycleReason"] + } + } + }, "stampClasses": { "core": { "meaning": "identity / holder", "ko": "신원·홀더" }, "governance": { "meaning": "binding decision acts", "ko": "구속력 있는 의사결정 행위" }, @@ -187,14 +223,15 @@ "label": "Crypto, macro & geopolitics media curation", "labelKo": "크립토·매크로·국제정세 큐레이션", "section": "ecosystem", - "status": "paused", + "status": "operational", + "statusVerifiedAt": "2026-08-23T01:01:52Z", "passportEligible": false, "stampType": null, "stampClass": null, "sourceOfTruth": "alpha-canonical-store", "lastDataAt": null, "owner": "mossland", - "note": "Data backend empty across all queries (0 entities/topics/events, verified 2026-07-06); sitemap/rss list stale URLs that now 404. Pipeline suspended — not serving live data.", + "note": "Serving live data: /api/health?strict=1 returns status ok, db ok, worst_status ok over 1642 indexed pages, and sitemap.xml lists same-day entries (verified 2026-08-23). Supersedes the 2026-07-06 observation of an empty data backend and 404ing sitemap URLs, which the registry carried until this correction.", "hidden": false }, { @@ -317,14 +354,15 @@ "label": "AI agent orchestration engine", "labelKo": "AI 에이전트 조율 엔진", "section": "ecosystem", - "status": "degraded", + "status": "operational", + "statusVerifiedAt": "2026-08-23T01:01:52Z", "passportEligible": false, "stampType": null, "stampClass": null, "sourceOfTruth": null, "lastDataAt": null, "owner": "mossland", - "note": "Health/liveness OK but all 12 data endpoints return HTTP 500 (data layer down, verified 2026-07-06); UI shows SYSTEM ONLINE while pipeline output is unavailable.", + "note": "Serving live data: /api/status returns operational with api, database and llm_router healthy, and the sampled data endpoints return HTTP 200 (verified 2026-08-23). Supersedes the 2026-07-06 observation of HTTP 500 across the data endpoints, which the registry carried until this correction.", "hidden": false }, { diff --git a/ecosystem-registry.schema.json b/ecosystem-registry.schema.json index ec3ab25..f4fdafa 100644 --- a/ecosystem-registry.schema.json +++ b/ecosystem-registry.schema.json @@ -10,6 +10,11 @@ "$schema": { "type": "string" }, "version": { "type": "string", "description": "Semantic version of the registry document." }, "generatedAt": { "type": "string", "format": "date-time" }, + "lifecycleReviewedAt": { + "type": "string", + "format": "date-time", + "description": "When the lifecycle classifications were last reviewed as a whole. MIP-1 Article 4 makes this registry the single source of truth for service lifecycle state and requires a review once a month; this field is that review's timestamp. Document-level because the review is of the registry, not of one entry at a time — per-entry freshness is `statusVerifiedAt`." + }, "owner": { "type": "string" }, "registry": { "type": "string", "format": "uri" }, "description": { "type": "string" }, @@ -23,7 +28,7 @@ "rubric": { "type": "object", "description": "The editorial rubric of record: how a service's fields map to the trust chip shown on the page, and what each Passport stampClass signifies. build/generate.mjs derives both the chip and the on-page legend from this object, so the rendered page cannot drift from the declared rubric.", - "required": ["chips", "stampClasses"], + "required": ["chips", "stampClasses", "lifecycle"], "additionalProperties": true, "properties": { "note": { "type": "string" }, @@ -75,6 +80,47 @@ } } }, + "lifecycle": { + "type": "object", + "description": "The MIP-1 lifecycle states and what each one promises. Declared as data so the policy, the CI check that enforces it, and the text a reader sees cannot drift apart — the same arrangement the chip rules use.", + "required": ["order", "unstaffedCap", "states"], + "additionalProperties": true, + "properties": { + "note": { "type": "string" }, + "unstaffedNote": { "type": "string" }, + "order": { + "type": "array", + "description": "The lifecycle states from strongest promise to weakest. Position is meaningful: it is what `unstaffedCap` is compared against.", + "items": { "type": "string", "enum": ["core", "beta", "lab", "archive"] }, + "minItems": 1 + }, + "unstaffedCap": { + "type": "string", + "enum": ["core", "beta", "lab", "archive"], + "description": "MIP-1 Article 3 — the strongest lifecycle a service may hold without a secondMaintainer. Anything stronger is an exception and requires lifecycleReason." + }, + "states": { + "type": "object", + "description": "Per-state meaning and the fields that state requires. `requires` is what .github/scripts/validate-registry.py enforces, so the promise and the check are one declaration.", + "additionalProperties": { + "type": "object", + "required": ["ko", "en", "promise", "requires"], + "additionalProperties": true, + "properties": { + "ko": { "type": "string" }, + "en": { "type": "string" }, + "promiseKo": { "type": "string" }, + "promise": { "type": "string" }, + "requires": { + "type": "array", + "items": { "type": "string", "enum": ["maintainer", "secondMaintainer", "lifecycleReason"] }, + "uniqueItems": true + } + } + } + } + } + }, "stampClasses": { "type": "object", "description": "What each stampClass signifies. Must cover every stampClass value used by a service.", @@ -117,6 +163,12 @@ "type": "string", "enum": ["operational", "degraded", "beta", "paused", "offline", "deprecated"] }, + "handle": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,38}(/[A-Za-z0-9._-]{1,100})?$", + "description": "A GitHub handle, or an `org/team` slug. This is a public repository (AGENTIC_ASSURANCE.md §9): record a handle or team, never a real name, an email address, or any other personal data. The pattern admits GitHub's username grammar and rejects address-like values.", + "examples": ["MosslandOpenDevs", "MosslandOpenDevs/registry-maintainers"] + }, "service": { "type": "object", "required": ["id", "name", "domain", "url", "tier", "status", "passportEligible"], @@ -140,6 +192,29 @@ "description": "Additional sections to also render this service in. Currently unused; reserved for a service that must appear in more than one section." }, "status": { "$ref": "#/$defs/status" }, + "statusVerifiedAt": { + "type": "string", + "format": "date-time", + "description": "When this entry's `status` was last checked against the live service. Distinct from `lastDataAt`, which records when the service's own data was last updated. This registry is not a live uptime monitor (assurance/SYSTEM.md §2); recording the observation time makes the age of a point-in-time judgement explicit instead of implied." + }, + "lifecycle": { + "type": "string", + "enum": ["core", "beta", "lab", "archive"], + "description": "MIP-1 lifecycle state — the maintenance promise this service carries. core = always-on (maintainer and second maintainer, incident response, changes pre-announced); beta = running but subject to change (maintainer assigned); lab = experimental, best-effort, may change or stop without notice; archive = ended, preserved read-only. Orthogonal to `status`, which reports observed availability at a point in time: an archived service can still be reachable, and a core service can be degraded. Optional because MIP-1 Article 1 scopes the obligation to public *.moss.land services and deployment-linked public repositories — third-party, off-domain channel, and data-artifact entries are out of scope and omit it." + }, + "maintainer": { + "$ref": "#/$defs/handle", + "description": "Responsible maintainer — MIP-1 Article 2's \"owner\". Deliberately NOT the existing `owner` field, which names the domain-owning party (mossland / third-party) and is depended on by INV-PASSPORT-001 and CLAIM-PASSPORT-002; reusing it would collide two unrelated meanings. Required for lifecycle core and beta." + }, + "secondMaintainer": { + "$ref": "#/$defs/handle", + "description": "Backup maintainer — MIP-1 Article 2's \"second owner\", who must also hold deploy and recovery rights. Required for lifecycle core. Its absence caps the lifecycle at lab unless an exception reason is recorded (Article 3)." + }, + "lifecycleReason": { + "type": "string", + "minLength": 1, + "description": "Recorded justification for the lifecycle state. MIP-1 requires it in two cases: Article 3 — a service held above `lab` without a second maintainer is an exception and must record why; Article 4 — a lifecycle change, Archive in particular, must be recorded with its grounds." + }, "passportEligible": { "type": "boolean", "description": "Whether this domain may back a Passport ecosystem stamp. MUST be false for all third_party/market and off-domain channel entries." @@ -182,6 +257,39 @@ { "if": { "properties": { "tier": { "const": "channel" } } }, "then": { "properties": { "passportEligible": { "const": false } } } + }, + { + "$comment": "MIP-1 Article 2 — Core requires a maintainer AND a second maintainer, both holding deploy and recovery rights. Article 2 states no exception, so this is unconditional. The `required: [lifecycle]` guard is load-bearing: without it the `if` is vacuously true for every entry that has no lifecycle at all, and out-of-scope entries (markets, channels, data artifacts) would be dragged into the requirement.", + "if": { + "required": ["lifecycle"], + "properties": { "lifecycle": { "const": "core" } } + }, + "then": { "required": ["maintainer", "secondMaintainer"] } + }, + { + "$comment": "MIP-1 state table — Beta promises an assigned maintainer. Core is already covered above; naming both keeps the rule readable next to the policy it encodes.", + "if": { + "required": ["lifecycle"], + "properties": { "lifecycle": { "enum": ["core", "beta"] } } + }, + "then": { "required": ["maintainer"] } + }, + { + "$comment": "MIP-1 Article 3 — a service without a second maintainer is capped at lab; holding it above lab is an exception and the reason must be recorded in the registry. Annex A applies exactly this exception when it publishes an unstaffed Core candidate as Beta.", + "if": { + "required": ["lifecycle"], + "properties": { "lifecycle": { "enum": ["core", "beta"] } }, + "not": { "required": ["secondMaintainer"] } + }, + "then": { "required": ["lifecycleReason"] } + }, + { + "$comment": "MIP-1 Article 4 — a lifecycle change, Archive in particular, is recorded with its grounds. Archive is not deletion: the record is what preserves why the service ended.", + "if": { + "required": ["lifecycle"], + "properties": { "lifecycle": { "const": "archive" } } + }, + "then": { "required": ["lifecycleReason"] } } ] }