diff --git a/CHANGELOG.md b/CHANGELOG.md index 938fd9d..56f9e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,8 +59,8 @@ Sub-issues #100–#105. to null on non-memorial states; `mutation_log_ref` pattern uses the same `..`-rejection lookahead as `life-package.schema.json`. [#102] -- `tools/test_lifecycle_schema.py` — 42 sanity-test cases (4 - happy-path + 38 negative) covering all four shapes, wired into +- `tools/test_lifecycle_schema.py` — 42 sanity-test cases (9 + happy-path + 33 negative) covering all four shapes, wired into `tools/batch_validate.py`. The 42 reflects the post-merge fixes applied in #110 (memorial `else` clause + `..` path-traversal rejection on `mutation_log_ref`) plus the asset_id pattern fix @@ -86,6 +86,31 @@ Sub-issues #100–#105. for path-traversal rejection on `surface.ui_hints.avatar_image_ref` and `surface.ui_hints.background_audio_ref`, applying the same cross-schema convention. [#103] +- `docs/LIFE_TIER_SPEC.md` — per-topic normative spec for Topic 3 + (Tier System). Defines a six-dimensional credit rating + (`identity_verification`, `asset_completeness`, + `consent_completeness`, `detail_level`, `audit_chain_strength`, + `jurisdiction_clarity`), a normative weighted-score formula + (consent + identity ×2, others ×1), 12 score → level boundaries + (I–XII), and a back-compat mapping from v0.7 `verification_level` + to `tier.dimensions.identity_verification`. [#104] +- `schemas/tier.schema.json` — JSON Schema for the v0.8 tier block + (`dlrs-life-tier/0.1` shape via `$defs`). 12 `allOf` / `if`-`then` + rules bind `score` ranges to Roman-numeral `level` values; + `computed_by` pattern requires `@` so hand-rolled + tier blocks fail validation. Standalone for v0.8; integration into + `life-package.schema.json` deferred to a follow-on PR. [#104] +- `docs/appendix/TIER_NAMING_SCHEMA_D.md` — versioned naming + appendix listing the 12 Schema D tiers (Cosmic Evolution: Quark → + Singularity), their canonical names, glyphs, score ranges, and + cosmological reading. The appendix is decoupled from + `LIFE_TIER_SPEC.md` so future naming schemes can ship without a + spec major bump. [#104] +- `tools/test_tier_schema.py` — 81 sanity-test cases (26 happy-path + + 55 negative) covering both ends of every score → level range, + every score → level mismatch boundary, every required-field + removal, every dimension off-enum, and the auto-computation guard + on `computed_by`. Wired into `tools/batch_validate.py`. [#104] - `docs/LIFE_RUNTIME_STANDARD.md` — appends Part B with normative v0.8 additions for Topic 4 (Runtime / Assembly): the five-stage assembly pipeline (Verify / Resolve / Assemble / Run / Guard), @@ -105,6 +130,7 @@ Sub-issues #100–#105. [#101]: https://github.com/Digital-Life-Repository-Standard/DLRS/issues/101 [#102]: https://github.com/Digital-Life-Repository-Standard/DLRS/issues/102 [#103]: https://github.com/Digital-Life-Repository-Standard/DLRS/issues/103 +[#104]: https://github.com/Digital-Life-Repository-Standard/DLRS/issues/104 [#105]: https://github.com/Digital-Life-Repository-Standard/DLRS/issues/105 diff --git a/docs/LIFE_TIER_SPEC.md b/docs/LIFE_TIER_SPEC.md new file mode 100644 index 0000000..181d71e --- /dev/null +++ b/docs/LIFE_TIER_SPEC.md @@ -0,0 +1,285 @@ +# LIFE Tier Specification (v0.8 / Topic 3) + +**Status**: normative for v0.8 \ +**Authoritative schema**: [`schemas/tier.schema.json`](../schemas/tier.schema.json) \ +**Tier naming appendix**: [`docs/appendix/TIER_NAMING_SCHEMA_D.md`](appendix/TIER_NAMING_SCHEMA_D.md) \ +**Architecture overview**: [`docs/LIFE_ASSET_ARCHITECTURE.md`](LIFE_ASSET_ARCHITECTURE.md) §5 \ +**Closes** sub-issue [#104](https://github.com/Digital-Life-Repository-Standard/DLRS/issues/104) of epic [#106](https://github.com/Digital-Life-Repository-Standard/DLRS/issues/106). + +This document is the per-topic normative specification for **Topic 3 — Tier System** +of the v0.8 Asset Architecture epic. It defines a six-dimensional credit-rating +system that replaces v0.7's single-dimension `verification_level` field. + +## Conformance language + +The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, REQUIRED, OPTIONAL are +to be interpreted as in [RFC 2119]. Schema-encodable rules are encoded in +`schemas/tier.schema.json`; cross-document rules and builder semantics are +described here. + +## 1. Why a tier system + +`.life` packages are not interchangeable. A studio-recorded, KYC-verified, +notarised consent document with a full audit chain is not the same artifact +as a self-attested text-only package, and runtimes / surfaces / downstream +verifiers need a machine-readable signal of that difference. v0.7 carried +this signal in a single field, `verification_level`, with three values: +`self_attested`, `third_party_verified`, `memorial_authorized`. This was +sufficient to express identity provenance but conflated four distinct +properties: + +- the strength of the **issuer's identity** +- the **completeness** of the bundled assets +- the rigour of the bundled **consent** +- the audit / legal / fidelity **stack supporting the package** + +Topic 3 unbundles these into six independent dimensions and derives a +single composite **score** (0–100) and **level** (I–XII) for use in UI, +discovery, and runtime decisions. The rating is fully public — credit +ratings cannot be hidden — and is auto-computed at build time so issuers +cannot inflate it. + +## 2. Decisions encoded + +| # | Decision | This spec realises it via | +|---|---|---| +| **D1=C** | Hybrid vocabulary on tier dimensions (closed enum at v0.8, no `x-` extension yet) | `enum` constraint on each of the six dimensions (`tier.schema.json::tier_dimensions`) | +| **D2=B(v0.8)** | Six dimensions adopted in full (no opt-out at v0.8) | All six fields are `required` in `tier_dimensions` | +| **D3=auto** | Tier MUST be auto-computed by the builder | `computed_by` pattern requires `@`; the builder rejects hand-filled tier blocks pre-write (operational invariant; see §4) | +| **D4=public** | Tier is fully public, no hiding | The tier block lives in `life-package.json` (the open root descriptor); no separate "private tier" file | +| **D5=replace** | Tier replaces v0.7 `verification_level` (back-compat via mapping table) | `tier.dimensions.identity_verification` enum carries v0.7 semantics; mapping table in §6 | + +## 3. The `tier` block + +The tier block is a single JSON object. When integrated into +`life-package.schema.json` (a follow-on PR — out of scope here), it lives +under the top-level key `tier`. Standalone validation uses +`schemas/tier.schema.json`, which `$ref`s `#/$defs/tier_block`. + +### 3.1 Field summary + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `score` | integer 0–100 | yes | Composite score, auto-computed | +| `level` | string I–XII | yes | Roman-numeral tier, deterministically derived from `score` | +| `name` | string | yes | Human-readable tier name from the active naming appendix | +| `glyph` | string | yes | Display glyph from the active naming appendix | +| `dimensions` | object | yes | Six independent dimension levels (see §3.2) | +| `computed_at` | datetime | yes | RFC 3339 / ISO 8601 UTC timestamp; equals `created_at` when integrated | +| `computed_by` | string | yes | Builder identifier `@` (e.g. `tools/build_life_package.py@0.2.0`) | + +`additionalProperties: false` at the block level. Unknown fields are +rejected — there is no `x-` extension namespace at the tier block level +(extensions, if any, attach to dimensions in a future spec revision). + +### 3.2 The six dimensions + +Each dimension is a closed enum (low → high). All six are required. + +| Dimension | Levels (low → high) | Meaning | +|---|---|---| +| `identity_verification` | `unverified`, `self_attested`, `email_verified`, `id_verified`, `kyc_verified`, `notarized` | How firmly the issuer's identity is established | +| `asset_completeness` | `minimal`, `partial`, `standard`, `comprehensive`, `archive_grade` | How many capability classes have at least one bound asset | +| `consent_completeness` | `none`, `text_only`, `signed`, `notarized`, `multi_party_attested` | How well-founded the consent base is | +| `detail_level` | `low_fidelity`, `medium`, `high_fidelity`, `cinematic` | Per-asset fidelity averaged across the package | +| `audit_chain_strength` | `minimal`, `linked`, `signed_chain`, `notarized_chain` | Strength of the in-package audit chain | +| `jurisdiction_clarity` | `unspecified`, `declared`, `cross_validated`, `court_recognized` | How clearly the package declares its legal context | + +Detailed level semantics live in the schema's `description` fields and are +the source of truth for builders. + +## 4. Auto-computation (decision D3) + +The builder MUST compute the tier at build time. Hand-filled tier blocks +are rejected: + +- The schema's `computed_by` pattern (`^[A-Za-z0-9_./-]+@[A-Za-z0-9_.+-]+$`) + rejects identifiers without a builder/version separator. +- The build tool (`tools/build_life_package.py`) computes `dimensions` from + the package contents and writes the resulting block. A future PR wires + this in fully; v0.8 ships the spec + schema + sanity tests, and the + builder integration lands in the same PR that integrates the tier block + into `life-package.schema.json`. +- An external party who hand-crafts a `tier` block can technically pass + schema validation if they spoof a `computed_by` value, but the resulting + block is verifiable: `score` MUST equal the deterministic function of + `dimensions` (formula in §4.1), and `level` MUST satisfy the boundary + binding (§4.2). Verifiers SHOULD recompute and reject mismatches. + +### 4.1 Score formula + +For dimension *i* with level index *kᵢ* (0-based, lowest = 0) and maximum +level index *Mᵢ*, contribution + +``` +contributionᵢ = (kᵢ / Mᵢ) × wᵢ +``` + +with default weights *wᵢ*: + +| Dimension | Default weight | +|---|---| +| `identity_verification` | 2 | +| `consent_completeness` | 2 | +| `asset_completeness` | 1 | +| `detail_level` | 1 | +| `audit_chain_strength` | 1 | +| `jurisdiction_clarity` | 1 | + +``` +score = round( Σ contributionᵢ × 100 / Σ wᵢ ) # then clamp to [0, 100] +``` + +The weights are NORMATIVE for v0.8 — every conforming builder MUST use them +so two builders see the same package and produce the same score. Future +spec revisions MAY adjust weights, in which case the spec MUST also bump +the naming-appendix major version (so older `.life` packages keep their +historical tier). + +### 4.2 Score → level boundaries + +The boundaries are normative and enforced by the schema via `allOf` / +`if`-`then` rules: + +| Level | Score range (inclusive) | +|---|---| +| I | 0 – 8 | +| II | 9 – 16 | +| III | 17 – 24 | +| IV | 25 – 32 | +| V | 33 – 40 | +| VI | 41 – 50 | +| VII | 51 – 60 | +| VIII | 61 – 68 | +| IX | 69 – 76 | +| X | 77 – 84 | +| XI | 85 – 92 | +| XII | 93 – 100 | + +Rationale lives in `docs/appendix/TIER_NAMING_SCHEMA_D.md`. A score of 67 +with `level: "VII"` MUST fail validation (67 belongs to range VIII). + +## 5. Naming appendix decoupling + +`name` and `glyph` are sourced from a versioned appendix +(`docs/appendix/TIER_NAMING_SCHEMA_.md`). For v0.8 the only appendix is +**Schema D — Cosmic Evolution** (Quark → Singularity). Future appendices +MAY ship without a spec major bump. Consumers MUST treat `level` (the +Roman numeral) as the canonical machine identifier and SHOULD NOT +machine-match on `name` or `glyph`. + +A `.life` integrating Schema D MUST use the canonical names and glyphs +listed in the appendix table; UI consumers MAY substitute alternative +glyphs for accessibility, but the in-package values MUST be canonical +(reproducibility). + +## 6. Migrating from v0.7 `verification_level` + +v0.7's `verification_level` field carried three values. The mapping into +v0.8's `identity_verification` dimension is: + +| v0.7 `verification_level` | v0.8 `tier.dimensions.identity_verification` | +|---|---| +| `self_attested` | `self_attested` | +| `third_party_verified` | `id_verified` (default) or `kyc_verified` (when issuer recorded a KYC chain) | +| `memorial_authorized` | `notarized` | + +Notes: + +- The mapping is a **default** for builders that don't have richer + signals. Issuers SHOULD prefer the most specific level supported by + their evidence. +- `memorial_authorized` carried *role-based* semantics in v0.7 (the + issuer's `role == memorial_executor`). v0.8 keeps that role-based + semantics in `life-package.schema.json` (untouched here) and uses + `identity_verification: notarized` only as the tier-system mirror. +- Until the schema-integration PR lands, both v0.7 `verification_level` + and v0.8 `tier` MAY coexist; integration will mark `verification_level` + as deprecated in favour of `tier.dimensions.identity_verification`. + +## 7. Cross-document interactions + +| Other doc / schema | Interaction | +|---|---| +| `docs/LIFE_FILE_STANDARD.md` | The `.life` package descriptor (`life-package.json`) gains a `tier` block in a follow-on integration PR. No file format changes here. | +| `docs/LIFE_RUNTIME_STANDARD.md` | Topic 4 Assembly stage 2 (Resolve) MAY use `tier` to choose between candidate providers (lower-tier packages SHOULD prefer offline / lighter providers; higher-tier MAY prefer hosted higher-fidelity providers). Not normative — runtime is free to ignore tier. | +| `docs/LIFE_GENESIS_SPEC.md` | Genesis assets and tier are independent — a heavily-derived asset (low `reproducibility_level`) does not cap tier. | +| `docs/LIFE_LIFECYCLE_SPEC.md` | Tier MUST be recomputed on each new package version (`computed_at` advances with the lifecycle). | +| `docs/LIFE_BINDING_SPEC.md` | Binding and tier are orthogonal — tier rates the package itself; binding rates how runtimes should plug into it. A future spec MAY allow binding to assert a `tier_floor` (already implemented in `binding.schema.json::tier_floor`). | + +## 8. Worked example + +```json +{ + "tier": { + "score": 54, + "level": "VII", + "name": "Main Sequence", + "glyph": "★", + "dimensions": { + "identity_verification": "id_verified", + "asset_completeness": "comprehensive", + "consent_completeness": "signed", + "detail_level": "high_fidelity", + "audit_chain_strength": "linked", + "jurisdiction_clarity": "declared" + }, + "computed_at": "2026-04-26T14:00:00Z", + "computed_by": "tools/build_life_package.py@0.2.0" + } +} +``` + +Score derivation (level indices are 0-based; max-index is `len(enum) - 1`): + +``` +identity_verification = 3/5 × 2 = 1.20 (id_verified at index 3 of 5) +consent_completeness = 2/4 × 2 = 1.00 (signed at index 2 of 4) +asset_completeness = 3/4 × 1 = 0.75 (comprehensive at index 3 of 4) +detail_level = 2/3 × 1 = 0.67 (high_fidelity at index 2 of 3) +audit_chain_strength = 1/3 × 1 = 0.33 (linked at index 1 of 3) +jurisdiction_clarity = 1/3 × 1 = 0.33 (declared at index 1 of 3) +sum = 4.28 +sum_of_weights = 8 +score = round(4.28 × 100 / 8) = round(53.5) = 54 → tier VII (51–60) +``` + +The example block ships `score: 54`, exactly matching the formula's +output, and `level: "VII"` (range 51–60). Verifiers MAY recompute and +MUST reject any block where the recomputed score / level disagrees +with the persisted values. + +## 9. What this spec does NOT cover + +- **Builder integration into `life-package.schema.json`** — deferred to a + follow-on integration PR that will fold the `tier` block into the + package descriptor and migrate `verification_level` to deprecated. +- **`build_life_package.py` auto-compute implementation** — schema-only PR; + the builder change lands together with the integration. +- **Runtime tier-aware provider selection** — non-normative; described + briefly in the runtime spec via Topic 4. +- **Issuer signing of tier** — tier is auto-computed and inherits the + package's signature; no separate tier signature. + +## 10. Sanity tests + +`tools/test_tier_schema.py` exercises **81 cases** (26 happy-path + 55 +negative) covering: + +- both boundaries of every score → level range (24 happy: 12 tiers × low + high) +- all-lowest and all-highest `tier_dimensions` (2 happy) +- score → level mismatch at every adjacent tier boundary (22 negative) +- missing required tier-block fields (7 negative) +- out-of-range / wrong-type score (4 negative) +- off-enum / wrong-type level (2 negative) +- empty / overlong name / glyph (3 negative) +- `computed_by` hand-rolled patterns (2 negative) +- `computed_at` type guard (1 negative) +- tier-block additional property (1 negative) +- missing each dimension (6 negative) +- per-dimension off-enum (6 negative) +- tier_dimensions additional property (1 negative) + +The suite is wired into `tools/batch_validate.py`. + +[RFC 2119]: https://www.rfc-editor.org/rfc/rfc2119 diff --git a/docs/appendix/TIER_NAMING_SCHEMA_D.md b/docs/appendix/TIER_NAMING_SCHEMA_D.md new file mode 100644 index 0000000..b4b56a1 --- /dev/null +++ b/docs/appendix/TIER_NAMING_SCHEMA_D.md @@ -0,0 +1,93 @@ +# Tier Naming Appendix — Schema D (Cosmic Evolution) + +**Status**: normative for v0.8 \ +**Versioned independently from `docs/LIFE_TIER_SPEC.md`** — +naming-appendix revisions MAY ship without a spec major bump. +Consumers MUST treat the Roman-numeral `level` (I–XII) as the +canonical machine identifier; `name` and `glyph` are presentation- +layer aliases sourced from this appendix. + +## Why Schema D + +The user-locked theme for the tier naming layer is **sci-fi / AI / data +/ cosmic** (议题 3, Q2). Five candidate schemes were reviewed +during the v0.8 architecture discussion: + +| Schema | Ordering | Imagery | Rejected because | +|---|---|---|---| +| A — Moon phases | 12 phases | culturally shared | reads as mystical / horoscopic for a technical credit rating | +| B — Minerals (Mohs hardness) | 12 minerals | natural ordering | tier-XI/XII names (Lonsdaleite, Carbonado) too obscure | +| C — Pure geometric glyphs | 12 abstract symbols | culturally neutral | abstract, not memorable for social use | +| E — Data architecture (Bit → Singularity) | 12 data terms | engineering-native | Bit / Nibble / Byte too low-level for a social-facing concept | +| F — Hybrid Data → Cosmic (Bit → Tensor → Galaxy → Singularity) | 12 mixed | bridges data + cosmos | requires the reader to span two domains; not as clean as D | +| **D — Cosmic Evolution (Quark → Singularity)** | **12 emergent steps** | **physics-native, sci-fi friendly** | **chosen** | + +Schema D maps `.life` package strength to cosmological emergence: +the lowest tier is sub-atomic, the highest is a singularity. The +ordering is monotonic and physically motivated — every tier is +"more emergent" than the previous one — which gives the credit- +rating semantics a natural intuition bridge. + +## Tier table (canonical, v0.8) + +| Level | Name | 中文 | Glyph | Score range | Cosmological reading | +|---|---|---|---|---|---| +| I | Quark | 夸克 | ⋅ | 0–8 | sub-atomic, no structure yet | +| II | Atom | 原子 | ⊙ | 9–16 | minimal stable structure | +| III | Molecule | 分子 | ⋮⋮ | 17–24 | composition begins | +| IV | Stardust | 星尘 | ✧ | 25–32 | ingredients of a system but unbound | +| V | Nebula | 星云 | 🌫 | 33–40 | gravitational organization starting | +| VI | Protostar | 原恒星 | ✦ | 41–50 | self-sustaining process forming | +| VII | Main Sequence | 主序星 | ★ | 51–60 | healthy steady-state — the social-target tier | +| VIII | Red Giant | 红巨星 | ◉ | 61–68 | mature, expanded, full-bodied | +| IX | White Dwarf | 白矮星 | ⚪ | 69–76 | dense, compact, long-lived | +| X | Neutron Star | 中子星 | ⚫ | 77–84 | extreme density, rare | +| XI | Pulsar | 脉冲星 | ◎ | 85–92 | precise, observable, well-attested | +| XII | Singularity | 奇点 | ● | 93–100 | end-state — full archive-grade `.life` | + +Score boundaries are **inclusive on both ends**. The boundaries are +chosen so: + +- Each tier covers either 8 or 9 score-points in the lower half (I–V + span 0–40 in five tiers of 8–9 points). +- Tier VI–VII are slightly wider (10 points each) to make + "Main Sequence" a comfortable steady-state band — a healthy + comprehensive `.life` lands in VII without needing exotic perfection. +- Tiers X–XII narrow again (8 points each) so the top tiers are + rare and meaningful. + +## Compliance rules + +A `.life` builder claiming Schema D MUST satisfy all of: + +1. **Canonical names** — the `name` field is exactly the value in the + "Name" column above for the corresponding `level`. No translation + is performed at build time; localisation is a UI-layer concern. +2. **Canonical glyphs** — the `glyph` field is exactly the value in + the "Glyph" column above. UI consumers MAY substitute alternative + glyphs for accessibility (e.g. high-contrast or text-only + variants), but the in-package glyph MUST be the canonical one so + downstream verifiers can reproduce the build. +3. **Score → level binding** — the schema enforces this via + `allOf/if/then` rules; see `schemas/tier.schema.json::tier_block`. + +## Future appendices + +This file is **Schema D / v0.8.0**. Future naming schemes (e.g. a +hypothetical Schema G — "Music notation tiers") MAY ship as a sibling +appendix file under `docs/appendix/TIER_NAMING_SCHEMA_.md`. The +machine-readable `level` field MUST remain backwards-compatible +(I–XII Roman numerals) so a Schema-G `.life` and a Schema-D `.life` +remain comparable at the machine level. + +A `.life` MUST declare which appendix is in force — for v0.8 every +package implicitly uses Schema D because no other appendix exists +yet. From v0.9 onwards, an explicit `naming_schema_id` field in the +tier block MAY be added (deferred to v0.9 design review). + +## Source of truth + +This appendix is the source of truth for the human-facing tier +labels. The architecture overview (`docs/LIFE_ASSET_ARCHITECTURE.md` +§5 + appendix A) summarises the same table for context but defers +to this appendix in case of any drift. diff --git a/schemas/tier.schema.json b/schemas/tier.schema.json new file mode 100644 index 0000000..afcd5dc --- /dev/null +++ b/schemas/tier.schema.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dlrs.standard/schemas/tier.schema.json", + "title": "DLRS .life Tier Block", + "description": "v0.8 Topic 3 — multi-dimensional `tier` block for the `.life` package descriptor. Replaces v0.7's single-dimension `verification_level` field with a 6-dimensional credit-rating system. The builder MUST auto-compute `score`, `level`, and `dimensions` from the package contents; hand-filled tier blocks are out-of-spec and rejected by `tools/build_life_package.py` at build time. The `name` and `glyph` are sourced from a versioned naming appendix (Schema D Cosmic Evolution: Quark → Singularity, see `docs/appendix/TIER_NAMING_SCHEMA_D.md`) and may evolve without a spec major bump. Authoritative spec: `docs/LIFE_TIER_SPEC.md`. This schema exports its shapes via `$defs` so they can be `$ref`-included from `life-package.schema.json` in a future integration PR; no v0.7 file's validation behaviour changes until that integration lands.", + "$ref": "#/$defs/tier_block", + "$defs": { + "tier_block": { + "type": "object", + "description": "The full `tier` object embedded in `life-package.json`. All seven fields are required so machine consumers can rely on the contract; no field is optional. Decisions: tier is fully public (D5.4 — credit rating cannot be hidden); machine fields (`score`, `level`, `dimensions`) are spec-frozen; `name` + `glyph` belong to the versioned naming appendix.", + "additionalProperties": false, + "required": [ + "score", + "level", + "name", + "glyph", + "dimensions", + "computed_at", + "computed_by" + ], + "properties": { + "score": { + "type": "integer", + "description": "Composite tier score on a 0–100 scale. Auto-computed by the builder from the six `dimensions` using default weights: `consent_completeness` ×2, `identity_verification` ×2, others ×1. Each dimension contributes (level_index / max_level_index) × weight × 100 / sum_of_weights. The result is rounded to the nearest integer and clamped to [0, 100].", + "minimum": 0, + "maximum": 100 + }, + "level": { + "type": "string", + "description": "Roman-numeral tier level I–XII. Derived deterministically from `score` via the boundaries fixed in `docs/appendix/TIER_NAMING_SCHEMA_D.md`. The schema enforces the score → level mapping via `allOf` rules; a mismatch fails validation.", + "enum": [ + "I", + "II", + "III", + "IV", + "V", + "VI", + "VII", + "VIII", + "IX", + "X", + "XI", + "XII" + ] + }, + "name": { + "type": "string", + "description": "Human-readable tier name from the active naming appendix. For Schema D the canonical names are: I=Quark, II=Atom, III=Molecule, IV=Stardust, V=Nebula, VI=Protostar, VII=Main Sequence, VIII=Red Giant, IX=White Dwarf, X=Neutron Star, XI=Pulsar, XII=Singularity. Future appendices may swap names without a schema major bump; consumers MUST treat `level` (not `name`) as the canonical machine identifier.", + "minLength": 1, + "maxLength": 64 + }, + "glyph": { + "type": "string", + "description": "Single visual glyph (1–4 Unicode codepoints) representing the tier. Schema D glyphs: ⋅ ⊙ ⋮⋮ ✧ 🌫 ✦ ★ ◉ ⚪ ⚫ ◎ ●. Glyphs are display-only; consumers SHOULD NOT match on glyph string. The codepoint count cap of 16 leaves headroom for combining marks and ZWJ sequences.", + "minLength": 1, + "maxLength": 16 + }, + "dimensions": { + "$ref": "#/$defs/tier_dimensions" + }, + "computed_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 / ISO 8601 UTC timestamp at which the tier was computed. MUST equal the package's `created_at` when integrated into `life-package.json` (the tier is computed during the build, not after)." + }, + "computed_by": { + "type": "string", + "description": "Identifier of the builder tool + version that computed the tier. Convention: `@`, e.g. `tools/build_life_package.py@0.2.0`. The `@` separator is mandatory so consumers can split builder identity from version. Hand-rolled tier blocks (e.g. `human@manual`) are explicitly rejected by this pattern.", + "pattern": "^[A-Za-z0-9_./-]+@[A-Za-z0-9_.+-]+$", + "minLength": 3, + "maxLength": 256 + } + }, + "allOf": [ + { + "description": "Score → level binding for tier I (0–8).", + "if": { "properties": { "score": { "minimum": 0, "maximum": 8 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "I" } } } + }, + { + "description": "Score → level binding for tier II (9–16).", + "if": { "properties": { "score": { "minimum": 9, "maximum": 16 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "II" } } } + }, + { + "description": "Score → level binding for tier III (17–24).", + "if": { "properties": { "score": { "minimum": 17, "maximum": 24 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "III" } } } + }, + { + "description": "Score → level binding for tier IV (25–32).", + "if": { "properties": { "score": { "minimum": 25, "maximum": 32 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "IV" } } } + }, + { + "description": "Score → level binding for tier V (33–40).", + "if": { "properties": { "score": { "minimum": 33, "maximum": 40 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "V" } } } + }, + { + "description": "Score → level binding for tier VI (41–50).", + "if": { "properties": { "score": { "minimum": 41, "maximum": 50 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "VI" } } } + }, + { + "description": "Score → level binding for tier VII (51–60).", + "if": { "properties": { "score": { "minimum": 51, "maximum": 60 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "VII" } } } + }, + { + "description": "Score → level binding for tier VIII (61–68).", + "if": { "properties": { "score": { "minimum": 61, "maximum": 68 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "VIII" } } } + }, + { + "description": "Score → level binding for tier IX (69–76).", + "if": { "properties": { "score": { "minimum": 69, "maximum": 76 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "IX" } } } + }, + { + "description": "Score → level binding for tier X (77–84).", + "if": { "properties": { "score": { "minimum": 77, "maximum": 84 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "X" } } } + }, + { + "description": "Score → level binding for tier XI (85–92).", + "if": { "properties": { "score": { "minimum": 85, "maximum": 92 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "XI" } } } + }, + { + "description": "Score → level binding for tier XII (93–100).", + "if": { "properties": { "score": { "minimum": 93, "maximum": 100 } }, "required": ["score"] }, + "then": { "properties": { "level": { "const": "XII" } } } + } + ] + }, + "tier_dimensions": { + "type": "object", + "description": "The six independent tier dimensions. All six are required — partial dimension blocks are not valid; missing observability counts as the lowest level for that dimension. Decisions D5.3 (six dimensions adopted in full).", + "additionalProperties": false, + "required": [ + "identity_verification", + "asset_completeness", + "consent_completeness", + "detail_level", + "audit_chain_strength", + "jurisdiction_clarity" + ], + "properties": { + "identity_verification": { + "type": "string", + "description": "How firmly the issuer's identity is established at issuance time. Levels (low → high): `unverified` (no identity check); `self_attested` (subject claims identity, no third party); `email_verified` (control of an email address proven); `id_verified` (government ID checked); `kyc_verified` (full KYC by a regulated provider); `notarized` (in-person notarial witness). Replaces v0.7's `verification_level` field; mapping table in spec §6.", + "enum": [ + "unverified", + "self_attested", + "email_verified", + "id_verified", + "kyc_verified", + "notarized" + ] + }, + "asset_completeness": { + "type": "string", + "description": "How many .life capability classes have at least one bound asset. Levels (low → high): `minimal` (one capability, e.g. text-only); `partial` (2–3 capabilities); `standard` (4–6 capabilities, the typical complete `.life`); `comprehensive` (7+ capabilities including all primary modalities); `archive_grade` (every capability the runtime can name plus full longitudinal coverage).", + "enum": [ + "minimal", + "partial", + "standard", + "comprehensive", + "archive_grade" + ] + }, + "consent_completeness": { + "type": "string", + "description": "How well-founded the consent base is. Levels (low → high): `none` (no consent on file — only valid when the subject is the issuer and the package is self-issued); `text_only` (free-text consent statement, no signature); `signed` (cryptographic / handwritten signature on the consent document); `notarized` (notarial witness on the consent document); `multi_party_attested` (executor + at least one independent attesting party signed).", + "enum": [ + "none", + "text_only", + "signed", + "notarized", + "multi_party_attested" + ] + }, + "detail_level": { + "type": "string", + "description": "Per-asset fidelity averaged across the package. Levels (low → high): `low_fidelity` (compressed / heavily downsampled assets); `medium` (standard distribution quality); `high_fidelity` (production-grade quality, e.g. studio-recorded voice); `cinematic` (raw masters, lossless audio, motion-captured visuals).", + "enum": [ + "low_fidelity", + "medium", + "high_fidelity", + "cinematic" + ] + }, + "audit_chain_strength": { + "type": "string", + "description": "Strength of the in-package audit chain (`audit/events.jsonl`). Levels (low → high): `minimal` (events present but no `prev_hash` chain); `linked` (every event has `prev_hash` linking to the previous); `signed_chain` (the chain head is signed by the issuer); `notarized_chain` (the signed chain head is countersigned by an independent notary).", + "enum": [ + "minimal", + "linked", + "signed_chain", + "notarized_chain" + ] + }, + "jurisdiction_clarity": { + "type": "string", + "description": "How clearly the package declares its legal context. Levels (low → high): `unspecified` (no jurisdiction declared); `declared` (jurisdiction + governing law + executor legal status all filled); `cross_validated` (declarations validated against an external registry); `court_recognized` (a court or competent authority has recognised the declarations).", + "enum": [ + "unspecified", + "declared", + "cross_validated", + "court_recognized" + ] + } + } + } + } +} diff --git a/tools/batch_validate.py b/tools/batch_validate.py index d68d6ae..7dcbca1 100644 --- a/tools/batch_validate.py +++ b/tools/batch_validate.py @@ -40,6 +40,7 @@ ("test_genesis_schema", [sys.executable, str(TOOLS / "test_genesis_schema.py")]), ("test_lifecycle_schema", [sys.executable, str(TOOLS / "test_lifecycle_schema.py")]), ("test_binding_schema", [sys.executable, str(TOOLS / "test_binding_schema.py")]), + ("test_tier_schema", [sys.executable, str(TOOLS / "test_tier_schema.py")]), # The 'pipelines' step calls tools/test_pipelines.py, which itself # dispatches every per-pipeline test plus the v0.6 cross-cutting # tests transitively. The cross-cutting tests are also listed diff --git a/tools/test_tier_schema.py b/tools/test_tier_schema.py new file mode 100644 index 0000000..582d536 --- /dev/null +++ b/tools/test_tier_schema.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Sanity tests for ``schemas/tier.schema.json`` (.life Tier Block, v0.8). + +The tier schema exports two shapes via ``$defs``: + +- ``tier_block`` — full tier object (score, level, name, glyph, dimensions, + computed_at, computed_by) embedded in ``life-package.json`` post-integration. +- ``tier_dimensions`` — the six-dimension sub-object. + +The top-level schema ``$ref``-s ``#/$defs/tier_block`` so a consumer that loads +the schema directly validates against the full block. This test suite uses the +top-level schema for ``tier_block`` cases and a sub-validator for the +``tier_dimensions``-only cases. + +Pattern mirrors ``test_lifecycle_schema.py``: a known-good builder for each +shape, then negative mutations exercising every enum, every required field, +every score → level binding boundary, and the auto-computation guard on +``computed_by``. +""" +from __future__ import annotations + +import json +import sys +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "schemas" / "tier.schema.json" + + +def _top_validator(Draft202012Validator, schema: dict): + return Draft202012Validator(schema) + + +def _dims_validator(Draft202012Validator, schema: dict): + sub = {"$ref": "#/$defs/tier_dimensions", "$defs": schema["$defs"]} + return Draft202012Validator(sub) + + +def _good_dimensions() -> dict: + return { + "identity_verification": "kyc_verified", + "asset_completeness": "comprehensive", + "consent_completeness": "notarized", + "detail_level": "high_fidelity", + "audit_chain_strength": "signed_chain", + "jurisdiction_clarity": "declared", + } + + +def _good_tier_block(score: int, level: str) -> dict: + return { + "score": score, + "level": level, + "name": "Main Sequence", + "glyph": "★", + "dimensions": _good_dimensions(), + "computed_at": "2026-04-26T14:00:00Z", + "computed_by": "tools/build_life_package.py@0.2.0", + } + + +# Canonical (score, level) pairs from the Schema D appendix. One representative +# per tier, plus both boundaries of every range. +TIER_RANGES = [ + ("I", 0, 8), + ("II", 9, 16), + ("III", 17, 24), + ("IV", 25, 32), + ("V", 33, 40), + ("VI", 41, 50), + ("VII", 51, 60), + ("VIII", 61, 68), + ("IX", 69, 76), + ("X", 77, 84), + ("XI", 85, 92), + ("XII", 93, 100), +] + + +def _validate(validator, instance: dict) -> list[str]: + return [e.message for e in validator.iter_errors(instance)] + + +def _run() -> int: + try: + from jsonschema import Draft202012Validator + except ImportError: + print("ERROR: jsonschema not installed; run: pip install -r tools/requirements.txt") + return 2 + + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + top = _top_validator(Draft202012Validator, schema) + dims = _dims_validator(Draft202012Validator, schema) + + # cases is a list of (name, validator, instance, should_pass) tuples. + cases: list[tuple[str, object, dict, bool]] = [] + + # ----- tier_block: happy path for every tier ----- + for level, lo, hi in TIER_RANGES: + cases.append((f"tier {level}: low boundary score={lo}", top, _good_tier_block(lo, level), True)) + cases.append((f"tier {level}: high boundary score={hi}", top, _good_tier_block(hi, level), True)) + + # ----- tier_block negative: score → level mismatch at every boundary ----- + # For every adjacent pair of tiers, verify the boundary is not crossable. + for (low_lvl, _lo, low_hi), (hi_lvl, hi_lo, _hi) in zip(TIER_RANGES, TIER_RANGES[1:]): + # score belongs to low tier but level says high tier + bad = _good_tier_block(low_hi, hi_lvl) + cases.append((f"score {low_hi} declared {hi_lvl} (must be {low_lvl})", top, bad, False)) + # score belongs to high tier but level says low tier + bad = _good_tier_block(hi_lo, low_lvl) + cases.append((f"score {hi_lo} declared {low_lvl} (must be {hi_lvl})", top, bad, False)) + + # ----- tier_block negative: missing fields ----- + for missing in ["score", "level", "name", "glyph", "dimensions", "computed_at", "computed_by"]: + bad = _good_tier_block(55, "VII") + bad.pop(missing) + cases.append((f"tier_block missing {missing}", top, bad, False)) + + # ----- tier_block negative: out-of-range score ----- + bad = _good_tier_block(55, "VII"); bad["score"] = -1 + cases.append(("tier_block score < 0 rejected", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["score"] = 101 + cases.append(("tier_block score > 100 rejected", top, bad, False)) + + # ----- tier_block negative: wrong type for score ----- + bad = _good_tier_block(55, "VII"); bad["score"] = 55.5 + cases.append(("tier_block score must be integer (no float)", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["score"] = "55" + cases.append(("tier_block score must be integer (no string)", top, bad, False)) + + # ----- tier_block negative: level off-enum ----- + bad = _good_tier_block(55, "VII"); bad["level"] = "XIII" + cases.append(("tier_block level XIII off-enum", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["level"] = "7" + cases.append(("tier_block level Arabic numeral rejected", top, bad, False)) + + # ----- tier_block negative: empty / overlong name and glyph ----- + bad = _good_tier_block(55, "VII"); bad["name"] = "" + cases.append(("tier_block name empty", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["glyph"] = "" + cases.append(("tier_block glyph empty", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["glyph"] = "G" * 17 + cases.append(("tier_block glyph too long", top, bad, False)) + + # ----- tier_block negative: computed_by hand-rolled (no @ separator) ----- + bad = _good_tier_block(55, "VII"); bad["computed_by"] = "manual" + cases.append(("tier_block computed_by missing @-separator", top, bad, False)) + bad = _good_tier_block(55, "VII"); bad["computed_by"] = "tools/build@" + cases.append(("tier_block computed_by missing version", top, bad, False)) + + # ----- tier_block negative: computed_at must be a string ----- + bad = _good_tier_block(55, "VII"); bad["computed_at"] = 1714137600 + cases.append(("tier_block computed_at must be a string (epoch rejected)", top, bad, False)) + + # ----- tier_block negative: unknown top-level field ----- + bad = _good_tier_block(55, "VII"); bad["secret_grade"] = "AAA" + cases.append(("tier_block additional property rejected", top, bad, False)) + + # ----- tier_dimensions: happy path with each dimension at its lowest ----- + low = { + "identity_verification": "unverified", + "asset_completeness": "minimal", + "consent_completeness": "none", + "detail_level": "low_fidelity", + "audit_chain_strength": "minimal", + "jurisdiction_clarity": "unspecified", + } + cases.append(("tier_dimensions all-lowest valid", dims, low, True)) + + # ----- tier_dimensions: happy path with each dimension at its highest ----- + high = { + "identity_verification": "notarized", + "asset_completeness": "archive_grade", + "consent_completeness": "multi_party_attested", + "detail_level": "cinematic", + "audit_chain_strength": "notarized_chain", + "jurisdiction_clarity": "court_recognized", + } + cases.append(("tier_dimensions all-highest valid", dims, high, True)) + + # ----- tier_dimensions: missing each dimension ----- + for missing in [ + "identity_verification", + "asset_completeness", + "consent_completeness", + "detail_level", + "audit_chain_strength", + "jurisdiction_clarity", + ]: + bad = _good_dimensions(); bad.pop(missing) + cases.append((f"tier_dimensions missing {missing}", dims, bad, False)) + + # ----- tier_dimensions: each enum off-value ----- + off_values = { + "identity_verification": "passport_verified", + "asset_completeness": "deluxe", + "consent_completeness": "verbal", + "detail_level": "ultra", + "audit_chain_strength": "verified", + "jurisdiction_clarity": "global", + } + for field, off in off_values.items(): + bad = _good_dimensions(); bad[field] = off + cases.append((f"tier_dimensions {field}={off} off-enum", dims, bad, False)) + + # ----- tier_dimensions: additional property rejected ----- + bad = _good_dimensions(); bad["future_dimension"] = "experimental" + cases.append(("tier_dimensions additional property rejected", dims, bad, False)) + + # ----- run all cases ----- + failures: list[str] = [] + for name, validator, instance, should_pass in cases: + errors = _validate(validator, instance) + passed = len(errors) == 0 + if passed != should_pass: + failures.append( + f" - {name}: expected {'pass' if should_pass else 'fail'}, got " + f"{'pass' if passed else 'fail'} ({errors!r})" + ) + print(f"run: {len(cases)} cases, failures: {len(failures)}") + for line in failures: + print(line) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_run())