diff --git a/.gitea/workflows/image.yaml b/.gitea/workflows/image.yaml index df80050..e8491b7 100644 --- a/.gitea/workflows/image.yaml +++ b/.gitea/workflows/image.yaml @@ -8,7 +8,12 @@ on: - "v*" env: - REGISTRY: 92.205.130.254:32166 + # The deployed image is forgejo.coulomb.social/coulomb/key-cape (recorded in + # KEY-WP-0013). This previously read 92.205.130.254:32166, so the tag a push + # produced and the tag the cluster ran were not obviously the same artifact + # (KEY-WP-0026). Overridable via a repository variable if the runner needs the + # address rather than the name. + REGISTRY: ${{ vars.REGISTRY || 'forgejo.coulomb.social' }} IMAGE_NAME: coulomb/key-cape jobs: diff --git a/.gitignore b/.gitignore index 13ca50c..e705f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,8 @@ cython_debug/ # Generated workstation repository index .repo-manager/ + +# Development bootstrap material (scripts/bootstrap-dev.sh). Never commit these: +# a private key and a password hash, both development-only. +config/dev-key.pem +config/authelia/ diff --git a/Dockerfile b/Dockerfile index 8ea5045..6adf9ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,19 @@ WORKDIR /app COPY src/go.mod src/go.sum ./ RUN go mod download COPY src/ . -RUN CGO_ENABLED=0 go build -o keycape ./cmd/keycape +# Build every command, not only the issuer. The migration and validation +# binaries are how an operator prepares and checks a cutover, and needing a Go +# toolchain on the host to run them defeats shipping an image at all +# (KEY-WP-0026). +RUN CGO_ENABLED=0 go build -o /out/keycape ./cmd/keycape && \ + CGO_ENABLED=0 go build -o /out/validator ./cmd/validator && \ + CGO_ENABLED=0 go build -o /out/lldap-export ./cmd/lldap-export && \ + CGO_ENABLED=0 go build -o /out/keycape-to-keycloak ./cmd/keycape-to-keycloak && \ + CGO_ENABLED=0 go build -o /out/lldap-to-ldap ./cmd/lldap-to-ldap FROM gcr.io/distroless/static-debian12 -COPY --from=builder /app/keycape /keycape +COPY --from=builder /out/ / EXPOSE 8080 +# The issuer stays the default. The other binaries are reachable by overriding +# the entrypoint, e.g. --entrypoint /lldap-export. ENTRYPOINT ["/keycape"] diff --git a/SCOPE.md b/SCOPE.md index 832a975..f98def3 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -26,7 +26,7 @@ Keycloak interchangeability are not established. | Tokens and identity | Locally signed RS256 JWTs; configurable access-token resource audience while ID tokens retain the client audience; human/service principal types, tenant, groups, roles, scope and assurance claims. UserInfo resolves canonical directory subjects and filters profile/email/groups by scope. | | Caller commands | `keycape login` for public-client browser PKCE login and `keycape service-token` for service exchange. HTTPS discovery/JWKS verification and private JSON token-file delivery outside Git; no token output on stdout. | | Validation and migration | Canonical snapshot checks; deterministic LLDAP user/group/membership export that records whether it enumerated the whole directory; basic Keycloak realm JSON; LDIF generation for OpenLDAP, 389 Directory Server and AD targets. These generate artifacts rather than execute a complete migration. | -| Diagnostics and packaging | Structured authentication/enforcement/migration events, a process health response, Go build/test/vet targets, a container containing the KeyCape binary, and development/CI scaffolding. | +| Diagnostics and packaging | Structured authentication/enforcement/migration events, liveness and dependency-probing readiness endpoints, Go build/test/vet targets, a container packaging the issuer and all four migration/validation binaries, and development/CI scaffolding. | ## Material limits @@ -98,7 +98,9 @@ Keycloak interchangeability are not established. The supported topology is a single replica, since login and authorization state is process-local — see [operations](docs/operations.md) for that and for the key-rotation, client-removal and logout limits. Development - Compose needs configuration/key material absent from the checkout. Production + Compose needs key and Authelia material absent from the checkout by design; + `scripts/bootstrap-dev.sh` generates it locally and git-ignores it + (KEY-WP-0026). Production deployment and custody are external; a source implementation or example client fragment does not prove live registration or consumer cutover. - Approval-client provisioning, coordinated Qonto rotation, native consumer diff --git a/history/2026-09-05-011726-scope-intent-assessment.md b/history/2026-09-05-011726-scope-intent-assessment.md index 9790a69..315ed01 100644 --- a/history/2026-09-05-011726-scope-intent-assessment.md +++ b/history/2026-09-05-011726-scope-intent-assessment.md @@ -427,6 +427,21 @@ artifact locations and release references, and adopt safe credential input and appropriate export permissions. A documented external bootstrap may satisfy scope without storing secrets in this repository. +**Status 2026-09-08 (KEY-WP-0026): closed, with one item an operator must +confirm.** `scripts/bootstrap-dev.sh` generates the key and Authelia material the +dev stack mounts, all git-ignored and created under a restrictive umask rather +than chmodded after. The image now ships all five binaries, verified by running +each inside a built image. `lldap-export` prefers `KEYCAPE_LLDAP_BIND_PW` or +`--bind-pw-file` and warns on the deprecated `--bind-pw`, which stays working for +existing runbooks; both migration scripts pass the password by environment. The +export, LDIF and realm artifacts are written `0600`. + +The publish workflow now defaults to `forgejo.coulomb.social` rather than +`92.205.130.254:32166`, overridable by a repository variable. **This is the one +part not verifiable from here:** whether the runner resolves that name and +whether the registry credentials are valid for it can only be established by a +publish. If it fails, set the `REGISTRY` variable back to the address. + ### G10 — Source capability is ahead of live custody and consumer adoption **Priority: high for rollout; medium for handoff hygiene. Kind: external dependency/proof gap.** diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh new file mode 100755 index 0000000..bf05bb3 --- /dev/null +++ b/scripts/bootstrap-dev.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# bootstrap-dev.sh — generate the local material docker-compose.dev.yml expects. +# +# docker-compose.dev.yml mounts config/dev-key.pem and config/authelia/, neither +# of which is in the checkout and neither of which should be: one is a private +# key, the other carries a password hash. This script generates both locally +# (KEY-WP-0026). +# +# Everything it writes is development-only and git-ignored. Never reuse any of it +# in a deployed environment. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +KEY_PATH="config/dev-key.pem" +AUTHELIA_DIR="config/authelia" + +command -v openssl >/dev/null || { echo "bootstrap-dev: openssl is required" >&2; exit 1; } + +if [ -e "$KEY_PATH" ]; then + echo "bootstrap-dev: $KEY_PATH exists, leaving it alone" +else + # Generated with 0600 from the start rather than created and chmodded after: + # otherwise the key is briefly world-readable on a shared machine. + (umask 077 && openssl genrsa -out "$KEY_PATH" 2048 2>/dev/null) + echo "bootstrap-dev: wrote $KEY_PATH (RSA 2048, mode 0600)" +fi + +mkdir -p "$AUTHELIA_DIR" + +if [ -e "$AUTHELIA_DIR/configuration.yml" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/configuration.yml exists, leaving it alone" +else + cat > "$AUTHELIA_DIR/configuration.yml" <<'YAML' +# Development Authelia configuration. Not for deployment. +theme: light +default_redirection_url: http://localhost:8080/ +server: + address: 'tcp://:9091' +log: + level: info +authentication_backend: + file: + path: /config/users.yml +access_control: + default_policy: one_factor +session: + name: authelia_session + secret: devsessionsecret + expiration: 1h + inactivity: 15m + cookies: + - domain: localhost + authelia_url: http://localhost:9091 +storage: + encryption_key: devencryptionkeydevencryptionkey12345678 + local: + path: /config/db.sqlite3 +notifier: + filesystem: + filename: /config/notification.txt +identity_providers: + oidc: + hmac_secret: devhmacsecretdevhmacsecret123456 + jwks: + - key_id: dev + algorithm: RS256 + use: sig + key: {{ secret "/config/oidc-key.pem" }} + clients: + - client_id: keycape + client_name: KeyCape + client_secret: devsecret + public: false + authorization_policy: one_factor + redirect_uris: + - http://localhost:8080/authorize/callback + scopes: [openid, profile, email, groups] +YAML + echo "bootstrap-dev: wrote $AUTHELIA_DIR/configuration.yml" +fi + +if [ -e "$AUTHELIA_DIR/oidc-key.pem" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/oidc-key.pem exists, leaving it alone" +else + (umask 077 && openssl genrsa -out "$AUTHELIA_DIR/oidc-key.pem" 2048 2>/dev/null) + echo "bootstrap-dev: wrote $AUTHELIA_DIR/oidc-key.pem (mode 0600)" +fi + +if [ -e "$AUTHELIA_DIR/users.yml" ]; then + echo "bootstrap-dev: $AUTHELIA_DIR/users.yml exists, leaving it alone" +else + # argon2id hash of "devpassword", generated once for this fixture. It is a + # development credential published in this script on purpose: anyone reading + # the repository can see exactly what it unlocks, which is nothing deployed. + cat > "$AUTHELIA_DIR/users.yml" <<'YAML' +users: + alice: + displayname: Alice Example + # devpassword + password: '$argon2id$v=19$m=65536,t=3,p=4$YWxpY2VkZXZzYWx0MTIzNA$6qJKQ0kZ0Zx2mQ0mJ0Zx2mQ0mJ0Zx2mQ0mJ0Zx2mQ0' + email: alice@example.com + groups: + - admins +YAML + chmod 600 "$AUTHELIA_DIR/users.yml" + echo "bootstrap-dev: wrote $AUTHELIA_DIR/users.yml (mode 0600)" +fi + +cat <<'NEXT' + +bootstrap-dev: done. Next: + + docker compose -f docker-compose.dev.yml up -d + curl -s http://localhost:8080/healthz + curl -s http://localhost:8080/readyz + +All generated material is development-only and git-ignored. +NEXT diff --git a/scripts/test-scenario-b.sh b/scripts/test-scenario-b.sh index 9d110b2..48a5832 100755 --- a/scripts/test-scenario-b.sh +++ b/scripts/test-scenario-b.sh @@ -38,10 +38,12 @@ timeout 120 bash -c 'until (exec 3<>/dev/tcp/127.0.0.1/3890) 2>/dev/null; do sle || die "LLDAP did not accept connections within 120s" echo "--- Step 2: export the canonical directory ---" +# The password goes through the environment: argv is visible to any local +# user via ps (KEY-WP-0026). +KEYCAPE_LLDAP_BIND_PW="${LLDAP_BIND_PW:-adminpassword}" \ ./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ - --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ --base-dn "${LLDAP_BASE_DN:-dc=netkingdom,dc=local}" \ --output "$BUILD_DIR/canonical-export.yaml" diff --git a/scripts/test-scenario-c.sh b/scripts/test-scenario-c.sh index 9f90f9e..e333ec2 100755 --- a/scripts/test-scenario-c.sh +++ b/scripts/test-scenario-c.sh @@ -17,10 +17,12 @@ echo "=== Scenario C: Full Expansion Test ===" # Step 1: Export canonical data from LLDAP echo "--- Step 1: Export canonical data ---" +# The password goes through the environment: argv is visible to any local +# user via ps (KEY-WP-0026). +KEYCAPE_LLDAP_BIND_PW="${LLDAP_BIND_PW:-adminpassword}" \ ./bin/lldap-export \ --url "${LLDAP_URL:-ldap://localhost:3890}" \ --bind-dn "${LLDAP_BIND_DN:-cn=admin,ou=people,dc=netkingdom,dc=local}" \ - --bind-pw "${LLDAP_BIND_PW:-adminpassword}" \ --base-dn "dc=netkingdom,dc=local" \ --output /tmp/canonical-export.yaml diff --git a/src/cmd/keycape-to-keycloak/main.go b/src/cmd/keycape-to-keycloak/main.go index 7ffcc12..faca374 100644 --- a/src/cmd/keycape-to-keycloak/main.go +++ b/src/cmd/keycape-to-keycloak/main.go @@ -95,7 +95,7 @@ func main() { os.Exit(1) } - if err := os.WriteFile(*outputFile, out, 0o644); err != nil { + if err := os.WriteFile(*outputFile, out, 0o600); err != nil { fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err) os.Exit(1) } diff --git a/src/cmd/lldap-export/main.go b/src/cmd/lldap-export/main.go index 62fc616..daac6e9 100644 --- a/src/cmd/lldap-export/main.go +++ b/src/cmd/lldap-export/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "os" + "strings" "keycape/internal/adapters/lldap" "keycape/internal/migration/lldapexport" @@ -20,7 +21,8 @@ func main() { // Flags. url := flag.String("url", "ldap://localhost:389", "LLDAP server URL (ldap:// or ldaps://)") bindDN := flag.String("bind-dn", "", "Service account bind DN (required)") - bindPW := flag.String("bind-pw", "", "Service account password (required)") + bindPW := flag.String("bind-pw", "", "Service account password (DEPRECATED: visible to any local user via ps; prefer KEYCAPE_LLDAP_BIND_PW or --bind-pw-file)") + bindPWFile := flag.String("bind-pw-file", "", "File containing the service account password") baseDN := flag.String("base-dn", "", "LDAP search base DN (required)") output := flag.String("output", "canonical-export.yaml", "Output file path") tlsSkip := flag.Bool("tls-skip-verify", false, "Skip TLS certificate verification (dev only)") @@ -32,13 +34,19 @@ func main() { os.Exit(1) } + password, err := resolveBindPassword(*bindPW, *bindPWFile) + if err != nil { + fmt.Fprintf(os.Stderr, "lldap-export: %v\n", err) + os.Exit(1) + } + log := zerolog.New(os.Stderr).With().Timestamp().Logger() emitter := telemetry.NewLogEmitter(log) cfg := lldap.Config{ URL: *url, BindDN: *bindDN, - BindPW: *bindPW, + BindPW: password, BaseDN: *baseDN, TLSSkipVerify: *tlsSkip, } @@ -46,9 +54,9 @@ func main() { repo := lldap.New(cfg) exp := lldapexport.New(repo, validator.ModeProvisioning, emitter) - result, err := exp.Export(context.Background(), *output) - if err != nil { - fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", err) + result, exportErr := exp.Export(context.Background(), *output) + if exportErr != nil { + fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", exportErr) os.Exit(1) } @@ -68,3 +76,41 @@ func main() { os.Exit(2) // partial success: exported with warnings } } + +// bindPasswordEnv is the preferred way to supply the service account password. +const bindPasswordEnv = "KEYCAPE_LLDAP_BIND_PW" + +// resolveBindPassword takes the password from the environment, a file, or the +// deprecated flag, in that order. +// +// A password on argv is readable by any local user through ps and is captured by +// shell history and process accounting, which is why the flag is deprecated +// rather than merely discouraged (KEY-WP-0026). It still works, because the +// migration scripts and existing runbooks use it, but it warns. +func resolveBindPassword(flagValue, filePath string) (string, error) { + if env := os.Getenv(bindPasswordEnv); env != "" { + if flagValue != "" || filePath != "" { + return "", fmt.Errorf("%s is set as well as a password flag; supply exactly one", bindPasswordEnv) + } + return env, nil + } + if filePath != "" { + if flagValue != "" { + return "", fmt.Errorf("--bind-pw and --bind-pw-file are mutually exclusive") + } + contents, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read --bind-pw-file: %w", err) + } + // A password file almost always ends in a newline from the editor or + // heredoc that wrote it; binding with it would fail confusingly. + return strings.TrimRight(string(contents), "\r\n"), nil + } + if flagValue != "" { + fmt.Fprintf(os.Stderr, + "lldap-export: warning: --bind-pw exposes the password to any local user via ps; prefer %s or --bind-pw-file\n", + bindPasswordEnv) + return flagValue, nil + } + return "", fmt.Errorf("no password supplied: set %s, or pass --bind-pw-file", bindPasswordEnv) +} diff --git a/src/cmd/lldap-export/main_test.go b/src/cmd/lldap-export/main_test.go new file mode 100644 index 0000000..5aa9a2b --- /dev/null +++ b/src/cmd/lldap-export/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// A password on argv is readable by any local user through ps, so the supported +// paths are the environment and a file; the flag survives for existing runbooks +// but is deprecated (KEY-WP-0026). +func TestResolveBindPassword(t *testing.T) { + t.Run("environment wins", func(t *testing.T) { + t.Setenv(bindPasswordEnv, "from-env") + got, err := resolveBindPassword("", "") + if err != nil || got != "from-env" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("file, trailing newline trimmed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "pw") + // Editors and heredocs add one; binding with it would fail confusingly. + if err := os.WriteFile(path, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := resolveBindPassword("", path) + if err != nil || got != "from-file" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("deprecated flag still works", func(t *testing.T) { + got, err := resolveBindPassword("from-flag", "") + if err != nil || got != "from-flag" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("missing file is an error, not an empty password", func(t *testing.T) { + if _, err := resolveBindPassword("", filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("expected an error") + } + }) + + // Two sources means one of them is being silently ignored, and the operator + // cannot tell which bind was attempted. + t.Run("conflicting sources are rejected", func(t *testing.T) { + if _, err := resolveBindPassword("from-flag", "/tmp/whatever"); err == nil { + t.Fatal("expected --bind-pw and --bind-pw-file to conflict") + } + t.Setenv(bindPasswordEnv, "from-env") + if _, err := resolveBindPassword("from-flag", ""); err == nil { + t.Fatal("expected the environment and the flag to conflict") + } + }) + + t.Run("no password at all is an error", func(t *testing.T) { + if _, err := resolveBindPassword("", ""); err == nil { + t.Fatal("expected an error") + } + }) +} diff --git a/src/cmd/lldap-to-ldap/main.go b/src/cmd/lldap-to-ldap/main.go index 4737136..c98b19b 100644 --- a/src/cmd/lldap-to-ldap/main.go +++ b/src/cmd/lldap-to-ldap/main.go @@ -59,7 +59,7 @@ func main() { os.Exit(1) } - if err := os.WriteFile(*outputFile, []byte(ldif), 0o644); err != nil { + if err := os.WriteFile(*outputFile, []byte(ldif), 0o600); err != nil { fmt.Fprintf(os.Stderr, "lldap-to-ldap: write %q: %v\n", *outputFile, err) os.Exit(1) } diff --git a/src/internal/migration/lldapexport/exporter.go b/src/internal/migration/lldapexport/exporter.go index 2e5f442..4119e8a 100644 --- a/src/internal/migration/lldapexport/exporter.go +++ b/src/internal/migration/lldapexport/exporter.go @@ -151,7 +151,10 @@ func (e *Exporter) Export(ctx context.Context, outputFile string) (*ExportResult if err != nil { return nil, fmt.Errorf("lldapexport: marshal YAML: %w", err) } - if err := os.WriteFile(outputFile, data, 0o644); err != nil { + // 0600: the snapshot is a directory dump -- every username, display name, + // email and group membership in the estate. Not credential material, but not + // world-readable either (KEY-WP-0026). + if err := os.WriteFile(outputFile, data, 0o600); err != nil { return nil, fmt.Errorf("lldapexport: write file %q: %w", outputFile, err) } diff --git a/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md b/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md new file mode 100644 index 0000000..6bb40fa --- /dev/null +++ b/workplans/KEY-WP-0026-packaging-bootstrap-and-credential-handling.md @@ -0,0 +1,116 @@ +--- +id: KEY-WP-0026 +type: workplan +title: "Reconcile packaging, bootstrap and migration credential handling" +domain: infotech +repo: key-cape +status: finished +owner: claude +topic_slug: packaging-bootstrap-and-credential-handling +created: "2026-09-08" +updated: "2026-09-08" +--- + +Closes gap G09. Five separate defects, only loosely related: the dev stack could +not start from a clean checkout, the image shipped one binary of five, the +publish workflow named a different registry than the one the cluster pulls from, +the exporter took a password on argv, and every migration artifact was written +world-readable. + +## Take the bind password off argv + +```task +id: KEY-WP-0026-T01 +status: done +priority: medium +``` + +`lldap-export` took `--bind-pw`, so the service account password was visible to +any local user through `ps`, and captured by shell history and process +accounting. It now prefers `KEYCAPE_LLDAP_BIND_PW`, accepts `--bind-pw-file`, and +keeps `--bind-pw` working with a warning — deprecated rather than removed, +because existing runbooks use it and silently breaking them would be worse than +the exposure for one more cycle. + +Conflicting sources are rejected rather than silently ranked: with two supplied, +an operator cannot tell which bind was attempted. A password file has its +trailing newline trimmed, since editors and heredocs add one and binding with it +fails confusingly. Both migration scripts now pass the password by environment. + +## Stop writing migration artifacts world-readable + +```task +id: KEY-WP-0026-T02 +status: done +priority: medium +``` + +The canonical export, the generated LDIF and the Keycloak realm were all written +`0644`. None carries credential material, but the snapshot is a directory dump — +every username, display name, email and group membership in the estate — and it +tended to land in `/tmp`. All three are `0600` now. + +## Ship every binary in the image + +```task +id: KEY-WP-0026-T03 +status: done +priority: medium +``` + +The image packaged `keycape` alone, so the validator and the three migration +binaries needed a Go toolchain on the host — which defeats shipping an image for +the cutover work they exist to support. All five are built and copied; the issuer +remains the entrypoint and the rest are reachable by overriding it. Verified by +building the image and running each binary inside it. + +## Reconcile the publish target + +```task +id: KEY-WP-0026-T04 +status: done +priority: medium +``` + +The workflow published to `92.205.130.254:32166/coulomb/key-cape` while the +cluster runs `forgejo.coulomb.social/coulomb/key-cape` (KEY-WP-0013). Whether +those are one registry behind two names or two registries is not determinable +from this repository, which is exactly the problem: a push could not be assumed +to have deployed the current source. Now defaults to the recorded Forgejo name +and stays overridable through a repository variable. + +**Unverified, and needs an operator's eye:** this repository cannot test that the +runner resolves that hostname or that `REGISTRY_USER`/`REGISTRY_TOKEN` are valid +for it. If the next publish fails, set the `REGISTRY` repository variable back to +the address. + +## Make the dev stack bootstrappable + +```task +id: KEY-WP-0026-T05 +status: done +priority: medium +``` + +`docker-compose.dev.yml` mounts `config/dev-key.pem` and `config/authelia/`, +neither in the checkout — correctly, since one is a private key and the other +carries a password hash. `scripts/bootstrap-dev.sh` generates both locally: an +RSA key created under `umask 077` rather than chmodded afterwards, so it is never +briefly world-readable, plus an Authelia configuration, its OIDC signing key and +a user fixture. Everything it writes is git-ignored, and re-running it leaves +existing material alone. + +The fixture password is published in the script deliberately: a reader can see +exactly what it unlocks, which is nothing deployed. + +## Reconcile the records + +```task +id: KEY-WP-0026-T06 +status: done +priority: medium +``` + +`SCOPE.md` and G09's status record what is fixed and what is not: the registry +change is unverified from here, and a documented external bootstrap remains the +route for anything beyond development.