diff --git a/.github/workflows/hydra-gates-schema-sync.yml b/.github/workflows/hydra-gates-schema-sync.yml new file mode 100644 index 00000000..cb37ac2a --- /dev/null +++ b/.github/workflows/hydra-gates-schema-sync.yml @@ -0,0 +1,203 @@ +# Vendored manifest schema sync: keep hydra-gates/scripts/schemas/ +# app-manifest-v2.schema.json level with the copy @conduction/nextcloud-vue +# publishes, and open a pull request the moment it is not. +# +# ══════════════════════════════════════════════════════════════════════════ +# WHY THIS EXISTS: A VENDORED COPY WITH NO UPDATE PATH DRIFTS BY DEFAULT +# ══════════════════════════════════════════════════════════════════════════ +# +# Gates 22 and 53 validate an app's manifest against the schema vendored HERE, +# on purpose: the fleet's pinned @conduction/nextcloud-vue generations span a +# wide range, and pinned-first meant "valid against whatever the app happened +# to install". check_manifest.js says so in its own header. +# +# What was never decided is how the vendored copy CATCHES UP. Nothing watched +# the registry, so the answer was "when somebody notices", and measured +# 2026-09-19 nobody had for four minor versions: +# +# vendored 2.33.0 +# @conduction/nextcloud-vue 3.4.0 ships 2.37.0 +# +# The cost is not abstract. dossiq adopted `savedViewPlaces`, a key the library +# published and validates, `npm run check:manifest` passed with zero errors, +# and gates 22 and 53 rejected the same file. Two instruments, opposite +# verdicts, and `quality / Hydra Gates` is a required check on `development`, +# so the app could not merge on a manifest that was correct. Filed as #785. +# +# An app cannot fix this. The schema is not in its repo. So the fix has to +# live where the copy lives, which is here. +# +# ══════════════════════════════════════════════════════════════════════════ +# WHY IT OPENS A PULL REQUEST AND DOES NOT PUSH TO main +# ══════════════════════════════════════════════════════════════════════════ +# +# Because CI resolves these gates at `@main`. A push here reaches all 21 swept +# apps the same minute, and a schema bump is only SAFE when it is additive. +# Four minors were additive; the fifth need not be. A tightened `required`, a +# narrowed enum or a new closed property would redden manifests that pass +# today, and the first anyone would learn of it is a fleet of red PRs. +# +# So this workflow reports what moved and hands the judgement to a reviewer, +# who can run the additive check the issue describes: diff the two schemas for +# any constraint that got STRICTER, and validate every swept app's effective +# manifest against both before merging. +# +# It never fails a run over drift. Drift is a fact about the registry, not a +# defect in the pull request that happened to trigger the check, and a red +# leg nobody caused is a red leg nobody reads. +# ══════════════════════════════════════════════════════════════════════════ + +name: Vendored manifest schema sync + +on: + schedule: + # Mondays 04:00 UTC, an hour BEFORE fleet-shared-dep-bump. An app that is + # about to be moved onto a newer nextcloud-vue should find the gate's + # schema already able to read what that release added. + # + # CRON IS UTC AND DOES NOT KNOW ABOUT SUMMER TIME. Nothing here needs a + # precise local hour. + - cron: "0 4 * * 1" + workflow_dispatch: + inputs: + dry_run: + description: "Report what would move, but open no pull request." + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: hydra-gates-schema-sync + cancel-in-progress: false + +jobs: + sync: + name: "Compare vendored schema against the published one" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Resolve both versions and diff + id: diff + env: + VENDORED: hydra-gates/scripts/schemas/app-manifest-v2.schema.json + run: | + set -euo pipefail + + if [ ! -f "${VENDORED}" ]; then + echo "::error::${VENDORED} is missing. There is nothing to keep in step, which is a bigger problem than drift." + exit 1 + fi + + LIB=$(npm view @conduction/nextcloud-vue version) + if [ -z "${LIB}" ]; then + echo "::error::Could not resolve @conduction/nextcloud-vue from the registry. Refusing to compare against a blank: every comparison below would read 'already current'." + exit 1 + fi + + npm pack "@conduction/nextcloud-vue@${LIB}" >/dev/null + tar xzf "conduction-nextcloud-vue-${LIB}.tgz" + PUBLISHED=package/src/schemas/app-manifest-v2.schema.json + if [ ! -f "${PUBLISHED}" ]; then + echo "::error::@conduction/nextcloud-vue ${LIB} does not ship src/schemas/app-manifest-v2.schema.json. The vendored copy has no source to follow any more, so this workflow is the thing that needs changing." + exit 1 + fi + + HAVE=$(node -p "require('./${VENDORED}').version || 'unset'") + WANT=$(node -p "require('./${PUBLISHED}').version || 'unset'") + echo "vendored=${HAVE} published=${WANT} (from @conduction/nextcloud-vue ${LIB})" + + { + echo "have=${HAVE}" + echo "want=${WANT}" + echo "lib=${LIB}" + } >> "$GITHUB_OUTPUT" + + if cmp -s "${VENDORED}" "${PUBLISHED}"; then + echo "drift=no" >> "$GITHUB_OUTPUT" + echo "The vendored schema is byte-identical to the published one at ${WANT}." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "drift=yes" >> "$GITHUB_OUTPUT" + cp "${VENDORED}" "${PUBLISHED}.was-vendored" + cp "${PUBLISHED}" "${VENDORED}" + + # Name every constraint that got STRICTER. An additive diff cannot + # redden a manifest that passes today; a tightened one can, and the + # reviewer needs to know which of the two this is before merging to + # a branch the whole fleet resolves at @main. The differ exits 1 when + # it finds one, which must NOT end this run: reporting the tightening + # is the whole point, and the pull request is where it gets read. + node hydra-gates/scripts/lib/diff_schema_strictness.js \ + "${PUBLISHED}.was-vendored" "${VENDORED}" > tightened.txt || true + cat tightened.txt + + - name: Open the pull request + if: steps.diff.outputs.drift == 'yes' && inputs.dry_run != true + env: + GH_TOKEN: ${{ github.token }} + HAVE: ${{ steps.diff.outputs.have }} + WANT: ${{ steps.diff.outputs.want }} + LIB: ${{ steps.diff.outputs.lib }} + run: | + set -euo pipefail + BRANCH="chore/vendored-manifest-schema-${WANT}" + + # `set -e` plus a grep that finds nothing would end the run here and + # report a green "nothing to do", so the count is read into a + # variable and compared, not piped into a test. + OPEN=$(gh pr list --head "${BRANCH}" --state open --json number --jq 'length') + if [ "${OPEN}" != "0" ]; then + echo "A pull request for ${BRANCH} is already open. Leaving it alone." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${BRANCH}" + git add hydra-gates/scripts/schemas/app-manifest-v2.schema.json + git commit -m "chore(hydra-gates): vendor manifest schema ${WANT}" \ + -- hydra-gates/scripts/schemas/app-manifest-v2.schema.json + git push -u origin "${BRANCH}" + + { + echo "The vendored manifest schema was at ${HAVE}. @conduction/nextcloud-vue ${LIB} ships ${WANT}." + echo + echo "Gates 22 and 53 validate every app's manifest against the vendored copy, so until this merges an app that adopts a key ${WANT} added is rejected by the gate while its own \`npm run check:manifest\` passes." + echo + echo "## Before merging" + echo + echo "CI resolves these gates at \`@main\`, so this reaches all 21 swept apps the minute it lands. Constraints that got stricter in this diff:" + echo + echo '```' + cat tightened.txt + echo '```' + echo + echo "\`none\` means the diff is additive and cannot redden a manifest that passes today. Anything else means it can, and the fleet's effective manifests need validating against both schemas first." + echo + echo "Opened by .github/workflows/hydra-gates-schema-sync.yml. See #785 for why the vendored copy needs a keeper." + } > pr-body.md + + gh pr create --base main --head "${BRANCH}" \ + --title "chore(hydra-gates): vendor manifest schema ${WANT}" \ + --body-file pr-body.md + + - name: Report + if: always() + env: + DRIFT: ${{ steps.diff.outputs.drift }} + HAVE: ${{ steps.diff.outputs.have }} + WANT: ${{ steps.diff.outputs.want }} + run: | + if [ "${DRIFT:-}" = "yes" ]; then + echo "::notice::Vendored manifest schema ${HAVE} is behind the published ${WANT}." + fi diff --git a/hydra-gates/README.md b/hydra-gates/README.md index 2b97b8ac..c62b89e4 100644 --- a/hydra-gates/README.md +++ b/hydra-gates/README.md @@ -32,6 +32,30 @@ vendored schemas (`scripts/schemas/`) and the distributable entry point (`bin/hydra-gates`). `ConductionNL/hydra` no longer carries a copy — it delegates here (see [Why it lives in `.github`](#why-it-lives-in-github)). +### The vendored manifest schema, and who keeps it current + +`scripts/schemas/app-manifest-v2.schema.json` is a **copy** of the schema +`@conduction/nextcloud-vue` publishes. Gates 22 and 53 judge every app's +manifest against this copy on purpose: the fleet's pinned library generations +differ, and pinned-first would mean "valid against whatever the app happened to +install". + +A copy with no keeper drifts. Measured 2026-09-19 it was four minor versions +behind (2.33.0 against a published 2.37.0), and the two instruments gave +opposite verdicts on the same file: dossiq's `npm run check:manifest` passed +with zero errors while gates 22 and 53 rejected `savedViewPlaces`, a key the +library had published. No app could fix that, because the schema is not in any +app's repo (#785). + +`.github/workflows/hydra-gates-schema-sync.yml` now watches the registry every +Monday and opens a pull request when the copy falls behind. It opens a pull +request rather than pushing, because CI resolves these gates at `@main`: a +bump reaches all 21 swept apps the minute it lands. The pull request carries +the output of `scripts/lib/diff_schema_strictness.js`, which names every +constraint that got **stricter**. `none` means the bump can only turn red into +green. Anything else means it can redden a manifest that passes today, and the +fleet's effective manifests need validating against both schemas first. + --- ## Adopting it in a repo diff --git a/hydra-gates/scripts/lib/diff_schema_strictness.js b/hydra-gates/scripts/lib/diff_schema_strictness.js new file mode 100755 index 00000000..28b85bf3 --- /dev/null +++ b/hydra-gates/scripts/lib/diff_schema_strictness.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// +// diff_schema_strictness.js — name every constraint that got STRICTER between +// two JSON Schema documents. +// +// WHY THIS EXISTS. The manifest schema under scripts/schemas/ is a VENDORED +// copy of the one @conduction/nextcloud-vue publishes, and gates 22 and 53 +// judge every app's manifest against it. CI resolves those gates at `@main`, +// so replacing the file reaches all 21 swept apps the minute it merges. +// +// That is safe when the newer schema only ADDS: a manifest valid under the old +// one stays valid, and the bump can only turn red into green. It is not safe +// when the newer schema tightens, because then a manifest that passes today +// starts failing and nothing in the app changed. The two cases look identical +// in a line diff of a three-thousand line schema, which is why this reads the +// structure instead. +// +// What counts as stricter: +// - a `required` list that gained an entry +// - an `enum` that lost a member +// - `additionalProperties` flipped from true to false +// - a new pattern / minLength / minItems / minimum / maximum / maxLength / +// maxItems / const where there was none +// - a declared property that DISAPPEARED from a `properties` block whose +// sibling `additionalProperties` is false. A removed property is not a +// relaxation there: the key it used to name becomes an unknown property +// and the object is refused. This is the case a line diff reads as +// "fewer rules" and it is the one that reddens a whole fleet. +// +// A key that is ABSENT from the old schema entirely is not reported: it cannot +// constrain a manifest the old schema already rejected as an unknown property. +// +// Usage: node diff_schema_strictness.js OLD.json NEW.json +// +// Exit codes: +// 0 — the diff is additive: nothing got stricter +// 1 — at least one constraint got stricter (each is printed, one per line) +// 2 — an argument is missing or is not parseable JSON + +'use strict' + +const fs = require('fs') + +const [oldPath, newPath] = process.argv.slice(2) +if (!oldPath || !newPath) { + console.error('usage: diff_schema_strictness.js OLD.json NEW.json') + process.exit(2) +} + +function load(p) { + try { + return JSON.parse(fs.readFileSync(p, 'utf8')) + } catch (e) { + console.error(`cannot read ${p}: ${e.message}`) + process.exit(2) + } +} + +const NEW_CONSTRAINT_KEYS = [ + 'pattern', 'minLength', 'maxLength', 'minItems', 'maxItems', + 'minimum', 'maximum', 'const', 'uniqueItems', +] + +const findings = [] + +function walk(a, b, path) { + if (a === null || b === null) return + if (typeof a !== 'object' || typeof b !== 'object') return + if (Array.isArray(a) !== Array.isArray(b)) return + + if (Array.isArray(a)) { + // Positional. A reordered schema keyword list would read as a change + // here; that is a false positive worth having over missing a real one. + for (let i = 0; i < Math.min(a.length, b.length); i++) { + walk(a[i], b[i], `${path}[${i}]`) + } + return + } + + // A `properties` block under `additionalProperties: false` refuses every + // key it does not name, so dropping an entry from it is a TIGHTENING even + // though the file got shorter. + if (b.additionalProperties === false && a.properties && b.properties + && typeof a.properties === 'object' && typeof b.properties === 'object') { + for (const gone of Object.keys(a.properties)) { + if (!Object.prototype.hasOwnProperty.call(b.properties, gone)) { + findings.push(`${path}/properties/${gone}: property removed while additionalProperties is false, so the key is now refused`) + } + } + } + + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { + const here = `${path}/${key}` + const inA = Object.prototype.hasOwnProperty.call(a, key) + const inB = Object.prototype.hasOwnProperty.call(b, key) + + if (key === 'required' && Array.isArray(b[key])) { + const before = Array.isArray(a[key]) ? a[key] : [] + const gained = b[key].filter((v) => !before.includes(v)) + if (gained.length) findings.push(`${here}: newly required ${JSON.stringify(gained)}`) + } + + if (key === 'enum' && Array.isArray(a[key]) && Array.isArray(b[key])) { + const after = b[key].map((v) => JSON.stringify(v)) + const lost = a[key].filter((v) => !after.includes(JSON.stringify(v))) + if (lost.length) findings.push(`${here}: enum no longer allows ${JSON.stringify(lost)}`) + } + + if (key === 'additionalProperties' && a[key] === true && b[key] === false) { + findings.push(`${here}: additionalProperties true -> false`) + } + + if (!inA && inB && NEW_CONSTRAINT_KEYS.includes(key)) { + findings.push(`${here}: new ${key} constraint ${JSON.stringify(b[key])}`) + } + + if (inA && inB) walk(a[key], b[key], here) + } +} + +walk(load(oldPath), load(newPath), '') + +if (findings.length === 0) { + console.log('none') + process.exit(0) +} + +for (const f of findings.sort()) console.log(f) +process.exit(1) diff --git a/hydra-gates/scripts/lib/test_diff_schema_strictness.js b/hydra-gates/scripts/lib/test_diff_schema_strictness.js new file mode 100644 index 00000000..ff6ce9af --- /dev/null +++ b/hydra-gates/scripts/lib/test_diff_schema_strictness.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: EUPL-1.2 +// +// test_diff_schema_strictness.js — assertions for the vendored-schema +// strictness differ. +// +// The property this suite has to protect is that the differ can say NO. A +// checker that answers "additive" to everything reads exactly like one that +// looked, and it would wave through the bump that reddens the fleet. So every +// additive case below is paired with a planted tightening of the same shape, +// and the planted one must be reported. + +'use strict' + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { execFileSync } = require('child_process') + +const HELPER = path.resolve(__dirname, 'diff_schema_strictness.js') +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'diff-strictness-')) + +let failures = 0 + +function run(oldDoc, newDoc) { + const a = path.join(TMP, 'old.json') + const b = path.join(TMP, 'new.json') + fs.writeFileSync(a, JSON.stringify(oldDoc)) + fs.writeFileSync(b, JSON.stringify(newDoc)) + try { + return { code: 0, out: execFileSync(process.execPath, [HELPER, a, b], { encoding: 'utf8' }) } + } catch (e) { + return { code: e.status, out: (e.stdout || '') + (e.stderr || '') } + } +} + +function check(name, fn) { + try { + fn() + console.log(`PASS — ${name}`) + } catch (e) { + failures++ + console.log(`FAIL — ${name}: ${e.message}`) + } +} + +// --- additive cases: the differ must stay quiet ----------------------------- + +check('a new optional property is additive', () => { + const r = run( + { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } }, + { type: 'object', additionalProperties: false, properties: { a: { type: 'string' }, b: { type: 'string' } } }, + ) + assert.strictEqual(r.code, 0, `exit ${r.code}`) + assert.match(r.out, /none/) +}) + +check('a widened enum is additive', () => { + const r = run({ enum: ['a'] }, { enum: ['a', 'b'] }) + assert.strictEqual(r.code, 0, `exit ${r.code}`) +}) + +check('a new $defs entry is additive', () => { + const r = run({ $defs: { x: { type: 'string' } } }, { $defs: { x: { type: 'string' }, y: { type: 'number' } } }) + assert.strictEqual(r.code, 0, `exit ${r.code}`) +}) + +// --- planted tightenings: each additive case has a twin that must be caught -- + +check('a newly required key is reported', () => { + const r = run({ required: ['a'] }, { required: ['a', 'b'] }) + assert.strictEqual(r.code, 1, `exit ${r.code}`) + assert.match(r.out, /newly required \["b"\]/) +}) + +check('a narrowed enum is reported', () => { + const r = run({ enum: ['a', 'b'] }, { enum: ['a'] }) + assert.strictEqual(r.code, 1, `exit ${r.code}`) + assert.match(r.out, /enum no longer allows \["b"\]/) +}) + +check('additionalProperties true -> false is reported', () => { + const r = run({ additionalProperties: true }, { additionalProperties: false }) + assert.strictEqual(r.code, 1, `exit ${r.code}`) + assert.match(r.out, /additionalProperties true -> false/) +}) + +check('a new pattern where there was none is reported', () => { + const r = run({ type: 'string' }, { type: 'string', pattern: '^x' }) + assert.strictEqual(r.code, 1, `exit ${r.code}`) + assert.match(r.out, /new pattern constraint/) +}) + +check('a property REMOVED under additionalProperties:false is reported', () => { + // The case a line diff reads as "fewer rules". The key stops being named, + // so the object refuses it, and every manifest that used it turns red. + const r = run( + { type: 'object', additionalProperties: false, properties: { a: {}, b: {} } }, + { type: 'object', additionalProperties: false, properties: { a: {} } }, + ) + assert.strictEqual(r.code, 1, `exit ${r.code}`) + assert.match(r.out, /properties\/b: property removed while additionalProperties is false/) +}) + +// --- the real pair this helper was written for ------------------------------ + +check('the shipped 2.33.0 -> 2.37.0 bump reads additive in BOTH directions of the check', () => { + // Forward: nothing got stricter, which is why #785 could be merged without + // a warning period. Reverse: the same two files DO produce findings, which + // is the control proving the forward "none" was a measurement and not a + // checker that cannot speak. + const vendored = path.resolve(__dirname, '..', 'schemas', 'app-manifest-v2.schema.json') + assert.ok(fs.existsSync(vendored), 'vendored schema is missing') + const doc = JSON.parse(fs.readFileSync(vendored, 'utf8')) + const stripped = JSON.parse(JSON.stringify(doc)) + delete stripped.$defs.savedViewPlaces + delete stripped.$defs.page.properties.savedViewPlaces + + const forward = run(stripped, doc) + assert.strictEqual(forward.code, 0, `adding savedViewPlaces back should be additive, got exit ${forward.code}: ${forward.out}`) + + const reverse = run(doc, stripped) + assert.strictEqual(reverse.code, 1, 'removing savedViewPlaces must be reported as a tightening') + assert.match(reverse.out, /savedViewPlaces/) +}) + +fs.rmSync(TMP, { recursive: true, force: true }) + +if (failures) { + console.log(`\n${failures} diff_schema_strictness assertion(s) FAILED`) + process.exit(1) +} +console.log('\nALL diff_schema_strictness assertions PASSED') diff --git a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json index 1536c66b..3edddbcb 100644 --- a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json +++ b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json @@ -3,7 +3,7 @@ "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", "title": "Conduction App Manifest v2", "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).", - "version": "2.33.0", + "version": "2.37.0", "type": "object", "required": ["$schema", "version"], "allOf": [ @@ -437,6 +437,114 @@ } }, "$defs": { + "rowIndicator": { + "type": "object", + "additionalProperties": false, + "required": [ + "field", + "text" + ], + "description": "One declared state indicator on an index page's rows. Renders as an icon with a text alternative and a tooltip, never as colour alone.", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable id for this indicator, used as its test id and its render key. Defaults to `field`." + }, + "field": { + "type": "string", + "minLength": 1, + "description": "Dotted path on the row the condition reads." + }, + "equals": { + "description": "The indicator applies when the field equals this value exactly." + }, + "in": { + "type": "array", + "description": "The indicator applies when the field's value is one of these." + }, + "icon": { + "type": "string", + "description": "CnIcon name (PascalCase). Defaults to InformationOutline." + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The text alternative, read out beside the icon. Required: an indicator without one does not render." + }, + "tooltip": { + "type": "string", + "description": "Hover text. Defaults to `text`." + } + } + }, + "folderSidebarScope": { + "type": "object", + "additionalProperties": true, + "description": "One folder in `config.folderSidebar.folders[]`, which doubles as a SCOPE: the three optional list-layout keys below let one index page show each folder the way that folder needs. Every other key (id, name, icon, ...) stays free-form, so an existing folder list validates unchanged. The page's `config.columns` still decides which columns this page HAS; a scope only decides which of them are shown, in what order, sorted by what and searched over what. A scope naming a column the page does not declare is refused by validateManifestV2, not silently rendered.", + "properties": { + "columns": { + "type": "array", + "description": "The columns this scope shows, in order. Same shapes as `config.columns`: a bare key string or a column object. Every key must be one the page's `config.columns` declares. Omit to show the page's own columns.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": true + } + ] + } + }, + "defaultSort": { + "description": "The sort the fetch carries while this scope is active. One `{ key, order }` entry or a list of them; `order` is 'asc' (default) or 'desc'. `field` is accepted in place of `key`, matching the CnIndexPage `defaultSort` prop.", + "oneOf": [ + { + "$ref": "#/$defs/folderSidebarScopeSort" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/folderSidebarScopeSort" + } + } + ] + }, + "searchFields": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "The fields the search box searches over while this scope is active. Omit to search whatever the page searches by default." + } + } + }, + "folderSidebarScopeSort": { + "type": "object", + "additionalProperties": false, + "description": "One sort entry for a `folderSidebar` scope. Name the column with `key` (manifest spelling) or `field` (the CnIndexPage prop spelling); an entry carrying neither is dropped rather than sent as an empty sort key.", + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "field": { + "type": "string", + "minLength": 1 + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + } + } + }, "supportButton": { "type": "object", "additionalProperties": false, @@ -740,6 +848,189 @@ } } }, + "savedViewPlaces": { + "type": "object", + "additionalProperties": false, + "description": "A saved view of this page is a place. buildManifestRoutes() emits //:viewId under the name __view, the view's own presentation config decides how it opens, and a pinned view hangs under this page's navigation entry rather than beside it. Index pages only, and only meaningful alongside allowSavedViews: without the dropdown there is no view to be a place. Absent, the page keeps the dropdown and the ?view= query it has today.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether this page's saved views are places. False reads as a page that considered it and declined, and renders exactly as a page that never named the key." + }, + "routeBase": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "default": "views", + "description": "The path segment between the page's own route and the view id, so 'views' gives /cases/views/42. Lowercase and dash only, because it is a segment of an address a person reads and sends." + }, + "navGroup": { + "type": "string", + "description": "The id of the navigation entry a pinned view hangs under. Omitted, it hangs under the entry that points at this page. A pinned view never becomes a top-level entry, so an app's navigation budget cannot be spent by a user pinning (ADR-097)." + }, + "pinnedCap": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 5, + "description": "How many pinned views the navigation renders under that entry. Past the cap the rest stay reachable from the page itself, so a person who pins twenty views gets a navigation that still reads (ADR-097)." + } + } + }, + "savedViewTree": { + "type": "object", + "additionalProperties": false, + "description": "Saved views render as a tree rather than a flat list, carry labels, and may ship with the app. Two hundred personal views in one dropdown is the problem this solves. Index pages only, and only meaningful alongside allowSavedViews: without the dropdown there is no view to put in a tree. Absent, the page renders the flat dropdown it renders today. The view entity itself, its parent, its labels and its slug are OpenRegister's (saved-search-views); this key declares how this page presents them.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Whether this page renders its saved views as a tree. False reads as a page that considered it and declined, and renders exactly as a page that never named the key." + }, + "maxDepth": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "default": 3, + "description": "How deep a parent chain may go. A bound rather than a taste: inheritance resolves by walking the chain, so an unbounded depth turns one render into an unbounded walk, and a person who can nest for ever will. Past the bound a save is refused naming the view, which is louder than a tree nobody can read." + }, + "templates": { + "type": "array", + "description": "What a new view may start from. A template presets the columns, the sort and the export field set, and the person may change each. Declare none and Save current view means what it has always meant: the list as it stands.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "name"], + "properties": { + "slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "name": { "type": "string" }, + "columns": { "type": "array", "items": { "type": "string" } }, + "defaultSort": { "type": "string" }, + "exportFields": { "type": "array", "items": { "type": "string" } } + } + } + }, + "landingView": { + "type": "object", + "additionalProperties": false, + "description": "Which view a role opens this page on, as { role: slug }. A personal choice always wins and Reset returns here, so this is where somebody starts rather than where they are kept. A role's landing view changing mid-session applies on the next arrival: moving a reader's list out from under them is worse than a stale default.", + "patternProperties": { + "^[A-Za-z0-9_.-]+$": { "type": "string" } + } + }, + "columnsPerRole": { + "type": "object", + "additionalProperties": false, + "description": "Which columns a role sees, as { role: [column] }. NARROWS index-columns-per-scope rather than replacing it: a role naming a column the scope does not offer gets nothing extra, because a per-role list that could add one would be a second, quieter way to put a field on screen the scope deliberately left off.", + "patternProperties": { + "^[A-Za-z0-9_.-]+$": { "type": "array", "items": { "type": "string" } } + } + }, + "groupBy": { + "type": "string", + "description": "A field the list groups on, with a count per group. The counts are of the rows the list HOLDS, so on a paged list they are of the page and the surface says so; a second fetch per group is the cost this grouping exists to avoid." + }, + "seeded": { + "type": "array", + "description": "Views that ship with the app. They render as their own group above the user's own, and a user may copy one but not delete it: a view the product promises is not a view one person can remove from everybody's install.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "name"], + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "description": "The stable name a dashboard widget, an export action or an API caller cites this view by. Lowercase and dash only, because it appears in an address and in a manifest somebody else writes. Required: a seeded view with no slug is a view nothing can call, which is most of the reason to seed it." + }, + "name": { + "type": "string", + "description": "What the view is called in the control. Translatable." + }, + "label": { + "type": "string", + "description": "One label this view carries, for the label filter. A view may carry none and stays reachable when no label filter is active." + }, + "parent": { + "type": "string", + "description": "The slug of the view this one hangs under. Omitted, it sits at the root of the seeded group." + }, + "actions": { + "type": "array", + "description": "The action ids this view offers, so a triage view offers three and not thirty. INTERSECTED with what the reader may run and never added to: a view declaring an action the reader has no right to does not grant it. Declare none and the view offers the page's actions, as today.", + "items": { "type": "string" }, + "uniqueItems": true + }, + "inherits": { + "type": "array", + "description": "Which parts this view takes from its parent. Each part resolves independently, so a child may narrow the criteria and keep the parent's columns. Declared on a view with no parent it is a contradiction and the validator refuses it: there is nothing to inherit from.", + "items": { + "type": "string", + "enum": ["criteria", "columns", "sorting", "defaultSort", "exportFields"] + }, + "uniqueItems": true + } + }, + "allOf": [ + { + "description": "inherits without a parent is a declaration that reads as configured and resolves to nothing. Refused here rather than silently ignored at render, which is the shape this whole change exists to stop.", + "if": { + "required": ["inherits"] + }, + "then": { + "required": ["parent"] + } + } + ] + } + } + } + }, + "boardView": { + "type": "object", + "additionalProperties": false, + "required": ["statusField"], + "description": "The board view of this list: one column per stage of a status field. Index pages only, and only offered when `viewModes` also lists `board`. The COLUMNS COME FROM THE SCHEMA, not from the values present in the loaded rows: a board built from the data would lose the stage nothing is in, which is the one you drag a card into, and would reorder itself as work moved. A move goes through the host's transition and never writes the status field, so a board can never move a case past a rule the case page enforces.", + "properties": { + "statusField": { + "type": "string", + "description": "The property the columns come from. Required: a board with no status field has nothing to be a board of. Its enum or lifecycle states are read from the register schema at RUNTIME, so a field with neither cannot be caught here; the view says so on screen instead of drawing empty columns." + }, + "cardFields": { + "type": "array", + "items": { "type": "string" }, + "description": "The fields a card shows. Keep it short: a card is scanned, not read." + }, + "swimlaneField": { + "type": "string", + "description": "A second field to group the board into rows by, while the columns stay the stages. Cards with no value for it land in one named row, last, rather than being dropped: grouping by handler and hiding the unassigned work turns a board into a picture of what is already somebody's problem." + } + } + }, + "dateAxisView": { + "type": "object", + "additionalProperties": false, + "required": ["startField", "endField"], + "description": "The date-axis view of this list: rows on a time scale, overlaps visible. Index pages only, and only offered when `viewModes` also lists `dateAxis`. IT HIDES NOTHING AND RESCHEDULES NOTHING: overlapping bars get their own track so the lane grows taller rather than losing a bar, rows with no usable dates go to a visible unplanned lane, and the only gesture is opening a row. A view that moved a bar on drag would be changing statutory dates from a picture.", + "properties": { + "startField": { + "type": "string", + "description": "Where a row's start is. Required: a bar needs both ends, and a row missing one is unplanned rather than drawn." + }, + "endField": { + "type": "string", + "description": "Where a row's end is. Required, for the same reason." + }, + "laneField": { + "type": "string", + "description": "What to put in a lane together, usually the person the work is on. Omitted, everything shares one lane." + }, + "labelField": { + "type": "string", + "description": "The field a bar is named by, on screen and in its accessible name. A bar positioned by percentage says nothing at all to a reader who cannot see it." + } + } + }, "personalisation": { "type": "object", "additionalProperties": false, @@ -1039,6 +1330,16 @@ "additionalProperties": false, "description": "A uniform widget placement entry used on every page type in v2. Replaces the v1 widgetDef + layoutItem pair with a single shape. Its props/dataSource/filter string leaves are guarded against the closed sentinel vocabulary via the sentinelGuardedValue allOf. The cross-field constraint gridX + gridWidth <= 12 CANNOT be expressed in JSON Schema (arithmetic over sibling fields) — it is documented here and enforced at runtime by validateManifestV2() as a post-schema check. Failure message: \"Widget '{widgetKey}' in slot '{slot}': gridX ({gridX}) + gridWidth ({gridWidth}) exceeds 12\".", "properties": { + "roles": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Group ids that may see this widget. A DECLARATION THE HOST APP ENFORCES, not something this library evaluates: the check belongs on the read, where the figures are, because a widget hidden in the browser still had its data in the page payload (ADR-004). Pair it with `visibleWhen` pointing at an endpoint of the app's own, which IS evaluated here and collapses the cell when the fetch or the shape fails, so the declaration and the layout cannot disagree. Omitting it means every reader of the page sees the widget. Added because the key was expressible in the legacy `config.widgets[]` array, whose items are open, and refused here, so the same declaration validated on one page and was rejected on another.", + "examples": [["controllers", "beheerders", "admin"]] + }, + "visibleWhen": { + "$ref": "#/$defs/visibleWhen", + "description": "Render this widget only while the predicate holds. The same shape every other surface takes it in. It was already honoured at runtime for widgets (CnDashboardPage reads `def.visibleWhen`) and could not be declared in this shape, so a widget in the uniform entry had no way to express a visibility condition at all while one in the legacy array did." + }, "requiredApp": { "type": "string", "minLength": 1, @@ -2317,6 +2618,22 @@ "$ref": "#/$defs/splitView", "description": "Index pages only. Opens a row beside the list rather than instead of it, at the sibling route /split/:id that buildManifestRoutes() emits. Refused on any other page type." }, + "savedViewPlaces": { + "$ref": "#/$defs/savedViewPlaces", + "description": "Index pages only. Makes each saved view of this page a place: its own route, the presentations its view config declares, and an entry under this page's navigation entry once a user pins it. Refused on any other page type." + }, + "board": { + "$ref": "#/$defs/boardView", + "description": "Index pages only. The board view's configuration, handed to CnIndexPage as the `board` prop. The segment appears only when `viewModes` also lists `board`. Refused on any other page type." + }, + "dateAxis": { + "$ref": "#/$defs/dateAxisView", + "description": "Index pages only. The date-axis view's configuration, handed to CnIndexPage as the `dateAxis` prop. The segment appears only when `viewModes` also lists `dateAxis`. Refused on any other page type." + }, + "savedViewTree": { + "$ref": "#/$defs/savedViewTree", + "description": "Index pages only. Renders this page's saved views as a tree with labels, and declares the views that ship with the app. Refused on any other page type, for the same reason savedViewPlaces is: a page with no saved-view dropdown has nothing to put in a tree." + }, "manualOrder": { "type": "boolean", "default": false, @@ -2563,6 +2880,20 @@ ] } }, + "folderSidebar": { + "type": "object", + "additionalProperties": true, + "description": "type='index': the opt-in folder navigation pane (CnFolderSidebar) rendered left of the list. Selecting a folder filters the list by `filterField` (or `field`); \"All\" clears it. Only the per-scope list-layout keys are typed here; the pane's own keys (source, register, schema, idField, nameField, folders, allLabel, title, allowCreate, ...) stay free-form. Maps to CnIndexPage `folderSidebar`.", + "properties": { + "folders": { + "type": "array", + "description": "Explicit folder list for source:'custom', and the place a folder declares its own list layout. Every other folder key stays free-form.", + "items": { + "$ref": "#/$defs/folderSidebarScope" + } + } + } + }, "layout": { "type": "array", "description": "Dashboard layout entries (for type='dashboard', legacy). Carry-forward from v1.", @@ -2609,6 +2940,61 @@ }, "description": "Declarative actions on a page. Governed ONLY by \"an object carrying a non-empty label\", deliberately: this key is polymorphic by page type (index rows dispatch $defs/action; detail pages also carry type='lifecycle-transition' entries), so one closed grammar here would reject working manifests. A label is what every dialect needs — CnRowActions renders an entry from its label and icon, so an entry without one became a full-height, clickable, EMPTY row in the overflow menu. Two shapes hit this, both meaning \"show the built-in\": a bare string (\"edit\") and a key-only object ({\"key\": \"edit\"}). Built-ins are enabled with config.actionToggles / the show*Action keys, never by naming them here." }, + "rowIndicators": { + "type": "array", + "description": "type='index': state indicators the page declares for its rows. Each entry names a `field` (a dotted path on the row), a condition (`equals`, `in`, or plain truthiness when neither is given), an `icon` (a CnIcon name) and a `text` (the text alternative, required). An entry without `text` does not render, because an icon with no text is colour and shape alone. The page declares which indicators exist; a record cannot add one the page has not declared. Maps to CnIndexPage `rowIndicators`.", + "items": { + "$ref": "#/$defs/rowIndicator" + } + }, + "rowIndicatorCap": { + "type": "integer", + "minimum": 0, + "default": 3, + "description": "type='index': how many declared indicators render on the row itself before the rest move into the row menu. Maps to CnIndexPage `rowIndicatorCap`." + }, + "quickEditFields": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "type='index': the fields a quick edit asks for, opened from a row without leaving the list. The page names them; a record cannot open a form on a field the page did not name. A field this caller may not write (see `writableField`) renders read-only rather than missing. Maps to CnIndexPage `quickEditFields`." + }, + "writableField": { + "type": "string", + "default": "@self.writableFields", + "description": "type='index': the dotted path where a record lists the fields this caller may write. A row carrying nothing there leaves every field the quick edit named editable; an empty list is a refusal and locks them all. Maps to CnIndexPage `writableField`." + }, + "viewTabs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "type='index': saved view ids this page renders as tabs instead of as entries in the views control. A view appears in one place or the other, never both. An id naming a view that no longer exists produces no tab, rather than a tab that opens nothing. Maps to CnIndexPage `viewTabs`." + }, + "priorityField": { + "type": "string", + "description": "type='index': the field carrying a record's DERIVED priority. The list reads the value and sorts on it; it never computes one. A record with no priority, or one outside `priorityLevels`, sorts after every ranked record in BOTH directions and stays in the list. Maps to CnIndexPage `priorityField`." + }, + "priorityLevels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "type='index': the priority values in rank order, lowest first (e.g. ['low', 'medium', 'high']). Needed because a priority does not sort alphabetically into the order a person means. Maps to CnIndexPage `priorityLevels`." + }, + "listShortcuts": { + "type": "boolean", + "default": false, + "description": "type='index': offer the list's keyboard shortcuts. Every shortcut is also listed in the command palette and on the help key, from one catalogue, because a shortcut nobody can find does not count. Maps to CnIndexPage `listShortcuts`." + }, + "rowActionField": { + "type": "string", + "description": "type='index': the dotted path on each row where the server lists the actions this caller may run on that record (default '@self.actions'). A row's menu is then the intersection of what `config.actions` declares and what the server allows: an action the server allows but the page does not declare stays out, and an action the page declares but the server refuses stays out with its reason kept. A row carrying nothing at this path leaves the declaration standing. Maps to CnIndexPage `rowActionField`." + }, "editOpensDetail": { "type": "boolean", "description": "type='index': send the Edit row action to the record's detail page instead of opening the edit modal. Set automatically by CnPageRenderer when a type='detail' page exists for the same register+schema (or config.rowRoute is set); declare it explicitly only to override that. Maps to CnIndexPage `editOpensDetail`." @@ -2715,7 +3101,7 @@ }, "allOf": [ { - "description": "splitView and manualOrder are index-page mechanics. splitView emits a second route beside an index route and mounts the detail pane inside CnIndexPage; manualOrder holds a per-user row order for a list. Declared on a detail, dashboard or custom page they would validate and then do nothing, which is the failure this branch refuses out loud (case-page-and-list-as-a-place).", + "description": "splitView, manualOrder, savedViewPlaces, savedViewTree, board and dateAxis are index-page mechanics. splitView emits a second route beside an index route and mounts the detail pane inside CnIndexPage; manualOrder holds a per-user row order for a list; savedViewPlaces turns this page's saved views into addressable places. Declared on a detail, dashboard or custom page they would validate and then do nothing, which is the failure this branch refuses out loud (case-page-and-list-as-a-place, saved-view-as-a-place).", "if": { "not": { "properties": { "type": { "const": "index" } }, @@ -2726,7 +3112,11 @@ "not": { "anyOf": [ { "required": ["splitView"] }, - { "required": ["manualOrder"] } + { "required": ["manualOrder"] }, + { "required": ["savedViewPlaces"] }, + { "required": ["savedViewTree"] }, + { "required": ["board"] }, + { "required": ["dateAxis"] } ] } }