{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Public project status record",
  "description": "One status record for one public project for one update cycle. Records are appended to a hash-chained log and never edited; a correction is a new record.",
  "type": "object",
  "additionalProperties": false,
  "required": ["record_id", "schema_version", "project_id", "as_of", "kind", "summary", "sources", "approved_by", "next_expected_update", "prev_hash", "hash"],
  "properties": {
    "record_id": {"type": "string", "description": "<project_id>-<as_of>-<n>. Set by the system."},
    "schema_version": {"type": "string", "enum": ["1.0"]},
    "project_id": {"type": "string", "description": "Stable id from projects.json."},
    "as_of": {"type": "string", "format": "date", "description": "The day this record speaks for."},
    "kind": {"type": "string", "enum": ["update", "no_change", "no_update_received", "correction", "comment"], "description": "no_update_received is written by the system when nobody filed a record by the cut-off. comment is from a body that does not own the project (e.g. the local municipality); it never changes status or clears a flag."},
    "status": {"type": ["string", "null"], "enum": ["planned", "in_progress", "paused", "stopped", "complete", null], "description": "Null on no_update_received: the system never repeats an old status as current."},
    "public_impact": {"type": ["string", "null"], "maxLength": 200, "description": "What it means for the public today, e.g. lane closures or water cuts."},
    "percent_complete": {"type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "Only when a source states it."},
    "target_date": {"type": ["string", "null"], "format": "date"},
    "target_change_reason": {"type": ["string", "null"], "maxLength": 300, "description": "Required whenever target_date differs from the previous official record."},
    "next_milestone": {"type": ["string", "null"], "maxLength": 200},
    "last_verified_at": {"type": ["string", "null"], "format": "date", "description": "Date of the last real site check behind this record."},
    "next_expected_update": {"type": "string", "format": "date", "description": "Set by the system from the project's cadence."},
    "summary": {"type": "string", "maxLength": 280, "description": "Plain language, one or two sentences."},
    "sources": {"type": "array", "description": "Every claim traces to a document: [{title, url, date}].", "items": {"type": "object", "required": ["title", "url", "date"]}},
    "approved_by": {"type": "string", "maxLength": 120, "description": "Role and department, not a personal name. The internal audit trail keeps the person."},
    "corrects": {"type": ["string", "null"], "description": "record_id this record corrects (kind = correction only)."},
    "comments_on": {"type": ["string", "null"], "description": "record_id a comment responds to (kind = comment only)."},
    "last_record": {"type": ["string", "null"], "description": "On no_update_received: the last official record_id."},
    "prev_hash": {"type": "string", "description": "hash of the previous line in the log (64 zeros for the first)."},
    "hash": {"type": "string", "description": "sha256 of this record without the hash field, keys sorted."}
  }
}
