feat(deploy): ship the canon classification vocabulary with the release
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-24 23:46:05 +02:00
parent 05dda8d276
commit ac21accd7a
6 changed files with 270 additions and 2 deletions

View file

@ -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) \

View file

@ -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]+)*$"

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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.

View file

@ -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())