diff --git a/.map/decisions/0001-human-and-ai-readable-contracts.md b/.map/decisions/0001-human-and-ai-readable-contracts.md index 7a1c3ac..5d2ceba 100644 --- a/.map/decisions/0001-human-and-ai-readable-contracts.md +++ b/.map/decisions/0001-human-and-ai-readable-contracts.md @@ -4,6 +4,8 @@ id: adr-0001-human-and-ai-readable-contracts title: Human- and AI-readable project contracts status: accepted date: 2026-09-13 +owners: [rajanbor] +tags: [contracts, schemas] priority: high targets: [agents, claude, gemini, cursor, copilot] --- diff --git a/library/docs/schemas/README.md b/library/docs/schemas/README.md index ff7fec8..0f0ce6e 100644 --- a/library/docs/schemas/README.md +++ b/library/docs/schemas/README.md @@ -7,6 +7,7 @@ published with a guide, examples, invalid fixtures, and an offline CI check. |---|---|---| | Typed `.map/` document frontmatter | [`document.schema.json`](../../schemas/document.schema.json) | [Document envelope](document.md) | | `.map/map.config.json` | [`project.schema.json`](../../schemas/project.schema.json) | [Project manifest](project.md) | +| Architecture decision record | [`decision.schema.json`](../../schemas/decision.schema.json) | [Decision records](decision.md) | Schemas use JSON Schema draft 2020-12. Stable fields are strict; experiments use an `x-` prefix. A schema change that alters accepted meaning requires compatibility and diff --git a/library/docs/schemas/decision.md b/library/docs/schemas/decision.md new file mode 100644 index 0000000..04ec8fb --- /dev/null +++ b/library/docs/schemas/decision.md @@ -0,0 +1,62 @@ +# Architecture decision records + +A MAP ADR records why a durable choice was made. Its frontmatter is structured for +indexing and lifecycle checks; its Markdown is the complete reviewable explanation. + +Machine contract: [`decision.schema.json`](../../schemas/decision.schema.json). + +## Frontmatter + +| Field | Required | Meaning | +|---|---:|---| +| `kind` | yes | Always `decision`. | +| `id` | yes | Stable ADR identifier, normally `adr-NNNN-short-title`. | +| `title` | yes | Human-readable decision title. | +| `status` | yes | `proposed`, `accepted`, `rejected`, or `superseded`. | +| `date` | yes | Decision date in `YYYY-MM-DD`. | +| `owners` | yes | People accountable for the decision. | +| `tags` | no | Searchable lowercase topics. | +| `supersedes` | no | Older ADR IDs replaced by this decision. | +| `supersededBy` | conditional | Required when status is `superseded`. | +| `scope`, `priority`, `targets`, `x-*` | no | Shared [document envelope](document.md) fields. | + +## Required Markdown sections + +`Context`, `Decision`, `Consequences`, and `Verification` are required for accepted +ADRs. Proposed ADRs add `Alternatives considered` before review. A section must say +what is unknown rather than remain empty. + +## Complete example + +```markdown +--- +kind: decision +id: adr-0002-local-validation +title: Validate contracts offline +status: accepted +date: 2026-09-13 +owners: [platform-team] +tags: [validation] +--- + +# ADR-0002: Validate contracts offline + +## Context +CI and local development need identical validation. + +## Decision +Contract validation MUST run without network access. + +## Consequences +Schemas and fixtures ship in the repository. + +## Verification +The schema CI job succeeds with networking unavailable. +``` + +## Lifecycle and compatibility + +Do not edit the meaning of an accepted ADR. Create a new ADR with `supersedes`, then +mark the old one `superseded` and set `supersededBy`. Validators reject a dangling +reference. Reverting implementation does not erase the record; add a new decision +that explains the rollback. diff --git a/library/schemas/decision.schema.json b/library/schemas/decision.schema.json new file mode 100644 index 0000000..ceca1c3 --- /dev/null +++ b/library/schemas/decision.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/rajanbor/map/main/library/schemas/decision.schema.json", + "title": "MAP architecture decision record", + "description": "Frontmatter contract for an ADR Markdown document.", + "type": "object", + "required": ["kind", "id", "title", "status", "date", "owners"], + "properties": { + "kind": { "const": "decision" }, + "id": { "$ref": "document.schema.json#/$defs/id" }, + "title": { "$ref": "document.schema.json#/$defs/title" }, + "status": { "enum": ["proposed", "accepted", "rejected", "superseded"] }, + "date": { "$ref": "document.schema.json#/$defs/date" }, + "owners": { + "type": "array", + "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$" }, + "minItems": 1, + "uniqueItems": true + }, + "tags": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "uniqueItems": true + }, + "supersedes": { + "type": "array", + "items": { "$ref": "document.schema.json#/$defs/id" }, + "minItems": 1, + "uniqueItems": true + }, + "supersededBy": { "$ref": "document.schema.json#/$defs/id" }, + "scope": { "$ref": "document.schema.json#/$defs/scope" }, + "priority": { "$ref": "document.schema.json#/$defs/priority" }, + "targets": { "$ref": "document.schema.json#/$defs/targets" } + }, + "patternProperties": { "^x-[a-z0-9][a-z0-9-]*$": true }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "status": { "const": "superseded" } } }, + "then": { "required": ["supersededBy"] } + } + ] +} diff --git a/library/schemas/document.schema.json b/library/schemas/document.schema.json index 194a4a5..e8bbd16 100644 --- a/library/schemas/document.schema.json +++ b/library/schemas/document.schema.json @@ -6,6 +6,20 @@ "type": "object", "required": ["kind", "id", "title", "status"], "properties": { + "kind": { "$ref": "#/$defs/kind" }, + "id": { "$ref": "#/$defs/id" }, + "title": { "$ref": "#/$defs/title" }, + "date": { "$ref": "#/$defs/date" }, + "status": { "$ref": "#/$defs/status" }, + "scope": { "$ref": "#/$defs/scope" }, + "priority": { "$ref": "#/$defs/priority" }, + "targets": { "$ref": "#/$defs/targets" } + }, + "patternProperties": { + "^x-[a-z0-9][a-z0-9-]*$": true + }, + "additionalProperties": false, + "$defs": { "kind": { "type": "string", "pattern": "^[a-z][a-z0-9]*(?:[/-][a-z0-9][a-z0-9-]*)*$" @@ -48,16 +62,9 @@ }, "targets": { "type": "array", - "items": { - "type": "string", - "pattern": "^[a-z][a-z0-9-]*$" - }, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "minItems": 1, "uniqueItems": true } - }, - "patternProperties": { - "^x-[a-z0-9][a-z0-9-]*$": true - }, - "additionalProperties": false + } } diff --git a/library/schemas/fixtures/decision/invalid/missing-owner.json b/library/schemas/fixtures/decision/invalid/missing-owner.json new file mode 100644 index 0000000..0bcb1d4 --- /dev/null +++ b/library/schemas/fixtures/decision/invalid/missing-owner.json @@ -0,0 +1,7 @@ +{ + "kind": "decision", + "id": "adr-0002-no-owner", + "title": "Ownerless decision", + "status": "proposed", + "date": "2026-09-13" +} diff --git a/library/schemas/fixtures/decision/invalid/superseded-without-replacement.json b/library/schemas/fixtures/decision/invalid/superseded-without-replacement.json new file mode 100644 index 0000000..cfb5ebb --- /dev/null +++ b/library/schemas/fixtures/decision/invalid/superseded-without-replacement.json @@ -0,0 +1,8 @@ +{ + "kind": "decision", + "id": "adr-0001-old-contract", + "title": "Old contract", + "status": "superseded", + "date": "2026-01-01", + "owners": ["platform-team"] +} diff --git a/library/schemas/fixtures/decision/invalid/wrong-kind.json b/library/schemas/fixtures/decision/invalid/wrong-kind.json new file mode 100644 index 0000000..8723247 --- /dev/null +++ b/library/schemas/fixtures/decision/invalid/wrong-kind.json @@ -0,0 +1,8 @@ +{ + "kind": "agent", + "id": "adr-0002-wrong-kind", + "title": "Wrong kind", + "status": "proposed", + "date": "2026-09-13", + "owners": ["platform-team"] +} diff --git a/library/schemas/fixtures/decision/valid/accepted.json b/library/schemas/fixtures/decision/valid/accepted.json new file mode 100644 index 0000000..2795178 --- /dev/null +++ b/library/schemas/fixtures/decision/valid/accepted.json @@ -0,0 +1,11 @@ +{ + "kind": "decision", + "id": "adr-0001-human-and-ai-readable-contracts", + "title": "Human- and AI-readable project contracts", + "status": "accepted", + "date": "2026-09-13", + "owners": ["rajanbor"], + "tags": ["contracts", "schemas"], + "priority": "high", + "targets": ["agents", "claude", "gemini", "cursor", "copilot"] +} diff --git a/library/schemas/fixtures/decision/valid/superseded.json b/library/schemas/fixtures/decision/valid/superseded.json new file mode 100644 index 0000000..c426f0d --- /dev/null +++ b/library/schemas/fixtures/decision/valid/superseded.json @@ -0,0 +1,9 @@ +{ + "kind": "decision", + "id": "adr-0001-old-contract", + "title": "Old contract", + "status": "superseded", + "date": "2026-01-01", + "owners": ["platform-team"], + "supersededBy": "adr-0002-new-contract" +} diff --git a/library/scripts/validate-schemas.ts b/library/scripts/validate-schemas.ts index 774b24c..b5fdee0 100644 --- a/library/scripts/validate-schemas.ts +++ b/library/scripts/validate-schemas.ts @@ -4,6 +4,7 @@ import { readFile, readdir } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseYaml } from "../../tooling/packages/cli/src/compiler/yaml-parse.ts"; type JsonSchema = boolean | Record; @@ -20,13 +21,21 @@ interface Contract { const CONTRACTS: readonly Contract[] = [ { name: "document", schema: "document.schema.json", fixtures: "document" }, { name: "project", schema: "project.schema.json", fixtures: "project" }, + { name: "decision", schema: "decision.schema.json", fixtures: "decision" }, ]; const failures: string[] = []; +const schemaRegistry = new Map(); + +for (const contract of CONTRACTS) { + const schema = await readJson(join(SCHEMA_ROOT, contract.schema)) as Record; + schemaRegistry.set(contract.schema, schema); + if (typeof schema.$id === "string") schemaRegistry.set(schema.$id, schema); +} for (const contract of CONTRACTS) { const schemaPath = join(SCHEMA_ROOT, contract.schema); - const schema = await readJson(schemaPath) as Record; + const schema = schemaRegistry.get(contract.schema) as Record; if (schema.$schema !== DRAFT) { failures.push(`${display(schemaPath)}: $schema must be ${DRAFT}`); continue; @@ -62,6 +71,8 @@ for (const [name, value] of [ if (errors.length > 0) failures.push(`${name}: ${withRemediation(errors[0]!)}`); } +await validateDecisionDocuments(); + if (failures.length > 0) { failures.forEach((failure) => process.stderr.write(`error: ${failure}\n`)); process.stderr.write(`schema validation failed with ${failures.length} error(s).\n`); @@ -88,6 +99,11 @@ function validate( if (Array.isArray(schema.allOf)) { schema.allOf.forEach((candidate) => errors.push(...validate(value, candidate as JsonSchema, root, path))); } + if (schema.if !== undefined) { + const conditionMatches = validate(value, schema.if as JsonSchema, root, path).length === 0; + const branch = conditionMatches ? schema.then : schema.else; + if (branch !== undefined) errors.push(...validate(value, branch as JsonSchema, root, path)); + } for (const keyword of ["anyOf", "oneOf"] as const) { const candidates = schema[keyword]; if (!Array.isArray(candidates)) continue; @@ -205,9 +221,12 @@ function validateObject( } function resolveReference(reference: string, root: Record): JsonSchema { - if (!reference.startsWith("#/")) throw new Error(`unsupported external schema reference: ${reference}`); - let current: unknown = root; - for (const encoded of reference.slice(2).split("/")) { + const [resource, fragment = ""] = reference.split("#", 2); + let current: unknown = resource === "" ? root : schemaRegistry.get(resource!); + if (current === undefined) throw new Error(`unresolved schema resource: ${resource}`); + if (fragment === "") return current as JsonSchema; + if (!fragment.startsWith("/")) throw new Error(`unsupported schema reference: ${reference}`); + for (const encoded of fragment.slice(1).split("/")) { const key = encoded.replace(/~1/g, "/").replace(/~0/g, "~"); if (!isRecord(current) || !(key in current)) throw new Error(`unresolved schema reference: ${reference}`); current = current[key]; @@ -218,6 +237,60 @@ function resolveReference(reference: string, root: Record): Jso return current; } +async function validateDecisionDocuments(): Promise { + const directory = join(WORKSPACE_ROOT, ".map/decisions"); + const paths = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^\d{4}-.+\.md$/.test(entry.name)) + .map((entry) => join(directory, entry.name)) + .sort(); + const schema = schemaRegistry.get("decision.schema.json") as Record; + const ids = new Set(); + const documents: Array<{ path: string; metadata: Record }> = []; + + for (const path of paths) { + const source = await readFile(path, "utf8"); + const match = /^---\n([\s\S]*?)\n---\n/.exec(source); + if (match === null) { + failures.push(`${display(path)}: typed decision requires YAML frontmatter`); + continue; + } + const parsed = parseYaml(match[1]!); + if (!isRecord(parsed)) { + failures.push(`${display(path)}: frontmatter must be a mapping`); + continue; + } + const errors = validate(parsed, schema, schema); + if (errors.length > 0) failures.push(`${display(path)}: ${withRemediation(errors[0]!)}`); + if (typeof parsed.id === "string") ids.add(parsed.id); + documents.push({ path, metadata: parsed }); + + if (parsed.status === "accepted") { + for (const heading of ["Context", "Decision", "Consequences", "Verification"]) { + if (!hasNonEmptySection(source, heading)) { + failures.push(`${display(path)}: accepted decision requires a non-empty '${heading}' section`); + } + } + } + } + + for (const document of documents) { + const references = [ + ...(Array.isArray(document.metadata.supersedes) ? document.metadata.supersedes : []), + ...(typeof document.metadata.supersededBy === "string" ? [document.metadata.supersededBy] : []), + ]; + references.forEach((reference) => { + if (typeof reference === "string" && !ids.has(reference)) { + failures.push(`${display(document.path)}: decision reference '${reference}' does not exist`); + } + }); + } +} + +function hasNonEmptySection(source: string, heading: string): boolean { + const match = new RegExp(`^## ${heading}\\s*$\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "mu").exec(source); + return match !== null && match[1]!.trim().length > 0; +} + function matchesType(value: unknown, type: string): boolean { if (type === "object") return isRecord(value); if (type === "array") return Array.isArray(value); diff --git a/tooling/packages/cli/templates/workspace/decisions/ADR_TEMPLATE.md b/tooling/packages/cli/templates/workspace/decisions/ADR_TEMPLATE.md index abd784c..449d2cf 100644 --- a/tooling/packages/cli/templates/workspace/decisions/ADR_TEMPLATE.md +++ b/tooling/packages/cli/templates/workspace/decisions/ADR_TEMPLATE.md @@ -4,6 +4,8 @@ id: adr-NNNN-short-title title: Short decision title status: proposed date: YYYY-MM-DD +owners: [github-handle-or-team] +tags: [architecture] priority: normal ---