diff --git a/.github/ISSUE_TEMPLATE/clause.yml b/.github/ISSUE_TEMPLATE/clause.yml new file mode 100644 index 0000000..35a4fa2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/clause.yml @@ -0,0 +1,59 @@ +name: Clause +description: Cláusula del contrato de aplicación (ADR-0011) +title: "[Clause] " +labels: ["kind:clause"] +body: + - type: input + id: clause_id + attributes: + label: Id de cláusula + description: Identificador cerrado CLAUSE-*-001. + placeholder: CLAUSE-BASE-001 + validations: + required: true + - type: input + id: schema_path + attributes: + label: Ruta del schema + description: Norma JSON Schema Draft 2020-12 de esta cláusula. + placeholder: contracts/json-schema/clause-base.schema.json + validations: + required: true + - type: input + id: test_name + attributes: + label: Nombre del test + description: Función de test Rust de cumplimiento estricto. + placeholder: clause_base_001_accepts_valid_and_rejects_invalid + validations: + required: true + - type: dropdown + id: formats + attributes: + label: Formatos afectados + description: Targets DEC-001 sobre los que aplica la cláusula. + multiple: true + options: + - archmaker-profile + - archinstall-profile + - report + validations: + required: true + - type: dropdown + id: compliance + attributes: + label: Estado de cumplimiento + options: + - pending + - red + - verified + default: 0 + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notas + description: Evidencia de rojo/verde, issues bloqueantes o exclusiones. + validations: + required: false diff --git a/.github/workflows/json-schema.yml b/.github/workflows/json-schema.yml index 8cda191..098d6ee 100644 --- a/.github/workflows/json-schema.yml +++ b/.github/workflows/json-schema.yml @@ -113,6 +113,14 @@ jobs: "artifact", "installation-plan", "runner-envelope", + "application-contract", + "clause-base", + "clause-manifest", + "clause-option", + "clause-rule", + "clause-bytes", + "clause-host", + "clause-boot", ] # Casos que se validan contra un subschema de common o un $defs concreto. diff --git a/contracts/json-schema/CHANGELOG.md b/contracts/json-schema/CHANGELOG.md index 85bf2f3..4df12e5 100644 --- a/contracts/json-schema/CHANGELOG.md +++ b/contracts/json-schema/CHANGELOG.md @@ -21,6 +21,20 @@ declaran `schemaVersion: "1"`; la etiqueta `v0` es la revisión del contrato, no `schemaVersion`). Cambiar la forma de un contrato exige incrementar `schemaVersion` y una _migration note_ (`docs/03-data/versioning-migrations.md`). +## v0.2 — Contrato de aplicación (ADR-0011) + +### Añadidos + +- `application-contract.schema.json`: contrato generado desde la selección resuelta, base + Arch Linux x86_64 y formato DEC-001. +- `clause-base.schema.json` … `clause-boot.schema.json`: siete cláusulas con `$id` propio. +- Corpus `24-application-contract.*` y `25-clause-base.*` … `31-clause-boot.*`. + +### Notas + +- No se amplía el enum de targets de `artifact.schema.json` (DEC-001 intacto). +- `CLAUSE-BOOT-001` no admite `status: satisfied` mientras el formato de imagen esté cerrado. + ## v0.1 — MVP-1 (domain / catalog / migration) ### Modificados diff --git a/contracts/json-schema/README.md b/contracts/json-schema/README.md index cd91b1f..0ffab43 100644 --- a/contracts/json-schema/README.md +++ b/contracts/json-schema/README.md @@ -26,19 +26,27 @@ tipado v1 y nunca un script. `$ref` es **por `$id`**, no por ruta de fichero (registro de `$id` en el gate). Los `$defs` compartidos viven una sola vez en `common.schema.json`. -| Archivo | Agregado | Ejes de versión | Estado | -| ------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------- | ------------ | -| `common.schema.json` | `$defs` compartidos | — | v0 | -| `draft.schema.json` | DM-DRAFT | `documentVersion`, `schemaVersion`, `revision`, `catalogRef.version`, `targetRef.version` | v0 | -| `catalog.schema.json` | DM-CATALOG | `documentVersion`, `schemaVersion`, `catalogRef.version` | v0 | -| `rule.schema.json` | RULE-* | `documentVersion`, `schemaVersion` | v0 (AUD-017) | -| `diagnostic.schema.json` | Diagnostic (AM-*) | `schemaVersion` | v0 | -| `core-error.schema.json` | CoreError (AM-*) | `schemaVersion` | v0 | -| `changeset.schema.json` | ChangeSet | `schemaVersion`, `baseRevision` | v0 | -| `manifest.schema.json` | DM-MANIFEST | `documentVersion`, `schemaVersion`, `catalogRef.version`, `targetRef.version`, `producer.version` | v0 | -| `artifact.schema.json` | DM-ARTIFACT | `documentVersion`, `schemaVersion`, `targetRef.version`, `producer.version` | v0 | -| `installation-plan.schema.json` | DM-PLAN (v1) | `documentVersion`, `schemaVersion`, `protocolVersion` | v0 | -| `runner-envelope.schema.json` | DM-SESSION (protocolo v1) | `protocolVersion`, `schemaVersion` | v0 | +| Archivo | Agregado | Ejes de versión | Estado | +| ---------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | ------------ | +| `common.schema.json` | `$defs` compartidos | — | v0 | +| `draft.schema.json` | DM-DRAFT | `documentVersion`, `schemaVersion`, `revision`, `catalogRef.version`, `targetRef.version` | v0 | +| `catalog.schema.json` | DM-CATALOG | `documentVersion`, `schemaVersion`, `catalogRef.version` | v0 | +| `rule.schema.json` | RULE-* | `documentVersion`, `schemaVersion` | v0 (AUD-017) | +| `diagnostic.schema.json` | Diagnostic (AM-*) | `schemaVersion` | v0 | +| `core-error.schema.json` | CoreError (AM-*) | `schemaVersion` | v0 | +| `changeset.schema.json` | ChangeSet | `schemaVersion`, `baseRevision` | v0 | +| `manifest.schema.json` | DM-MANIFEST | `documentVersion`, `schemaVersion`, `catalogRef.version`, `targetRef.version`, `producer.version` | v0 | +| `artifact.schema.json` | DM-ARTIFACT | `documentVersion`, `schemaVersion`, `targetRef.version`, `producer.version` | v0 | +| `installation-plan.schema.json` | DM-PLAN (v1) | `documentVersion`, `schemaVersion`, `protocolVersion` | v0 | +| `runner-envelope.schema.json` | DM-SESSION (protocolo v1) | `protocolVersion`, `schemaVersion` | v0 | +| `application-contract.schema.json` | Contrato aplicación (ADR-0011) | `documentVersion`, `schemaVersion` | v0 | +| `clause-base.schema.json` | CLAUSE-BASE-001 | — | v0 | +| `clause-manifest.schema.json` | CLAUSE-MANIFEST-001 | — | v0 | +| `clause-option.schema.json` | CLAUSE-OPTION-001 | — | v0 | +| `clause-rule.schema.json` | CLAUSE-RULE-001 | — | v0 | +| `clause-bytes.schema.json` | CLAUSE-BYTES-001 | — | v0 | +| `clause-host.schema.json` | CLAUSE-HOST-001 | — | v0 | +| `clause-boot.schema.json` | CLAUSE-BOOT-001 | — | v0 | Los ejes son independientes (ADR-0006). Formato congelado en MVP-1: `documentVersion` = `vNext`/`vNext.N`; `schemaVersion` y `protocolVersion` = entero positivo; `catalogRef` / diff --git a/contracts/json-schema/application-contract.schema.json b/contracts/json-schema/application-contract.schema.json new file mode 100644 index 0000000..b87621f --- /dev/null +++ b/contracts/json-schema/application-contract.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/application-contract.schema.json", + "title": "ArchMaker Application Contract (ADR-0011)", + "description": "Contract produced from a resolved configurator selection, an Arch Linux x86_64 base identity and an admitted export format (DEC-001). Each clause is verified independently. Does not admit an image target. Contract state: v0.", + "$comment": "ADR-0011. additionalProperties:false. Format enum matches artifact.targetRef.id (DEC-001). Image format is not admitted; CLAUSE-BOOT-001 cannot be satisfied yet.", + "type": "object", + "additionalProperties": false, + "required": [ + "documentVersion", + "schemaVersion", + "contractId", + "manifestDigest", + "base", + "format", + "clauses" + ], + "properties": { + "documentVersion": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/documentVersion" + }, + "schemaVersion": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/schemaVersion" + }, + "contractId": { + "description": "Stable identity of this contract instance.", + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/uuid" + }, + "manifestDigest": { + "description": "Semantic digest of the sealed manifest that all formats project.", + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + }, + "base": { + "description": "Cited Arch Linux base ISO identity (DEC-008).", + "type": "object", + "additionalProperties": false, + "required": ["distro", "arch", "kind"], + "properties": { + "distro": { + "type": "string", + "const": "arch-linux" + }, + "arch": { + "type": "string", + "const": "x86_64" + }, + "kind": { + "type": "string", + "const": "base-iso" + } + } + }, + "format": { + "description": "Admitted export format (DEC-001). Image is not admitted.", + "type": "string", + "enum": ["archmaker-profile", "archinstall-profile", "report"] + }, + "clauses": { + "description": "Closed list of seven clauses. Order is fixed.", + "type": "array", + "minItems": 7, + "maxItems": 7, + "items": { + "$ref": "#/$defs/clauseEntry" + } + }, + "packageNames": { + "description": "Package names projected from loaded options for the admitted format. No cmd, no pacstrap.", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9+._-]*$" + }, + "uniqueItems": true + }, + "publicName": { + "description": "Visible artifact name. Must not start with ARCH while ADR-0010 is proposed (NFR-MARK-001).", + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?![Aa][Rr][Cc][Hh]).+$" + } + }, + "$defs": { + "clauseId": { + "description": "Closed vocabulary of application-contract clauses (ADR-0011).", + "type": "string", + "enum": [ + "CLAUSE-BASE-001", + "CLAUSE-MANIFEST-001", + "CLAUSE-OPTION-001", + "CLAUSE-RULE-001", + "CLAUSE-BYTES-001", + "CLAUSE-HOST-001", + "CLAUSE-BOOT-001" + ] + }, + "clauseStatus": { + "description": "Verification status of a clause. satisfied is forbidden for CLAUSE-BOOT-001 until an image format is admitted.", + "type": "string", + "enum": ["pending", "satisfied", "failed", "not-applicable"] + }, + "clauseEntry": { + "type": "object", + "additionalProperties": false, + "required": ["id", "required", "status"], + "properties": { + "id": { + "$ref": "#/$defs/clauseId" + }, + "required": { + "type": "boolean" + }, + "status": { + "$ref": "#/$defs/clauseStatus" + } + } + } + } +} diff --git a/contracts/json-schema/clause-base.schema.json b/contracts/json-schema/clause-base.schema.json new file mode 100644 index 0000000..41bf1a4 --- /dev/null +++ b/contracts/json-schema/clause-base.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-base.schema.json", + "title": "CLAUSE-BASE-001 — Arch base and derived mark", + "description": "The cited base is Arch Linux x86_64. The published artifact is a derivative: it may say derived from Arch Linux and must not present itself as official Arch Linux, use the Arch logo, or publish a visible name starting with ARCH while ADR-0010 is proposed.", + "$comment": "ADR-0011 / CLAUSE-BASE-001. Normative schema; tracking issue kind:clause.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "base", + "presentsAsOfficialArch", + "usesArchLogo", + "publicName" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-BASE-001" + }, + "base": { + "type": "object", + "additionalProperties": false, + "required": ["distro", "arch", "kind"], + "properties": { + "distro": { "type": "string", "const": "arch-linux" }, + "arch": { "type": "string", "const": "x86_64" }, + "kind": { "type": "string", "const": "base-iso" } + } + }, + "presentsAsOfficialArch": { + "type": "boolean", + "const": false + }, + "usesArchLogo": { + "type": "boolean", + "const": false + }, + "publicName": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?![Aa][Rr][Cc][Hh]).+$" + }, + "derivedFromStatement": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } +} diff --git a/contracts/json-schema/clause-boot.schema.json b/contracts/json-schema/clause-boot.schema.json new file mode 100644 index 0000000..3009ce8 --- /dev/null +++ b/contracts/json-schema/clause-boot.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-boot.schema.json", + "title": "CLAUSE-BOOT-001 — Derived image boot (not yet admitted)", + "description": "UEFI x86_64 boot of the derived image. Until an image format is admitted by scope and DEC-001, status must not be satisfied. aarch64 is rejected.", + "$comment": "ADR-0011 / CLAUSE-BOOT-001. Satisfied is excluded from the status enum while image format is closed.", + "type": "object", + "additionalProperties": false, + "required": ["id", "status", "arch", "firmware"], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-BOOT-001" + }, + "status": { + "description": "Cannot be satisfied until image format is admitted.", + "type": "string", + "enum": ["pending", "failed", "not-applicable"] + }, + "arch": { + "type": "string", + "const": "x86_64" + }, + "firmware": { + "type": "string", + "const": "uefi" + }, + "imageFormatAdmitted": { + "type": "boolean", + "const": false + } + } +} diff --git a/contracts/json-schema/clause-bytes.schema.json b/contracts/json-schema/clause-bytes.schema.json new file mode 100644 index 0000000..921cd10 --- /dev/null +++ b/contracts/json-schema/clause-bytes.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-bytes.schema.json", + "title": "CLAUSE-BYTES-001 — Deterministic bytes", + "description": "The same selection, catalog, base and format produce the same artifact bytes and binary digest. Changing one option changes the manifest digest. Changing format changes artifact bytes and leaves the manifest digest alone.", + "$comment": "ADR-0011 / CLAUSE-BYTES-001. NFR-DET-001.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "format", + "exportA", + "exportB", + "manifestDigestA", + "manifestDigestB" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-BYTES-001" + }, + "format": { + "type": "string", + "enum": ["archmaker-profile", "archinstall-profile", "report"] + }, + "exportA": { + "$ref": "#/$defs/exportSample" + }, + "exportB": { + "$ref": "#/$defs/exportSample" + }, + "manifestDigestA": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + }, + "manifestDigestB": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + }, + "sameSelection": { + "type": "boolean" + } + }, + "$defs": { + "exportSample": { + "type": "object", + "additionalProperties": false, + "required": ["binaryDigest"], + "properties": { + "binaryDigest": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/binaryDigest" + } + } + } + } +} diff --git a/contracts/json-schema/clause-host.schema.json b/contracts/json-schema/clause-host.schema.json new file mode 100644 index 0000000..9e1e47d --- /dev/null +++ b/contracts/json-schema/clause-host.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-host.schema.json", + "title": "CLAUSE-HOST-001 — No host-side execution", + "description": "Fulfilling and verifying the contract does not launch pacstrap, mkarchiso, shell or root on the configurator host, and does not require network to resolve the manifest (NFR-OFF-001). A package name outside the sealed profile is rejected.", + "$comment": "ADR-0011 / CLAUSE-HOST-001.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "spawnedProcesses", + "requiredNetworkForManifest", + "elevatedPrivileges", + "rejectedOutOfProfileNames" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-HOST-001" + }, + "spawnedProcesses": { + "description": "Must be empty after verify/export on the configurator host.", + "type": "array", + "maxItems": 0, + "items": { + "type": "string", + "enum": ["pacstrap", "mkarchiso", "bash", "sh", "sudo"] + } + }, + "requiredNetworkForManifest": { + "type": "boolean", + "const": false + }, + "elevatedPrivileges": { + "type": "boolean", + "const": false + }, + "rejectedOutOfProfileNames": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "uniqueItems": true + } + } +} diff --git a/contracts/json-schema/clause-manifest.schema.json b/contracts/json-schema/clause-manifest.schema.json new file mode 100644 index 0000000..465dc0d --- /dev/null +++ b/contracts/json-schema/clause-manifest.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-manifest.schema.json", + "title": "CLAUSE-MANIFEST-001 — One selection, one manifest", + "description": "All loaded options resolve to a single sealed manifest. Projecting to another admitted format does not change that content digest (ADR-0003).", + "$comment": "ADR-0011 / CLAUSE-MANIFEST-001.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "manifestDigest", + "formatA", + "formatB", + "digestAfterFormatA", + "digestAfterFormatB" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-MANIFEST-001" + }, + "manifestDigest": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + }, + "formatA": { + "type": "string", + "enum": ["archmaker-profile", "archinstall-profile", "report"] + }, + "formatB": { + "type": "string", + "enum": ["archmaker-profile", "archinstall-profile", "report"] + }, + "digestAfterFormatA": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + }, + "digestAfterFormatB": { + "$ref": "https://archmaker.dev/schemas/common.schema.json#/$defs/contentDigest" + } + } +} diff --git a/contracts/json-schema/clause-option.schema.json b/contracts/json-schema/clause-option.schema.json new file mode 100644 index 0000000..b57eb7a --- /dev/null +++ b/contracts/json-schema/clause-option.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-option.schema.json", + "title": "CLAUSE-OPTION-001 — Option fidelity", + "description": "A loaded option appears with its package name in the formats that must carry it. An explicit gap is not invented. A non-redistributable -bin candidate may be named as deferred fetch and its binary does not travel. A cmd field never appears. pacstrap is never content.", + "$comment": "ADR-0011 / CLAUSE-OPTION-001.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "loadedPackageNames", + "gapOptionIds", + "forbiddenTokens", + "embeddedBinaries" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-OPTION-001" + }, + "loadedPackageNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9+._-]*$" + }, + "uniqueItems": true + }, + "gapOptionIds": { + "description": "Canonical ids that remain explicit gaps (not loaded).", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 387 + }, + "uniqueItems": true + }, + "forbiddenTokens": { + "description": "Must be empty: no cmd, pacstrap, hooks or shell strings.", + "type": "array", + "maxItems": 0, + "items": { + "type": "string" + } + }, + "embeddedBinaries": { + "description": "Must be empty: -bin blobs do not travel inside the artifact.", + "type": "array", + "maxItems": 0, + "items": { + "type": "string" + } + }, + "deferredFetchNames": { + "description": "Non-redistributable package names cited only as deferred obtainment.", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "uniqueItems": true + } + } +} diff --git a/contracts/json-schema/clause-rule.schema.json b/contracts/json-schema/clause-rule.schema.json new file mode 100644 index 0000000..4b689e5 --- /dev/null +++ b/contracts/json-schema/clause-rule.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://archmaker.dev/schemas/clause-rule.schema.json", + "title": "CLAUSE-RULE-001 — Rules travel with the application", + "description": "A blocked selection satisfies no format. Informative rules do not block alone. A derivation is explained and does not mutate silently. Presets are not applied. Evaluation uses the normalized AST, not legacy count_gt at runtime.", + "$comment": "ADR-0011 / CLAUSE-RULE-001. Aligns with RULE-COMP-001, RULE-KERNEL-001, RULE-BROWSER-001, RULE-DM-001.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "blocking", + "presetsApplied", + "evaluatedOperatorForm", + "diagnostics" + ], + "properties": { + "id": { + "type": "string", + "const": "CLAUSE-RULE-001" + }, + "blocking": { + "type": "boolean" + }, + "presetsApplied": { + "type": "array", + "maxItems": 0, + "items": { + "type": "string", + "enum": ["minimal-dev", "full-workstation", "gaming"] + } + }, + "evaluatedOperatorForm": { + "description": "Runtime form after normalizing legacy count_gt.", + "type": "string", + "enum": ["gt-count", "required", "eq", "info-count"] + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "severity"], + "properties": { + "code": { + "type": "string", + "pattern": "^AM-[A-Z]+-[0-9]{3}$" + }, + "severity": { + "type": "boolean" + }, + "explained": { + "type": "boolean" + } + } + } + }, + "silentMutation": { + "type": "boolean", + "const": false + } + } +} diff --git a/contracts/json-schema/examples/24-application-contract.invalid.json b/contracts/json-schema/examples/24-application-contract.invalid.json new file mode 100644 index 0000000..5d98179 --- /dev/null +++ b/contracts/json-schema/examples/24-application-contract.invalid.json @@ -0,0 +1,19 @@ +{ + "documentVersion": "vNext", + "schemaVersion": "1", + "contractId": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "manifestDigest": { + "algorithm": "sha256", + "value": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "base": { + "distro": "arch-linux", + "arch": "aarch64", + "kind": "base-iso" + }, + "format": "archiso-image", + "clauses": [ + { "id": "CLAUSE-BASE-001", "required": true, "status": "pending" } + ], + "publicName": "ArchMaker-ISO" +} diff --git a/contracts/json-schema/examples/24-application-contract.valid.json b/contracts/json-schema/examples/24-application-contract.valid.json new file mode 100644 index 0000000..69ea00a --- /dev/null +++ b/contracts/json-schema/examples/24-application-contract.valid.json @@ -0,0 +1,26 @@ +{ + "documentVersion": "vNext", + "schemaVersion": "1", + "contractId": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "manifestDigest": { + "algorithm": "sha256", + "value": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "base": { + "distro": "arch-linux", + "arch": "x86_64", + "kind": "base-iso" + }, + "format": "archmaker-profile", + "clauses": [ + { "id": "CLAUSE-BASE-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-MANIFEST-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-OPTION-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-RULE-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-BYTES-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-HOST-001", "required": true, "status": "pending" }, + { "id": "CLAUSE-BOOT-001", "required": true, "status": "not-applicable" } + ], + "packageNames": ["linux", "btrfs-progs", "firefox"], + "publicName": "derived-workstation" +} diff --git a/contracts/json-schema/examples/25-clause-base.invalid.json b/contracts/json-schema/examples/25-clause-base.invalid.json new file mode 100644 index 0000000..54d5162 --- /dev/null +++ b/contracts/json-schema/examples/25-clause-base.invalid.json @@ -0,0 +1,11 @@ +{ + "id": "CLAUSE-BASE-001", + "base": { + "distro": "arch-linux", + "arch": "x86_64", + "kind": "base-iso" + }, + "presentsAsOfficialArch": true, + "usesArchLogo": true, + "publicName": "ArchLinux-Remix" +} diff --git a/contracts/json-schema/examples/25-clause-base.valid.json b/contracts/json-schema/examples/25-clause-base.valid.json new file mode 100644 index 0000000..e0fd225 --- /dev/null +++ b/contracts/json-schema/examples/25-clause-base.valid.json @@ -0,0 +1,12 @@ +{ + "id": "CLAUSE-BASE-001", + "base": { + "distro": "arch-linux", + "arch": "x86_64", + "kind": "base-iso" + }, + "presentsAsOfficialArch": false, + "usesArchLogo": false, + "publicName": "derived-workstation", + "derivedFromStatement": "derived from Arch Linux" +} diff --git a/contracts/json-schema/examples/26-clause-manifest.invalid.json b/contracts/json-schema/examples/26-clause-manifest.invalid.json new file mode 100644 index 0000000..69a329c --- /dev/null +++ b/contracts/json-schema/examples/26-clause-manifest.invalid.json @@ -0,0 +1,17 @@ +{ + "id": "CLAUSE-MANIFEST-001", + "manifestDigest": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "formatA": "archiso-image", + "formatB": "report", + "digestAfterFormatA": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "digestAfterFormatB": { + "algorithm": "sha256", + "value": "3333333333333333333333333333333333333333333333333333333333333333" + } +} diff --git a/contracts/json-schema/examples/26-clause-manifest.valid.json b/contracts/json-schema/examples/26-clause-manifest.valid.json new file mode 100644 index 0000000..6b7b848 --- /dev/null +++ b/contracts/json-schema/examples/26-clause-manifest.valid.json @@ -0,0 +1,17 @@ +{ + "id": "CLAUSE-MANIFEST-001", + "manifestDigest": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "formatA": "archmaker-profile", + "formatB": "report", + "digestAfterFormatA": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "digestAfterFormatB": { + "algorithm": "sha256", + "value": "2222222222222222222222222222222222222222222222222222222222222222" + } +} diff --git a/contracts/json-schema/examples/27-clause-option.invalid.json b/contracts/json-schema/examples/27-clause-option.invalid.json new file mode 100644 index 0000000..727091b --- /dev/null +++ b/contracts/json-schema/examples/27-clause-option.invalid.json @@ -0,0 +1,7 @@ +{ + "id": "CLAUSE-OPTION-001", + "loadedPackageNames": ["linux"], + "gapOptionIds": [], + "forbiddenTokens": ["pacstrap", "cmd"], + "embeddedBinaries": ["brave-bin.AppImage"] +} diff --git a/contracts/json-schema/examples/27-clause-option.valid.json b/contracts/json-schema/examples/27-clause-option.valid.json new file mode 100644 index 0000000..b99b891 --- /dev/null +++ b/contracts/json-schema/examples/27-clause-option.valid.json @@ -0,0 +1,8 @@ +{ + "id": "CLAUSE-OPTION-001", + "loadedPackageNames": ["linux", "firefox", "hyprland"], + "gapOptionIds": ["archmaker.boot.limine", "archmaker.pkg.git"], + "forbiddenTokens": [], + "embeddedBinaries": [], + "deferredFetchNames": ["brave-bin", "onlyoffice-bin"] +} diff --git a/contracts/json-schema/examples/28-clause-rule.invalid.json b/contracts/json-schema/examples/28-clause-rule.invalid.json new file mode 100644 index 0000000..96a7d8c --- /dev/null +++ b/contracts/json-schema/examples/28-clause-rule.invalid.json @@ -0,0 +1,8 @@ +{ + "id": "CLAUSE-RULE-001", + "blocking": false, + "presetsApplied": ["gaming"], + "evaluatedOperatorForm": "count_gt", + "diagnostics": [], + "silentMutation": true +} diff --git a/contracts/json-schema/examples/28-clause-rule.valid.json b/contracts/json-schema/examples/28-clause-rule.valid.json new file mode 100644 index 0000000..0bb4387 --- /dev/null +++ b/contracts/json-schema/examples/28-clause-rule.valid.json @@ -0,0 +1,14 @@ +{ + "id": "CLAUSE-RULE-001", + "blocking": false, + "presetsApplied": [], + "evaluatedOperatorForm": "gt-count", + "diagnostics": [ + { + "code": "AM-RULE-002", + "severity": false, + "explained": true + } + ], + "silentMutation": false +} diff --git a/contracts/json-schema/examples/29-clause-bytes.invalid.json b/contracts/json-schema/examples/29-clause-bytes.invalid.json new file mode 100644 index 0000000..76bfd3e --- /dev/null +++ b/contracts/json-schema/examples/29-clause-bytes.invalid.json @@ -0,0 +1,25 @@ +{ + "id": "CLAUSE-BYTES-001", + "format": "iso-image", + "exportA": { + "binaryDigest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "exportB": { + "binaryDigest": { + "algorithm": "sha256", + "value": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + "manifestDigestA": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "manifestDigestB": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "sameSelection": true +} diff --git a/contracts/json-schema/examples/29-clause-bytes.valid.json b/contracts/json-schema/examples/29-clause-bytes.valid.json new file mode 100644 index 0000000..1bc11c9 --- /dev/null +++ b/contracts/json-schema/examples/29-clause-bytes.valid.json @@ -0,0 +1,25 @@ +{ + "id": "CLAUSE-BYTES-001", + "format": "archmaker-profile", + "exportA": { + "binaryDigest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "exportB": { + "binaryDigest": { + "algorithm": "sha256", + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "manifestDigestA": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "manifestDigestB": { + "algorithm": "sha256", + "value": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "sameSelection": true +} diff --git a/contracts/json-schema/examples/30-clause-host.invalid.json b/contracts/json-schema/examples/30-clause-host.invalid.json new file mode 100644 index 0000000..985df1c --- /dev/null +++ b/contracts/json-schema/examples/30-clause-host.invalid.json @@ -0,0 +1,7 @@ +{ + "id": "CLAUSE-HOST-001", + "spawnedProcesses": ["pacstrap", "mkarchiso"], + "requiredNetworkForManifest": true, + "elevatedPrivileges": true, + "rejectedOutOfProfileNames": [] +} diff --git a/contracts/json-schema/examples/30-clause-host.valid.json b/contracts/json-schema/examples/30-clause-host.valid.json new file mode 100644 index 0000000..0df0ff8 --- /dev/null +++ b/contracts/json-schema/examples/30-clause-host.valid.json @@ -0,0 +1,7 @@ +{ + "id": "CLAUSE-HOST-001", + "spawnedProcesses": [], + "requiredNetworkForManifest": false, + "elevatedPrivileges": false, + "rejectedOutOfProfileNames": ["not-in-profile-pkg"] +} diff --git a/contracts/json-schema/examples/31-clause-boot.invalid.json b/contracts/json-schema/examples/31-clause-boot.invalid.json new file mode 100644 index 0000000..23fee56 --- /dev/null +++ b/contracts/json-schema/examples/31-clause-boot.invalid.json @@ -0,0 +1,7 @@ +{ + "id": "CLAUSE-BOOT-001", + "status": "satisfied", + "arch": "aarch64", + "firmware": "uefi", + "imageFormatAdmitted": true +} diff --git a/contracts/json-schema/examples/31-clause-boot.valid.json b/contracts/json-schema/examples/31-clause-boot.valid.json new file mode 100644 index 0000000..ec7253f --- /dev/null +++ b/contracts/json-schema/examples/31-clause-boot.valid.json @@ -0,0 +1,7 @@ +{ + "id": "CLAUSE-BOOT-001", + "status": "not-applicable", + "arch": "x86_64", + "firmware": "uefi", + "imageFormatAdmitted": false +} diff --git a/crates/archmaker-core/src/contract.rs b/crates/archmaker-core/src/contract.rs new file mode 100644 index 0000000..6b375e0 --- /dev/null +++ b/crates/archmaker-core/src/contract.rs @@ -0,0 +1,435 @@ +//! Application-contract verifier (ADR-0011). +//! +//! Semantic authority for CLAUSE-* compliance. Does not spawn shell, pacstrap, +//! mkarchiso or require network. Image format is not admitted: CLAUSE-BOOT-001 +//! cannot be satisfied. + +use serde_json::Value; + +const ADMITTED_FORMATS: &[&str] = &["archmaker-profile", "archinstall-profile", "report"]; +const ALLOWED_OPERATOR_FORMS: &[&str] = &["gt-count", "required", "eq", "info-count"]; +const REQUIRED_CLAUSE_IDS: &[&str] = &[ + "CLAUSE-BASE-001", + "CLAUSE-MANIFEST-001", + "CLAUSE-OPTION-001", + "CLAUSE-RULE-001", + "CLAUSE-BYTES-001", + "CLAUSE-HOST-001", + "CLAUSE-BOOT-001", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractVerdict { + pub ok: bool, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClauseVerdict { + pub ok: bool, + pub clause_id: String, + pub reason: Option, +} + +fn digest_hex(obj: &Value, field: &str) -> Result { + obj.get(field) + .and_then(|d| d.get("value")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| format!("missing {field}.value")) +} + +fn string_list(obj: &Value, field: &str) -> Result, String> { + let arr = obj + .get(field) + .and_then(|v| v.as_array()) + .ok_or_else(|| format!("missing array {field}"))?; + arr.iter() + .map(|item| { + item.as_str() + .map(str::to_string) + .ok_or_else(|| format!("{field} items must be strings")) + }) + .collect() +} + +fn bool_field(obj: &Value, field: &str) -> Result { + obj.get(field) + .and_then(|v| v.as_bool()) + .ok_or_else(|| format!("missing bool {field}")) +} + +fn str_field<'a>(obj: &'a Value, field: &str) -> Result<&'a str, String> { + obj.get(field) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("missing string {field}")) +} + +fn clause_ok(clause_id: &str) -> ClauseVerdict { + ClauseVerdict { + ok: true, + clause_id: clause_id.to_string(), + reason: None, + } +} + +fn clause_fail(clause_id: &str, reason: impl Into) -> ClauseVerdict { + ClauseVerdict { + ok: false, + clause_id: clause_id.to_string(), + reason: Some(reason.into()), + } +} + +fn verify_base(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-BASE-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-BASE-001"); + } + let base = match value.get("base") { + Some(b) => b, + None => return clause_fail(id, "missing base"), + }; + if base.get("distro").and_then(|v| v.as_str()) != Some("arch-linux") { + return clause_fail(id, "base.distro must be arch-linux"); + } + if base.get("arch").and_then(|v| v.as_str()) != Some("x86_64") { + return clause_fail(id, "base.arch must be x86_64"); + } + if base.get("kind").and_then(|v| v.as_str()) != Some("base-iso") { + return clause_fail(id, "base.kind must be base-iso"); + } + match bool_field(value, "presentsAsOfficialArch") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "presentsAsOfficialArch must be false"), + Err(e) => return clause_fail(id, e), + } + match bool_field(value, "usesArchLogo") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "usesArchLogo must be false"), + Err(e) => return clause_fail(id, e), + } + match str_field(value, "publicName") { + Ok(name) if name.to_ascii_lowercase().starts_with("arch") => { + clause_fail(id, "publicName must not start with ARCH") + } + Ok(_) => clause_ok(id), + Err(e) => clause_fail(id, e), + } +} + +fn verify_manifest(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-MANIFEST-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-MANIFEST-001"); + } + for key in ["formatA", "formatB"] { + match str_field(value, key) { + Ok(fmt) if ADMITTED_FORMATS.contains(&fmt) => {} + Ok(fmt) => return clause_fail(id, format!("{key}={fmt} is not an admitted format")), + Err(e) => return clause_fail(id, e), + } + } + let manifest = match digest_hex(value, "manifestDigest") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let a = match digest_hex(value, "digestAfterFormatA") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let b = match digest_hex(value, "digestAfterFormatB") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + if a != manifest || b != manifest { + return clause_fail(id, "format projections must keep the same manifest digest"); + } + clause_ok(id) +} + +fn verify_option(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-OPTION-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-OPTION-001"); + } + match string_list(value, "forbiddenTokens") { + Ok(tokens) if tokens.is_empty() => {} + Ok(tokens) => { + return clause_fail(id, format!("forbiddenTokens must be empty, got {tokens:?}")) + } + Err(e) => return clause_fail(id, e), + } + match string_list(value, "embeddedBinaries") { + Ok(bins) if bins.is_empty() => {} + Ok(bins) => { + return clause_fail(id, format!("embeddedBinaries must be empty, got {bins:?}")) + } + Err(e) => return clause_fail(id, e), + } + if value + .get("loadedPackageNames") + .and_then(|v| v.as_array()) + .is_none() + { + return clause_fail(id, "missing loadedPackageNames"); + } + if value + .get("gapOptionIds") + .and_then(|v| v.as_array()) + .is_none() + { + return clause_fail(id, "missing gapOptionIds"); + } + clause_ok(id) +} + +fn verify_rule(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-RULE-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-RULE-001"); + } + match string_list(value, "presetsApplied") { + Ok(presets) if presets.is_empty() => {} + Ok(presets) => { + return clause_fail(id, format!("presets must not be applied, got {presets:?}")) + } + Err(e) => return clause_fail(id, e), + } + match bool_field(value, "silentMutation") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "silentMutation must be false"), + Err(e) => return clause_fail(id, e), + } + match str_field(value, "evaluatedOperatorForm") { + Ok(form) if ALLOWED_OPERATOR_FORMS.contains(&form) => {} + Ok(form) => { + return clause_fail( + id, + format!("evaluatedOperatorForm={form} is not normalized"), + ) + } + Err(e) => return clause_fail(id, e), + } + if value + .get("diagnostics") + .and_then(|v| v.as_array()) + .is_none() + { + return clause_fail(id, "missing diagnostics"); + } + clause_ok(id) +} + +fn verify_bytes(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-BYTES-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-BYTES-001"); + } + match str_field(value, "format") { + Ok(fmt) if ADMITTED_FORMATS.contains(&fmt) => {} + Ok(fmt) => return clause_fail(id, format!("format={fmt} is not admitted")), + Err(e) => return clause_fail(id, e), + } + let same = match bool_field(value, "sameSelection") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let export_a = match value.get("exportA") { + Some(v) => v, + None => return clause_fail(id, "missing exportA"), + }; + let export_b = match value.get("exportB") { + Some(v) => v, + None => return clause_fail(id, "missing exportB"), + }; + let bin_a = match digest_hex(export_a, "binaryDigest") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let bin_b = match digest_hex(export_b, "binaryDigest") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let man_a = match digest_hex(value, "manifestDigestA") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + let man_b = match digest_hex(value, "manifestDigestB") { + Ok(v) => v, + Err(e) => return clause_fail(id, e), + }; + if same && (bin_a != bin_b || man_a != man_b) { + return clause_fail( + id, + "sameSelection requires identical binary and manifest digests", + ); + } + clause_ok(id) +} + +fn verify_host(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-HOST-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-HOST-001"); + } + match string_list(value, "spawnedProcesses") { + Ok(procs) if procs.is_empty() => {} + Ok(procs) => { + return clause_fail(id, format!("spawnedProcesses must be empty, got {procs:?}")) + } + Err(e) => return clause_fail(id, e), + } + match bool_field(value, "requiredNetworkForManifest") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "requiredNetworkForManifest must be false"), + Err(e) => return clause_fail(id, e), + } + match bool_field(value, "elevatedPrivileges") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "elevatedPrivileges must be false"), + Err(e) => return clause_fail(id, e), + } + match string_list(value, "rejectedOutOfProfileNames") { + Ok(names) if !names.is_empty() => clause_ok(id), + Ok(_) => clause_fail(id, "rejectedOutOfProfileNames must be non-empty"), + Err(e) => clause_fail(id, e), + } +} + +fn verify_boot(value: &Value) -> ClauseVerdict { + let id = "CLAUSE-BOOT-001"; + if str_field(value, "id").ok() != Some(id) { + return clause_fail(id, "id must be CLAUSE-BOOT-001"); + } + match str_field(value, "status") { + Ok("satisfied") => return clause_fail(id, "status satisfied is not admitted yet"), + Ok("pending" | "failed" | "not-applicable") => {} + Ok(other) => return clause_fail(id, format!("status={other} is invalid")), + Err(e) => return clause_fail(id, e), + } + match bool_field(value, "imageFormatAdmitted") { + Ok(false) => {} + Ok(true) => return clause_fail(id, "imageFormatAdmitted must be false"), + Err(e) => return clause_fail(id, e), + } + match str_field(value, "arch") { + Ok("x86_64") => {} + Ok(other) => return clause_fail(id, format!("arch={other} rejected; only x86_64")), + Err(e) => return clause_fail(id, e), + } + match str_field(value, "firmware") { + Ok("uefi") => clause_ok(id), + Ok(other) => clause_fail(id, format!("firmware={other} must be uefi")), + Err(e) => clause_fail(id, e), + } +} + +/// Verify one clause document against ADR-0011 semantics. +pub fn verify_clause(clause_id: &str, value: &Value) -> Result { + let verdict = match clause_id { + "CLAUSE-BASE-001" => verify_base(value), + "CLAUSE-MANIFEST-001" => verify_manifest(value), + "CLAUSE-OPTION-001" => verify_option(value), + "CLAUSE-RULE-001" => verify_rule(value), + "CLAUSE-BYTES-001" => verify_bytes(value), + "CLAUSE-HOST-001" => verify_host(value), + "CLAUSE-BOOT-001" => verify_boot(value), + other => { + return Err(format!("unknown clause id {other}")); + } + }; + Ok(verdict) +} + +fn validate_contract_shape(value: &Value) -> Result<(), Vec> { + let mut failures = Vec::new(); + if value + .get("documentVersion") + .and_then(|v| v.as_str()) + .is_none() + { + failures.push("missing documentVersion".into()); + } + if value + .get("schemaVersion") + .and_then(|v| v.as_str()) + .is_none() + { + failures.push("missing schemaVersion".into()); + } + if value.get("contractId").and_then(|v| v.as_str()).is_none() { + failures.push("missing contractId".into()); + } + if digest_hex(value, "manifestDigest").is_err() { + failures.push("missing manifestDigest.value".into()); + } + match value.get("base") { + Some(base) => { + if base.get("distro").and_then(|v| v.as_str()) != Some("arch-linux") { + failures.push("base.distro must be arch-linux".into()); + } + if base.get("arch").and_then(|v| v.as_str()) != Some("x86_64") { + failures.push("base.arch must be x86_64".into()); + } + if base.get("kind").and_then(|v| v.as_str()) != Some("base-iso") { + failures.push("base.kind must be base-iso".into()); + } + } + None => failures.push("missing base".into()), + } + match value.get("format").and_then(|v| v.as_str()) { + Some(fmt) if ADMITTED_FORMATS.contains(&fmt) => {} + Some(fmt) => failures.push(format!("format={fmt} is not admitted (DEC-001)")), + None => failures.push("missing format".into()), + } + if let Some(name) = value.get("publicName").and_then(|v| v.as_str()) { + if name.to_ascii_lowercase().starts_with("arch") { + failures.push("publicName must not start with ARCH".into()); + } + } + match value.get("clauses").and_then(|v| v.as_array()) { + Some(clauses) if clauses.len() == 7 => { + let ids: Vec<&str> = clauses + .iter() + .filter_map(|c| c.get("id").and_then(|v| v.as_str())) + .collect(); + for required in REQUIRED_CLAUSE_IDS { + if !ids.contains(required) { + failures.push(format!("missing clause {required}")); + } + } + for clause in clauses { + if let Some("CLAUSE-BOOT-001") = clause.get("id").and_then(|v| v.as_str()) { + if clause.get("status").and_then(|v| v.as_str()) == Some("satisfied") { + failures.push("CLAUSE-BOOT-001 cannot be satisfied yet".into()); + } + } + } + } + Some(clauses) => failures.push(format!( + "clauses must have exactly 7 entries, got {}", + clauses.len() + )), + None => failures.push("missing clauses".into()), + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } +} + +/// Verify a full application-contract document. +pub fn verify_application_contract(value: &Value) -> Result { + match validate_contract_shape(value) { + Ok(()) => Ok(ContractVerdict { + ok: true, + failures: Vec::new(), + }), + Err(failures) => Ok(ContractVerdict { + ok: false, + failures, + }), + } +} diff --git a/crates/archmaker-core/src/lib.rs b/crates/archmaker-core/src/lib.rs index e160cbd..cd9ac9f 100644 --- a/crates/archmaker-core/src/lib.rs +++ b/crates/archmaker-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod capability; +pub mod contract; pub mod core; pub mod error; pub mod inputs; @@ -11,6 +12,7 @@ pub use capability::{ allowed_ops, check_invoke, check_invoke_named, denied_error, unknown_command_error, Window, ALL_OPS, }; +pub use contract::{verify_application_contract, verify_clause, ClauseVerdict, ContractVerdict}; pub use archmaker_domain::{ is_document_version, is_integer_version, is_semver, Artifact, ArtifactRef, BinaryDigest, diff --git a/crates/archmaker-core/tests/application_contract.rs b/crates/archmaker-core/tests/application_contract.rs new file mode 100644 index 0000000..47a1ac9 --- /dev/null +++ b/crates/archmaker-core/tests/application_contract.rs @@ -0,0 +1,287 @@ +//! Strict ADR-0011 application-contract compliance tests. +//! +//! Expects `archmaker_core::contract` (verify_application_contract / verify_clause). +//! These tests stay red until that module exists and enforces clause semantics. + +use archmaker_core::{verify_application_contract, verify_clause, ClauseVerdict, ContractVerdict}; +use serde_json::Value; + +fn parse_fixture(raw: &str) -> Value { + serde_json::from_str(raw).expect("fixture deserializes as JSON") +} + +fn assert_clause_accepted(clause_id: &str, value: &Value) { + let verdict = verify_clause(clause_id, value).expect("verify_clause must succeed on valid"); + assert_eq!(verdict.clause_id, clause_id); + assert!( + verdict.ok, + "CLAUSE {clause_id} must accept valid fixture; reason={:?}", + verdict.reason + ); +} + +fn assert_clause_rejected(clause_id: &str, value: &Value) { + match verify_clause(clause_id, value) { + Ok(ClauseVerdict { + ok, + clause_id: id, + reason, + }) => { + assert_eq!(id, clause_id); + assert!( + !ok, + "CLAUSE {clause_id} must reject invalid fixture; reason={reason:?}" + ); + } + Err(err) => { + assert!( + !err.is_empty(), + "CLAUSE {clause_id} rejection Err must carry a message" + ); + } + } +} + +fn digest_value<'a>(obj: &'a Value, field: &str) -> &'a str { + obj[field]["value"] + .as_str() + .unwrap_or_else(|| panic!("{field}.value must be a string")) +} + +fn string_array<'a>(obj: &'a Value, field: &str) -> Vec<&'a str> { + obj[field] + .as_array() + .unwrap_or_else(|| panic!("{field} must be an array")) + .iter() + .map(|item| { + item.as_str() + .unwrap_or_else(|| panic!("{field} items must be strings")) + }) + .collect() +} + +const ALLOWED_OPERATOR_FORMS: &[&str] = &["gt-count", "required", "eq", "info-count"]; + +#[test] +fn application_contract_accepts_valid_and_rejects_invalid() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/24-application-contract.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/24-application-contract.invalid.json" + )); + + let ContractVerdict { ok, failures } = + verify_application_contract(&valid).expect("valid contract verifies"); + assert!( + ok, + "valid application contract must pass; failures={failures:?}" + ); + assert!(failures.is_empty(), "ok contract must report no failures"); + + match verify_application_contract(&invalid) { + Ok(ContractVerdict { ok, failures }) => { + assert!(!ok, "invalid application contract must not be ok"); + assert!( + !failures.is_empty(), + "invalid contract must list at least one failure" + ); + } + Err(err) => { + assert!(!err.is_empty(), "invalid contract Err must carry a message"); + } + } +} + +#[test] +fn clause_base_001_accepts_valid_and_rejects_invalid() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/25-clause-base.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/25-clause-base.invalid.json" + )); + + assert_eq!(valid["presentsAsOfficialArch"], false); + assert_eq!(valid["usesArchLogo"], false); + let valid_name = valid["publicName"].as_str().expect("publicName"); + assert!( + !valid_name.to_ascii_lowercase().starts_with("arch"), + "valid publicName must not start with ARCH" + ); + + assert_eq!(invalid["presentsAsOfficialArch"], true); + assert_eq!(invalid["usesArchLogo"], true); + let invalid_name = invalid["publicName"].as_str().expect("publicName"); + assert!( + invalid_name.to_ascii_lowercase().starts_with("arch"), + "invalid fixture must violate ARCH publicName rule" + ); + + assert_clause_accepted("CLAUSE-BASE-001", &valid); + assert_clause_rejected("CLAUSE-BASE-001", &invalid); +} + +#[test] +fn clause_manifest_001_digest_stable_across_formats() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/26-clause-manifest.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/26-clause-manifest.invalid.json" + )); + + let valid_manifest = digest_value(&valid, "manifestDigest"); + let valid_a = digest_value(&valid, "digestAfterFormatA"); + let valid_b = digest_value(&valid, "digestAfterFormatB"); + assert_eq!(valid_a, valid_manifest); + assert_eq!(valid_b, valid_manifest); + assert_eq!(valid_a, valid_b); + + let invalid_manifest = digest_value(&invalid, "manifestDigest"); + let invalid_a = digest_value(&invalid, "digestAfterFormatA"); + let invalid_b = digest_value(&invalid, "digestAfterFormatB"); + assert!( + invalid_a != invalid_b || invalid_a != invalid_manifest || invalid_b != invalid_manifest, + "invalid fixture must break digest equality across formats" + ); + + assert_clause_accepted("CLAUSE-MANIFEST-001", &valid); + assert_clause_rejected("CLAUSE-MANIFEST-001", &invalid); +} + +#[test] +fn clause_option_001_rejects_cmd_pacstrap_and_embedded_binaries() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/27-clause-option.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/27-clause-option.invalid.json" + )); + + assert!(string_array(&valid, "forbiddenTokens").is_empty()); + assert!(string_array(&valid, "embeddedBinaries").is_empty()); + + let forbidden = string_array(&invalid, "forbiddenTokens"); + let embedded = string_array(&invalid, "embeddedBinaries"); + assert!( + !forbidden.is_empty() || !embedded.is_empty(), + "invalid fixture must carry forbiddenTokens or embeddedBinaries" + ); + assert!( + forbidden + .iter() + .any(|token| *token == "cmd" || *token == "pacstrap") + || !embedded.is_empty(), + "invalid fixture must expose cmd/pacstrap tokens or embedded binaries" + ); + + assert_clause_accepted("CLAUSE-OPTION-001", &valid); + assert_clause_rejected("CLAUSE-OPTION-001", &invalid); +} + +#[test] +fn clause_rule_001_rejects_presets_and_silent_mutation() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/28-clause-rule.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/28-clause-rule.invalid.json" + )); + + assert!(string_array(&valid, "presetsApplied").is_empty()); + assert_eq!(valid["silentMutation"], false); + let valid_op = valid["evaluatedOperatorForm"] + .as_str() + .expect("evaluatedOperatorForm"); + assert!( + ALLOWED_OPERATOR_FORMS.contains(&valid_op), + "valid operator form {valid_op} must be one of {ALLOWED_OPERATOR_FORMS:?}" + ); + + assert!(!string_array(&invalid, "presetsApplied").is_empty()); + assert_eq!(invalid["silentMutation"], true); + let invalid_op = invalid["evaluatedOperatorForm"] + .as_str() + .expect("evaluatedOperatorForm"); + assert!( + !ALLOWED_OPERATOR_FORMS.contains(&invalid_op), + "invalid operator form {invalid_op} must be outside {ALLOWED_OPERATOR_FORMS:?}" + ); + + assert_clause_accepted("CLAUSE-RULE-001", &valid); + assert_clause_rejected("CLAUSE-RULE-001", &invalid); +} + +#[test] +fn clause_bytes_001_idempotent_export_same_selection() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/29-clause-bytes.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/29-clause-bytes.invalid.json" + )); + + assert_eq!(valid["sameSelection"], true); + assert_eq!( + digest_value(&valid["exportA"], "binaryDigest"), + digest_value(&valid["exportB"], "binaryDigest") + ); + assert_eq!( + digest_value(&valid, "manifestDigestA"), + digest_value(&valid, "manifestDigestB") + ); + + assert_eq!(invalid["sameSelection"], true); + assert_ne!( + digest_value(&invalid["exportA"], "binaryDigest"), + digest_value(&invalid["exportB"], "binaryDigest") + ); + + assert_clause_accepted("CLAUSE-BYTES-001", &valid); + assert_clause_rejected("CLAUSE-BYTES-001", &invalid); +} + +#[test] +fn clause_host_001_no_spawn_no_root_no_network_for_manifest() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/30-clause-host.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/30-clause-host.invalid.json" + )); + + assert!(string_array(&valid, "spawnedProcesses").is_empty()); + assert_eq!(valid["requiredNetworkForManifest"], false); + assert_eq!(valid["elevatedPrivileges"], false); + assert!(!string_array(&valid, "rejectedOutOfProfileNames").is_empty()); + + assert!(!string_array(&invalid, "spawnedProcesses").is_empty()); + assert_eq!(invalid["requiredNetworkForManifest"], true); + assert_eq!(invalid["elevatedPrivileges"], true); + assert!(string_array(&invalid, "rejectedOutOfProfileNames").is_empty()); + + assert_clause_accepted("CLAUSE-HOST-001", &valid); + assert_clause_rejected("CLAUSE-HOST-001", &invalid); +} + +#[test] +fn clause_boot_001_rejects_satisfied_until_image_admitted() { + let valid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/31-clause-boot.valid.json" + )); + let invalid = parse_fixture(include_str!( + "../../../contracts/json-schema/examples/31-clause-boot.invalid.json" + )); + + assert_ne!(valid["status"], "satisfied"); + assert_eq!(valid["imageFormatAdmitted"], false); + assert_eq!(valid["arch"], "x86_64"); + + assert_eq!(invalid["status"], "satisfied"); + assert_eq!(invalid["imageFormatAdmitted"], true); + assert_ne!(invalid["arch"], "x86_64"); + + assert_clause_accepted("CLAUSE-BOOT-001", &valid); + assert_clause_rejected("CLAUSE-BOOT-001", &invalid); +} diff --git a/docs/02-architecture/adr/ADR-0011-contrato-de-aplicacion.md b/docs/02-architecture/adr/ADR-0011-contrato-de-aplicacion.md new file mode 100644 index 0000000..a2bfcdf --- /dev/null +++ b/docs/02-architecture/adr/ADR-0011-contrato-de-aplicacion.md @@ -0,0 +1,94 @@ +--- +id: ADR-0011 +phase: planning +priority: P0 +documentStatus: in-review +approvalStatus: pending +implementationStatus: partial +verificationStatus: partial +releaseStatus: ineligible +owners: + - architecture +reviewers: + - independent-reviewer +dependsOn: + - id: ADR-0003 + expectedDocumentStatus: accepted + - id: ADR-0005 + expectedDocumentStatus: accepted +--- + +# ADR-0011: Contrato de aplicación desde el configurador + +- Estado: proposed +- Fecha: 2026-10-14 +- Propietario: Arquitectura +- Requisitos: FR-EXPORT-001, FR-VALIDATE-001, FR-MANIFEST-001, NFR-DET-001, NFR-OFF-001, NFR-MARK-001 + +## Contexto + +Una especificación de ISO elegida en el configurador debe materializarse como un resultado +comprobable: el programa cumple un contrato y lo verifica. El seguimiento de cada cláusula no +puede vivir solo en Markdown: hace falta un esquema JSON Schema (ADR-0005), un issue de GitHub +con formulario y tests estrictos. ADR-0003 mantiene el manifiesto canónico separado del target. +DEC-001 fija hoy `archmaker-profile`, `archinstall-profile` y `report`; el formato de imagen +sigue fuera hasta un alcance y un registro de decisiones que lo admitan. + +## Drivers + +- FR-VALIDATE-001 / FR-MANIFEST-001: no se materializa un artefacto con errores bloqueantes. +- FR-EXPORT-001 / NFR-DET-001: cada formato es una proyección del mismo manifiesto. +- NFR-OFF-001: cumplir y verificar no exigen red ni shell en el puesto de quien configura. +- NFR-MARK-001 / ADR-0010: el artefacto es un derivado de Arch Linux, no Arch Linux oficial. +- ADR-0005: la norma de cada cláusula es un schema con `additionalProperties: false`. + +## Opciones + +1. Documentar solo en Markdown los objetivos de aplicación y dejar el cumplimiento a la + revisión humana. +2. Generar un contrato JSON desde la selección resuelta, con una cláusula tipada por + obligación, verificada en Rust y seguida en issues de GitHub. +3. Ampliar DEC-001 con un target de imagen ahora y ligar el contrato a `mkarchiso`. + +## Decisión + +Se adopta la opción 2 como propuesta. La selección resuelta, la identidad de la base Arch +Linux x86_64 y el formato admitido producen un contrato. El programa verifica cada cláusula +exigible y rechaza el conjunto si una falla. Cada cláusula tiene: + +1. Un schema Draft 2020-12 bajo `contracts/json-schema/` (norma). +2. Un issue de GitHub con etiqueta `kind:clause` (seguimiento). +3. Tests estrictos de cumplimiento escritos por el agente de pruebas y evaluados por un + verificador Rust (autoridad semántica, ADR-0001). + +No se añade un target de imagen. Pedir la cláusula de arranque como cumplida antes de que el +formato de imagen esté admitido es un incumplimiento, no un target nuevo. ADR-0003 y ADR-0005 +siguen vigentes. OBJ-001..006 no se enmiendan. + +## Consecuencias + +- Aparecen `application-contract.schema.json` y siete schemas de cláusula con corpus + válido/inválido. +- El job `json-schema` meta-valida esos contratos. +- Los issues `CLAUSE-*` rastrean el estado `pending` / `red` / `verified`. +- El verificador Rust no llama a `pacstrap`, `mkarchiso`, root ni shell. + +## Riesgos + +- Ampliar DEC-001 en silencio: se mitiga rechazando formatos fuera del enum actual. +- Confundir el contrato con autorización de imagen: la cláusula de arranque permanece + incumplible hasta un veredicto y un alcance `accepted`. + +## Verificación + +- Corpus JSON Schema en verde para el contrato y las siete cláusulas. +- Suite Rust de cumplimiento: caso positivo y negativo por cláusula. +- Issues de GitHub abiertos con id, ruta de schema y nombre de test. + +## Sustituye + +— + +## Sustituido por + +— diff --git a/docs/02-architecture/adr/README.md b/docs/02-architecture/adr/README.md index 7a0fcc8..2b1d9e1 100644 --- a/docs/02-architecture/adr/README.md +++ b/docs/02-architecture/adr/README.md @@ -38,6 +38,7 @@ Propietario, Requisitos) y las secciones de `templates/adr.md`. Estados posibles | [ADR-0008](ADR-0008-catalogos-firmados-y-procedencia.md) | Catálogos firmados y procedencia | accepted | 2026-10-05 | | [ADR-0009](ADR-0009-actualizacion-firmada-mvp.md) | Actualización firmada en MVP | accepted | 2026-10-05 | | [ADR-0010](ADR-0010-marca-y-nombre-de-artefacto.md) | Marca y nombre de artefacto público | proposed | 2026-10-14 | +| [ADR-0011](ADR-0011-contrato-de-aplicacion.md) | Contrato de aplicación desde el configurador | proposed | 2026-10-14 | ## Crear un ADR diff --git a/docs/index.md b/docs/index.md index d966512..60276e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,6 +54,7 @@ reviewers: | `dependency-rules.md` | DOC-ARCH-DEP-001 | draft | | `adr/ADR-0001..ADR-0009` | ADR-0001..0009 | accepted | | `adr/ADR-0010-marca-y-nombre-de-artefacto.md` | ADR-0010 | in-review | +| `adr/ADR-0011-contrato-de-aplicacion.md` | ADR-0011 | in-review | ## 03 — Datos