diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
new file mode 100644
index 0000000..45d9a51
--- /dev/null
+++ b/.claude/ralph-loop.local.md
@@ -0,0 +1,33 @@
+---
+active: true
+iteration: 1
+session_id:
+max_iterations: 30
+completion_promise: "HEUREKA"
+started_at: "2026-03-13T00:07:01Z"
+---
+
+Do a high quality implementation of workplan KEY-WP-0001-keycape-implementation.md as follows:
+
+Requirements and Boundaries
+- Keep the scope of the workplan
+- Use documents in spec/ and wiki/ folder to understand the big picture
+- Document important architecture decisions as ADRs in the architecture/ folder
+- Save relevant parts of documentation you need to grab online to the research/ folder
+- Keep the state-hub up to date with task completions
+- If stuck and when running out of iterations document what went wrong in problems/ folder
+
+WHILE TDD-Loop for all tasks:
+1) RED: write test → run → fail
+2) GREEN: implement minimal code → run → pass
+3) REFACTOR: refactor → run → pass
+REPEAT
+
+Success Criteria:
+- No linter errors
+- All tasks and requirements of workplan implemented
+- Documentation updated showing how the application works
+- Check Workstream DoD from the state-hub policies
+
+Refine until all succes-criteria have been met, otherwise
+output HEUREKA when done!
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..32a386e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,43 @@
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["**"]
+
+jobs:
+ build-and-test:
+ name: Build and Test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+
+ - name: Verify dependencies
+ working-directory: src
+ run: go mod verify
+
+ - name: Vet
+ working-directory: src
+ run: go vet ./...
+
+ - name: Test
+ working-directory: src
+ run: go test -v -race ./...
+
+ - name: Build all binaries
+ working-directory: src
+ run: |
+ mkdir -p ../bin
+ go build -o ../bin/keycape ./cmd/keycape/
+ go build -o ../bin/validator ./cmd/validator/
+ go build -o ../bin/lldap-export ./cmd/lldap-export/
+ go build -o ../bin/keycape-to-keycloak ./cmd/keycape-to-keycloak/
+ go build -o ../bin/lldap-to-ldap ./cmd/lldap-to-ldap/
diff --git a/CLAUDE.md b/CLAUDE.md
index 34adc93..aaf9278 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,3 +1,7 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
# KeyCape — Claude Code Instructions
## What This Repo Is
@@ -94,6 +98,27 @@ Application ──→ NetKingdom IAM Profile
5. **Telemetry** — demand visibility for unsupported features and auth events
6. **Migration tooling** — export/validate for LLDAP → Keycloak path
+## Normative Constraints (from spec — binding on implementation)
+
+**Never silently emulate unsupported features.** Any request outside the profile MUST fail with a structured error from this taxonomy:
+- `feature_not_supported_by_profile` — outside the NetKingdom IAM Profile entirely
+- `available_in_keycloak_mode_only` — exists in expanded mode, absent here by design
+- `rejected_for_profile_safety` — would weaken profile guarantees or security discipline
+- `invalid_profile_usage` — supported endpoint/feature used incorrectly
+
+**Security hard rules:** No handwritten cryptography. No handwritten password hashing. Use established protocol and crypto libraries. Strict redirect URI validation. Strict issuer consistency.
+
+**Canonical identity model** is the source of truth for test fixtures, provisioning, migration, and validation — not any backend's native schema.
+
+**Spec Pack structure** (`wiki/KeyCapeSpecificationPack_v0.1.md`) contains 7 normative components agents must read before implementing:
+1. Normative Specification — OIDC/PKCE contract, endpoints, scopes, claims, client model, MFA
+2. Canonical Identity Schema — User, Group, Membership, Client, Role, MFAEnrollmentReference, etc.
+3. Canonical LDAP Schema + Validator Rules — restricted LDAP expression of identity model
+4. Error Taxonomy — machine-readable/human-readable/loggable structured errors
+5. Telemetry Schema — event types, required fields (timestamp, env, client_id, endpoint, feature_category, correlation_id, …)
+6. Migration Contract — LLDAP → full LDAP, KeyCape → Keycloak migration paths
+7. Acceptance Test Matrix — lightweight baseline, IAM replacement, full expansion, negative profile tests
+
## Workplan Convention (ADR-001)
Workplans live in `workplans/-.md` with YAML frontmatter:
diff --git a/docs/adr/ADR-0001-choose-go-for-keycape.md b/docs/adr/ADR-0001-choose-go-for-keycape.md
new file mode 100644
index 0000000..52ab3e9
--- /dev/null
+++ b/docs/adr/ADR-0001-choose-go-for-keycape.md
@@ -0,0 +1,119 @@
+---
+id: ADR-0001
+title: "Implementation language for KeyCape: Go"
+status: accepted
+date: 2026-03-13
+decided_by: Bernd
+hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
+workstream: KEY-WP-0001 (keycape-implementation)
+alternatives_considered: [Rust]
+---
+
+# ADR-0001 — Implementation Language: Go
+
+## Status
+
+Accepted
+
+## Context
+
+KeyCape must be implemented in a language that satisfies the requirements of spec §11:
+
+> Keycape SHOULD be implemented in Go or Rust.
+> Key requirements: stateless, small memory footprint, simple deployment, clear logging,
+> structured telemetry.
+
+Both Go and Rust are valid per spec. A decision was needed before T01 (project setup).
+
+### What KeyCape actually does
+
+KeyCape is an **orchestrating boundary service**, not a protocol or security engine:
+
+- delegates authentication UI and session to **Authelia**
+- delegates identity storage to **LLDAP**
+- delegates MFA enforcement to **privacyIDEA**
+- its own code is: HTTP endpoints, config loading, JWT signing (via library), JSON/JWKS handling,
+ structured telemetry, adapter glue, migration CLI tooling, LDAP schema validator
+
+The main risks are **understandability and clean integration boundaries**, not memory safety in
+hot loops or complex parser internals.
+
+## Decision
+
+**Go.**
+
+## Rationale
+
+### Why Go fits KeyCape
+
+Go is especially strong for the actual implementation surface of KeyCape:
+
+| What KeyCape does | Go fit |
+|---|---|
+| HTTP API server | excellent |
+| Config loading and static validation | excellent |
+| Adapter code to Authelia, LLDAP, privacyIDEA | excellent |
+| JWT/JWKS/JSON handling | excellent |
+| Structured logging and Prometheus metrics | excellent |
+| CLI tooling (migration, validator, export) | excellent |
+| Integration tests in containers | excellent |
+
+Go also provides:
+
+- faster iteration to a working, testable v1
+- simpler dependency and build model
+- easy static binaries and minimal container images
+- low enough runtime overhead for the stated lightweight target
+- straightforward output for coding agents across a growing infra codebase
+
+### Why not Rust for this scope
+
+Rust's advantages are real — stronger compile-time safety, better memory control, excellent for
+security-critical infrastructure — but they pay back most clearly when:
+
+- substantial protocol machinery is implemented internally
+- complex async concurrency or parser-heavy code is required
+- the service is intended as a long-lived, standalone security product for others
+
+KeyCape's design **intentionally avoids** all of that. It stays narrow and delegates to existing
+components. For a façade/orchestrator, developer friction matters more than theoretical maximal
+correctness.
+
+### Decision rule
+
+> **Pick Go if KeyCape is primarily an orchestrating boundary service.**
+> **Pick Rust if KeyCape starts becoming a real protocol/security engine.**
+
+Based on the v0.1 spec, KeyCape is clearly the first.
+
+## Consequences
+
+### Positive
+
+- fastest path to a working, testable, operationally simple implementation
+- simple build, deploy, and CI story
+- lower friction for coding agents producing coherent infra code
+
+### Negative / risks
+
+- weaker compiler-enforced invariants than Rust
+- easier to write sloppy code if discipline lapses
+- error handling and domain modeling can drift if not designed carefully
+
+### Compensating guardrails (mandatory)
+
+To recover the rigor that Rust would provide via the type system:
+
+1. **Typed domain models** for the profile contract — no raw maps or untyped JSON in business logic
+2. **Narrow adapter interfaces** — `server/` layer never sees LDAP, Authelia, or privacyIDEA types directly
+3. **Layered architecture** — protocol layer | domain layer | adapter layer | migration layer (hard boundaries)
+4. **Strict schema/config validation** at startup and in CI
+5. **Fuzz and property tests** around the LDAP schema validator, redirect URI checker, and claim mapping
+6. **No cleverness** — small, deterministic functions
+
+## Revisit trigger
+
+Reconsider this decision if a subcomponent (e.g. LDAP schema validator, token normalization
+engine, or a future high-throughput policy evaluator) demonstrably needs stronger guarantees.
+That subcomponent could be redesigned in Rust without regretting the overall Go choice — Go and
+Rust interop via CGo or separate binaries is feasible.
diff --git a/spec/canonical-model.yaml b/spec/canonical-model.yaml
new file mode 100644
index 0000000..99f88be
--- /dev/null
+++ b/spec/canonical-model.yaml
@@ -0,0 +1,174 @@
+version: "0.1"
+description: >
+ Canonical Identity Model for KeyCape / NetKingdom IAM Profile.
+ This file is the source of truth for all identity entities.
+ All provisioning, tests, and migrations derive from these definitions.
+
+entities:
+ User:
+ description: "A person or service account in the identity directory."
+ fields:
+ id:
+ type: string
+ required: true
+ description: "Stable internal identifier. Immutable after creation."
+ username:
+ type: string
+ required: true
+ description: "Unique login name. Maps to LDAP uid."
+ displayName:
+ type: string
+ required: true
+ description: "Human-readable full name. Maps to LDAP cn."
+ email:
+ type: string
+ required: false
+ format: email
+ description: "Primary email address. Maps to LDAP mail."
+ enabled:
+ type: boolean
+ required: true
+ description: "Whether the account is active."
+ groups:
+ type: array
+ items:
+ type: string
+ ref: Group.id
+ description: "Group memberships by group ID."
+ roles:
+ type: array
+ items:
+ type: string
+ ref: Role.id
+ description: "Role assignments by role ID."
+ mfaEnrollment:
+ type: object
+ ref: MFAEnrollment
+ nullable: true
+ description: "MFA enrollment record if the user has enrolled."
+ ldapAttributes:
+ type: object
+ additionalProperties: true
+ description: "Raw LDAP attributes not covered by the canonical model."
+
+ Group:
+ description: "A named collection of users."
+ fields:
+ id:
+ type: string
+ required: true
+ description: "Stable internal identifier."
+ name:
+ type: string
+ required: true
+ description: "Unique group name. Maps to LDAP cn."
+ description:
+ type: string
+ required: false
+ description: "Human-readable description."
+ members:
+ type: array
+ items:
+ type: string
+ ref: User.id
+ description: "User IDs belonging to this group."
+
+ Role:
+ description: "A named permission set assigned to users."
+ fields:
+ id:
+ type: string
+ required: true
+ description: "Stable internal identifier."
+ name:
+ type: string
+ required: true
+ description: "Unique role name."
+ description:
+ type: string
+ required: false
+ description: "Human-readable description."
+
+ Client:
+ description: "A registered OIDC client. Registration is static in v0.1."
+ fields:
+ clientId:
+ type: string
+ required: true
+ description: "OAuth2 client_id."
+ displayName:
+ type: string
+ required: true
+ description: "Human-readable client name."
+ redirectUris:
+ type: array
+ items:
+ type: string
+ format: uri
+ required: true
+ minItems: 1
+ description: "Allowed redirect URIs. Wildcards are NEVER permitted."
+ allowedScopes:
+ type: array
+ items:
+ type: string
+ required: true
+ description: "Scopes this client may request."
+ grantTypes:
+ type: array
+ items:
+ type: string
+ enum: [authorization_code]
+ required: true
+ description: "Allowed OAuth2 grant types. Only authorization_code in v0.1."
+ clientType:
+ type: string
+ enum: [confidential, public]
+ required: true
+ description: "confidential = server-side app; public = SPA or native."
+ secretRef:
+ type: string
+ nullable: true
+ description: "Reference to the client secret (confidential clients only)."
+ tokenProfile:
+ type: string
+ description: "Optional: token configuration profile name."
+ environments:
+ type: array
+ items:
+ type: string
+ description: "Environments this client is registered for (e.g. prod, staging)."
+
+ Membership:
+ description: "Explicit link between a user and a group."
+ fields:
+ userId:
+ type: string
+ required: true
+ ref: User.id
+ groupId:
+ type: string
+ required: true
+ ref: Group.id
+
+ MFAEnrollment:
+ description: "Records MFA enrollment state for a user via privacyIDEA."
+ fields:
+ userId:
+ type: string
+ required: true
+ ref: User.id
+ provider:
+ type: string
+ required: true
+ enum: [privacyidea]
+ description: "MFA provider. Only privacyidea is supported in v0.1."
+ state:
+ type: string
+ required: true
+ enum: [enabled, disabled, pending]
+ description: "Current enrollment state."
+ enrolledAt:
+ type: string
+ format: datetime
+ description: "ISO 8601 timestamp of enrollment."
diff --git a/spec/ldap-schema.yaml b/spec/ldap-schema.yaml
new file mode 100644
index 0000000..56e1543
--- /dev/null
+++ b/spec/ldap-schema.yaml
@@ -0,0 +1,91 @@
+version: "0.1"
+description: >
+ Canonical LDAP Schema for KeyCape / NetKingdom IAM Profile.
+ Expresses the canonical identity model in LDAP terms.
+ Portable across LLDAP, OpenLDAP, 389DS, and Active Directory.
+
+base_dn: "dc=netkingdom,dc=local"
+
+organization_units:
+ users:
+ dn: "ou=users,dc=netkingdom,dc=local"
+ description: "User accounts"
+ object_classes:
+ required:
+ - inetOrgPerson
+ - organizationalPerson
+ - person
+ - top
+ attributes:
+ required:
+ - uid # canonical: username
+ - cn # canonical: displayName
+ - sn # canonical: surname (may be set to displayName if absent)
+ optional:
+ - mail # canonical: email
+ - memberOf # back-reference to group membership
+ forbidden: []
+ naming_attr: uid
+ examples:
+ - dn: "uid=alice,ou=users,dc=netkingdom,dc=local"
+ uid: alice
+ cn: "Alice Example"
+ sn: Example
+ mail: alice@example.com
+
+ groups:
+ dn: "ou=groups,dc=netkingdom,dc=local"
+ description: "User groups"
+ object_classes:
+ required:
+ - groupOfNames
+ - top
+ attributes:
+ required:
+ - cn # canonical: name
+ - member # list of member DNs
+ optional:
+ - description
+ forbidden: []
+ naming_attr: cn
+ examples:
+ - dn: "cn=admins,ou=groups,dc=netkingdom,dc=local"
+ cn: admins
+ member:
+ - "uid=alice,ou=users,dc=netkingdom,dc=local"
+
+ clients:
+ dn: "ou=clients,dc=netkingdom,dc=local"
+ description: "OIDC client registrations"
+ object_classes:
+ required:
+ - inetOrgPerson
+ - top
+ attributes:
+ required:
+ - uid # canonical: clientId
+ - cn # canonical: displayName
+ optional:
+ - description
+ forbidden: []
+ naming_attr: uid
+
+validation_rules:
+ structural:
+ - name: valid_dn_structure
+ description: "All DNs must conform to the base_dn and OU layout above."
+ - name: required_attributes_present
+ description: "Every entry must carry all required attributes for its OU."
+ - name: no_unknown_attributes
+ description: "No attributes outside the allowed set may appear."
+ - name: valid_group_memberships
+ description: "All member values must be non-empty valid DNs."
+ semantic:
+ - name: referenced_users_exist
+ description: "Every user ID referenced in group members must exist."
+ - name: no_cyclic_groups
+ description: "Groups may not contain other group IDs as members."
+ - name: usernames_unique
+ description: "The uid attribute must be unique across ou=users."
+ - name: email_format_valid
+ description: "mail, when present, must be a valid RFC 5322 address."
diff --git a/src/Makefile b/src/Makefile
new file mode 100644
index 0000000..083df0b
--- /dev/null
+++ b/src/Makefile
@@ -0,0 +1,29 @@
+GOBIN ?= $(shell go env GOPATH)/bin
+BINDIR = ../bin
+
+.PHONY: all build test lint vet clean
+
+all: vet lint test build
+
+build:
+ go build -o $(BINDIR)/keycape ./cmd/keycape/
+ go build -o $(BINDIR)/validator ./cmd/validator/
+ go build -o $(BINDIR)/lldap-export ./cmd/lldap-export/
+ go build -o $(BINDIR)/keycape-to-keycloak ./cmd/keycape-to-keycloak/
+ go build -o $(BINDIR)/lldap-to-ldap ./cmd/lldap-to-ldap/
+
+test:
+ go test ./...
+
+vet:
+ go vet ./...
+
+lint:
+ @if command -v golangci-lint >/dev/null 2>&1; then \
+ golangci-lint run ./...; \
+ else \
+ echo "golangci-lint not installed, skipping (run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)"; \
+ fi
+
+clean:
+ rm -rf $(BINDIR)
diff --git a/src/cmd/keycape-to-keycloak/main.go b/src/cmd/keycape-to-keycloak/main.go
new file mode 100644
index 0000000..4d0fbf7
--- /dev/null
+++ b/src/cmd/keycape-to-keycloak/main.go
@@ -0,0 +1,13 @@
+// keycape-to-keycloak migrates a KeyCape canonical snapshot to a Keycloak
+// realm export format. Part of the NetKingdom IAM migration contract.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "keycape-to-keycloak: not yet implemented (T06+)")
+ os.Exit(1)
+}
diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go
new file mode 100644
index 0000000..5b2a2f0
--- /dev/null
+++ b/src/cmd/keycape/main.go
@@ -0,0 +1,14 @@
+// keycape is the main server binary for the KeyCape IAM profile service.
+// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
+// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "keycape server: not yet implemented (T05+)")
+ os.Exit(1)
+}
diff --git a/src/cmd/lldap-export/main.go b/src/cmd/lldap-export/main.go
new file mode 100644
index 0000000..4ae8df8
--- /dev/null
+++ b/src/cmd/lldap-export/main.go
@@ -0,0 +1,13 @@
+// lldap-export exports the LLDAP directory as a canonical YAML snapshot
+// for use with the validator and migration tools.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "lldap-export: not yet implemented (T06+)")
+ os.Exit(1)
+}
diff --git a/src/cmd/lldap-to-ldap/main.go b/src/cmd/lldap-to-ldap/main.go
new file mode 100644
index 0000000..5b1b3d4
--- /dev/null
+++ b/src/cmd/lldap-to-ldap/main.go
@@ -0,0 +1,13 @@
+// lldap-to-ldap migrates LLDAP directory data to standard LDAP (LDIF format).
+// Part of the NetKingdom IAM migration contract.
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Fprintln(os.Stderr, "lldap-to-ldap: not yet implemented (T06+)")
+ os.Exit(1)
+}
diff --git a/src/cmd/validator/main.go b/src/cmd/validator/main.go
new file mode 100644
index 0000000..4112720
--- /dev/null
+++ b/src/cmd/validator/main.go
@@ -0,0 +1,69 @@
+// validator is the CLI binary for the KeyCape canonical LDAP schema validator.
+// It reads a YAML directory snapshot and emits a machine-readable JSON report.
+//
+// Usage:
+//
+// validator --mode=ci|provisioning|migration --input=
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+
+ "keycape/internal/domain"
+ "keycape/internal/validator"
+)
+
+func main() {
+ mode := flag.String("mode", "ci", "validation mode: ci, provisioning, or migration")
+ input := flag.String("input", "", "path to YAML directory snapshot (required)")
+ flag.Parse()
+
+ if *input == "" {
+ fmt.Fprintln(os.Stderr, "error: --input is required")
+ flag.Usage()
+ os.Exit(2)
+ }
+
+ m := validator.Mode(*mode)
+ switch m {
+ case validator.ModeCI, validator.ModeProvisioning, validator.ModeMigration:
+ // valid
+ default:
+ fmt.Fprintf(os.Stderr, "error: unknown mode %q (must be ci, provisioning, or migration)\n", *mode)
+ os.Exit(2)
+ }
+
+ data, err := os.ReadFile(*input)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error reading input: %v\n", err)
+ os.Exit(1)
+ }
+
+ var dir domain.Directory
+ if err := yaml.Unmarshal(data, &dir); err != nil {
+ fmt.Fprintf(os.Stderr, "error parsing YAML: %v\n", err)
+ os.Exit(1)
+ }
+
+ snap := validator.Snapshot{
+ Users: dir.Users,
+ Groups: dir.Groups,
+ }
+ report := validator.Validate(snap, m)
+
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(report); err != nil {
+ fmt.Fprintf(os.Stderr, "error encoding report: %v\n", err)
+ os.Exit(1)
+ }
+
+ if !report.Passed {
+ os.Exit(1)
+ }
+}
diff --git a/src/go.mod b/src/go.mod
new file mode 100644
index 0000000..01b6073
--- /dev/null
+++ b/src/go.mod
@@ -0,0 +1,5 @@
+module keycape
+
+go 1.22
+
+require gopkg.in/yaml.v3 v3.0.1
diff --git a/src/go.sum b/src/go.sum
new file mode 100644
index 0000000..a62c313
--- /dev/null
+++ b/src/go.sum
@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go
new file mode 100644
index 0000000..6db7e2a
--- /dev/null
+++ b/src/internal/domain/model.go
@@ -0,0 +1,68 @@
+// Package domain contains the canonical identity model for KeyCape.
+// This is the source of truth for all user, group, client, and MFA data.
+// All provisioning, tests, and migrations derive from these types.
+package domain
+
+import "time"
+
+// User is the canonical identity entity — source of truth for all user data.
+type User struct {
+ ID string `yaml:"id" json:"id"`
+ Username string `yaml:"username" json:"username"`
+ DisplayName string `yaml:"displayName" json:"displayName"`
+ Email string `yaml:"email" json:"email"`
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ Groups []string `yaml:"groups" json:"groups"`
+ Roles []string `yaml:"roles" json:"roles"`
+ MFAEnrollment *MFAEnrollment `yaml:"mfaEnrollment,omitempty" json:"mfaEnrollment,omitempty"`
+ LDAPAttributes map[string]string `yaml:"ldapAttributes,omitempty" json:"ldapAttributes,omitempty"`
+}
+
+// Group is a named collection of users.
+type Group struct {
+ ID string `yaml:"id" json:"id"`
+ Name string `yaml:"name" json:"name"`
+ Description string `yaml:"description" json:"description"`
+ Members []string `yaml:"members" json:"members"`
+}
+
+// Role is a named permission set.
+type Role struct {
+ ID string `yaml:"id" json:"id"`
+ Name string `yaml:"name" json:"name"`
+ Description string `yaml:"description" json:"description"`
+}
+
+// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
+type Client struct {
+ ClientID string `yaml:"clientId" json:"clientId"`
+ DisplayName string `yaml:"displayName" json:"displayName"`
+ RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
+ AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
+ GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
+ ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
+ SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
+}
+
+// Membership links a user to a group.
+type Membership struct {
+ UserID string `yaml:"userId" json:"userId"`
+ GroupID string `yaml:"groupId" json:"groupId"`
+}
+
+// MFAEnrollment records that a user has enrolled MFA via privacyIDEA.
+type MFAEnrollment struct {
+ UserID string `yaml:"userId" json:"userId"`
+ Provider string `yaml:"provider" json:"provider"` // "privacyidea"
+ State string `yaml:"state" json:"state"` // "enabled" | "disabled" | "pending"
+ EnrolledAt time.Time `yaml:"enrolledAt,omitempty" json:"enrolledAt,omitempty"`
+}
+
+// Directory is the full canonical identity directory snapshot.
+// Used for provisioning, validation, and migration operations.
+type Directory struct {
+ Users []User `yaml:"users" json:"users"`
+ Groups []Group `yaml:"groups" json:"groups"`
+ Roles []Role `yaml:"roles" json:"roles"`
+ Clients []Client `yaml:"clients" json:"clients"`
+}
diff --git a/src/internal/errors/taxonomy.go b/src/internal/errors/taxonomy.go
new file mode 100644
index 0000000..80ebf07
--- /dev/null
+++ b/src/internal/errors/taxonomy.go
@@ -0,0 +1,85 @@
+// Package errors implements the KeyCape error taxonomy from spec §5.
+// All profile errors are structured and machine-readable.
+// Errors MUST NOT be silent — every unsupported or misused feature returns a typed error.
+package errors
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// ErrorType is a stable string identifier for profile error categories.
+type ErrorType string
+
+const (
+ // ErrFeatureNotSupported is returned when a feature is outside the NetKingdom IAM Profile.
+ ErrFeatureNotSupported ErrorType = "feature_not_supported_by_profile"
+
+ // ErrKeycloakModeOnly is returned when a feature exists only in expanded (Keycloak) mode.
+ ErrKeycloakModeOnly ErrorType = "available_in_keycloak_mode_only"
+
+ // ErrRejectedForSafety is returned when a feature is intentionally blocked for security reasons.
+ ErrRejectedForSafety ErrorType = "rejected_for_profile_safety"
+
+ // ErrInvalidProfileUsage is returned when a supported endpoint/feature is used incorrectly.
+ ErrInvalidProfileUsage ErrorType = "invalid_profile_usage"
+)
+
+// ProfileError is a structured error per spec §5.2.
+// JSON format: {"error": "...", "description": "...", "feature": "..."}
+type ProfileError struct {
+ Error ErrorType `json:"error"`
+ Description string `json:"description"`
+ Feature string `json:"feature,omitempty"`
+}
+
+// Write writes the error as JSON with the given HTTP status code.
+func (e *ProfileError) Write(w http.ResponseWriter, status int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(e)
+}
+
+// GoError implements the standard error interface.
+func (e *ProfileError) GoError() string {
+ if e.Feature != "" {
+ return string(e.Error) + ": " + e.Description + " [feature=" + e.Feature + "]"
+ }
+ return string(e.Error) + ": " + e.Description
+}
+
+// FeatureNotSupported constructs a feature_not_supported_by_profile error.
+func FeatureNotSupported(description, feature string) *ProfileError {
+ return &ProfileError{
+ Error: ErrFeatureNotSupported,
+ Description: description,
+ Feature: feature,
+ }
+}
+
+// KeycloakModeOnly constructs an available_in_keycloak_mode_only error.
+func KeycloakModeOnly(description, feature string) *ProfileError {
+ return &ProfileError{
+ Error: ErrKeycloakModeOnly,
+ Description: description,
+ Feature: feature,
+ }
+}
+
+// RejectedForSafety constructs a rejected_for_profile_safety error.
+func RejectedForSafety(description, feature string) *ProfileError {
+ return &ProfileError{
+ Error: ErrRejectedForSafety,
+ Description: description,
+ Feature: feature,
+ }
+}
+
+// InvalidProfileUsage constructs an invalid_profile_usage error.
+func InvalidProfileUsage(description, feature string) *ProfileError {
+ return &ProfileError{
+ Error: ErrInvalidProfileUsage,
+ Description: description,
+ Feature: feature,
+ }
+}
diff --git a/src/internal/errors/taxonomy_test.go b/src/internal/errors/taxonomy_test.go
new file mode 100644
index 0000000..2f5722e
--- /dev/null
+++ b/src/internal/errors/taxonomy_test.go
@@ -0,0 +1,141 @@
+package errors_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ profileerrors "keycape/internal/errors"
+)
+
+func TestErrorTypeConstants(t *testing.T) {
+ tests := []struct {
+ name string
+ errType profileerrors.ErrorType
+ expected string
+ }{
+ {"FeatureNotSupported", profileerrors.ErrFeatureNotSupported, "feature_not_supported_by_profile"},
+ {"KeycloakModeOnly", profileerrors.ErrKeycloakModeOnly, "available_in_keycloak_mode_only"},
+ {"RejectedForSafety", profileerrors.ErrRejectedForSafety, "rejected_for_profile_safety"},
+ {"InvalidProfileUsage", profileerrors.ErrInvalidProfileUsage, "invalid_profile_usage"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if string(tt.errType) != tt.expected {
+ t.Errorf("got %q, want %q", tt.errType, tt.expected)
+ }
+ })
+ }
+}
+
+func TestConstructorHelpers(t *testing.T) {
+ t.Run("FeatureNotSupported", func(t *testing.T) {
+ e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
+ if e.Error != profileerrors.ErrFeatureNotSupported {
+ t.Errorf("wrong error type: %v", e.Error)
+ }
+ if e.Description != "dynamic registration is not allowed" {
+ t.Errorf("wrong description: %v", e.Description)
+ }
+ if e.Feature != "dynamic_client_registration" {
+ t.Errorf("wrong feature: %v", e.Feature)
+ }
+ })
+
+ t.Run("KeycloakModeOnly", func(t *testing.T) {
+ e := profileerrors.KeycloakModeOnly("identity broker requires expanded mode", "identity_broker")
+ if e.Error != profileerrors.ErrKeycloakModeOnly {
+ t.Errorf("wrong error type: %v", e.Error)
+ }
+ if e.Feature != "identity_broker" {
+ t.Errorf("wrong feature: %v", e.Feature)
+ }
+ })
+
+ t.Run("RejectedForSafety", func(t *testing.T) {
+ e := profileerrors.RejectedForSafety("wildcard redirect URIs weaken security", "wildcard_redirect_uri")
+ if e.Error != profileerrors.ErrRejectedForSafety {
+ t.Errorf("wrong error type: %v", e.Error)
+ }
+ if e.Feature != "wildcard_redirect_uri" {
+ t.Errorf("wrong feature: %v", e.Feature)
+ }
+ })
+
+ t.Run("InvalidProfileUsage", func(t *testing.T) {
+ e := profileerrors.InvalidProfileUsage("PKCE code_challenge is required", "missing_pkce")
+ if e.Error != profileerrors.ErrInvalidProfileUsage {
+ t.Errorf("wrong error type: %v", e.Error)
+ }
+ if e.Feature != "missing_pkce" {
+ t.Errorf("wrong feature: %v", e.Feature)
+ }
+ })
+}
+
+func TestProfileErrorJSON(t *testing.T) {
+ e := profileerrors.FeatureNotSupported("dynamic registration is not allowed", "dynamic_client_registration")
+ data, err := json.Marshal(e)
+ if err != nil {
+ t.Fatalf("marshal error: %v", err)
+ }
+ s := string(data)
+ if !strings.Contains(s, `"error":"feature_not_supported_by_profile"`) {
+ t.Errorf("missing error field: %s", s)
+ }
+ if !strings.Contains(s, `"description":"dynamic registration is not allowed"`) {
+ t.Errorf("missing description field: %s", s)
+ }
+ if !strings.Contains(s, `"feature":"dynamic_client_registration"`) {
+ t.Errorf("missing feature field: %s", s)
+ }
+}
+
+func TestProfileErrorOmitsFeatureWhenEmpty(t *testing.T) {
+ e := &profileerrors.ProfileError{
+ Error: profileerrors.ErrInvalidProfileUsage,
+ Description: "bad request",
+ }
+ data, err := json.Marshal(e)
+ if err != nil {
+ t.Fatalf("marshal error: %v", err)
+ }
+ if strings.Contains(string(data), `"feature"`) {
+ t.Errorf("feature field should be omitted when empty: %s", data)
+ }
+}
+
+func TestProfileErrorWrite(t *testing.T) {
+ e := profileerrors.FeatureNotSupported("not supported", "some_feature")
+
+ rr := httptest.NewRecorder()
+ e.Write(rr, http.StatusBadRequest)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("expected status 400, got %d", rr.Code)
+ }
+ ct := rr.Header().Get("Content-Type")
+ if ct != "application/json" {
+ t.Errorf("expected Content-Type application/json, got %q", ct)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "feature_not_supported_by_profile") {
+ t.Errorf("body missing error type: %s", body)
+ }
+}
+
+func TestProfileErrorGoError(t *testing.T) {
+ e := profileerrors.FeatureNotSupported("desc", "feat")
+ s := e.GoError()
+ if !strings.Contains(s, "feature_not_supported_by_profile") {
+ t.Errorf("GoError missing error type: %s", s)
+ }
+ if !strings.Contains(s, "desc") {
+ t.Errorf("GoError missing description: %s", s)
+ }
+ if !strings.Contains(s, "feat") {
+ t.Errorf("GoError missing feature: %s", s)
+ }
+}
diff --git a/src/internal/validator/report.go b/src/internal/validator/report.go
new file mode 100644
index 0000000..87baa85
--- /dev/null
+++ b/src/internal/validator/report.go
@@ -0,0 +1,28 @@
+// Package validator implements the canonical LDAP schema validator for KeyCape.
+// The validator enforces the NetKingdom LDAP schema (spec §3, §4).
+// It runs in CI, provisioning, and migration modes.
+package validator
+
+// RuleResult captures the outcome of a single validation rule.
+type RuleResult struct {
+ Rule string `json:"rule"`
+ Passed bool `json:"passed"`
+ Message string `json:"message,omitempty"`
+}
+
+// Report is the machine-readable output of a validation run.
+type Report struct {
+ Mode string `json:"mode"`
+ Passed bool `json:"passed"`
+ Structural []RuleResult `json:"structural"`
+ Semantic []RuleResult `json:"semantic"`
+}
+
+// Mode identifies the operational context of the validator.
+type Mode string
+
+const (
+ ModeCI Mode = "ci"
+ ModeProvisioning Mode = "provisioning"
+ ModeMigration Mode = "migration"
+)
diff --git a/src/internal/validator/validator.go b/src/internal/validator/validator.go
new file mode 100644
index 0000000..268248e
--- /dev/null
+++ b/src/internal/validator/validator.go
@@ -0,0 +1,236 @@
+package validator
+
+import (
+ "fmt"
+ "net/mail"
+ "strings"
+
+ "keycape/internal/domain"
+)
+
+// Snapshot is the input to the validator: a resolved canonical directory.
+type Snapshot struct {
+ Users []domain.User
+ Groups []domain.Group
+}
+
+// Validate runs all structural and semantic rules against the snapshot.
+// The mode string is recorded in the report but does not change rule behaviour in v0.1.
+func Validate(snap Snapshot, mode Mode) Report {
+ report := Report{
+ Mode: string(mode),
+ }
+
+ report.Structural = runStructural(snap)
+ report.Semantic = runSemantic(snap)
+
+ report.Passed = allPassed(report.Structural) && allPassed(report.Semantic)
+ return report
+}
+
+// --- structural rules ---
+
+func runStructural(snap Snapshot) []RuleResult {
+ return []RuleResult{
+ checkValidDNStructure(snap),
+ checkRequiredAttributesPresent(snap),
+ checkNoUnknownAttributes(snap),
+ checkValidGroupMemberships(snap),
+ }
+}
+
+// checkValidDNStructure verifies that all user and group IDs are non-empty
+// and contain only characters valid in a LDAP uid/cn naming attribute.
+func checkValidDNStructure(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "valid_dn_structure", Passed: true}
+ for _, u := range snap.Users {
+ if u.ID == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, "user has empty id")
+ continue
+ }
+ if !isValidNamingValue(u.Username) {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid username for DN: %q", u.ID, u.Username))
+ }
+ }
+ for _, g := range snap.Groups {
+ if g.ID == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, "group has empty id")
+ continue
+ }
+ if !isValidNamingValue(g.Name) {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has invalid name for DN: %q", g.ID, g.Name))
+ }
+ }
+ return r
+}
+
+// checkRequiredAttributesPresent verifies users have uid, cn, sn equivalents
+// (id, username, displayName) and groups have id and name.
+func checkRequiredAttributesPresent(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "required_attributes_present", Passed: true}
+ for _, u := range snap.Users {
+ if u.Username == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: username (uid)", u.ID))
+ }
+ if u.DisplayName == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("user %q missing required attribute: displayName (cn)", u.ID))
+ }
+ }
+ for _, g := range snap.Groups {
+ if g.Name == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("group %q missing required attribute: name (cn)", g.ID))
+ }
+ }
+ return r
+}
+
+// checkNoUnknownAttributes is a placeholder for attribute allow-list enforcement.
+// In v0.1 with the canonical Go model all fields are known by type; this rule
+// checks that no LDAPAttributes keys are empty strings.
+func checkNoUnknownAttributes(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "no_unknown_attributes", Passed: true}
+ for _, u := range snap.Users {
+ for k := range u.LDAPAttributes {
+ if strings.TrimSpace(k) == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has blank LDAP attribute key", u.ID))
+ }
+ }
+ }
+ return r
+}
+
+// checkValidGroupMemberships verifies that every member ID listed in a group
+// is non-empty.
+func checkValidGroupMemberships(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "valid_group_memberships", Passed: true}
+ for _, g := range snap.Groups {
+ for i, m := range g.Members {
+ if strings.TrimSpace(m) == "" {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("group %q has blank member at index %d", g.ID, i))
+ }
+ }
+ }
+ return r
+}
+
+// --- semantic rules ---
+
+func runSemantic(snap Snapshot) []RuleResult {
+ return []RuleResult{
+ checkReferencedUsersExist(snap),
+ checkNoCyclicGroups(snap),
+ checkUsernamesUnique(snap),
+ checkEmailFormatValid(snap),
+ }
+}
+
+// checkReferencedUsersExist verifies that every member ID in every group
+// refers to an existing user.
+func checkReferencedUsersExist(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "referenced_users_exist", Passed: true}
+ userIDs := make(map[string]bool, len(snap.Users))
+ for _, u := range snap.Users {
+ userIDs[u.ID] = true
+ }
+ for _, g := range snap.Groups {
+ for _, m := range g.Members {
+ if !userIDs[m] {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("group %q references unknown user %q", g.ID, m))
+ }
+ }
+ }
+ return r
+}
+
+// checkNoCyclicGroups detects cycles in group.Members referencing other groups.
+// In v0.1 Members are user IDs (not group IDs), so any group ID in Members is a cycle.
+func checkNoCyclicGroups(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "no_cyclic_groups", Passed: true}
+ groupIDs := make(map[string]bool, len(snap.Groups))
+ for _, g := range snap.Groups {
+ groupIDs[g.ID] = true
+ }
+ for _, g := range snap.Groups {
+ for _, m := range g.Members {
+ if groupIDs[m] {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("group %q contains group member %q (cycles not allowed)", g.ID, m))
+ }
+ }
+ }
+ return r
+}
+
+// checkUsernamesUnique verifies no two users share the same username.
+func checkUsernamesUnique(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "usernames_unique", Passed: true}
+ seen := make(map[string]string) // username -> first user id
+ for _, u := range snap.Users {
+ if first, dup := seen[u.Username]; dup {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("duplicate username %q: users %q and %q", u.Username, first, u.ID))
+ } else {
+ seen[u.Username] = u.ID
+ }
+ }
+ return r
+}
+
+// checkEmailFormatValid verifies that all non-empty user email addresses parse correctly.
+func checkEmailFormatValid(snap Snapshot) RuleResult {
+ r := RuleResult{Rule: "email_format_valid", Passed: true}
+ for _, u := range snap.Users {
+ if u.Email == "" {
+ continue
+ }
+ if _, err := mail.ParseAddress(u.Email); err != nil {
+ r.Passed = false
+ r.Message = appendMsg(r.Message, fmt.Sprintf("user %q has invalid email %q: %v", u.ID, u.Email, err))
+ }
+ }
+ return r
+}
+
+// --- helpers ---
+
+func allPassed(results []RuleResult) bool {
+ for _, r := range results {
+ if !r.Passed {
+ return false
+ }
+ }
+ return true
+}
+
+func appendMsg(existing, msg string) string {
+ if existing == "" {
+ return msg
+ }
+ return existing + "; " + msg
+}
+
+// isValidNamingValue checks that a DN naming attribute value is non-empty
+// and does not contain characters that would break an LDAP DN.
+// The restricted characters are: , = + < > # ; \ "
+func isValidNamingValue(v string) bool {
+ if v == "" {
+ return false
+ }
+ for _, c := range v {
+ switch c {
+ case ',', '=', '+', '<', '>', '#', ';', '\\', '"':
+ return false
+ }
+ }
+ return true
+}
diff --git a/src/internal/validator/validator_test.go b/src/internal/validator/validator_test.go
new file mode 100644
index 0000000..34426c1
--- /dev/null
+++ b/src/internal/validator/validator_test.go
@@ -0,0 +1,314 @@
+package validator_test
+
+import (
+ "testing"
+
+ "keycape/internal/domain"
+ "keycape/internal/validator"
+)
+
+// --- helpers ---
+
+func makeUser(id, username, displayName, email string) domain.User {
+ return domain.User{
+ ID: id,
+ Username: username,
+ DisplayName: displayName,
+ Email: email,
+ Enabled: true,
+ }
+}
+
+func makeGroup(id, name string, members ...string) domain.Group {
+ return domain.Group{ID: id, Name: name, Members: members}
+}
+
+// --- structural: valid_dn_structure ---
+
+func TestValidDNStructure_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_dn_structure")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestValidDNStructure_EmptyID(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("", "alice", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_dn_structure")
+ if result.Passed {
+ t.Error("expected fail for empty user ID")
+ }
+}
+
+func TestValidDNStructure_InvalidUsername(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice,bad", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_dn_structure")
+ if result.Passed {
+ t.Error("expected fail for username with comma")
+ }
+}
+
+func TestValidDNStructure_InvalidGroupName(t *testing.T) {
+ snap := validator.Snapshot{
+ Groups: []domain.Group{makeGroup("g1", "bad=group")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_dn_structure")
+ if result.Passed {
+ t.Error("expected fail for group name with equals sign")
+ }
+}
+
+// --- structural: required_attributes_present ---
+
+func TestRequiredAttributesPresent_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ Groups: []domain.Group{makeGroup("g1", "admins")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "required_attributes_present")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestRequiredAttributesPresent_MissingUsername(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "required_attributes_present")
+ if result.Passed {
+ t.Error("expected fail for missing username")
+ }
+}
+
+func TestRequiredAttributesPresent_MissingDisplayName(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "required_attributes_present")
+ if result.Passed {
+ t.Error("expected fail for missing displayName")
+ }
+}
+
+// --- structural: no_unknown_attributes ---
+
+func TestNoUnknownAttributes_Pass(t *testing.T) {
+ u := makeUser("u1", "alice", "Alice", "alice@example.com")
+ u.LDAPAttributes = map[string]string{"sn": "Example"}
+ snap := validator.Snapshot{Users: []domain.User{u}}
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "no_unknown_attributes")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestNoUnknownAttributes_BlankKey(t *testing.T) {
+ u := makeUser("u1", "alice", "Alice", "alice@example.com")
+ u.LDAPAttributes = map[string]string{"": "value"}
+ snap := validator.Snapshot{Users: []domain.User{u}}
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "no_unknown_attributes")
+ if result.Passed {
+ t.Error("expected fail for blank attribute key")
+ }
+}
+
+// --- structural: valid_group_memberships ---
+
+func TestValidGroupMemberships_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Groups: []domain.Group{makeGroup("g1", "admins", "u1", "u2")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_group_memberships")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestValidGroupMemberships_BlankMember(t *testing.T) {
+ snap := validator.Snapshot{
+ Groups: []domain.Group{makeGroup("g1", "admins", "u1", "")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Structural, "valid_group_memberships")
+ if result.Passed {
+ t.Error("expected fail for blank member ID")
+ }
+}
+
+// --- semantic: referenced_users_exist ---
+
+func TestReferencedUsersExist_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "referenced_users_exist")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestReferencedUsersExist_UnknownUser(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ Groups: []domain.Group{makeGroup("g1", "admins", "u99")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "referenced_users_exist")
+ if result.Passed {
+ t.Error("expected fail for unknown user reference")
+ }
+}
+
+// --- semantic: no_cyclic_groups ---
+
+func TestNoCyclicGroups_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ Groups: []domain.Group{makeGroup("g1", "admins", "u1")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "no_cyclic_groups")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestNoCyclicGroups_GroupInMembers(t *testing.T) {
+ snap := validator.Snapshot{
+ Groups: []domain.Group{
+ makeGroup("g1", "admins", "g2"),
+ makeGroup("g2", "users", "g1"),
+ },
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "no_cyclic_groups")
+ if result.Passed {
+ t.Error("expected fail for group referencing another group")
+ }
+}
+
+// --- semantic: usernames_unique ---
+
+func TestUsernamesUnique_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{
+ makeUser("u1", "alice", "Alice", "alice@example.com"),
+ makeUser("u2", "bob", "Bob", "bob@example.com"),
+ },
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "usernames_unique")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestUsernamesUnique_Duplicate(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{
+ makeUser("u1", "alice", "Alice", "alice@example.com"),
+ makeUser("u2", "alice", "Alice Two", "alice2@example.com"),
+ },
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "usernames_unique")
+ if result.Passed {
+ t.Error("expected fail for duplicate username")
+ }
+}
+
+// --- semantic: email_format_valid ---
+
+func TestEmailFormatValid_Pass(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "email_format_valid")
+ if !result.Passed {
+ t.Errorf("expected pass, got: %s", result.Message)
+ }
+}
+
+func TestEmailFormatValid_InvalidEmail(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "email_format_valid")
+ if result.Passed {
+ t.Error("expected fail for invalid email format")
+ }
+}
+
+func TestEmailFormatValid_EmptyEmailSkipped(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ result := findRule(r.Semantic, "email_format_valid")
+ if !result.Passed {
+ t.Errorf("empty email should pass (optional): %s", result.Message)
+ }
+}
+
+// --- report overall pass/fail ---
+
+func TestReportPassed_AllGood(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "alice@example.com")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ if !r.Passed {
+ t.Errorf("expected overall pass, but report failed")
+ }
+}
+
+func TestReportFailed_OneRuleFails(t *testing.T) {
+ snap := validator.Snapshot{
+ Users: []domain.User{makeUser("u1", "alice", "Alice", "not-an-email")},
+ }
+ r := validator.Validate(snap, validator.ModeCI)
+ if r.Passed {
+ t.Error("expected overall fail when email is invalid")
+ }
+}
+
+func TestModeRecordedInReport(t *testing.T) {
+ snap := validator.Snapshot{}
+ r := validator.Validate(snap, validator.ModeMigration)
+ if r.Mode != "migration" {
+ t.Errorf("expected mode migration, got %q", r.Mode)
+ }
+}
+
+// --- helper ---
+
+func findRule(results []validator.RuleResult, name string) validator.RuleResult {
+ for _, r := range results {
+ if r.Rule == name {
+ return r
+ }
+ }
+ return validator.RuleResult{Rule: name, Passed: false, Message: "rule not found in report"}
+}
diff --git a/workplans/KEY-WP-0001-keycape-implementation.md b/workplans/KEY-WP-0001-keycape-implementation.md
new file mode 100644
index 0000000..33659dd
--- /dev/null
+++ b/workplans/KEY-WP-0001-keycape-implementation.md
@@ -0,0 +1,475 @@
+---
+id: KEY-WP-0001
+type: workplan
+title: "KeyCape Implementation — Lightweight IAM Profile"
+domain: netkingdom
+repo: key-cape
+status: active
+owner: Bernd
+topic_slug: netkingdom
+workstream_id: 2c9caad8-2ced-492d-9d63-376387b4b9b0
+topic_id: a6c6e745-bf54-4465-9340-1534a2be493e
+repo_id: 8a99bb74-1ec0-4478-ac70-35a7cddb0e3c
+created: 2026-03-13
+spec_refs:
+ - wiki/KeyCapeSpecification_v0.1.md
+ - wiki/KeyCapeSpecificationPack_v0.1.md
+decisions:
+ - id: ADR-0001
+ title: "Implementation language: Go"
+ hub_decision_id: 620beb04-fa3f-4a9d-9806-02890a7a2b0d
+ status: accepted
+ ref: docs/adr/ADR-0001-choose-go-for-keycape.md
+---
+
+# KEY-WP-0001 — KeyCape Implementation
+
+Implements the **NetKingdom IAM Profile** via KeyCape: a stateless, profile-constrained OIDC
+server orchestrating Authelia, LLDAP, and privacyIDEA. Replaceable by Keycloak without
+application changes.
+
+## Language Decision
+
+**Go** — decided 2026-03-13 by Bernd. See `docs/adr/ADR-0001-choose-go-for-keycape.md`.
+
+KeyCape is an orchestrating boundary service (HTTP, adapters, JWT via library, CLI tooling) —
+Go's strongest domain. Rust revisitable if a subcomponent needs stronger guarantees later.
+Mandatory guardrails: typed domain models, narrow adapter interfaces, layered architecture,
+fuzz tests on validator/redirect/claim-mapping, no cleverness.
+
+## Repo Structure (from spec §12)
+
+```
+src/
+ server/
+ oidc/ # Profile endpoints
+ telemetry/ # Structured event emission
+ errors/ # Error taxonomy + enforcement middleware
+ adapters/
+ authelia/ # Auth flow delegation
+ lldap/ # Identity directory reads
+ privacyidea/ # MFA enforcement
+ validator/ # Canonical LDAP schema validator binary
+ migration/
+ lldap-export/ # LLDAP → canonical
+ keycape-to-keycloak/ # Canonical → Keycloak realm import
+ lldap-to-ldap/ # LLDAP → OpenLDAP/389DS/AD LDIF
+spec/
+ canonical-model.yaml # Source of truth for all identity data
+ ldap-schema.yaml # Canonical LDAP schema rules
+tests/
+ profile/ # Scenario A — lightweight baseline
+ negative/ # Scenario D — unsupported feature rejection
+ migration/ # Scenarios B & C — replacement
+```
+
+## Dependency Order
+
+```
+T01 (project setup)
+ └─ T02 (canonical model) T04 (error taxonomy)
+ └─ T03 (LDAP validator) └─ T13 (telemetry)
+ └─ T10 (LLDAP adapter) └─ T14 (enforcement layer)
+ └─ T11 (Authelia) │
+ └─ T12 (privacyIDEA) │
+ │ │
+ T05 ─ T06 ─ T07 ─ T08 ─┴─ T09 (OIDC server)
+ │
+ T18 (profile tests / Scenario A)
+ T21 (negative tests / Scenario D)
+ │
+ T15 → T16 → T19 (Scenario B)
+ T15 → T17 → T20 (Scenario C)
+ │
+ T22 (dev stack)
+ T23 (production packaging)
+```
+
+---
+
+## Phase 1 — Foundations
+
+## T01 — Project setup: Go module, repo layout, CI skeleton
+
+```task
+id: KEY-WP-0001-T01
+hub_task_id: 25613e3f-2a65-409e-afaa-d23ded0bc256
+priority: high
+status: todo
+```
+
+Initialise language module in `src/`. Create directory skeleton per spec §12. Add Makefile
+targets: `build`, `test`, `lint`. Set up CI (build + test on every push). Scaffolding only —
+no application code. **Agent must call `record_decision()` with chosen language (Go or Rust).**
+
+## T02 — Canonical identity model: machine-readable schema
+
+```task
+id: KEY-WP-0001-T02
+hub_task_id: deee2929-9386-41db-bf91-fbd9ad646c28
+priority: high
+status: todo
+depends_on: [T01]
+```
+
+Write `spec/canonical-model.yaml`. Six entities: User, Group, Role, Client, Membership,
+MFAEnrollment (fields per spec §2). Include JSON Schema or CUE schema for programmatic
+validation. This file is the **source of truth** — all other code derives from it.
+
+## T03 — Canonical LDAP schema + validator
+
+```task
+id: KEY-WP-0001-T03
+hub_task_id: 02592c65-db23-474b-b06b-019e95df8146
+priority: high
+status: todo
+depends_on: [T01, T02]
+```
+
+Write `spec/ldap-schema.yaml`: tree layout (`ou=users`, `ou=groups`, `ou=clients` under
+`dc=netkingdom,dc=local`), object classes (`inetOrgPerson`, `groupOfNames`), required/optional
+attributes. Implement `validator/` binary. Structural rules: valid DN, required attrs, no unknown
+attrs, valid group memberships. Semantic rules: referenced users exist, no cycles, usernames
+unique, email format valid. Validator runs in `--mode=ci|provisioning|migration`. Emits
+machine-readable report.
+
+## T04 — Error taxonomy: types, JSON format, middleware
+
+```task
+id: KEY-WP-0001-T04
+hub_task_id: 46870fd6-0672-432b-8824-6bc2e24811b3
+priority: high
+status: todo
+depends_on: [T01]
+```
+
+Implement four error types (spec §5):
+- `feature_not_supported_by_profile`
+- `available_in_keycloak_mode_only`
+- `rejected_for_profile_safety`
+- `invalid_profile_usage`
+
+JSON format: `{"error": "...", "description": "...", "feature": "..."}`. HTTP middleware wraps
+all handler errors. Error type strings are stable and test-assertable.
+
+---
+
+## Phase 2 — OIDC Server
+
+## T05 — OIDC discovery endpoint (/.well-known/openid-configuration)
+
+```task
+id: KEY-WP-0001-T05
+hub_task_id: 92eb8916-cb22-4786-9f16-a8a07272f818
+priority: high
+status: todo
+depends_on: [T04]
+```
+
+`GET /.well-known/openid-configuration`. Advertise **only** profile-supported features:
+`authorization_code`, S256 PKCE, RS256, static scopes. Must NOT advertise: dynamic registration,
+implicit flow. Issuer configurable. Cacheable response.
+
+## T06 — Authorization endpoint with PKCE and redirect URI validation
+
+```task
+id: KEY-WP-0001-T06
+hub_task_id: c3df620b-9864-4ff1-ba6d-26057c6f4d59
+priority: high
+status: todo
+depends_on: [T04, T11, T12, T13, T14]
+```
+
+`GET/POST /authorize`. Validate: `client_id` (static config), `redirect_uri` (exact match —
+wildcard → `rejected_for_profile_safety`), `response_type=code`, `scope` contains `openid`,
+`code_challenge` present (missing → `invalid_profile_usage`), `code_challenge_method=S256`.
+Delegate to Authelia adapter. Store PKCE state server-side. No implicit or hybrid flow.
+
+## T07 — Token endpoint: JWT/RS256, canonical claim mapping
+
+```task
+id: KEY-WP-0001-T07
+hub_task_id: d3248f4d-e0e9-4144-9844-c9768dc896d6
+priority: high
+status: todo
+depends_on: [T06, T08, T10]
+```
+
+`POST /token`. Validate PKCE `code_verifier`. Issue RS256 JWT via standard library (no custom
+crypto). Mandatory claims: `iss`, `sub` (canonical user ID), `aud`, `exp`, `iat`. Optional
+claims: `preferred_username` (LDAP `uid`), `email` (LDAP `mail`), `groups` (groupOfNames),
+`roles`. Short, explicitly configured token lifetime. Any other grant type →
+`feature_not_supported_by_profile`.
+
+## T08 — JWKS endpoint (/jwks)
+
+```task
+id: KEY-WP-0001-T08
+hub_task_id: 58a1d705-b788-4d66-8c0a-33edff63a885
+priority: high
+status: todo
+depends_on: [T01]
+```
+
+`GET /jwks`. RS256 public key in JWK Set format. Key loaded from config. Key rotation: serve
+multiple keys during rotation window, keyed by `kid`. Standard library key generation only.
+
+## T09 — Userinfo endpoint (/userinfo)
+
+```task
+id: KEY-WP-0001-T09
+hub_task_id: 742d3924-21e9-4304-86e9-0400af0e81ee
+priority: medium
+status: todo
+depends_on: [T07, T10]
+```
+
+`GET /userinfo`. Optional per spec — implement if any registered client requires it. Validate
+Bearer token (RS256 + expiry). Return claim subset scoped to granted scopes. Claims must be
+identical to ID token for same scopes. If no client needs it: stub returning
+`available_in_keycloak_mode_only`.
+
+---
+
+## Phase 3 — Backend Adapters
+
+## T10 — LLDAP adapter: user and group reads
+
+```task
+id: KEY-WP-0001-T10
+hub_task_id: 2043b10a-6822-45f8-abcc-4e233d918fb0
+priority: high
+status: todo
+depends_on: [T02, T03]
+```
+
+`adapters/lldap`. LDAP protocol connection to LLDAP. Interface: `LookupUser(username) → canonical
+User`, `LookupGroups(userDN) → []Group`, `ValidatePassword(username, password) → bool`. Attribute
+map: `uid→username`, `cn→displayName`, `mail→email`, `memberOf→groups`. Run canonical LDAP schema
+validator on every read. No LDAP internals exposed to `server/`.
+
+## T11 — Authelia adapter: session and auth flow delegation
+
+```task
+id: KEY-WP-0001-T11
+hub_task_id: ad129a14-1552-4717-b1dd-b529d18ce681
+priority: high
+status: todo
+depends_on: [T04, T13]
+```
+
+`adapters/authelia`. Initiate auth redirect to Authelia, receive callback, extract authenticated
+user identity, hand off to MFA check (T12). Must not leak Authelia session tokens/cookies into
+profile layer. Unavailable Authelia → fail closed (`auth_failure` event).
+
+## T12 — privacyIDEA adapter: MFA enforcement
+
+```task
+id: KEY-WP-0001-T12
+hub_task_id: 1ef196e6-2304-4cf6-b205-47ac1da879ec
+priority: high
+status: todo
+depends_on: [T02, T13]
+```
+
+`adapters/privacyidea`. **KeyCape must NOT implement MFA logic.** Interface:
+`CheckMFARequired(user) → bool`, `ValidateMFAToken(user, token) → bool`. MFA failure → no token
+issued + `auth_failure` telemetry. MFA enrollment state from canonical `MFAEnrollment` entity.
+privacyIDEA remains stable across lightweight → expanded migration.
+
+---
+
+## Phase 4 — Telemetry & Enforcement
+
+## T13 — Telemetry pipeline: structured event emission
+
+```task
+id: KEY-WP-0001-T13
+hub_task_id: 704146bf-cd60-4922-b18b-3d209cff3ac3
+priority: high
+status: todo
+depends_on: [T01]
+```
+
+`server/telemetry`. Event types (spec §6.1): `auth_start`, `auth_success`, `auth_failure`,
+`token_issued`, `unsupported_feature`, `invalid_request`, `migration_event`. Required fields
+(spec §6.2): `timestamp`, `client_id`, `endpoint`, `feature`, `result`, `error_type`, `scopes`,
+`grant_type`, `environment`, `trace_id`. Pluggable outputs: structured log (default), Prometheus
+metrics endpoint. Every auth and error path emits an event — **no silent paths**.
+
+## T14 — Unsupported feature enforcement layer
+
+```task
+id: KEY-WP-0001-T14
+hub_task_id: 71f44886-ab61-4160-a435-72b35af472a0
+priority: high
+status: todo
+depends_on: [T04, T13]
+```
+
+`server/errors` enforcement middleware. Intercept any parameter, grant type, scope, or client
+config exceeding the profile. Return correct error type + emit `unsupported_feature` telemetry.
+Maintain a **registry** of unsupported features (adding new ones requires no handler changes):
+`dynamic_client_registration`, `identity_broker`, `wildcard_redirect_uri`, `implicit_flow`, etc.
+Every registry entry must have a corresponding test in T21.
+
+---
+
+## Phase 5 — Migration Tooling
+
+## T15 — Migration: LLDAP → canonical export
+
+```task
+id: KEY-WP-0001-T15
+hub_task_id: 1bd13f76-2d62-429d-b230-d785ef6a3f2f
+priority: medium
+status: todo
+depends_on: [T02, T03, T10]
+```
+
+`migration/lldap-export` tool. Read all users, groups, memberships, attributes from LLDAP. Map
+to canonical model. Validate against LDAP schema validator before writing. Output:
+`canonical-export.yaml`. Emit `migration_event` telemetry. Idempotent. Include incompatibility
+report for unmappable LLDAP data.
+
+## T16 — Migration: canonical → Keycloak import
+
+```task
+id: KEY-WP-0001-T16
+hub_task_id: f3d50e80-f6b5-4c0c-a5f3-08308ea1a95e
+priority: medium
+status: todo
+depends_on: [T15]
+```
+
+`migration/keycape-to-keycloak` tool. Read canonical export (T15). Transform to Keycloak realm
+import format: users, groups, clients, roles, scope mappings. Preserve: same issuer, same claims,
+same scopes, same client behavior. Output: `keycloak-realm-import.json`. Emit `migration_event`.
+Include round-trip validation report.
+
+## T17 — Migration: LLDAP → full LDAP (OpenLDAP / 389DS / AD)
+
+```task
+id: KEY-WP-0001-T17
+hub_task_id: 044d99c8-39cb-4f35-9308-912ae829bd22
+priority: medium
+status: todo
+depends_on: [T15]
+```
+
+`migration/lldap-to-ldap` tool. Export via T15 canonical export. Generate LDIF for target
+(`--target=openldap|389ds|ad`). Migrate: users, groups, memberships, attributes. Run validator
+on output LDIF before import. Produce validation report. **Orthogonal to T16** — the two
+migration dimensions are independent (spec §14.1).
+
+---
+
+## Phase 6 — Replacement Tests
+
+## T18 — Profile test suite: Scenario A (lightweight baseline)
+
+```task
+id: KEY-WP-0001-T18
+hub_task_id: 76abc3f6-9c4e-4aca-9995-72b728925812
+priority: high
+status: todo
+depends_on: [T05, T06, T07, T08, T09, T22]
+```
+
+`tests/profile`. Provision canonical fixtures into LLDAP + Authelia + KeyCape. Test categories
+(spec §15.3): discovery, login flow (PKCE), token claim assertions (all mandatory + optional),
+redirect validation, client config, MFA policy, logout (if implemented). Tests are
+**backend-agnostic** — same suite runs in T19 and T20. Must pass for Scenario A conformance.
+
+## T19 — Replacement test suite: Scenario B (IAM swap, same directory)
+
+```task
+id: KEY-WP-0001-T19
+hub_task_id: 56d03e89-934b-4992-bfe4-b32f275882e3
+priority: medium
+status: todo
+depends_on: [T18, T16]
+```
+
+Run T18 suite against Keycloak + LLDAP (configured from T16 canonical export). **No test code
+changes allowed.** Migration successful only if all T18 tests pass. Proves IAM replaceability
+without directory migration.
+
+## T20 — Replacement test suite: Scenario C (full expansion)
+
+```task
+id: KEY-WP-0001-T20
+hub_task_id: ec3cae5c-9942-4be7-acc0-1eb9f02aba45
+priority: medium
+status: todo
+depends_on: [T19, T17]
+```
+
+Apply T17 LLDAP→OpenLDAP migration, then T16 Keycloak import. Run T18 suite. Migration successful
+only if all tests pass. privacyIDEA must remain stable (no MFA re-enrollment required).
+
+## T21 — Negative profile tests: Scenario D (unsupported feature rejection)
+
+```task
+id: KEY-WP-0001-T21
+hub_task_id: a5112b63-121f-4d17-ac1a-fb46d160413e
+priority: high
+status: todo
+depends_on: [T14]
+```
+
+`tests/negative`. For every entry in T14 unsupported-feature registry: attempt usage, assert
+correct `error.error` string, assert `unsupported_feature` telemetry event emitted. Covered
+cases: `dynamic_client_registration`, `implicit_flow`, wildcard redirect URIs, identity brokering,
+missing PKCE, unknown scopes, unknown grant types. **Must run in CI.** Proves enforcement layer
+is complete.
+
+---
+
+## Phase 7 — Operations
+
+## T22 — Dev mode stack: docker-compose with LLDAP + Authelia
+
+```task
+id: KEY-WP-0001-T22
+hub_task_id: e840963f-cd19-4b38-857a-7c40df165d3d
+priority: medium
+status: todo
+depends_on: [T01]
+```
+
+`docker-compose.dev.yml`: KeyCape, LLDAP, Authelia, privacyIDEA (or stub). Pre-seeded with
+canonical fixtures from T02. Makefile targets: `make dev` (start + verify basic auth flow),
+`make seed` (re-apply fixtures without full restart). Deterministic: same seed → same state.
+Test environment for T18 and T21.
+
+## T23 — Slim production packaging: binary + config + health
+
+```task
+id: KEY-WP-0001-T23
+hub_task_id: d5723683-f739-4362-b62e-71213dc5a89e
+priority: low
+status: todo
+depends_on: [T18, T21]
+```
+
+Single stateless binary. Declarative YAML config: profile version, client definitions, backend
+connections, LDAP schema rules, privacyIDEA settings, telemetry destination, token lifetime,
+issuer URL. Static config validation on startup. `/healthz` endpoint. Minimal container image
+(distroless or Alpine). Config environment-promotable via env var overrides only.
+
+---
+
+## Acceptance Criteria (from spec §15.4 and §20)
+
+A release is conformant when:
+
+- [ ] Scenario A tests pass (T18)
+- [ ] Scenario D tests pass (T21)
+- [ ] Scenario B tests pass (T19) — IAM migration verified
+- [ ] Scenario C tests pass (T20) — full expansion verified
+- [ ] All error responses use taxonomy types from spec §5
+- [ ] All auth/error paths emit structured telemetry (T13)
+- [ ] Canonical LDAP schema validator passes on all fixtures (T03)
+- [ ] No handwritten cryptography anywhere in the codebase
+- [ ] Config is statically validated at startup (T23)