From 9b9d823a675bd9dc68025dd76de90c023086f3c3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 14:54:07 -0500 Subject: [PATCH] feat(zddc): .zddc JSON Schema (machine grammar) with structure/option tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authoritative machine form of the GRAMMAR.md grammar: zddc.schema.json (draft 2020-12) describes every .zddc key with type, enum, description, and x-zddc-tier — "structure" (the project shape an end user shouldn't change: paths, worm, *_tool, views, available_tools, auto_own*, party_source, history*, records, acl, created_by) vs "option" (the blanks an operator fills: roles members, field_codes, convert, display, admins, title, planned dates). This is the contract a future .zddc form view uses to render option fields editable and structure fields read-only. Embedded (ZddcSchemaBytes) and served at GET /.api/zddc-schema for the client. Test locks the tier classification. Scope note: the schema uses $ref (recursive paths:) + patternProperties, which the in-tree internal/jsonschema validator doesn't support — so it drives the form/client now; wiring it as the SERVER validator (replacing validate.go's hand-rolled checks) needs a $ref-capable validator and is a separate decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/cmd/zddc-server/main.go | 7 + zddc/internal/handler/schemahandler.go | 28 ++++ zddc/internal/zddc/schema.go | 20 +++ zddc/internal/zddc/schema_test.go | 59 +++++++ zddc/internal/zddc/zddc.schema.json | 203 +++++++++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 zddc/internal/handler/schemahandler.go create mode 100644 zddc/internal/zddc/schema.go create mode 100644 zddc/internal/zddc/schema_test.go create mode 100644 zddc/internal/zddc/zddc.schema.json diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 3828c55..0e3d1ef 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -796,6 +796,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // The .zddc JSON Schema (machine grammar) — drives the .zddc form view + + // client validation. Static, read-only, no auth. + if urlPath == handler.ZddcSchemaPath { + handler.ServeZddcSchema(w, r) + return + } + // Auth check endpoints — machine-only forward_auth targets used by // upstream proxies (e.g. the dev-shell pod's Caddy in front of // code-server) to gate routes on root-admin status. Handled before diff --git a/zddc/internal/handler/schemahandler.go b/zddc/internal/handler/schemahandler.go new file mode 100644 index 0000000..f219e96 --- /dev/null +++ b/zddc/internal/handler/schemahandler.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// ZddcSchemaPath is the JSON endpoint serving the .zddc JSON Schema (the machine +// grammar). The browse client + the .zddc form view fetch it to drive editing +// (per-property x-zddc-tier marks structure vs option) and validation. +const ZddcSchemaPath = "/.api/zddc-schema" + +// ServeZddcSchema returns the embedded .zddc JSON Schema. Read-only, no auth — +// it documents the policy grammar and leaks nothing. GET/HEAD only. +func ServeZddcSchema(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/schema+json; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(zddc.ZddcSchemaBytes()) +} diff --git a/zddc/internal/zddc/schema.go b/zddc/internal/zddc/schema.go new file mode 100644 index 0000000..501c17b --- /dev/null +++ b/zddc/internal/zddc/schema.go @@ -0,0 +1,20 @@ +package zddc + +import _ "embed" + +// schemaBytes is the embedded .zddc JSON Schema (draft 2020-12). The +// authoritative machine grammar: it drives the form view (per-property +// x-zddc-tier: structure|option decides editable vs read-only) and client +// validation. Server-side structural validation still lives in validate.go — +// this schema uses $ref + patternProperties, which the in-tree +// internal/jsonschema validator does not yet support. +// +//go:embed zddc.schema.json +var schemaBytes []byte + +// ZddcSchemaBytes returns a copy of the embedded .zddc JSON Schema. +func ZddcSchemaBytes() []byte { + out := make([]byte, len(schemaBytes)) + copy(out, schemaBytes) + return out +} diff --git a/zddc/internal/zddc/schema_test.go b/zddc/internal/zddc/schema_test.go new file mode 100644 index 0000000..b076ac9 --- /dev/null +++ b/zddc/internal/zddc/schema_test.go @@ -0,0 +1,59 @@ +package zddc + +import ( + "encoding/json" + "testing" +) + +// The embedded .zddc schema must be valid JSON, cover the grammar's keys, and +// tag every property structure|option — the contract the form view relies on. +func TestZddcSchemaTiers(t *testing.T) { + var doc struct { + Properties map[string]struct { + Tier string `json:"x-zddc-tier"` + } `json:"properties"` + } + if err := json.Unmarshal(ZddcSchemaBytes(), &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + if len(doc.Properties) == 0 { + t.Fatal("schema has no properties") + } + + // Every property carries a valid tier. + for name, p := range doc.Properties { + if p.Tier != "structure" && p.Tier != "option" { + t.Errorf("property %q has x-zddc-tier=%q, want structure|option", name, p.Tier) + } + } + + // Spot-check the classification the form depends on. + wantTier := map[string]string{ + "roles": "option", // who's in a group — the headline customisation + "field_codes": "option", + "convert": "option", + "display": "option", + "admins": "option", + "title": "option", + "paths": "structure", // the project shape + "worm": "structure", + "party_source": "structure", + "default_tool": "structure", + "available_tools": "structure", + "auto_own": "structure", + "acl": "structure", + "records": "structure", + "views": "structure", + "created_by": "structure", + } + for name, want := range wantTier { + p, ok := doc.Properties[name] + if !ok { + t.Errorf("schema missing expected key %q", name) + continue + } + if p.Tier != want { + t.Errorf("key %q tier=%q, want %q", name, p.Tier, want) + } + } +} diff --git a/zddc/internal/zddc/zddc.schema.json b/zddc/internal/zddc/zddc.schema.json new file mode 100644 index 0000000..b14438f --- /dev/null +++ b/zddc/internal/zddc/zddc.schema.json @@ -0,0 +1,203 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zddc.varasys.io/schema/zddc.schema.json", + "title": ".zddc policy document", + "description": "Machine schema for the .zddc grammar (see GRAMMAR.md). Each property carries x-zddc-tier: 'structure' (the project shape an end user should not change — paths, WORM, tools, behaviors) or 'option' (the blanks an operator fills — role members, field-code vocabularies, names, labels). A form view renders option fields editable and structure fields read-only. NOTE: not all keys are valid at every level; the cascade + the per-location form decide relevance. Server-side validation still lives in validate.go (this draft-2020-12 schema uses $ref + patternProperties, which the in-tree validator does not yet support); the schema drives the form + client today.", + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "Human title for this directory.", + "x-zddc-tier": "option" + }, + "created_by": { + "type": "string", + "description": "Email of the user who created this folder. Set by the server; audit only.", + "x-zddc-tier": "structure" + }, + "admins": { + "type": "array", + "items": { "type": "string" }, + "description": "Principals (emails, globs, or role names) who administer this subtree. Root admins are super-admins; deeper entries are subtree admins. Elevation-gated full bypass over scope.", + "x-zddc-tier": "option" + }, + "roles": { + "type": "object", + "description": "Named principal groups referenced by acl/worm/admins. Membership UNIONS across the cascade. The operator fills the members.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "members": { + "type": "array", + "items": { "type": "string" }, + "description": "Email patterns (alice@x, *@acme.com, *) in this role." + }, + "reset": { + "type": "boolean", + "description": "Stop the membership union here: ancestor definitions above this level are excluded." + } + } + }, + "x-zddc-tier": "option" + }, + "acl": { + "type": "object", + "description": "Access control for this level. permissions maps a principal (email/glob/role) to a verb string from r w c d a (empty string = explicit deny). inherit:false clamps the ACL level-walk so ancestor levels' grants do not apply.", + "additionalProperties": false, + "properties": { + "inherit": { "type": "boolean", "description": "false = this level's ACL does not inherit ancestor levels." }, + "permissions": { + "type": "object", + "patternProperties": { + "^.+$": { "type": "string", "pattern": "^[rwcda]*$", "description": "Verb subset of r w c d a; empty = explicit deny." } + }, + "additionalProperties": false + } + }, + "x-zddc-tier": "structure" + }, + "worm": { + "type": "array", + "items": { "type": "string" }, + "description": "WORM zone: write/delete/admin stripped for all; create survives only for the listed principals; admins bypass. Unions across the cascade.", + "x-zddc-tier": "structure" + }, + "inherit": { + "type": "boolean", + "description": "false = stop the cascade here; everything below (ancestors + embedded defaults) is ignored. Makes a subtree a self-contained island.", + "x-zddc-tier": "structure" + }, + "default_tool": { + "type": "string", + "enum": ["archive", "transmittal", "classifier", "browse", "tables", "landing", "form"], + "description": "Tool served at (no trailing slash). Sugar for views.dir.tool.", + "x-zddc-tier": "structure" + }, + "dir_tool": { + "type": "string", + "enum": ["archive", "transmittal", "classifier", "browse", "tables", "landing", "form"], + "description": "Tool served at / (trailing slash). Sugar for views.dir_slash.tool; defaults to browse.", + "x-zddc-tier": "structure" + }, + "views": { + "type": "object", + "description": "Per-URL-shape tool + supporting-config mapping.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "tool": { "type": "string", "enum": ["archive", "transmittal", "classifier", "browse", "tables", "landing", "form"] }, + "config": { "type": "string", "description": "Supporting-file name resolved under .zddc.d/ (no slashes)." } + } + }, + "x-zddc-tier": "structure" + }, + "available_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Tools the apps subsystem may auto-serve here and below. Concat-dedupe union across the cascade.", + "x-zddc-tier": "structure" + }, + "auto_own": { + "type": "boolean", + "description": "mkdir here writes a creator-owned .zddc (creator: rwcda).", + "x-zddc-tier": "structure" + }, + "auto_own_fenced": { + "type": "boolean", + "description": "The auto-own .zddc is written with acl.inherit:false (private to its creator).", + "x-zddc-tier": "structure" + }, + "auto_own_roles": { + "type": "array", + "items": { "type": "string" }, + "description": "Roles also granted rwcda in the auto-own .zddc, alongside the creator.", + "x-zddc-tier": "structure" + }, + "virtual": { + "type": "boolean", + "description": "Never materialise on disk; treat requests as virtual routes.", + "x-zddc-tier": "structure" + }, + "drop_target": { + "type": "boolean", + "description": "This directory accepts drag-drop uploads (browse drop-zone). Leaf-only.", + "x-zddc-tier": "structure" + }, + "party_source": { + "type": "string", + "description": "A new / here requires registration in /.yaml (e.g. 'ssr'). Leaf-only.", + "x-zddc-tier": "structure" + }, + "history": { + "type": "boolean", + "description": "Snapshot text (markdown) edits to .history/ in this subtree with a server-stamped audit line.", + "x-zddc-tier": "structure" + }, + "history_globs": { + "type": "array", + "items": { "type": "string" }, + "description": "Which basenames get edit history (default [\"*.md\"]).", + "x-zddc-tier": "structure" + }, + "convert": { + "type": "object", + "description": "Template variables for MD→{docx,html,pdf} conversion. Cascades leaf→root, per-key latest wins.", + "additionalProperties": false, + "properties": { + "client": { "type": "string" }, + "project": { "type": "string" }, + "contractor": { "type": "string" }, + "project_number": { "type": "string" } + }, + "x-zddc-tier": "option" + }, + "field_codes": { + "type": "object", + "description": "Vocabularies for tracking-number / record field components. Map-merged per code across the cascade.", + "additionalProperties": { "type": "object" }, + "x-zddc-tier": "option" + }, + "records": { + "type": "object", + "description": "Per-record-type rules keyed by filename pattern (filename_format, field_defaults, locked, row_field, row_scope_fields, folder_fields).", + "additionalProperties": { "type": "object" }, + "x-zddc-tier": "structure" + }, + "display": { + "type": "object", + "description": "Human labels for child entries (on-disk name → label). Leaf-only.", + "additionalProperties": { "type": "string" }, + "x-zddc-tier": "option" + }, + "tables": { + "type": "object", + "description": "Legacy directory-of-YAML table views (stem → spec path).", + "additionalProperties": { "type": "string" }, + "x-zddc-tier": "structure" + }, + "received_path": { + "type": "string", + "description": "Links a workflow folder back to its canonical submittal in received/. Set by Plan Review.", + "x-zddc-tier": "structure" + }, + "planned_review_date": { + "type": "string", + "description": "Doc-controller's committed review-completion date (YYYY-MM-DD), on the canonical submittal.", + "x-zddc-tier": "option" + }, + "planned_response_date": { + "type": "string", + "description": "Doc-controller's committed response-issuance date (YYYY-MM-DD), on the canonical submittal.", + "x-zddc-tier": "option" + }, + "paths": { + "type": "object", + "description": "Virtual sub-directory rules. Each key is a single path segment (literal or '*'); the value is a nested .zddc applied at the matching child directory. Recursive.", + "additionalProperties": { "$ref": "#" }, + "x-zddc-tier": "structure" + } + } +}