diff --git a/.github/workflows/cleanliness.yml b/.github/workflows/cleanliness.yml new file mode 100644 index 0000000..9453894 --- /dev/null +++ b/.github/workflows/cleanliness.yml @@ -0,0 +1,37 @@ +# Scheduled repo-cleanliness run (the nine questions — brief: croadfeldt/dav +# docs/repo-cleanliness-review.md). PR gating is validate.yml; this is the monthly +# full-suite sweep + the semantic-review prompt, so cleanliness stays ACTIVE. +name: cleanliness +on: + schedule: + - cron: "17 7 1 * *" + workflow_dispatch: +permissions: + issues: write + contents: read +jobs: + cleanliness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + - run: pip install jsonschema pyyaml + - name: gates + id: gates + run: | + set +e; fail=0; : > /tmp/summary.md + for c in tests/validate_contracts.py tests/check_terminology.py tests/check_estate_tokens.py tests/check_links.py; do + out=$(python3 "$c" 2>&1); rc=$? + [ $rc -ne 0 ] && fail=1 + printf '### `%s` — %s\n```\n%s\n```\n' "$c" "$([ $rc -eq 0 ] && echo PASS || echo FAIL)" "$(echo "$out" | tail -8)" >> /tmp/summary.md + done + echo "fail=$fail" >> "$GITHUB_OUTPUT" + - name: open review issue + env: { GH_TOKEN: "${{ github.token }}" } + run: | + title="Cleanliness review $(date -u +%Y-%m)" + { echo "Monthly repo-cleanliness run — deterministic gates $([ '${{ steps.gates.outputs.fail }}' = '1' ] && echo '**FAILED — fix before other work**' || echo 'all green')."; + echo; cat /tmp/summary.md; echo; + echo "**Semantic sweep due (Q1–Q7 residues):** run the nine-question review brief — croadfeldt/dav \`docs/repo-cleanliness-review.md\` — with review agents over this repo (boundary/ADR-008 both directions, profile-ladder + vocab drift in prose, doc scoping); findings as file:line + severity into a cleanup plan."; } > /tmp/body.md + gh issue create --title "$title" --body-file /tmp/body.md diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml new file mode 100644 index 0000000..b044f88 --- /dev/null +++ b/.github/workflows/lint-openapi.yml @@ -0,0 +1,24 @@ +name: AEP OpenAPI lint + +# ADR-AEP-001: lint DCM's OpenAPI specs against the AEP conventions (aep.dev) +# using the AEP Spectral ruleset. ADVISORY for now (continue-on-error) — findings +# are reported while the baseline is burned down (see schemas/openapi/AEP-CONFORMANCE.md); +# flip `continue-on-error` to false once the error baseline reaches zero. + +on: + pull_request: + paths: [ "schemas/openapi/**", ".spectral.yaml", ".github/workflows/lint-openapi.yml" ] + workflow_dispatch: {} + +jobs: + aep-lint: + runs-on: ubuntu-latest + continue-on-error: true # advisory until the baseline is cleared + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: "20" } + - name: Install AEP linter + run: npm install --no-audit --no-fund @stoplight/spectral-cli @aep_dev/aep-openapi-linter + - name: Lint OpenAPI specs + run: npx spectral lint "schemas/openapi/*.yaml" --ruleset .spectral.yaml --format github-actions || true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2b75493 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,57 @@ +# Architecture validation gates for the DCM spec repo (docs + machine-readable contracts). +# Three independent gates so a failure names its own dimension: +# contracts — JSON Schema + OpenAPI parse/structure (tests/validate_contracts.py) +# terminology — locked naming decisions, 2026-06-30 (tests/check_terminology.py) +# estate-tokens — no homelab identifiers in the public spec (tests/check_estate_tokens.py) +name: validate +on: + pull_request: + push: + branches: [main] + +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install jsonschema pyyaml + # JSON Schemas valid + OpenAPI parse/structure (catches a broken contract merging). + - run: python3 tests/validate_contracts.py + # UC dimension vocabulary (DIM-001) — needs pyyaml, lives in this job + - run: python3 tests/check_uc_dimensions.py + # UC persona vocabulary (PER-001) — canonical persona set, single-sourced + - run: python3 tests/check_uc_personas.py + + terminology: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Locked terminology (policy-type merge, Realized not Fulfilled, etc.). Pure stdlib. + - run: python3 tests/check_terminology.py + + estate-tokens: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # No homelab identifiers in the public spec (hashed denylist). Pure stdlib. + - run: python3 tests/check_estate_tokens.py + + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Referential integrity (repo-cleanliness Q8 — croadfeldt/dav docs/repo-cleanliness-review.md): + # every relative markdown link resolves. Pure stdlib. + - run: python3 tests/check_links.py diff --git a/examples/dependency-resolution-walkthrough.md b/examples/dependency-resolution-walkthrough.md new file mode 100644 index 0000000..b2d98f6 --- /dev/null +++ b/examples/dependency-resolution-walkthrough.md @@ -0,0 +1,58 @@ +# Example: resolving the dependency-modeling estate + +A worked walkthrough of the resolution pass (`architecture/dependency-resolution.md`) over the +anonymized example estate (the UDLM repo's `examples/dependency-modeling/`). It shows how +dependencies authored several ways collapse into one effective graph and a derived order — and how +little the resolver actually has to *do*, because most inheritance is plain transitivity. + +## The authored estate (15 resources) + +- **Power (component chain).** `host-a` contains two power supplies: `psu-a1 → feed-a` and + `psu-a2 → feed-b` — two independent rails. `host-b` (at the bench) contains one: `psu-b1 → + feed-wall`. Power is authored on the PSU, not the host. +- **Bundling (a shared node).** `core-services` declares the shared platform dependencies once: + `depends_on idm` (DirectoryService) + `depends_on dns` (AddressService). `svc-app` and `svc-db` + each add a single `depends_on core-services`. +- **Scope.** `host-a` and `host-b` carry no identity edge — they share the realm via `tenant_uuid`. +- **App (direct edge).** `svc-app depends_on svc-db`. +- **Location.** `host-a` in `loc-rack`, `host-b` in `loc-bench`. + +Each resource authored only its *local* facts — a PSU names its feed, a service names the shared node +it bundles through and the DB it talks to. Nobody hand-wired "host-a depends on feed-a and feed-b" or +"svc-app depends on idm and dns." + +## Resolution (build-time, not stored) + +1. **Seed** with the authored edges. +2. **Scope derivation** — the one real derivation: `tenant_uuid` is a field, not an edge, so the + resolver injects `host-a`/`host-b` → the realm's `idm`/`dns` (cycle-safe; the realm's own upstreams + are excluded). +3. **Everything else is already there.** No bundle-expansion pass, no transitive-chain pass: + - `svc-app → core-services → {idm, dns}` — svc-app inherits idm+dns as **secondary dependencies** + by traversal alone. Depend on a node; get its deps. That is all "bundling" is. + - `host-a → psu-a1 → feed-a` and `host-a → psu-a2 → feed-b` — **host-a's power is the union of both + feeds**. A coarse "host-a on one UPS" edge would have dropped the second rail. + +Effective graph = authored + the scope edges, 0 cycles. + +## Derived shutdown order (topological) + +| step | resources | note | +|---|---|---| +| 0 | psu-a1, psu-a2, psu-b1, svc-app | leaf consumers stop first | +| 1 | feed-a, feed-b, feed-wall, host-a, svc-db | | +| 2 | core-services, host-b, loc-rack | | +| 3 | **dns, idm**, loc-bench | control-plane gate — identity/DNS hold until last | + +Reverse for startup. The payoff: `host-a` stops before **both** its feeds (redundancy honored), +`svc-app` before `core-services` before `idm`/`dns` (bundled secondary deps ordered correctly), and +identity/DNS last — with the resolver only having to derive the scope edges. + +## Reproduce + +``` +python3 shutdown_order.py /examples/dependency-modeling # from the estate-explorer tools +``` + +See `architecture/dependency-resolution.md` for the mechanics (including the anti-pattern note on why +there is no bundle type) and the UDLM repo's `docs/dependency-modeling.md` for the data-model side. diff --git a/examples/orchestration-scenarios.md b/examples/orchestration-scenarios.md new file mode 100644 index 0000000..b21f64c --- /dev/null +++ b/examples/orchestration-scenarios.md @@ -0,0 +1,418 @@ +--- +Document Status: 📋 Draft — Initial Specification +Document Type: Examples — DCM Orchestration Scenarios +Established: 2026-05-26 +Maps to: udlm/foundations/examples.md +--- + +# DCM Orchestration Scenarios + +> **Builds on the canonical examples in UDLM**: +> [udlm/foundations/examples.md](https://github.com/croadfeldt/udlm/blob/main/foundations/examples.md). +> UDLM's examples illustrate the four-states lifecycle at contract level +> (intent → requested → realized → discovered). This document illustrates +> DCM-specific orchestration features: full dependency group orchestration, +> timeout enforcement and cancellation propagation, provider-side internal +> lifecycle reconciliation, retry mechanics, scoring-driven placement, and +> recovery policy actions. + +--- + +## 1. Three-Tier Application — full dependency group orchestration + +A consumer deploys a three-tier application (database, backend, frontend) +using a single dependency-group submission. This scenario shows DCM's +end-to-end orchestration: dependency parsing, sequential dispatch, field +injection, and failure handling. + +### 1.1 Consumer submission + +```json +POST /api/v1/request-groups +{ + "group_handle": "pet-clinic-deploy", + "on_failure": "cancel_remaining", + "timeout": "PT2H", + "requests": [ + { + "ref": "db", + "catalog_item_uuid": "", + "fields": { "engine": "postgresql", "storage_gb": 50, "environment": "staging" } + }, + { + "ref": "backend", + "catalog_item_uuid": "", + "fields": { "app_name": "pet-clinic", "environment": "staging" }, + "depends_on": [ + { "ref": "db", + "wait_for": "realized", + "inject_fields": [ + { "from_field": "realized_fields.ip_address", "to_field": "fields.config.db_host" }, + { "from_field": "realized_fields.port", "to_field": "fields.config.db_port" }, + { "from_field": "realized_fields.credentials_ref", "to_field": "fields.config.db_credentials_ref" } + ] + } + ] + }, + { + "ref": "frontend", + "catalog_item_uuid": "", + "fields": { "app_name": "pet-clinic", "replicas": 2, "environment": "staging" }, + "depends_on": [ + { "ref": "backend", + "wait_for": "realized", + "inject_fields": [ + { "from_field": "realized_fields.ip_address", "to_field": "fields.config.api_host" }, + { "from_field": "realized_fields.port", "to_field": "fields.config.api_port" } + ] + } + ] + } + ] +} +``` + +### 1.2 DCM orchestration + +``` +Request Orchestrator parses the dependency graph (DAG validation passes) + ▼ All three requests get entity UUIDs immediately + │ db: ACKNOWLEDGED + │ backend: PENDING_DEPENDENCY (waiting on db: realized) + │ frontend: PENDING_DEPENDENCY (waiting on backend: realized) + ▼ Policy Engine evaluates the db request + │ Validation (compliance): staging environment authorized for tenant + │ Validation: storage_gb within tier limits + │ Transformation: monitoring agent injected + ▼ Placement Engine selects PostgreSQL provider for db + │ Score: 87; tie-breaker: cost analysis prefers eu-west-prod-2 + ▼ Dispatcher dispatches db with PT15M dcm_interaction credential + ▼ Provider realizes PostgreSQL instance + │ Callbacks: ip_address: 10.0.1.50, port: 5432, credentials_ref: vault:secret/pet-clinic-db + ▼ db.realized event published + ▼ Request Orchestrator unblocks backend + │ Injects: config.db_host = 10.0.1.50 + │ Injects: config.db_port = 5432 + │ Injects: config.db_credentials_ref = vault:secret/pet-clinic-db + ▼ backend enters standard assembly with injected fields + ▼ Placement selects KubeVirt provider for backend + ▼ Dispatcher dispatches backend; provider realizes + │ Callbacks: ip_address: 10.0.2.30, port: 8080 + ▼ backend.realized event published + ▼ Request Orchestrator unblocks frontend + │ Injects: config.api_host = 10.0.2.30 + │ Injects: config.api_port = 8080 + ▼ frontend dispatched; provider realizes 2 VM replicas + │ Callbacks: ip_addresses: [10.0.3.10, 10.0.3.11], port: 443 + ▼ All requests REALIZED + ▼ request.group_completed event (urgency: medium) + ▼ Consumer sees: pet-clinic group complete +``` + +### 1.3 What the consumer sees + +``` +pet-clinic-deploy (group) — completed +├── pet-clinic-db (Database.PostgreSQL) — OPERATIONAL +│ ip_address: 10.0.1.50, port: 5432 +├── pet-clinic-backend (Compute.VirtualMachine) — OPERATIONAL +│ ip_address: 10.0.2.30, config.db_host: 10.0.1.50 +└── pet-clinic-frontend (Compute.VirtualMachine × 2) — OPERATIONAL + ip_addresses: [10.0.3.10, 10.0.3.11], config.api_host: 10.0.2.30 +``` + +### 1.4 Failure variation: backend fails + +If backend dispatch fails (provider returns error): + +``` +backend status → FAILED + ▼ on_failure: cancel_remaining applies + ▼ frontend (PENDING_DEPENDENCY) → CANCELLED with failure_reason: dependency_failed + ▼ db (already REALIZED) is NOT auto-decommissioned (it's its own entity) + ▼ Group status → failed + ▼ Consumer notified: backend failed, frontend cancelled, db remains +``` + +The consumer may then decommission db manually if desired, or re-submit +backend separately to recover. + +--- + +## 2. VM Provisioning — timeout enforcement and cancellation propagation + +A standard VM request with dispatch_timeout enforcement. This scenario +illustrates DCM's timeout and recovery mechanics. + +### 2.1 Setup + +- Active profile: `prod` +- `dispatch_timeout`: PT30M +- Resource type: `Compute.VirtualMachine` +- Recovery profile: `recovery-notify-and-wait` + +### 2.2 Sequence + +``` +Consumer submits VM request + ▼ Standard nine-step assembly; placement selects eu-west-prod-1 + ▼ Dispatcher dispatches; entity → PROVISIONING; dispatch_timeout timer starts + ▼ ... PT35M elapse without provider callback ... + ▼ DISPATCH_TIMEOUT recovery trigger fires + ▼ Recovery Policy evaluation: + │ Profile recovery-notify-and-wait → NOTIFY_AND_WAIT with deadline: PT4H + ▼ Entity transitions to TIMEOUT_PENDING + ▼ Consumer notification fired with urgency: high + │ "Provider eu-west-prod-1 did not respond within PT30M. Decide: + │ DRIFT_RECONCILE, DISCARD_AND_REQUEUE, or DISCARD_NO_REQUEUE." + ▼ ... consumer reviews and selects DISCARD_AND_REQUEUE within deadline ... + ▼ DCM: + │ 1. Sends best-effort cancellation to provider + │ 2. Entity → FAILED (terminal for this cycle) + │ 3. New request cycle created from original Intent State + │ 4. Placement re-runs (eu-west-prod-1 may now be marked degraded) + │ 5. Selects eu-west-prod-2; dispatches + ▼ eu-west-prod-2 realizes; entity → OPERATIONAL + ▼ Orphan detection runs on eu-west-prod-1 to find any leaked resources + │ from the original dispatch + ▼ Orphan candidates surface to platform admin if found +``` + +The Recovery Policy gives operators control over how aggressive cleanup +should be. In `recovery-discard-and-requeue` profile, the same scenario +would auto-decide DISCARD_AND_REQUEUE without consumer interaction. + +--- + +## 3. IP Allocation — provider-side internal lifecycle reconciliation + +An IP allocation showing how DCM consumes provider-side internal lifecycle +states (without modeling them in DCM) and reconciles to DCM's lifecycle +vocabulary. + +### 3.1 Setup + +- Resource type: `Network.IPAddress` +- Provider: InfoBlox IPAM (declares both `realize_resources` and + `serve_data` capabilities) +- Tenant: AppTeam + +### 3.2 Sequence + +``` +Consumer submits IP allocation request for VM-A + ▼ Placement selects InfoBlox; reserve_query confirms capacity in + subnet 10.1.0.0/16 + ▼ Dispatcher dispatches: allocation_request for IPAddress in this subnet + ▼ InfoBlox internal lifecycle: + │ 1. reserved — internal hold during the allocation + │ 2. allocated — IP carved out + │ 3. committed — DCM dispatched with metadata + ▼ Provider callback: PUT /api/v1/instances/{ip-resource-id}/status + │ { lifecycle_state: REALIZED, fields: { address: 10.1.45.23, + │ prefix_length: 32, address_family: IPv4, allocated_from_pool_uuid: ... } } + ▼ DCM: + │ - Validates mTLS + interaction credential + │ - Verifies entity ownership binding (resource_id matches dispatch) + │ - Writes Realized State; sets is_current = true + │ - Emits entity.realized event + ▼ Entity → OPERATIONAL with full DCM lifecycle visibility + │ IP-entity ownership: AppTeam Tenant + │ AllocationRecord relationship: allocated_from IPAddressPool (owned by NetworkOps) +``` + +### 3.3 InfoBlox internal lifecycle mapping + +DCM does NOT model InfoBlox's `reserved → allocated → committed`. It +consumes the final state via the standard provider callback API. The +provider-internal phases are visible only through: + +- The InfoBlox provider's audit trail (provider-side) +- DCM lifecycle events emitted by the provider via the lifecycle event API + (optional) + +DCM's Realized State is updated only when the provider declares the +allocation final and pushes the callback. + +### 3.4 Reconciliation cycle + +Twelve hours later, the Discovery Service runs against InfoBlox: + +``` +Discovery query: list all allocated IPs in subnet 10.1.0.0/16 + ▼ Discovery returns InfoBlox's current truth + ▼ DCM compares to Realized State + ▼ 10.1.45.23 still allocated to VM-A — no drift + ▼ Discovery snapshot written; no drift event +``` + +If a discrepancy is found (e.g., InfoBlox reports the IP allocated to +different owner), DCM fires `drift.detected`. Recovery Policy evaluates; +default action: ESCALATE for IP-level drift in production profile. + +--- + +## 4. Scoring-driven placement — high-value request with mixed signals + +A consumer submits a request that scores into the `verified` tier; this +scenario shows scoring-driven approval routing and how multiple signals +compose. + +### 4.1 Setup + +- Active profile: `standard` +- Resource type: `Compute.VirtualMachine` +- Request: 64-vCPU VM, 256GB RAM, in production environment +- Scoring signals: + - Signal 1 (size): high (64-vCPU is above typical) + - Signal 2 (cost): medium (estimated $850/month) + - Signal 3 (compliance): low (no PHI/PCI fields) + - Signal 4 (consumer history): high (consumer has clean record) + - Signal 5 (provider accreditation richness): high + +### 4.2 Sequence + +``` +Consumer submits request + ▼ Layer assembly + Policy Engine evaluation + ▼ All compliance validation policies pass (boolean gates: compliance, sovereignty) + ▼ Scoring Model computes risk score + │ Signal weights × signal values = 67 + ▼ Profile standard threshold lookup: + │ - auto: max_score 24 + │ - reviewed: max_score 59 + │ - verified: max_score 79 ← request scores into this band + │ - authorized: max_score 100 + ▼ Required tier: verified + ▼ Approval record created with required_tier_weight: 3 + ▼ Two qualified reviewers notified via Notification Service + ▼ Reviewer A records APPROVE via Admin API + │ pipeline_status: pending_verified (1 of 2 approvals) + ▼ Reviewer B records APPROVE via ServiceNow (recorded_via: servicenow) + │ pipeline_status: activating (2 of 2 approvals) + ▼ Dispatcher resumes pipeline + ▼ Placement Engine selects highest-scoring provider + ▼ VM realized; entity → OPERATIONAL +``` + +The audit trail includes both reviewers' decisions with `recorded_via` +provenance ("dcm_admin_ui" for Reviewer A, "servicenow" for Reviewer B). + +--- + +## 5. Retry-driven recovery — transient provider failure + +A request dispatched during transient provider degradation; this scenario +shows the `recovery-aggressive-retry` posture in action. + +### 5.1 Setup + +- Active profile: `dev` with tenant override to `recovery-aggressive-retry` +- Resource type: `Compute.VirtualMachine` +- Provider: `compute-prod-1` (transiently overloaded) + +### 5.2 Sequence + +``` +Consumer submits VM request + ▼ Dispatcher dispatches; provider returns 503 (overloaded) + ▼ ASSEMBLY_TIMEOUT recovery trigger fires + │ (dispatch returned error before realization; treated as timeout + │ in aggressive-retry posture for re-attempt) + ▼ Recovery Policy: RETRY with exponential backoff + │ max_attempts: 5 + │ initial_interval: PT15S + │ max_interval: PT5M + ▼ Retry 1: wait PT15S; dispatch again; 503 + ▼ Retry 2: wait PT30S; dispatch again; 503 + ▼ Retry 3: wait PT1M; dispatch again; 200 acknowledged + ▼ Provider proceeds to realize; entity → OPERATIONAL after PT3M + ▼ All retries audited with attempt_number and elapsed_time +``` + +If all 5 retries exhausted, `on_exhaustion: NOTIFY_AND_WAIT` with `PT2H` +deadline; consumer reviews and decides. + +--- + +## 6. Brownfield ingestion — discovery to promotion + +An existing VM in OpenStack not provisioned through DCM is brought under +DCM lifecycle management. + +### 6.1 Setup + +- Existing VM in OpenStack with `vm_id: vm-legacy-0001` +- DCM Discovery Service runs scheduled OpenStack discovery +- Ingestion Engine subscribes to discovery events + +### 6.2 Sequence + +``` +Discovery Service runs scheduled OpenStack discovery + ▼ Discovery finds vm-legacy-0001 + ▼ Match against existing Realized State: no match → brownfield candidate + ▼ ingestion.candidate_identified event published + ▼ Ingestion Engine creates ingestion record: + │ ingestion_source: brownfield_discovery + │ discovered_state_uuid: + │ tenant: __transitional__ + │ state: INGESTED + ▼ Auto-assignment signal evaluation: + │ Signal 1 (explicit_tenant_tag): no tag found + │ Signal 2 (network_segment_mapping): VLAN-100 → maps to AppTeam Tenant + │ Assignment: AppTeam Tenant; confidence: medium + ▼ Tenant assigned; state → ENRICHING + ▼ Information Provider enrichment: + │ CMDB Information Provider queried; returns business unit "Payments", + │ cost center "PAY-4421", product owner "Jane Smith" + │ Confidence descriptor: authority_level: primary, corroboration: single_source + ▼ Operator reviews; promotes via POST /api/v1/admin/ingest/{uuid}:promote + ▼ state → PROMOTED + ▼ Discovered State promoted to initial Realized State + │ provenance.origin.source_type: brownfield_discovery + ▼ Entity → OPERATIONAL; standard drift detection begins +``` + +From this point, future OpenStack discoveries compare against the Realized +State; any deviation fires `drift.detected`. + +--- + +## 7. Cross-provider write-back + +A consumer updates a VM's owner_business_unit field; DCM updates the CMDB +via write-back. + +### 7.1 Setup + +- VM entity owned by AppTeam Tenant +- CMDB Information Provider with `write_back: true`, supported_operations: + [update], fields: [hostname, ip_address, owner_business_unit, lifecycle_state] +- Transformation policy: trigger CMDB write-back on `STATE_TRANSITION` or + field update for `Compute.VirtualMachine` + +### 7.2 Sequence + +``` +Consumer submits PATCH /api/v1/resources/{vm_uuid}/fields + { "owner_business_unit": "Payments-Platform" } + ▼ Standard nine-step assembly with new field value + ▼ Transformation policy fires: + │ trigger_write_back: provider: CMDB, operation: update, + │ fields: [owner_business_unit] + ▼ Placement: VM provider selected (state transition only; no new VM) + ▼ Dispatcher updates VM at the VM provider + ▼ Write-back executor runs: + │ - Resolves CMDB write-back endpoint + │ - Issues dcm_interaction credential scoped to CMDB + update operation + │ - Sends payload: { entity_uuid, fields: { owner_business_unit: "Payments-Platform" } } + │ - Records ENRICH audit record with source_type: information_provider_write_back + ▼ CMDB updates; returns success + ▼ Realized State updated with new field value + ▼ Consumer receives confirmation +``` + +If write-back fails, the policy's retry configuration drives behavior; +failure does not roll back the entity update — write-back is a side effect, +not a precondition. diff --git a/tests/check_estate_tokens.py b/tests/check_estate_tokens.py new file mode 100644 index 0000000..b5f8105 --- /dev/null +++ b/tests/check_estate_tokens.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Estate-token guard: no personal-infrastructure identifiers in the public specs. + +The specs are estate-neutral by policy — personal host/site names belong only in the private +estate-data repo. This gate scans every tracked text file, tokenizes on [a-z0-9]+ (lowercased), +and compares each token's sha256 against a denylist of HASHES (so the denylist itself leaks +nothing). A hit fails CI with the file/line; the plaintext token is NOT printed. + +Wired into .github/workflows/validate.yml. Purge performed + gate added 2026-07-05. +""" +import hashlib +import re +import subprocess +import sys + +DENY = { + "14d3337700f95b77f60b30dd2a0948d232bff5d10df716101e1f5c321a50784c", + "16f5107edd52050d007b73ec4e548be222959b20245f38012cbb4d8e2a542e74", + "17e44715bf16ba1c9ff7c482c279f11bdb5749159ee3ea1060e50b295bfa5c2f", + "1a5e497a2bfa7bfd8aab38a1d576ed882f4a82e855ec610880b4c186ec3f4e73", + "234d6d31ecb9d31204f97fa13cf7c5af2dd45a1bdb862311e3ac259e98e8f796", + "3b8c9f270579816f8675538796f438d7b25705ea9b0a77a78d81e0a30240827f", + "446af8cff106a0b2fbac22a09ea0123f5c46a40b0666fed937ce49a6a9fdb0b2", + "4f56851c1b69a1ee591be7f525910fb1bfaee89095c06ef1a90e9cfbe7ac20c9", + "6cadf2f0f34dc55acde751c0f5e4b7cae56694f304c41bbd77ae351421884008", + "7e7d8c699ee576ce17f16a12a4eae22b8b01a4e64ce15128c796e0a96c9cb704", + "81ca6e9019679c4cf5073ede5f8a28527c869565b5d4725466978d459ae82d65", + "9078e43e365a0d2849587c33e1623ccdbd92ad1ea81c5762414e9fbee6f20c03", + "9f566c001b95e0357886c0a7dd79cadbde47a87fc5ed8c5137c9c939b6d5bf3c", + "b1bf957a6f16444d406d7ff4e261dec297850777e865298822a19a7c9a78d4a3", + "bb44bf07cf9a2db0554bba63a03d822c927deae77df101874496df5a6a3e896d", + "d5a747cf10fc537e7b8ca64306fab4203f3c652b00ffab7cdb610e7ee6d5e63c", + "e2284dc3b5535645288cde2bad818404be728fb8c9f70b055c0b52023b0ff0a0", +} + +def main() -> int: + files = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.splitlines() + hits = 0 + for f in files: + try: + text = open(f, encoding="utf-8", errors="ignore").read() + except (IsADirectoryError, FileNotFoundError): + continue + for i, line in enumerate(text.splitlines(), 1): + for tok in re.findall(r"[a-z0-9]+", line.lower()): + if hashlib.sha256(tok.encode()).hexdigest() in DENY: + print(f"FAIL [PII-001] {f}:{i}: contains a denylisted estate token (redacted)") + hits += 1 + print(f"\n{len(files)} files scanned, {hits} estate-token hit(s)") + return 1 if hits else 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_links.py b/tests/check_links.py new file mode 100644 index 0000000..b570f7a --- /dev/null +++ b/tests/check_links.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Referential integrity gate (repo-cleanliness Q8): every relative markdown link resolves. + +Scans all tracked *.md files; checks `](path)` targets (fragments allowed, http/mailto skipped) +resolve to an existing file or directory. Exit 1 on any broken link. +""" +import os, re, subprocess, sys + +def main() -> int: + files = subprocess.run(["git", "ls-files", "*.md"], capture_output=True, text=True).stdout.split() + link = re.compile(r"\]\(([^)#\s]+)(#[^)\s]*)?\)") + broken = 0 + for f in files: + base = os.path.dirname(f) + try: + text = open(f, encoding="utf-8").read() + except OSError: + continue + for m in link.finditer(text): + p = m.group(1) + if p.startswith(("http://", "https://", "mailto:")): + continue + target = os.path.normpath(os.path.join(base, p)) + if not os.path.exists(target): + print(f"BROKEN {f}: {p}") + broken += 1 + print(f"{len(files)} files scanned, {broken} broken link(s)") + return 1 if broken else 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_terminology.py b/tests/check_terminology.py new file mode 100644 index 0000000..4206957 --- /dev/null +++ b/tests/check_terminology.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Terminology guard: enforce the locked terminology decisions (AGENTS.md, 2026-06-30). + +The DCM architecture converged on a small set of naming decisions. This gate keeps the +normative spec from drifting back to the retired terms. It scans tracked text files and +fails CI on a forbidden term, UNLESS the hit is (a) in a decision/history document that +legitimately discusses the retired term, or (b) on a line that explicitly documents the +change (carries a history marker like "formerly", "merged into", "superseded"). + +Decisions enforced (see AGENTS.md "Terminology decisions (locked 2026-06-30)"): + - "Gating Policy" is NOT a standalone type — it merged into Validation Policy + (enforcement_class: compliance is the hard gate). + - "gatekeeper policy" is not used (OPA Gatekeeper collision). + - "resource provider" is not used (proposed rename was reversed). + - "fulfilled" is not the lifecycle state — the state is "Realized". + - "likeC4" is not a DCM-native concept (customer-specific format). + +Wired into .github/workflows/validate.yml and .gitlab-ci.yml. +""" +import re +import subprocess +import sys + +# (label, compiled pattern). Patterns are matched case-insensitively per line. +RULES = [ + ("gating policy (merged into Validation Policy)", re.compile(r"gating\s+polic(?:y|ies)", re.I)), + ("gatekeeper policy (OPA Gatekeeper collision)", re.compile(r"gatekeeper\s+polic(?:y|ies)", re.I)), + ("resource provider (rename was reversed)", re.compile(r"resource\s+provider", re.I)), + # "fulfilled" only as the lifecycle STATE — NOT the English verb ("a role is fulfilled by") + # and NOT the act noun "fulfillment" (the verb "fulfill a request" is blessed by the anti-vocabulary). + ("fulfilled (lifecycle state → use 'Realized')", + re.compile(r"fulfilled\s+service|been\s+fulfilled|fulfilled\s+at\s+the\s+request", re.I)), + ("likeC4 (customer-specific format, not DCM-native)", re.compile(r"likec4", re.I)), + # Retired edge vocabulary (UDLM data-model-core §4): the authoritative fields are + # `edge_type` + `strength: hard|soft` + declared `relation`; nature is derived. NOT + # `relationship_type`/`dependency_type`. ('stake_strength' — ownership stake — is a + # separate, current concept and is NOT retired.) + ("relationship_type (retired edge field → 'relation')", re.compile(r"relationship_type", re.I)), + ("dependency_type (retired → 'strength' or 'required_resource_type')", re.compile(r"dependency_type", re.I)), +] + +# Whole-file exemptions: documents whose PURPOSE is to record decisions / proposals / +# open questions, and therefore legitimately name the retired terms as history. +EXEMPT_FILES = { + "AGENTS.md", # the terminology decisions themselves + "CLAUDE.md", # mirror of AGENTS.md + "architecture/DISCUSSION-TOPICS.md", # open-questions / decision log (historical) + "docs/archive/service-taxonomy-reconciliation.md", # archived proposal record (Service/Resource rename; landed) + "tests/check_terminology.py", # this gate names the forbidden terms +} + +# Per-line exemption: a hit is allowed if the line explicitly documents the change. +HISTORY_MARKER = re.compile( + r"formerly|previously|no longer|merged into|renamed|renames|reversed|superseded|" + r"deprecat|was called|do not use|proposed but|2026-06-30|process provider|convert", + re.I, +) + +TEXT_SUFFIXES = (".md", ".json", ".yaml", ".yml") + + +def main() -> int: + files = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.splitlines() + hits = 0 + for f in files: + if f in EXEMPT_FILES or not f.endswith(TEXT_SUFFIXES): + continue + try: + text = open(f, encoding="utf-8", errors="ignore").read() + except (IsADirectoryError, FileNotFoundError): + continue + for i, line in enumerate(text.splitlines(), 1): + if HISTORY_MARKER.search(line): + continue + for label, pat in RULES: + if pat.search(line): + print(f"FAIL [TERM-001] {f}:{i}: uses retired term — {label}") + hits += 1 + print(f"\n{len(files)} files scanned, {hits} terminology violation(s)") + return 1 if hits else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_uc_dimensions.py b/tests/check_uc_dimensions.py new file mode 100644 index 0000000..64313e6 --- /dev/null +++ b/tests/check_uc_dimensions.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""UC dimension-vocabulary gate (mirror of udlm's DIM-001). Every use case's +scenario.dimensions.* value must be in dav/use-cases/DIMENSION-VOCABULARY.yaml — the closed, +single-sourced vocabulary. See the udlm copy for the full rationale (2026-07-28 sweep F1).""" +import glob, os, sys, yaml +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VOCAB = os.path.join(ROOT, "dav", "use-cases", "DIMENSION-VOCABULARY.yaml") + +def main(): + spec = yaml.safe_load(open(VOCAB, encoding="utf-8")) + allowed = {k: set(v) for k, v in spec["dimensions"].items()} + aliases = spec.get("folded_aliases") or {} + fails, n = [], 0 + for path in sorted(glob.glob(os.path.join(ROOT, "dav", "use-cases", "**", "*.yaml"), recursive=True)): + doc = yaml.safe_load(open(path, encoding="utf-8")) or {} + dims = ((doc.get("scenario") or {}).get("dimensions")) or {} + if not dims: + continue # not a use-case file (README, vocabulary, taxonomy) + n += 1 + rel = os.path.relpath(path, ROOT) + for dim, val in dims.items(): + if dim not in allowed: + fails.append(f"{rel}: unknown dimension {dim!r}"); continue + if str(val) not in allowed[dim]: + hint = aliases.get(dim, {}).get(str(val)) + tip = f" — use {hint!r} (folded alias)" if hint else " — add to DIMENSION-VOCABULARY.yaml first if real" + fails.append(f"{rel}: {dim}={val!r} off-vocabulary{tip}") + for f in fails: print("FAIL [DIM-001] " + f) + print(f"{n} use case(s) checked, {len(fails)} off-vocabulary value(s)") + return 1 if fails else 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_uc_personas.py b/tests/check_uc_personas.py new file mode 100644 index 0000000..69ad923 --- /dev/null +++ b/tests/check_uc_personas.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""UC persona-vocabulary gate (mirror of udlm's PER-001/PER-002). Every use case's persona +references must resolve to the canonical persona set in dav/use-cases/PERSONAS.yaml (a canonical +id or a folded alias): + - PER-001 scenario.actor.persona — the persona that drives the use case (required) + - PER-002 scenario.perspectives[] — the additional personas it must be analyzed FROM (optional) +Also prints a non-failing COVERAGE line: canonical personas on no UC here. See the udlm copy for +the full rationale.""" +import glob, os, sys, yaml +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VOCAB = os.path.join(ROOT, "dav", "use-cases", "PERSONAS.yaml") + +def main(): + spec = yaml.safe_load(open(VOCAB, encoding="utf-8")) + canon = {p["id"] for p in spec["personas"]} + aliases = spec.get("folded_aliases") or {} + resolvable = canon | set(aliases) + c = lambda v: v if v in canon else aliases.get(v) + fails, n, seen = [], 0, set() + for path in sorted(glob.glob(os.path.join(ROOT, "dav", "use-cases", "**", "*.yaml"), recursive=True)): + doc = yaml.safe_load(open(path, encoding="utf-8")) or {} + sc = doc.get("scenario") or {} + actor = (sc.get("actor") or {}).get("persona") + if actor is None: + continue + n += 1 + rel = os.path.relpath(path, ROOT) + if str(actor) not in resolvable: + fails.append(f"[PER-001] {rel}: actor.persona={actor!r} off-vocabulary — add to PERSONAS.yaml first") + else: + seen.add(c(str(actor))) + for p in (sc.get("perspectives") or []): + if str(p) not in resolvable: + fails.append(f"[PER-002] {rel}: perspective {p!r} off-vocabulary — add to PERSONAS.yaml first") + else: + seen.add(c(str(p))) + for f in fails: print("FAIL " + f) + uncovered = sorted(canon - seen) + print(f"{n} use case(s) checked, {len(fails)} unresolved persona reference(s)") + if uncovered: + print(f"COVERAGE (informational): {len(uncovered)} persona(s) on no use case here — " + ", ".join(uncovered)) + return 1 if fails else 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/validate_contracts.py b/tests/validate_contracts.py new file mode 100644 index 0000000..9c8488f --- /dev/null +++ b/tests/validate_contracts.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Architecture contract gate. Validates the machine-readable contracts that back the +DCM architecture spec, so a broken contract can't merge: + + - schemas/jsonschema/*.json — must be a valid JSON Schema (2020-12 meta-schema). + - schemas/openapi/*.yaml — must parse and be a structurally valid OpenAPI 3.x + document (openapi-spec-validator when installed; + otherwise a YAML-parse + minimal-structure check). + +KNOWN_BROKEN lists contracts with a pre-existing defect tracked for repair — they are +reported as a WARNING and do NOT fail the gate, so the gate stays green on everything +else while the debt is visible. Remove an entry once its contract is fixed. + +Exit non-zero if any non-exempt contract is invalid. Wire into CI.""" +import glob +import json +import os +import pathlib +import sys + +try: + from jsonschema import Draft202012Validator +except ImportError: + sys.exit("requires: pip install jsonschema") +try: + import yaml +except ImportError: + sys.exit("requires: pip install pyyaml") + +# Full OpenAPI 3.x compliance is OPT-IN (STRICT_OPENAPI=1): the specs currently miss +# some required `description` fields, so full validation would fail on pre-existing +# debt. Default is a deterministic parse + minimal-structure check regardless of what +# is installed. Flip STRICT_OPENAPI once the descriptions are backfilled. +STRICT_OPENAPI = bool(os.environ.get("STRICT_OPENAPI")) +try: + from openapi_spec_validator import validate as openapi_validate + HAVE_OPENAPI_VALIDATOR = True +except Exception: + HAVE_OPENAPI_VALIDATOR = False +USE_FULL = STRICT_OPENAPI and HAVE_OPENAPI_VALIDATOR + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +# Contracts with a tracked pre-existing defect — reported, not fatal. Fix + remove. +# (dcm-admin-api.yaml — broken since 72afcd9 "Restructure #2" 2026-04-07 — was +# reconstructed in this change and removed from this list; the gate now validates it.) +KNOWN_BROKEN = {} + +failures = 0 +warnings = 0 + + +def rel(p): + return str(pathlib.Path(p).relative_to(ROOT)) + + +print("== JSON Schemas ==") +for f in sorted(glob.glob(str(ROOT / "schemas/jsonschema/*.json"))): + try: + Draft202012Validator.check_schema(json.load(open(f))) + print(f"ok {rel(f)}") + except Exception as e: + print(f"FAIL {rel(f)}: {str(e)[:100]}") + failures += 1 + +print("== OpenAPI ==") +for f in sorted(glob.glob(str(ROOT / "schemas/openapi/*.yaml"))): + r = rel(f) + exempt = KNOWN_BROKEN.get(r) + try: + doc = yaml.safe_load(open(f)) + if USE_FULL: + openapi_validate(doc) + else: + assert isinstance(doc, dict) and "openapi" in doc and "paths" in doc, \ + "missing openapi/paths" + print(f"ok {r} (openapi {doc.get('openapi', '?')}, {len(doc.get('paths', {}))} paths)") + except Exception as e: + if exempt: + print(f"WARN {r}: KNOWN-BROKEN — {exempt}") + warnings += 1 + else: + print(f"FAIL {r}: {str(e).splitlines()[0][:100]}") + failures += 1 + +if not USE_FULL: + print("NOTE: OpenAPI checked for parse + basic structure (set STRICT_OPENAPI=1 with " + "openapi-spec-validator installed for full 3.x compliance once descriptions are backfilled).") +print(f"\n{failures} failure(s), {warnings} known-broken warning(s)") +sys.exit(1 if failures else 0)