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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .map/decisions/0001-human-and-ai-readable-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
---
Expand Down
1 change: 1 addition & 0 deletions library/docs/schemas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions library/docs/schemas/decision.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions library/schemas/decision.schema.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
]
}
25 changes: 16 additions & 9 deletions library/schemas/document.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-]*)*$"
Expand Down Expand Up @@ -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
}
}
7 changes: 7 additions & 0 deletions library/schemas/fixtures/decision/invalid/missing-owner.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"kind": "decision",
"id": "adr-0002-no-owner",
"title": "Ownerless decision",
"status": "proposed",
"date": "2026-09-13"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"kind": "decision",
"id": "adr-0001-old-contract",
"title": "Old contract",
"status": "superseded",
"date": "2026-01-01",
"owners": ["platform-team"]
}
8 changes: 8 additions & 0 deletions library/schemas/fixtures/decision/invalid/wrong-kind.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"kind": "agent",
"id": "adr-0002-wrong-kind",
"title": "Wrong kind",
"status": "proposed",
"date": "2026-09-13",
"owners": ["platform-team"]
}
11 changes: 11 additions & 0 deletions library/schemas/fixtures/decision/valid/accepted.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions library/schemas/fixtures/decision/valid/superseded.json
Original file line number Diff line number Diff line change
@@ -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"
}
81 changes: 77 additions & 4 deletions library/scripts/validate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand All @@ -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<string, JsonSchema>();

for (const contract of CONTRACTS) {
const schema = await readJson(join(SCHEMA_ROOT, contract.schema)) as Record<string, unknown>;
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<string, unknown>;
const schema = schemaRegistry.get(contract.schema) as Record<string, unknown>;
if (schema.$schema !== DRAFT) {
failures.push(`${display(schemaPath)}: $schema must be ${DRAFT}`);
continue;
Expand Down Expand Up @@ -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`);
Expand All @@ -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;
Expand Down Expand Up @@ -205,9 +221,12 @@ function validateObject(
}

function resolveReference(reference: string, root: Record<string, unknown>): 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];
Expand All @@ -218,6 +237,60 @@ function resolveReference(reference: string, root: Record<string, unknown>): Jso
return current;
}

async function validateDecisionDocuments(): Promise<void> {
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<string, unknown>;
const ids = new Set<string>();
const documents: Array<{ path: string; metadata: Record<string, unknown> }> = [];

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Loading