From ac21accd7ae2d98434429ac8cf1c8558ee963ff0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 23:46:05 +0200 Subject: [PATCH] feat(deploy): ship the canon classification vocabulary with the release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API validates repo classification against the-custodian canon allowed values. A container has no such checkout, so every classification write failed with a 500 and classification could only ever be written from a workstation. Mounts the vocabulary as a ConfigMap and points REPO_CLASSIFICATION_ALLOWED_PATH at it. The copy is the risk, so it is owned rather than trusted: scripts/sync_classification_allowed.py regenerates it from canon and --check fails on drift. make check-classification-allowed and make railiance-state-hub-render both refuse to proceed when the copy diverges, so a release cannot silently validate against a stale vocabulary. The container volumeMounts and env blocks are merged rather than appended — a second pair would have produced duplicate YAML keys as soon as sweep was re-enabled. Refs CUST-WP-0067-T09 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- Makefile | 12 +- .../files/repo-classification.allowed.yaml | 124 ++++++++++++++++++ .../templates/classification-configmap.yaml | 12 ++ .../state-hub/templates/deployment.yaml | 24 +++- .../apps/charts/state-hub/values.yaml | 9 ++ scripts/sync_classification_allowed.py | 91 +++++++++++++ 6 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml create mode 100644 deploy/railiance/apps/charts/state-hub/templates/classification-configmap.yaml create mode 100755 scripts/sync_classification_allowed.py diff --git a/Makefile b/Makefile index f89092b..33ceacd 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,17 @@ dashboard: check: curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool -railiance-state-hub-render: +# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values +# because a container has no checkout to read it from. Regenerate after any +# canon vocabulary change; the -check target fails on drift so a release cannot +# validate classification against a stale vocabulary. +sync-classification-allowed: + python3 scripts/sync_classification_allowed.py + +check-classification-allowed: + python3 scripts/sync_classification_allowed.py --check + +railiance-state-hub-render: check-classification-allowed $(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \ --namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \ -f $(RAILIANCE_STATE_HUB_VALUES) \ diff --git a/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml b/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml new file mode 100644 index 0000000..a29137c --- /dev/null +++ b/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml @@ -0,0 +1,124 @@ +# GENERATED — do not edit. +# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml +# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09). +# Canon is authoritative; this copy exists only so the container has one. +# Machine-readable allowed-values for the Repo Classification Standard. +# +# Single source of truth for the standard's controlled vocabularies, derived +# from canon/standards/repo-classification-standard_v1.0.md. Consumed by: +# - the per-repo .repo-classification.yaml linter (tools/validate_repo_classification.py) +# - the State Hub registration validator (CUST-WP-0050 T04) +# +# When the standard's vocabularies change, update this file and bump `version` +# to match the standard version. CUST-WP-0050 T01. + +standard: "Repo Classification Standard" +version: "1.0" +canon_id: "canon-repo-classification" + +# category — exactly 1 required (§5) +categories: + - experimental + - research + - project + - tooling + - product + - business + +# domain / secondary_domains — primary exactly 1; secondaries 0..n (§6) +domains: + - infotech + - financials + - communication + - consumer + - health + - industrials + - energy + - utilities + - materials + - realestate + - crypto + - agents + - space + - government + +# business_stake — 0..n; 2..6 recommended (§8) +business_stake: + - execution + - intelligence + - finance + - legal + - sales + - experience + - technology + - operations + - product + - people + - procurement + - sustainability + - automation + +# business_mechanics — 0..n, optional (§9) +business_mechanics: + - intention + - control + - coordination + - operation + - adaptation + +# capability_tags are intentionally OPEN-ENDED (§7): lowercase kebab-case, not +# restricted to this set. The families below are the standard's recommended +# canonical tags — used to warn on likely synonyms/typos, never to reject. +capability_families: + identity_and_access: + - identity + - authentication + - authorization + - access-control + - user-management + - tenancy + knowledge_and_evidence: + - knowledge + - citations + - evidence + - source-management + - traceability + - documentation + - decision-support + platform_and_operations: + - platform + - deployment + - operations + - observability + - feature-control + - configuration + - orchestration + market_and_coordination: + - marketplace + - pricing + - monetization + - reputation + - challenges + - bounties + - collaboration + - coordination + product_and_lifecycle: + - product-development + - lifecycle + governance_and_control: + - governance + - policy + - compliance + - risk + - audit + - control + games_and_entertainment: + - game + - entertainment + +# Validation guidance (advisory bounds the linter applies as warnings) +guidance: + secondary_domains_max: 3 + business_stake_recommended_min: 2 + business_stake_recommended_max: 6 + capability_tag_pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" diff --git a/deploy/railiance/apps/charts/state-hub/templates/classification-configmap.yaml b/deploy/railiance/apps/charts/state-hub/templates/classification-configmap.yaml new file mode 100644 index 0000000..b471c69 --- /dev/null +++ b/deploy/railiance/apps/charts/state-hub/templates/classification-configmap.yaml @@ -0,0 +1,12 @@ +{{- if .Values.classificationAllowed.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.classificationAllowed.name | quote }} + labels: {{- include "statehub.labels" . | nindent 4 }} +data: + # Generated copy of the-custodian canon allowed-values. Kept in sync by + # scripts/sync_classification_allowed.py; `--check` fails the release on drift. + repo-classification.allowed.yaml: | +{{ .Files.Get "files/repo-classification.allowed.yaml" | indent 4 }} +{{- end }} diff --git a/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml b/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml index ade8c0a..a72abae 100644 --- a/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml +++ b/deploy/railiance/apps/charts/state-hub/templates/deployment.yaml @@ -22,8 +22,9 @@ spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} - {{- if .Values.sweep.enabled }} + {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }} volumes: + {{- if .Values.sweep.enabled }} - name: sweep-repos hostPath: path: {{ .Values.sweep.hostPath | quote }} @@ -32,6 +33,12 @@ spec: hostPath: path: {{ .Values.sweep.sshHostPath | quote }} type: DirectoryOrCreate + {{- end }} + {{- if .Values.classificationAllowed.enabled }} + - name: classification-allowed + configMap: + name: {{ .Values.classificationAllowed.name | quote }} + {{- end }} {{- end }} containers: - name: state-hub @@ -50,17 +57,32 @@ spec: - /bin/sh - -c - git config --global --add safe.directory '*' + {{- end }} + {{- if or .Values.sweep.enabled .Values.classificationAllowed.enabled }} volumeMounts: + {{- if .Values.sweep.enabled }} - name: sweep-repos mountPath: {{ .Values.sweep.hostPath | quote }} - name: sweep-ssh mountPath: /root/.ssh readOnly: true + {{- end }} + {{- if .Values.classificationAllowed.enabled }} + - name: classification-allowed + mountPath: {{ .Values.classificationAllowed.mountPath | quote }} + readOnly: true + {{- end }} env: + {{- if .Values.sweep.enabled }} - name: STATE_HUB_SWEEP_HOSTNAME value: {{ .Values.sweep.hostname | quote }} - name: GIT_SSH_COMMAND value: "ssh -o StrictHostKeyChecking=accept-new -F /root/.ssh/config" + {{- end }} + {{- if .Values.classificationAllowed.enabled }} + - name: REPO_CLASSIFICATION_ALLOWED_PATH + value: {{ printf "%s/repo-classification.allowed.yaml" .Values.classificationAllowed.mountPath | quote }} + {{- end }} {{- end }} envFrom: {{- if .Values.config.enabled }} diff --git a/deploy/railiance/apps/charts/state-hub/values.yaml b/deploy/railiance/apps/charts/state-hub/values.yaml index a409637..3f414e3 100644 --- a/deploy/railiance/apps/charts/state-hub/values.yaml +++ b/deploy/railiance/apps/charts/state-hub/values.yaml @@ -47,6 +47,15 @@ ingress: traefik.ingress.kubernetes.io/router.tls: "true" cert-manager.io/cluster-issuer: letsencrypt-prod +# Classification allowed-values (CUST-WP-0067-T09). The API validates repo +# classification against the-custodian canon; a container has no such checkout, +# so the file travels with the release as a ConfigMap. Without it every +# classification write fails with a 500. +classificationAllowed: + enabled: true + name: state-hub-classification-allowed + mountPath: /etc/state-hub/classification + # MCP layer (CUST-WP-0067-T08). Runs from the same image with a different # command, as a stateless HTTP client over the API service. ClusterIP only — # it proxies an unauthenticated API, so it must never gain an Ingress. diff --git a/scripts/sync_classification_allowed.py b/scripts/sync_classification_allowed.py new file mode 100755 index 0000000..322770f --- /dev/null +++ b/scripts/sync_classification_allowed.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Sync the canon classification allowed-values into the chart (CUST-WP-0067-T09). + +The State Hub validates repository classification against +``the-custodian/canon/standards/repo-classification.allowed.yaml``. That file is +canon and must stay authoritative, but a container has no the-custodian +checkout, so a copy has to travel with the release. + +A copy that nobody checks is a copy that silently drifts. This script owns the +copy: ``--check`` fails when it diverges from canon, so the release can refuse +to ship a stale vocabulary rather than validate against yesterday's rules. + +Usage: + python scripts/sync_classification_allowed.py # write the copy + python scripts/sync_classification_allowed.py --check # verify, exit 1 on drift +""" +from __future__ import annotations + +import argparse +import difflib +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART_COPY = ( + REPO_ROOT + / "deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml" +) +CANON_CANDIDATES = ( + Path("/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"), + Path("/home/tegwick/the-custodian/canon/standards/repo-classification.allowed.yaml"), + REPO_ROOT.parent / "the-custodian/canon/standards/repo-classification.allowed.yaml", +) + +HEADER = ( + "# GENERATED — do not edit.\n" + "# Synced from the-custodian/canon/standards/repo-classification.allowed.yaml\n" + "# by scripts/sync_classification_allowed.py (CUST-WP-0067-T09).\n" + "# Canon is authoritative; this copy exists only so the container has one.\n" +) + + +def find_canon() -> Path: + for candidate in CANON_CANDIDATES: + if candidate.is_file(): + return candidate + raise SystemExit( + "ERROR: canon allowed-values not found. Looked in:\n " + + "\n ".join(str(c) for c in CANON_CANDIDATES) + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Verify the chart copy matches canon; exit 1 on drift", + ) + args = parser.parse_args() + + canon = find_canon() + desired = HEADER + canon.read_text(encoding="utf-8") + + if args.check: + if not CHART_COPY.is_file(): + print(f"DRIFT: {CHART_COPY} is missing; run without --check to create it") + return 1 + current = CHART_COPY.read_text(encoding="utf-8") + if current != desired: + print(f"DRIFT: chart copy differs from canon ({canon})") + sys.stdout.writelines( + difflib.unified_diff( + current.splitlines(keepends=True), + desired.splitlines(keepends=True), + fromfile="chart copy", + tofile="canon", + ) + ) + return 1 + print(f"OK: chart copy matches canon ({canon})") + return 0 + + CHART_COPY.parent.mkdir(parents=True, exist_ok=True) + CHART_COPY.write_text(desired, encoding="utf-8") + print(f"Synced {canon} -> {CHART_COPY}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())