Neon Law
  • Fractional CTO
  • Litigation
  • Fractional GC
  • Legal Services
  • Sign in
← Operating Neon Law Navigator

Operating Neon Law Navigator

0 / 44 viewed

Open any slide to read it. View them all to unlock your certificate.

1

Chapter 1

Intro

Deploy your own

Follow this workshop to stand up your own Neon Law Navigator. navigator ops gcp setup stands the same Rust stack our attorneys run up on your Google Cloud project. Free, open, and yours to keep.

1. Deploy your own

Agenda

Six steps, each tagged with its Bloom verb. You are the admin operator; the navigator CLI is the instrument:

  • Create — stand up a billed project and authenticate.
  • Predict — run --dry-run and read every API call before sending one.
  • Identify — name the twenty-two Google Cloud APIs the provisioner enables.
  • Explain — describe the VPC, the five buckets, the private image registry, and the three service deployments.
  • Execute — bring up the cluster, the static IP, and Fleet membership.
  • Verify — ship the service images and confirm /readyz answers 200.
2. Agenda
2

Chapter 2

Prepare Google Cloud

Bring your own project

navigator ops gcp setup provisions into a project; it does not create one. Start by creating a project and attaching a billing account, then authenticate so the CLI can act as you:


gcloud projects create your-project-id --name "Neon Law Navigator"
gcloud billing projects link your-project-id --billing-account "$BILLING_ACCOUNT_ID"
gcloud auth login --force --update-adc
gcloud auth print-access-token >/dev/null
gcloud auth application-default print-access-token >/dev/null

For the Navigator matrix, prove billing is enabled on the hub and all three runtime projects before provisioning:


for project in \
  ghcr neon-law-stg neon-law-prod neon-law
do
  gcloud billing projects describe "$project" \
    --format='value(projectId,billingEnabled)'
done

All five lines must end in True. If one is False, attach it before running setup:


gcloud billing projects link neon-law-prod \
  --billing-account "$BILLING_ACCOUNT_ID"
3. Bring your own project

Dry-run first

Before you change anything, read the plan. The --dry-run flag records every REST call and gcloud shell-out and prints them without sending traffic:


cargo run -p cli -- ops gcp setup --project-id your-project-id --dry-run
4. Dry-run first

Private assets and domain restricted sharing

Every deployment bucket remains private. navigator ops gcp setup grants the deployment Google service account roles/storage.objectAdmin on its assets, documents, exports, logs, and applications buckets, then maps both Kubernetes service accounts (navigator-web and workflows-service) to that Google identity through Workload Identity. It never adds allUsers and never changes constraints/iam.allowedPolicyMemberDomains. The applications bucket holds each Project's published client-portal bundle at {project-code}/portal/, which web streams same-origin through /app/projects/{code}/portal so the session and Project-participation gate stay on every request.

The public website receives only marketing bytes through GET /assets/*. web reads that object from NAVIGATOR_ASSETS_BUCKET with the deployment identity and returns it with its stored content type, X-Content-Type-Options: nosniff, and a one-hour public cache. Unsafe keys and missing objects return 404; storage failures return 502. There is no parallel anonymous route for documents, exports, or logs. This is an application delivery boundary, not a Navigator persons.role or Project-participation grant.

Set NAVIGATOR_ASSET_BASE_URL=$NAV_BASE_URL/assets in each deployment's config.toml — it is a plaintext coordinate the ops ship preflight requires. Setup also gives the active gcloud identity the same bucket-scoped object CRUD role so the operator and the Kubernetes runtime can inspect and repair objects without a public or project-wide grant. After setup, verify both identities and reject an anonymous principal:


runtime_gsa="$NAVIGATOR_GCP_SERVICE_ACCOUNT_ID@$NAVIGATOR_GCP_PROJECT_ID.iam.gserviceaccount.com"
operator_account="$(gcloud config get-value account)"
case "$operator_account" in
  *.gserviceaccount.com) operator_member="serviceAccount:$operator_account" ;;
  *) operator_member="user:$operator_account" ;;
esac

for bucket_name in \
  "$NAVIGATOR_ASSETS_BUCKET" \
  "$NAVIGATOR_DOCUMENTS_BUCKET" \
  "$NAVIGATOR_EXPORTS_BUCKET" \
  "$NAVIGATOR_LOGS_BUCKET"
do
  iam_rows="$(
    gcloud storage buckets get-iam-policy "gs://$bucket_name" \
      --flatten='bindings[].members[]' \
      --format='value(bindings.role,bindings.members)'
  )"
  printf '%s\n' "$iam_rows" |
    awk -v runtime="serviceAccount:$runtime_gsa" -v operator="$operator_member" '
      $1 == "roles/storage.objectAdmin" && $2 == runtime { runtime_ok = 1 }
      $1 == "roles/storage.objectAdmin" && $2 == operator { operator_ok = 1 }
      $2 == "allUsers" { public = 1 }
      END { if (!runtime_ok || !operator_ok || public) exit 1 }
    '
done
5. Private assets and domain restricted sharing

The one project that is public on purpose

neon-law-marketing is the exception, and it proves the rule. It holds the brand marketing site as static files — a React build, no server, no database, no cluster — and its buckets are anonymously readable, because a GCS backend bucket behind a load balancer is fetched anonymously and there is no service-account path for one.

So it carries a project-scoped override of constraints/iam.allowedPolicyMemberDomains. The organization-wide constraint is untouched, and every runtime project still inherits it.

Runtime projectsneon-law-marketing
Bucketsprivate, objectAdmin to named identitiesallUsers objectViewer
Contentsclient documents, exports, logspublished marketing HTML
Servesauthenticated requestsstatic files only
Provisionerops gcp setupops gcp marketing setup

navigator ops gcp marketing setup --dry-run
navigator ops gcp marketing setup
6. The one project that is public on purpose

The Navigator deployment matrix

One deployments/<name>/ directory is one deployment. Its config.toml supplies the project, region, cluster, namespace, domain, and immutable brand image, and its secrets.enc.yaml the mail and provider credentials, for exactly one site; it never shares a store database, bucket, Kubernetes namespace, or runtime Secret with another row.

DeploymentGCP projectSiteImageResource prefix
neon-law-stgneon-law-stgwww.neonlaw.comneon-serverneon-law-stg
neon-law-prodneon-law-prodwww.neonlaw.comneon-serverneon-law-prod
neon-law-prodneon-lawwww.neonlaw.comneon-serverneon-law-prod
7. The Navigator deployment matrix

The /app mount and HTTP route ownership

/app is the container filesystem mount, not a public URL prefix. The images install their executable and common runtime material there:

ImageEntrypointShared mounted material
neon-server/app/neon/app/public, /app/content, /app/templates
neon-server/app/neon/app/public, /app/content, /app/templates

Each executable supplies only its public brand routes to portal::bootstrap. The mounted application crate always owns these HTTP paths and every descendant:

  • operational and public ingress: /health, /readyz, /version, /assets/*, /webhook/*, /docusign/*, /public/*, and /dioxus-demo;
  • application and control surfaces: /app/*, /lawyer/*, /admin/*, /app/api/*, /auth/*, /mcp/*, and /docs/*;
  • API documentation: /app/api and /app/api/openapi.json.

The brand-owned public routes are:

  • Neon Law: /, /contact, /team, /team/{slug}, /blog, /blog/{slug}, /privacy, /terms, /robots.txt, /sitemap.xml, and /llms.txt;
  • Neon: /, /foundation, /foundation/mission, /notations, /transparency/*, /workshops/*, /foundation/nebula/*, /events/*, /privacy, /terms, /robots.txt, /sitemap.xml, and /llms.txt.

This precedence is fail-closed, not merge order. Each brand declares every route it mounts; startup returns an error if an exact path or descendant overlaps a Navigator-owned prefix. A brand therefore cannot shadow data access, authorization, control, API, health, or protocol routes.

8. The `/app` mount and HTTP route ownership

neon — the whole brand seam

A brand crate is exactly one value: the portal::hosting::Brand each compiles — its key, its telemetry service name, and the bounded public routes listed on the previous slide. Everything else is the shared application they mount, and the portal renders under the mounting brand's chrome, so branding the binary brands the portal too: one brand seam carries the public site, the signed-in portal, and the telemetry identity together.

That thinness is the customization story. A custom Navigator changes minimal surface area: write your own brand crate in the neon shape — a Brand value and a call to the shared run loop, nothing more — build it into your own <brand>-server image, and interact with the remaining published images (navigator-workflows-service, navigator-gateway, and the trigger images) unchanged. The two brand Containerfiles are deliberately identical modulo the brand name, so the image recipe for a new brand is the existing one with your crate's name.

9. `neon` — the whole brand seam

Live rollout checkpoint

As of 31 July 2026, the two production substrates have completed every setup stage, and neon-law-stg is a created but unprovisioned project:

  • Both production GKE Autopilot clusters are RUNNING in us-west4, and no deployment reads Postgres: ENG-22 moved the store to SurrealDB, /health pings SurrealDB, and ops gcp setup provisions no Cloud SQL instance, so a deleted one is not recreated on the next run. An operator must export the two legacy production Postgres 15 instances to each deployment's own exports bucket and then delete them. Nothing has ever archived those instances — the nightly archive lane covers the SurrealDB tables only — so the export is what makes the deletion reversible, and it is not optional.
  • neon-law-stg and ghcr exist in the neonlaw.com organization and are linked to billing, but neither has been through its provisioner yet. Run ops gcp hub setup against the hub first, then ops gcp setup against staging — the environment provisioner grants against the hub repository, so the hub must exist before any environment names it.
  • Each provisioned row has five private assets, documents, exports, logs, and applications buckets.
  • On those rows, both the deployment runtime identity and the active operator have bucket-scoped roles/storage.objectAdmin; none of the buckets grants allUsers.

The five-bucket list is the current checkpoint, not the target topology. Issue #1103 coordinates the migration to exactly one private object-storage bucket per deployment and never one bucket per Project. Project growth adds rows and logical key space, with lanes such as {project-code}/documents/ and {project-code}/exports/ inside the deployment bucket; it does not create cloud buckets. Marketing bytes remain available through the same-origin /assets/* application route while the bucket itself stays private and does not grant allUsers. Until that atomic migration lands, setup reconciles the five existing bucket resources recorded above.

New clusters are created with --enable-fleet; the subsequent idempotent reconciliation can therefore report Changing existing fleet membership is not supported. Navigator treats only that exact response as already reconciled and continues; unrelated Fleet errors still stop setup.

Infrastructure is not deployment. The two production clusters currently have no application namespace, Deployment, Service, Gateway, or HTTPRoute, so their public hosts do not yet answer TLS. An operator must ship one immutable release to Neon production and Neon Law production, then prove /readyz, /version, certificate readiness, Restate registration, and the browser surface before posting the #navigator handoff.

For a prefix <name>, set NAVIGATOR_GKE_CLUSTER_NAME=<name>, NAVIGATOR_GKE_CONTEXT=gke_<project>_<region>_<name>, NAVIGATOR_K8S_NAMESPACE=<name>, NAVIGATOR_VPC_NAME=<name>-vpc, NAVIGATOR_SUBNETWORK_NAME=<name>-subnet, NAVIGATOR_GATEWAY_IP_NAME=<name>-gateway-ip, NAVIGATOR_ASSETS_BUCKET=<name>-assets, NAVIGATOR_DOCUMENTS_BUCKET=<name>-documents, NAVIGATOR_EXPORTS_BUCKET=<name>-exports, NAVIGATOR_LOGS_BUCKET=<name>-logs, NAVIGATOR_APPLICATIONS_BUCKET=<name>-applications, NAVIGATOR_GCP_SERVICE_ACCOUNT_ID=<name>-web, NAVIGATOR_DRIVE_GCP_SERVICE_ACCOUNT_ID=<name>-drive, and NAVIGATOR_WEB_SECRET_NAME=<name>-web-secrets. Bucket names are global; add one stable organization prefix if a short bucket name is already taken.

Also set NAVIGATOR_GCP_PROJECT_ID, NAVIGATOR_GCP_LOCATION, NAVIGATOR_PUBLIC_HOST, NAVIGATOR_WORKFLOWS_HOST, NAV_BASE_URL=https://$NAVIGATOR_PUBLIC_HOST, NAVIGATOR_WORKFLOWS_URL=https://$NAVIGATOR_WORKFLOWS_HOST/, NAVIGATOR_WEB_IMAGE, and NAVIGATOR_ASSET_BASE_URL=$NAV_BASE_URL/assets in that deployment's deployments/<name>/config.toml. Set that row's own NAVIGATOR_SURREAL_* coordinates and credentials in its secrets.enc.yaml.

Keep the mail rail complete in every deployment's secrets.enc.yaml: SENDGRID_API_KEY, SENDGRID_FROM_EMAIL, SENDGRID_INBOUND_SECRET, SENDGRID_EVENTS_SECRET, and SENDGRID_EVENTS_PUBLIC_KEY; staging uses non-production SendGrid credentials and production uses live credentials authenticated for its domain.

Provision each row with the same region-agnostic command — one run per deployments/ directory, its coordinates exported from that directory's config.toml (populate the directory first; the config is the source of every coordinate the provisioner reads, so a row is provisioned only once its directory exists). Each run creates five buckets and one Autopilot cluster; re-running reconciles only that row:


set -a; eval "$(grep ' = "' deployments/neon-law-stg/config.toml | sed 's/ = /=/')"; set +a
navigator ops gcp setup --dry-run

Check every printed project, region, resource prefix, and the two API batches. Provision the registry hub once before the runtime stacks; it runs no workloads:


navigator ops gcp hub setup \
  --project-id ghcr --region us-west4 --dry-run
navigator ops gcp hub setup \
  --project-id ghcr --region us-west4

Then apply the already-reviewed plans one deployment at a time. Keep staging first, and do not begin production until its command finishes:


set -a; eval "$(grep ' = "' deployments/neon-law-stg/config.toml | sed 's/ = /=/')"; set +a
navigator ops gcp setup

That is the live resource boundary, per deployment: five buckets, one VPC/subnet pair, one reserved gateway address, one Autopilot cluster, one KMS key, and two deployment service accounts. The command also enables the twenty-two required APIs in that runtime project. A completed command is not yet a deployed website; ops ship, DNS, and the browser checks later in this workshop remain required.

Setup provisions no database and generates no credential, so there is nothing printed to record. The store's own credentials go into deployments/<name>/secrets.enc.yaml (sops set — the plaintext never touches disk), then navigator ops secrets apply --deployment <name>; rotation happens at the provider first, per docs/deployment-secrets.md.

Resolve every reserved address and record the public value as NAVIGATOR_GATEWAY_IP in the matching config.toml — an IP is a public coordinate, so it is edited and committed like any other:


for name in $(ls deployments); do
  set -a; eval "$(grep ' = "' "deployments/${name}/config.toml" | sed 's/ = /=/')"; set +a
  echo "${name}:"
  gcloud compute addresses describe "$NAVIGATOR_GATEWAY_IP_NAME" \
    --global --project "$NAVIGATOR_GCP_PROJECT_ID" --format 'value(address)'
done

Populate the matching local Kubernetes context before shipping a row:


set -a; eval "$(grep ' = "' deployments/neon-law-stg/config.toml | sed 's/ = /=/')"; set +a
gcloud container clusters get-credentials \
  "$NAVIGATOR_GKE_CLUSTER_NAME" --region "$NAVIGATOR_GCP_LOCATION" \
  --project "$NAVIGATOR_GCP_PROJECT_ID"

The resulting context name must equal that config's NAVIGATOR_GKE_CONTEXT. Repeat once for each deployment you are operating.

The canonical matrix is the compact reference for every row.

10. Live rollout checkpoint

Set one site to one version

The version is a release tag, never latest. To set one site, name its deployment with the required --deployment flag and replace YY.M.D with the published tag — every coordinate comes from deployments/<name>/config.toml, never the shell. The command preflights that the selected brand image and worker image exist, checks the Secret keys before changing Kubernetes, then records the tag in the deployment.


navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D

curl --fail --show-error https://www.neonlaw.com/version

The #navigator hand-off derives its exact command list from the deployments/ tree — one run per directory, staging first, then the two production rows:


navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D
navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D
navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D

Staging goes first. Do not start either production row until staging /readyz and /version checks pass; the two production rows gate on staging and not on each other, which is why the release run rolls them in parallel. To preview without changing one site, append --dry-run; to refresh its pods after a secret rotation without changing the version, use --restart-only. A deployment can be named here only once it has a deployments/<name>/ directory; that directory is what puts it in this list.

11. Set one site to one version
3

Chapter 3

Provision the Infrastructure

The APIs that light up

A real run first enables twenty-two Google Cloud APIs in bounded Service Usage batchEnable calls: compute, servicenetworking, storage, iam, iamcredentials, sts, cloudresourcemanager, artifactregistry, container, gkehub, gkebackup, anthosconfigmanagement, logging, monitoring, cloudtrace, secretmanager, certificatemanager, identitytoolkit, speech, drive, admin, and cloudkms.

12. The APIs that light up

Network and five buckets

With the APIs on, the CLI provisions the data plane and runtime identity:

  • A custom-mode VPC with one explicitly named regional subnet and private Google access. The deployment's GKE cluster is pinned to both names.
  • Five private Cloud Storage buckets, all uniform bucket-level access: -assets (marketing objects served through the same-origin /assets/* application route), -documents (client documents), -exports (Parquet/Iceberg archives), -logs (the Nearline log-sink destination), and -applications (each Project's published client-portal bundle, streamed same-origin through /app/projects/{code}/portal).
  • A deployment-specific Google service account with the Secret Manager accessor role, object access on only that deployment's five buckets, Workload Identity bindings for the namespace's navigator-web and workflows-service Kubernetes service accounts, and permission to sign its own GCS URLs.
  • A separate Workspace Drive service account with no runtime GCP roles. An operator creates one JSON key, records it in that deployment's secrets.enc.yaml, and grants its OAuth client domain-wide delegation in the selected Workspace.
13. Network and five buckets

How a Project portal reaches a client

Each Project has its own source repository — neon-law/spotonix, say — holding a React application under portal/. That bundle is never committed anywhere in Navigator, and it never touches git on the way to a client. It is built in the Project repository's own CI, published to the deployment's private -applications bucket, and streamed from there by web — same-origin, and only after the session and Project participation row are checked.


flowchart LR
  subgraph repo["Project repo — neon-law/spotonix"]
    src["portal/ — React + Vite"]
    ci["CI on push to main:<br/>validate + application-publish"]
    src --> ci
  end
  subgraph gcp["Deployment project — neon-law"]
    bucket[("neon-law-prod-applications<br/>spotonix/portal/ — private, UBLA")]
  end
  subgraph nav["Navigator — neon-server"]
    web["web streams the bundle at<br/>/app/projects/spotonix/portal/"]
  end
  client(["Client browser"])

  ci -- "keyless WIF (navigator-app-publisher):<br/>upload dist/ — hashed assets first,<br/>index.html last, never delete" --> bucket
  client -- "GET /app/projects/spotonix/portal/" --> web
  web -- "check session + participation" --> web
  web -- "stream objects, same-origin" --> bucket
14. How a Project portal reaches a client

One matter's document never backs another matter's

Inside the documents bucket, two matters never share an object:

  • Content addressing dedupes within a matter, never across matters. A governed expunge deletes an object only when no asset row on another matter still points at it, and says so in the log when it declines.
15. One matter's document never backs another matter's

A private image registry

With storage in place, the provisioner creates the one private Artifact Registry repository that every navigator container image lives in, and the identities that push to and pull from it:

  • A Docker-format repository (default navigator) at your-region-docker.pkg.dev/your-project-id/navigator.
  • A keep-the-last-10-versions cleanup policy — a KEEP rule retaining the last 10 versions of each image plus a DELETE rule for everything else. Retention is a count rather than an age on purpose: an age-based rule is only safe while releases outrun it, and releases are tag-driven, so a quiet fortnight under the old 7-day rule would have let the registry delete the versions production was running. A count cannot expire. Keep policies take precedence over delete policies, which is what makes the pair mean "keep ten, delete the rest" — the delete half matches every version, so it is never applied alone.
  • A CI push identity (navigator-ci-pusher service account) with a repo-scoped roles/artifactregistry.writer binding, plus a GitHub Workload Identity federation pool and provider so CI authenticates keyless — no downloaded service-account key — pinned to this one repository and to the refs allowed to publish.
  • A repo-scoped roles/artifactregistry.reader binding for the GKE Autopilot node identity, so the cluster can pull.

Two values decide whether that federation works at all, and both are easy to get wrong in a way nothing reports:


issuerUri           https://token.actions.<your-tenant>.ghe.com   # NOT token.actions.githubusercontent.com
attributeCondition  assertion.repository == '<owner>/<repo>'
                        && (assertion.ref == 'refs/heads/main' || assertion.ref.startsWith('refs/tags/'))
16. A private image registry

The cluster comes up

The cluster is the one part driven through gcloud rather than REST. In order, the provisioner reserves a static IP, creates the GKE Autopilot cluster, and registers it as a Fleet member:


cargo run -p cli -- ops gcp setup --project-id your-project-id --region us-west4
17. The cluster comes up
4

Chapter 4

Environment Matrix

Three operating modes, two deployment profiles

Operating modeSelectorRuntime and data posture
Testdev + NAVIGATOR_CI_HARNESS=1Schemas/KIND; stubs; canonical + development portfolio + test fixtures
Devdev; harness normally unsetKIND/cloud namespace; sandbox vendors; canonical + dev portfolio
Productionproduction, empty, or unsetHosted services; production vendors; canonical seed + live data
18. Three operating modes, two deployment profiles

Configuration precedence: the first source wins

PrioritySourceWho owns itTypical contents
1process environmentshell, Kubernetes, CIExplicit one-run or deployed values
2.envdeveloper/operator; gitignoredOptional sandbox credentials and local overrides
3.devx/envgenerated by the dev CLILocal endpoints, ports, harness, session key
4code defaultseach typed config loaderPorts, content directories, optional feature fallbacks
19. Configuration precedence: the first source wins

Local dev controls: inputs read by navigator dev

ConcernEnvironment variablesDefaults / effect
TopologyNAVIGATOR_KIND_CLUSTER, NAVIGATOR_K8S_NAMESPACEnavigator, navigator
Dependency overlayNAVIGATOR_KIND_DEPS_OVERLAYdeps-only KIND
Full KIND overlayNAVIGATOR_KIND_OVERLAYfull KIND
GKE overlayNAVIGATOR_GKE_OVERLAYexample GKE manifests
Private mode gatewayNAVIGATOR_PRIVATE_MODEoff; on puts Pingora network + basic auth before web
Second store portNAVIGATOR_KIND_SURREAL_PORT18000
Restate portsNAVIGATOR_KIND_RESTATE_INGRESS_PORT, NAVIGATOR_KIND_RESTATE_ADMIN_PORT9080, 9070
Identity portNAVIGATOR_KIND_RAUTHY_PORT30080
Storage portNAVIGATOR_KIND_GARAGE_S3_PORT30900
Web portNAVIGATOR_KIND_WEB_PORT3001
ObservabilityNAVIGATOR_KIND_OPENOBSERVE_PORT, NAVIGATOR_KIND_OPENOBSERVE_OTLP_PORT5080, 5081
Documents keyNAVIGATOR_GARAGE_ACCESS_KEYdeterministic KIND-only default
Documents secretNAVIGATOR_GARAGE_SECRET_KEYdeterministic KIND-only default
Assets keyNAVIGATOR_GARAGE_ASSETS_ACCESS_KEYdeterministic KIND-only default
Assets secretNAVIGATOR_GARAGE_ASSETS_SECRET_KEYdeterministic KIND-only default
Applications keyNAVIGATOR_GARAGE_APPLICATIONS_ACCESS_KEYdeterministic KIND-only default
Applications secretNAVIGATOR_GARAGE_APPLICATIONS_SECRET_KEYdeterministic KIND-only default
LFS keyNAVIGATOR_GARAGE_LFS_ACCESS_KEYdeterministic KIND-only default
LFS secretNAVIGATOR_GARAGE_LFS_SECRET_KEYdeterministic KIND-only default
Published demo imageNAVIGATOR_IMAGE_TAGlatest dated YY.M.D tag when dev deploy pulls images
20. Local dev controls: inputs read by `navigator dev`

Local runtime: what .devx/env generates

ConcernGenerated environment variables
Profile and listenerPORT, NAVIGATOR_ENVIRONMENT, NAVIGATOR_CI_HARNESS
Repository writerNAVIGATOR_GIT_REPO_ROOT
StoreNAVIGATOR_SURREAL_ENDPOINT, NAVIGATOR_SURREAL_NAMESPACE, NAVIGATOR_SURREAL_DATABASE
Store credentialsNAVIGATOR_SURREAL_USER, NAVIGATOR_SURREAL_PASSWORD
Storage driverNAVIGATOR_STORAGE_BACKEND, NAVIGATOR_STORAGE_ENDPOINT
Storage bucketsNAVIGATOR_STORAGE_BUCKET, NAVIGATOR_ASSETS_BUCKET, NAVIGATOR_LFS_BUCKET
Applications bucketNAVIGATOR_APPLICATIONS_BUCKET
Archive bucketsNAVIGATOR_ICEBERG_BUCKET, NAVIGATOR_TELEMETRY_BUCKET
Storage regionNAVIGATOR_STORAGE_REGION
Documents credentialsNAVIGATOR_STORAGE_ACCESS_KEY, NAVIGATOR_STORAGE_SECRET_KEY
Assets credentialsNAVIGATOR_ASSETS_ACCESS_KEY, NAVIGATOR_ASSETS_SECRET_KEY
Applications credentialsNAVIGATOR_APPLICATIONS_ACCESS_KEY, NAVIGATOR_APPLICATIONS_SECRET_KEY
LFS credentialsNAVIGATOR_LFS_ACCESS_KEY, NAVIGATOR_LFS_SECRET_KEY
Browser OIDCOAUTH_ISSUER_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, SESSION_SECRET
Policy and workflowsRESTATE_BROKER_URL
Attachment scannerNAVIGATOR_CLAMD_ADDR
Harness-only integration placeholdersSENDGRID_API_KEY, SENDGRID_INBOUND_SECRET
Harness sink URLSENDGRID_BASE_URL, NAVIGATOR_SENDGRID_HARNESS_SECRET
Telemetry endpoint and UIOTEL_EXPORTER_OTLP_ENDPOINT, NAVIGATOR_OPENOBSERVE_URL
Telemetry credentialsNAVIGATOR_OPENOBSERVE_USERNAME, NAVIGATOR_OPENOBSERVE_PASSWORD
Telemetry routingNAVIGATOR_OPENOBSERVE_ORGANIZATION, NAVIGATOR_OPENOBSERVE_STREAM
21. Local runtime: what `.devx/env` generates

The store: SurrealDB

QuestionSurrealDB
Owns the dataYes — every table
Local shapeKIND pod, surreal start memory
SchemaOne idempotent DEFINE file plus schema_version
TestsEmbedded kv-mem engine per test
DeployedSurreal Cloud
Row-level permissionsPERMISSIONS NONE on every table, deliberately
22. The store: SurrealDB

Where SurrealDB authorization lives

LayerDecidesDuring and after the port
persons.roleThe tier: owner, admin, lawyer, clerk, clientUnchanged — the sole tier answer
person_project_roles.participationPer-matter scopeUnchanged — the sole scope answer
Embedded RegoRenders the decision per requestCompiled at web-process boot
Surreal PERMISSIONSPer-row engine enforcementExplicit NONE; every process signs in as root
23. Where SurrealDB authorization lives

Deployed runtime: core web and worker wiring

ConcernEnvironment variables
Profile fenceNAVIGATOR_ENVIRONMENT, NAVIGATOR_CI_HARNESS, NAVIGATOR_CREDENTIAL_ENVIRONMENT
HTTP identityPORT, NAV_BASE_URL, CANONICAL_HOST, NAVIGATOR_RATE_LIMIT_PER_MIN
Branding and public assetsNAVIGATOR_CUSTOM_BRANDING, NAVIGATOR_ASSET_BASE_URL
StoreNAVIGATOR_SURREAL_ENDPOINT, NAVIGATOR_SURREAL_NAMESPACE, NAVIGATOR_SURREAL_DATABASE
Storage driverNAVIGATOR_STORAGE_BACKEND, NAVIGATOR_STORAGE_ENDPOINT, NAVIGATOR_STORAGE_REGION
Documents/exportsNAVIGATOR_DOCUMENTS_BUCKET, NAVIGATOR_STORAGE_BUCKET, NAVIGATOR_EXPORTS_BUCKET
Other bucketsNAVIGATOR_ASSETS_BUCKET, NAVIGATOR_LFS_BUCKET
Filesystem storageNAVIGATOR_STORAGE_FS_ROOT
Entity Workspace DrivePer-Workspace Drive coordinates listed below
Generic S3 keyNAVIGATOR_STORAGE_ACCESS_KEY, NAVIGATOR_STORAGE_SECRET_KEY
Temporary S3 tokenNAVIGATOR_STORAGE_SESSION_TOKEN
Assets S3 keyNAVIGATOR_ASSETS_ACCESS_KEY, NAVIGATOR_ASSETS_SECRET_KEY
LFS S3 keyNAVIGATOR_LFS_ACCESS_KEY, NAVIGATOR_LFS_SECRET_KEY
Exports S3 keyNAVIGATOR_EXPORTS_ACCESS_KEY, NAVIGATOR_EXPORTS_SECRET_KEY
SessionsSESSION_SECRET
Restate clientRESTATE_BROKER_URL, RESTATE_AUTH_TOKEN, RESTATE_SERVICE
Trigger ingressRESTATE_INGRESS_URL
Worker listenerWORKFLOWS_SERVICE_LISTEN
Telemetry and log filteringOTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, RUST_LOG
Image-baked release identityNAVIGATOR_RELEASE_TAG, NAVIGATOR_GIT_SHA, NAVIGATOR_BUILD_TIME
24. Deployed runtime: core web and worker wiring

Deployed runtime: identity and access

ConcernEnvironment variablesBehavior when absent
Browser OIDC issuerOAUTH_ISSUER_URLBrowser login is not configured
Browser OIDC clientOAUTH_CLIENT_ID, OAUTH_CLIENT_SECRETBrowser login is not configured
Browser redirectOAUTH_REDIRECT_URIBrowser login is not configured
Bearer JWKSOIDC_JWKS_URL, OIDC_AUDIENCE, OIDC_ISSUERDeployed JWT verification
Bearer HMACOIDC_HS256_SECRETLocal/test verifier path
Dev bypassOIDC_DISABLEDOff; the dev profile and production reject true / 1
Bootstrap OwnerNAVIGATOR_BOOTSTRAP_OWNER_EMAILNo identity is JIT-created
Protected firm EntityNAVIGATOR_BOOTSTRAP_COMPANYShook Law PLLC is protected either way
Self-signupNAVIGATOR_SELF_SIGNUP_ENABLEDOff; an unknown email is refused (403)
Google token policyGOOGLE_OAUTH_CLIENT_IDS, GOOGLE_OAUTH_REQUIRED_HDNo client/domain pin
Google token endpointGOOGLE_TOKENINFO_URLGoogle default
Password doorNAVIGATOR_IDENTITY_PLATFORM_API_KEYOIDC-only login
Password endpointNAVIGATOR_IDENTITY_PLATFORM_ENDPOINTGoogle default
Identity Platform admin callsNAVIGATOR_GCP_METADATA_ENDPOINTGCE metadata server default
25. Deployed runtime: identity and access

Deployed runtime: email, signatures, and billing

CapabilityEnvironment variablesDev / production rule
Email backendNAVIGATOR_EMAIL_BACKENDMust be sendgrid outside the harness
Outbound SendGridSENDGRID_API_KEY, SENDGRID_FROM_EMAILRequired outside the harness
SendGrid base URLSENDGRID_BASE_URLOfficial hosts only outside the harness
Inbound SendGridSENDGRID_INBOUND_SECRETRequired outside the harness
Attachment scannerNAVIGATOR_CLAMD_ADDRRequired in every deployed profile; private clamd only
Event webhookSENDGRID_EVENTS_SECRET, SENDGRID_EVENTS_PUBLIC_KEYRequired outside the harness
Threaded mailNAVIGATOR_PARSE_HOST, NAVIGATOR_LAWYER_NOTIFY_EMAILBoth values enable it
DKIM fenceNAVIGATOR_DKIM_REQUIRE_DOMAINOptional domain pin
Internal ops noticesSLACK_WEBHOOK_URLOptional; otherwise captured in memory
DocuSign endpointDOCUSIGN_BASE_URLDeclares DocuSign; demo in dev, live in production
DocuSign accountDOCUSIGN_ACCOUNT_IDEnvironment-specific account
DocuSign JWT IDsDOCUSIGN_INTEGRATION_KEY, DOCUSIGN_USER_IDPreferred auth path
DocuSign JWT proofDOCUSIGN_PRIVATE_KEY, DOCUSIGN_OAUTH_BASEPreferred auth path
DocuSign static authDOCUSIGN_ACCESS_TOKENShort-lived fallback
DocuSign signerDOCUSIGN_SIGNER_EMAIL, DOCUSIGN_SIGNER_NAMERequired signer identity
DocuSign webhookDOCUSIGN_HMAC_KEY, DOCUSIGN_WEBHOOK_SECRETRequired once DOCUSIGN_BASE_URL is set
Xero tenantXERO_TENANT_ID, XERO_BASE_URLAll Xero values select real billing
Xero OAuth clientXERO_CLIENT_ID, XERO_CLIENT_SECRETOtherwise stub billing
Xero OAuth tokenXERO_TOKEN_URL, XERO_SCOPE, XERO_ACCESS_TOKENOtherwise stub billing
26. Deployed runtime: email, signatures, and billing

Deployed runtime: repositories, content, AI, and scheduled work

CapabilityEnvironment variables
Mounted Git writerNAVIGATOR_GIT_REPO_ROOT
Deployment's own organizationNAVIGATOR_GITHUB_ORG — required once a deployment is named, no default
Governance write boundaryNAVIGATOR_GIT_HOST — read only by ops github setup, no default
GitHub App identityNAVIGATOR_GITHUB_APP_ID
GitHub App proofNAVIGATOR_GITHUB_APP_PRIVATE_KEY, NAVIGATOR_GITHUB_INSTALLATION_ID
GitHub endpointNAVIGATOR_GITHUB_API_BASE
GitHub webhook receiverNAVIGATOR_GITHUB_WEBHOOK_SECRET, NAVIGATOR_GITHUB_CANONICAL_REPOSITORY
Receiver identity and Restate submitNAVIGATOR_GITHUB_APP_LOGIN, RESTATE_INGRESS_URL, RESTATE_AUTH_TOKEN
GitHub concurrency capNAVIGATOR_GITHUB_MAX_CONCURRENT
GitHub revision capNAVIGATOR_GITHUB_MAX_REVISE_ROUNDS
GitHub daily token capNAVIGATOR_GITHUB_MAX_DAILY_TOKENS
DevX Slack worker (in workflows-service)SLACK_WEBHOOK_URL
Main content rootsNAVIGATOR_PUBLIC_DIR, NAVIGATOR_BLOG_DIR, NAVIGATOR_WORKSHOPS_DIR
Other content rootsNAVIGATOR_MARKETING_DIR, NAVIGATOR_EVENTS_DIR, NAVIGATOR_FOUNDATION_DIR
CLI login fileNAVIGATOR_CREDENTIALS_FILE, NAVIGATOR_CONFIG_DIR
CLI live inquiryNAVIGATOR_NOTATION_TEMPLATE, NAVIGATOR_SPEECH_BACKEND
Harness worktree/cacheNAVIGATOR_WORKTREE_PATH, NAVIGATOR_CHROME_CACHE_DIR
Vertex coordinatesNAVIGATOR_GCP_PROJECT_ID, NAVIGATOR_GCP_LOCATION, GOOGLE_METADATA_URL
AIDA routerNAVIGATOR_ROUTER_MODEL
Contract reviewerNAVIGATOR_CONTRACT_REVIEW_MODEL plus the same GCP project, location, and metadata variables
On-chain attestationNAVIGATOR_ONCHAIN_BACKEND, SOLANA_RPC_URL, SOLANA_PROGRAM_ID, SOLANA_SIGNER_SECRET
Billing exportBILLING_EXPORT_TABLE, BIGQUERY_PROJECT
Billing noticesBILLING_CANARY_NOTIFY_EMAIL
Billing digestBILLING_DIGEST_NOTIFY_EMAIL, BILLING_DIGEST_WINDOW_DAYS
27. Deployed runtime: repositories, content, AI, and scheduled work

Provision and ship: variables read by the operator CLI

ConcernEnvironment variables
GCP targetNAVIGATOR_GCP_PROJECT_ID, NAVIGATOR_GCP_LOCATION
GKE targetNAVIGATOR_GKE_CLUSTER_NAME, NAVIGATOR_GKE_CONTEXT, NAVIGATOR_K8S_NAMESPACE
VPC, subnetNAVIGATOR_VPC_NAME, NAVIGATOR_SUBNETWORK_NAME
Gateway IPNAVIGATOR_GATEWAY_IP_NAME
Runtime identitiesNAVIGATOR_GCP_SERVICE_ACCOUNT_ID, NAVIGATOR_DRIVE_GCP_SERVICE_ACCOUNT_ID
Assets/documentsNAVIGATOR_ASSETS_BUCKET, NAVIGATOR_DOCUMENTS_BUCKET
Exports/logsNAVIGATOR_EXPORTS_BUCKET, NAVIGATOR_LOGS_BUCKET
Optional fork Config SyncNAVIGATOR_CONFIG_SYNC_REPO, NAVIGATOR_CONFIG_SYNC_DIR (unset for Navigator's three)
Image registryNAVIGATOR_IMAGE_REGISTRY, NAVIGATOR_WEB_IMAGE
Manifest sourceNAVIGATOR_GKE_OVERLAY
Public OAuth clientsNAVIGATOR_OAUTH_CLIENT_ID_BROWSER (required), NAVIGATOR_OAUTH_CLIENT_ID_GEMINI
Brand and base URLNAVIGATOR_CUSTOM_BRANDING, NAVIGATOR_PRIMARY_DOMAIN, NAV_BASE_URL
Public hostsNAVIGATOR_PUBLIC_HOST, NAVIGATOR_WORKFLOWS_HOST, GOOGLE_OAUTH_REQUIRED_HD
Runtime SecretNAVIGATOR_WEB_SECRET_NAME
Worker registrationNAVIGATOR_WORKFLOWS_URL
Restate adminRESTATE_ADMIN_URL, RESTATE_ADMIN_TOKEN
28. Provision and ship: variables read by the operator CLI

Ancillary operations and opt-in test controls

CapabilityEnvironment variables
Operator-local DNSimple CLISee the operator-shell list below; never store it in a deployment config
GitHub release authenticationGITHUB_TOKEN
Xero sandbox OAuthXERO_SANDBOX_CLIENT_ID, XERO_SANDBOX_CLIENT_SECRET
Engineering Slack routingSLACK_OPS_MENTION
Browser-driver overrideWEBDRIVER_URL, WEBDRIVER_HEADED
Deterministic GCP test tokenDEVX_GCP_FAKE_TOKEN, ARCHIVES_FAKE_TOKEN
Browser gateNAV_REQUIRE_HARNESS, NAV_REASK_SHOTS
Server-mode store laneNAV_REQUIRE_SURREAL

The DNSimple CLI transaction uses operator-shell variables DNS_SIMPLE (or legacy DNSIMPLE_API_TOKEN), DNSIMPLE_TOKEN, DNS_ACCT, and DNS_ZONE. None belongs in a deployment config.

29. Ancillary operations and opt-in test controls

When simulated data appears

SurfaceLocal devDisposable dev stagingPersistent hosted rows
Canonical database seedAlways runsAlways runsAlways runs
Disposable development portfolioAlways runs (dev)Always runs (dev)Never
Test-local database fixturesBrowser/E2E harness onlyIntegration harness onlyNever
EmailCapturingEmail by defaultNon-production SendGridProduction SendGrid
E-signatureStub IDs and documentsNon-binding DocuSign demoLive DocuSign
BillingStubBillingProvider when Xero is absentSame fallbackSame fallback today
Contract reviewStub without VertexSame fallbackReference uses Vertex
AIDA free-form routingNullRouter without VertexSame fallbackReference uses Vertex
30. When simulated data appears
5

Chapter 5

Configure the Trust Boundaries

Secrets: the invariants that gate the boot

web fails loudly rather than degrading silently: a missing required value crashes startup with a structured enforce_deployment_invariants error naming exactly what is absent. So a CrashLoopBackOff is almost always a missing secret:


kubectl logs deploy/navigator-web -n navigator
31. Secrets: the invariants that gate the boot

Sign-in: bring an OIDC provider; passwords live there, not here

Passwords matter — Neon Law Navigator's own database never stores a password. There is no password column and no hashing crate; the credential lives with an OIDC provider you bring, never in our database. Identity is delegated via the standard Authorization Code + PKCE flow. Four env vars wire it:


OAUTH_ISSUER_URL=...        # the provider's issuer; discovery hangs off /.well-known/openid-configuration
OAUTH_CLIENT_ID=...
OAUTH_CLIENT_SECRET=...
OAUTH_REDIRECT_URI=https://www.your-domain.example/auth/callback

Navigator speaks that flow against the provider (/auth/login → /auth/callback) and discovers every endpoint from <issuer>/.well-known/openid-configuration, so no provider URL is hard-coded. Worked examples for Rauthy, Google, Auth0, and Okta live in .env.example. The provider asserts only who you are (a stable sub and an email); your persons row owns what you can do (the single role), so granting or revoking access is one SQL statement — see docs/oidc.md for the full model.

For this workshop, that row should make you the owner. Navigator has five stored authorization roles, plus the anonymous public visitor:

  • owner — the system owner, highest in authority, inheriting every Admin and Lawyer capability. Only Owner may govern another Owner identity.
  • admin — a licensed lawyer with installation-wide administration authority. Admin cannot manage an Owner.
  • lawyer — a licensed lawyer working assigned matters and supervising any Clerk capability the application grants.
  • clerk — a supervised non-lawyer worker. Clerk's /clerk surface is a read-only list of firm-assigned Projects whose disclosed is_lawyer_dri row is a lawyer. Clerk has no legal-advice, approval, Git, MCP, or /lawyer authority by inheritance; upload and preparation work still need their own narrow, supervised routes.
  • client — represented people using the portal for their own matters.

Owner is the deployer's role because this class touches billing, secrets, OIDC, release state, and every Project. The role still lives in the database, not the IdP token; your OIDC provider proves identity, then Navigator reads the persons.role value to decide the tier.

One environment variable answers which person is the protected bootstrap Owner:


NAVIGATOR_BOOTSTRAP_OWNER_EMAIL=owner@example.com

Read NAVIGATOR_BOOTSTRAP_OWNER_EMAIL from the environment or secret source that supplies your deployment to determine the protected identity. Do not copy the deployed value into Git. An unset, empty, or whitespace-only value disables the bootstrap carve-out, so every person must already have a row before signing in. On a fresh installation, the first successful OIDC login with the configured email JIT-creates its persons row as owner; later sign-ins restore that role if the database has drifted. Its entire Person record is immutable in Navigator so an administrator cannot rename, demote, or delete the installation's recovery identity by accident.

After signing in as Owner or Admin, open /admin/people to manage the directory and change another Person's system-wide role among owner, admin, lawyer, clerk, and client. Owner appears first because it owns the deployed system. Only an Owner can assign or modify Owner. Admin can manage Admin and every lower tier. The bootstrap-Owner row remains read-only, and the command boundary rejects a hand-written update or delete as well. The Lawyer workbench does not grant role-management power. These values are persons.role; Project assignments such as attorney, paralegal, client, and co-counsel are Participation records and do not create another authorization tier.

We recommend Google. Verifying that a person is who they claim is real work — risk signals, step-up challenges, and hardware-key (passkey / security-key) verification — and Google invests far more in it than we could. A Google-backed sign-in is stronger than any password we would host ourselves. That is exactly the path NeonLaw's own prod takes: Sign in with Google, wired by the four env vars above with OAUTH_ISSUER_URL=https://accounts.google.com — the same standard redirect flow, no password anywhere. (Google-hosted email/password is a separate, opt-in door: set NAVIGATOR_IDENTITY_PLATFORM_API_KEY and /auth/login also renders a password form whose POST /auth/password checks the credential against GCP Identity Platform — Google still owns the password and lockout, never us. Reset and email confirmation need the admin door too: set NAVIGATOR_GCP_PROJECT_ID, run web where the GCE metadata server can mint a service-account bearer token, and grant that service account roles/identitytoolkit.admin. Leave the key unset and sign-in stays a pure Google redirect. Config lives in .env.example, not the redirect swap above.)

No-Google path. "Sign in with Google" cannot be the only front door of a public legal-services portal — the person a clinic serves may have no Google account. Any standards-compliant OIDC provider — discovery, Authorization Code + PKCE, RS256-signed ID tokens with RSA keys in JWKS — that hosts its own email/password login works with the env-swap above and zero Neon Law Navigator code changes. Rauthy — the same open-source IdP the local KIND loop already runs — serves email/password, self-registration, reset, and verification from its own pages, in your cluster with no per-user fee. Auth0 / Okta are hosted SaaS equivalents: same four env vars, same redirect flow.

32. Sign-in: bring an OIDC provider; passwords live there, not here

Role rings: who can do what

Outside to centre: Client; Clerk (supervised non-lawyer); Lawyer (licensed to practice); Admin (licensed lawyer with system administration); Owner (system owner). Anonymous is outside every ring and sees public pages only.
Client
own matter
Clerk
supervised non-lawyer
Lawyer
licensed to practice
Admin
lawyer + system administration
Owner
owns the system

The rings display the five stored roles in their authority order: owner > admin > lawyer > clerk > client. Owner inherits Admin and Lawyer capability; Admin inherits Lawyer capability but cannot govern Owner. Clerk is deliberately not a weakened Lawyer account. Anonymous is outside every ring and sees public pages only. Clients use the portal for their own matters. Clerks reach /app/projects like everyone else and get a read-only rendering of their firm-assigned Projects and the disclosed lawyer DRI; they never give legal advice. Owner, Admin, and Lawyer are lawyers, and MCP, Git, drafting, approval, and administration surfaces stay lawyer-only.

33. Role rings: who can do what

Provider signup and parity across the deployments

The infrastructure command can create GCP resources, but it cannot accept vendor contracts, prove domain ownership, choose paid plans, create globally owned GitHub organizations, or grant Workspace-wide authority. Those are explicit operator gates. One deployment is one provider attachment; the same key names appear in every row, but credentials, webhook secrets, signing keys, sender identities, Drive roots, Restate journals, and GitHub organizations never cross rows.

DeploymentGoogle browser callbackGitHub organization
neon-law-stghttps://www.neonlaw.com/auth/callbackneon-law
neon-law-prodhttps://www.neonlaw.com/auth/callbackneon-law-foundation
neon-law-prodhttps://www.neonlaw.com/auth/callbackneon-law

The three GitHub organizations use GitHub Free, the engineering contact mailbox recorded in docs/provider-environment-parity.md, and Shook Law PLLC as the controlling business. The bootstrap creates no additional invitations: the authenticated operator remains the sole initial owner until an explicit access review adds another person.

All three organizations and private Apps were created on GitHub Free with those exact slugs. Each App is installed only on the organization in its row, selects all current and future repositories, and grants repository Administration and Contents read/write. Webhook delivery is disabled. Each deployment's config.toml contains that row's App ID and installation ID, and its secrets.enc.yaml the distinct private key. The complete value-by-value worksheet lives in docs/provider-environment-parity.md.

ConfigGitHub App
neon-law-stgnavigator-neon-law-stg
neon-law-prodnavigator-neon-law-prod
neon-law-prodnavigator-neon-law-prod

Updating a provider credential

Rotation is two-sided and ordered: at the provider first, in the repository second. Re-encrypting alone revokes nothing — anyone holding repository history and the KMS key can still read every prior ciphertext, so the old value stays valid until the provider stops honouring it.

Two of these credentials cannot be re-read once created, which makes "rotate" mean replace, not look up:

  • Google OAuth client secret. Google removed retrieval entirely; the console shows only a masked suffix. Replace it with Add secret, capture the value from the one-time dialog, then delete the old secret.
  • GitHub App private key. GitHub hands over the .pem once, at generation. Replace it by generating a new key, then deleting the superseded one.

Capture the value at the moment it is shown. A secret minted without capturing it is not recoverable — it is only deletable, and it leaves a second live credential behind until you remove it.

Write the new value straight into the deployment's encrypted file. sops reads its key from the .sops.yaml creation rule for that path, encrypts per value on save, and never writes plaintext to disk:


sops deployments/neon-law-stg/secrets.enc.yaml

A PEM needs a YAML block scalar so its newlines survive:


NAVIGATOR_GITHUB_APP_PRIVATE_KEY: |
  -----BEGIN RSA PRIVATE KEY-----
  ...
  -----END RSA PRIVATE KEY-----

Then push it to that deployment's own Secret Manager. The CSI driver projects the new version into the pods:


navigator ops secrets apply --deployment <row> --deployments-dir . --dry-run
navigator ops secrets apply --deployment <row> --deployments-dir .

The dry run prints the target project and object names without decrypting anything, so it needs no KMS permission — run it first to confirm you are aimed at the deployment you meant.

Public coordinates are not key material and do not belong in this file. A GitHub App ID, an installation ID, and an OAuth client ID are greppable, diffable, reviewable values: they live in config.toml. Only the App private key and the OAuth client secret cross into secrets.enc.yaml.

Google OAuth: six clients, not three shared secrets

Create two OAuth clients per deployment in its row's GCP project:

  • a Web application client for browser sign-in, with exactly the callback in the table;
  • a Gemini Enterprise MCP client for that deployment's data store.

That is six clients total. The staging pair lives in the neon-law-stg GCP project's Google Auth Platform consent configuration. The three production pairs live in their matching projects. Store the browser ID in NAVIGATOR_OAUTH_CLIENT_ID_BROWSER, its secret in OAUTH_CLIENT_SECRET, and the Gemini ID in NAVIGATOR_OAUTH_CLIENT_ID_GEMINI. The Gemini client secret belongs in that deployment's Gemini data-store setup. Google matches a browser redirect exactly, so do not put several sites' callbacks on one client. An Internal audience requires the project to belong to the matching Workspace organization; an External audience needs test users or the applicable verification and domain-ownership work.

The staging browser client exists with the exact name and callback in this section. Its consent configuration is External/Testing, and the authenticated operator is its initial test user. Its deployment config carries only that browser ID and secret. The Gemini ID remains absent until the data store assigns it; ops ship temporarily renders a browser-only allowlist, and #1126 removes that seam after the authenticated staging AIDA smoke test.

These clients are configured in Google Auth Platform → Clients. They are general OAuth clients, not IAP or Workforce Identity Federation clients. Google does not permit creating or modifying them programmatically, so neither ordinary gcloud services enable/service-account commands nor gcloud iam oauth-clients replace this console step.

Create a browser OAuth client and save it safely

Do this once for each row that does not already have its browser client. The Google Auth Platform page shown below is safe to include in an operating record because it contains the client name only—never capture the following creation dialog, which reveals the secret once.

Google Auth Platform browser client form

  1. In the Google Cloud console, switch to the GCP project in the deployment matrix for the target deployment. This is neon-law for neon-law-prod, neon-law-prod for neon-law-prod, and neon-law-stg for neon-law-stg. Do not create a client's credentials in a different project and copy them across.

  2. Open Google Auth Platform → Clients, then select Create client. If Google first opens the Auth Platform setup screen, complete the organization-approved branding, support contact, audience, and contact-email setup before continuing. That one-time configuration is project-scoped; it is not a replacement for the client below.

  3. Choose OAuth client ID, set application type to Web application, and name it navigator-<deployment>-browser. For example, Neon Law production is navigator-neon-law-prod-browser.

  4. Leave Authorized JavaScript origins empty. Under Authorized redirect URIs, add exactly the single callback for that config from the preceding table. Do not add a wildcard, a second deployment's callback, or a guessed local URL.

  5. Select Create. Google displays a dialog with two values: Client ID and Client secret. Copy both before closing it. The secret cannot be recovered from this dialog later; create a replacement client if it is lost.

  6. In that deployment's deployments/<name>/ tree, save the values under these exact names:

    Google creation-dialog valueWhere it lives
    Client IDNAVIGATOR_OAUTH_CLIENT_ID_BROWSER in config.toml
    Client secretOAUTH_CLIENT_SECRET in secrets.enc.yaml (sops set)

    Save them only in their matching deployment. The Client ID is public metadata but is still deployment-specific; the client secret is a credential and must never go in a terminal transcript, Slack, this repository in plaintext, or a screenshot.

  7. Reopen Google Auth Platform → Clients and verify the client name and its single redirect URI. Then run the normal secret synchronization and ship dry run. The deployment operator preflight is deliberately non-interactive: it refuses a row whose NAVIGATOR_OAUTH_CLIENT_ID_BROWSER or OAUTH_CLIENT_SECRET is absent and prints that row's project, client name, and exact callback. It verifies presence, not that a non-empty value is a usable Google credential.

Do not recycle a browser client, callback, or secret across rows. The Gemini client ID, when a deployment's data store assigns one, remains NAVIGATOR_OAUTH_CLIENT_ID_GEMINI; it is not a substitute for either browser value above.

Delete the obsolete navigator-neon-law-stg-browser, navigator-neon-law-stg-gemini, navigator-neon-staging-browser, and navigator-neon-staging-gemini registrations from the neon-law-stg project. Their retired configs are not proof that the Google registrations are gone, and no surviving deployment may reuse one of those client IDs, callbacks, or secrets.

GitHub: four organizations and four private Apps

For each organization, a GitHub owner must:

  1. confirm the existing GitHub Free organization, contact email, controlling business, and current sole owner; add no bootstrap invitation, then handle two-factor enforcement and any approved recovery owner in a separate access review;
  2. confirm the private, organization-owned GitHub App in the table is installed only in that organization;
  3. grant repository Contents and Issues read/write;
  4. put that row's organization, App ID, and private key in NAVIGATOR_GITHUB_ORG, NAVIGATOR_GITHUB_APP_ID, NAVIGATOR_GITHUB_APP_PRIVATE_KEY, and optionally pin the discovered NAVIGATOR_GITHUB_INSTALLATION_ID.

The Apps own no Project repositories: Navigator provisions none. What is left is the neon-law-foundation/navigator webhook and the DevX Restate services, which are the neon-law-stg singleton.

Provider signups that require a human account owner

  • DocuSign: create Developer/demo attachments for staging. Production needs production eSignature accounts and Go-Live-approved integrations. The deployment's secrets.enc.yaml receives the account/base/OAuth IDs, JWT app/user/private key, signer, Connect HMAC, and path secret. Prove a completed demo or deliberate production envelope and its verified Connect delivery.
  • Twilio SendGrid: create account or subuser boundaries with four separately revocable keys and webhook configurations, and authenticate each sender domain. The deployment's secrets.enc.yaml receives the mail API key, From address, inbound and event secrets, and signed-event public key. Prove outbound delivery, inbound parse, and a signed event callback.
  • Google Workspace Drive: create three Shared Drives and service accounts. A Super Admin grants Drive domain-wide delegation to each service-account OAuth client. The deployment's tree receives the selected Drive ID and delegated user (config.toml) and service-account JSON (secrets.enc.yaml). Prove a synthetic file can be created, read, and archived only in the matching Drive.
  • Restate Cloud: arrange an account and plan supporting at least three environments; the free tier is insufficient. The deployment's secrets.enc.yaml receives that row's broker, ingress, admin URL, and API key. Prove the worker registers and completes a durable workflow in that environment.
  • Production contracts: an API-capable DocuSign plan and a SendGrid plan supporting the required webhook count may be paid subscriptions. A production deployment must not contain a demo DocuSign host, demo account, or staging sender.

For SendGrid, create a restricted mail-send API key rather than a billing-capable key and enable signed webhook verification. For Drive, authorize only https://www.googleapis.com/auth/drive. For Restate, never point two rows at the same environment in the target state: its journal is state, not a stateless endpoint.

Keep provider attachments deployment-local

Do not copy a production provider bundle from staging or another brand. Each deployment's deployments/<name>/ tree owns its GitHub App, Restate journal, DocuSign attachment, SendGrid credentials and webhooks, OAuth clients, Drive root, database, session key, and application-signing keys. Run the names-only parity gate (it runs inside the workspace test suite on every pull request) and the provider smoke tests per deployment, then let a --dry-run ship enforce the actual boot-key contract.

ops ship --dry-run detects a missing or empty boot key before it applies a workload, but it cannot prove that a non-empty provider credential is valid. Treat a placeholder such as SG.test only as a bounded diagnostic that proves the next preflight branch; replace it with the deployment's restricted SendGrid key and run the outbound, inbound, and signed-event smoke tests before a production ship. A placeholder can let a pod boot while the first real email fails, which is useful evidence during setup but never production readiness.

When a dry-run prints a kubectl patch secret remedy, read it as the exact missing-key diagnosis—not as the durable repair. Add the value only to the named deployment's secrets.enc.yaml (or config.toml for a coordinate), then run navigator ops secrets apply --deployment <name> and rerun the same ops ship --dry-run. A hand-patched Kubernetes Secret is drift the CSI projection will replace. Work one reported requirement at a time: the guard stops before the workload apply, so the next dry-run is the authoritative check for the next missing boot dependency.

Before the first ship, write each deployment's Secret Manager objects from its tree:


navigator ops secrets apply --deployment <row> --deployments-dir . --dry-run
navigator ops secrets apply --deployment <row> --deployments-dir .

Repeat per deployments/ directory. The dry run reads key names only — no KMS call, no decrypted value — and fails closed listing any object the SecretProviderClass projects that the tree does not supply. It never writes DNSimple, gcloud, or operator-session values into Secret Manager; the tree cannot even express them.

Presence is only the first audit, and it runs in CI: the parity gate in cli/src/devx/deployments.rs checks every deployment's key names against store::deployment::WEB_REQUIREMENTS on every PR. Then run navigator ops ship --deployment <name> --dry-run and perform the provider smoke tests above. Never paste a private key, token, service-account JSON, or decrypted value into Slack or workshop notes — see docs/deployment-secrets.md.

34. Provider signup and parity across the deployments

The external surface — every third party, in one place

Neon Law Navigator's runtime external surface has these services, in two kinds — platform services (the cloud the stack runs on) and feature vendors (each lights up one capability and stubs out cleanly when unconfigured):

ServiceWhat it gives youKindAt boot
Google CloudStorage, OIDC, archiveplatformrequired — provisioned by navigator ops gcp setup
Restate CloudDurable workflow execution (workflows-service)platformrequired — the workflow broker
Vertex AIThe A2A agent-router LLM (Gemini Flash in prod)platformoptional — NullRouter until configured
GitHubPrivate per-Project repositoriesplatformrequired in the requested cloud topology
DocuSignE-signaturefeatureCI-harness stub; required in a normal dev deployment and production
XeroAccounting / billing (ACCREC invoices)featureStubBillingProvider until XERO_* is complete
SendGridOutbound + inbound emailfeatureCI-harness capture; otherwise required
35. The external surface — every third party, in one place

The two service deployments

A production install runs one Rust application in two operational roles:

  • navigator-web — the public portal, AIDA/API routes, webhooks, health probes, embedded Rego authorization, and client-facing Documents/Engagements/Invoices views.
  • workflows-service — the durable Restate worker that renders documents, advances workflows, sends emails, and runs the background side effects the portal schedules.

The split keeps the portal stateless: every side effect that needs durable retries belongs to the worker rather than to a request handler. Lawyer and admin users with Project access work the matter through the firm workbench, over the participation-scoped list that surface resolves for them. Clients use the portal file surface: they see only Projects where they have a person_project_roles row, and the portal renders reviewed documents, Engagements, and invoices without exposing storage vocabulary or GCS credentials.

36. The two service deployments

Security architecture


flowchart TB
  client["Client browser"]
  lawyer["Lawyer/admin browser"]
  oidc["OIDC provider"]
  edge["HTTPS Gateway"]

  subgraph gke["GKE namespace"]
    web["navigator-web\nportal, AIDA, APIs"]
    worker["workflows-service\nDurable worker"]
  end

  subgraph data["Private data plane"]
    pg["SurrealDB\nroles + project access"]
    docs["GCS documents bucket\nprivate blobs"]
    assets["GCS assets bucket\nprivate marketing objects\nserved through /assets"]
  end

  client --> edge
  lawyer --> edge
  edge --> web
  web --> oidc
  web --> pg
  web --> docs
  web --> assets
  web --> worker
  worker --> pg
  worker --> docs

  client -. "portal session only" .-> web
  lawyer -. "portal session + lawyer role" .-> web

Clients never receive GCS IAM, bucket URLs, or object paths. A client request enters through navigator-web, resolves identity through OIDC, reads authorization from persons and person_project_roles, and streams only the portal-visible matter files back through the app. Lawyer and admin access is not a second door: it enters through the same service and the same database access model, and differs only in what the role and participation checks allow.

37. Security architecture
6

Chapter 6

Ship the Instance

Ship and verify

Provisioning gives you an empty cluster; now pin one deployment to one published release. The --deployment flag selects the deployments/<name>/config.toml that supplies the exact project, cluster context, namespace, image name, hosts, buckets, SQL instance, required browser OAuth client, optional post-registration Gemini client, and runtime Secret name. First-install order is load-bearing: apply the deployment's Secret Manager objects, install observability so navigator-otel-env exists, render the release, then apply it:

Before shipping from a checkout whose deployment changes have not reached your installed binary, install that checkout's CLI. A stale global binary may enforce an obsolete ship contract even when the selected deployment's config correctly carries the current production profile:


cargo install --path cli --force

An operator wrapper must make the same guarantee before it changes Kubernetes or GCP: build the selected checkout's cli package, then prove that navigator ops secrets apply --help exists. Do not fall back to an arbitrary pre-existing target/release/navigator; a stale binary can lack a subcommand the current runbook requires and stop only after it has already refreshed cluster credentials. Browser OAuth values belong in the deployment's tree before the wrapper starts, so preparation is non-interactive. Keep explicit confirmations only for irreversible resource retirement and the live release roll.

The failed guard runs before manifest rendering or cluster mutation. Install the matching CLI, set both environment and credential profiles to production in the deployment's config.toml, then run the complete sequence:


navigator ops secrets apply --deployment <row> --deployments-dir .

navigator ops observability --deployment <row> --deployments-dir .

navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D --dry-run

navigator ops ship --deployment <row> --deployments-dir . --tag YY.M.D

ops observability is safe before the application Deployments exist: it creates the namespace-scoped collector and navigator-otel-env ConfigMap, then skips the optional Deployment patch because ops ship renders that wiring into new Deployments. A cold Autopilot cluster may take several minutes to create its first nodes and start the managed Prometheus admission webhook. The CLI retries an idempotent IAM binding while a new navigator-otel Google service account propagates. Before it applies collector-monitoring.yaml, the Rust CLI uses Google ADC and the Container API to read the selected GKE cluster endpoint and CA, then queries the managed gmp-operator Endpoints object with its Kubernetes client. It waits only while that object has no ready addresses; a RUNNING GKE cluster is not treated as proof that the admission webhook is ready. The endpoint wait is bounded to three minutes and prints each attempt. If it expires, inspect the managed operator rather than deleting the collector or patching the Secret:


kubectl --context "$NAVIGATOR_GKE_CONTEXT" -n gke-gmp-system \
  get deployment,pods,endpoints gmp-operator

When the managed operator becomes ready, rerun the exact same navigator ops observability or three-deployment operator command. Namespace creation, Secret reconciliation, Google service-account creation, IAM bindings, and collector manifests are all idempotent; a partially completed first run is a resume point, not a cleanup instruction.

The staging dogfood run also proved two quota-independent defaults. Autopilot clusters are created with --enable-private-nodes, so a new region does not need one public in-use address per node. The link-out compatibility writer uses the standard persistent-disk class, not an SSD-backed class; it must not consume SSD_TOTAL_GB for an otherwise empty mount.

38. Ship and verify

Post the verified handoff in #navigator

Post only after the verification loop prints all three OK lines. Replace YY.M.D.H once with the deployed tag and preserve the config/host mapping exactly. The message body is:

:white_check_mark: Navigator YY.M.D.H is live on three deployment stacks

Verified /readyz, /version.release == "YY.M.D.H", and a browser visit:

  • staging
  • Neon production
  • Neon Law production

Set exactly one website to one published version:

Choose its name from the deployments/ tree (host is that config's NAVIGATOR_PUBLIC_HOST), set tag, then run:


name=neon-law-stg tag=YY.M.D.H
host=$(sed -n 's/^NAVIGATOR_PUBLIC_HOST = "\(.*\)"$/\1/p' "deployments/${name}/config.toml")
gcloud auth login --force --update-adc

set -a; eval "$(grep ' = "' "deployments/${name}/config.toml" | sed 's/ = /=/')"; set +a
gcloud container clusters get-credentials "$NAVIGATOR_GKE_CLUSTER_NAME" \
  --region "$NAVIGATOR_GCP_LOCATION" \
  --project "$NAVIGATOR_GCP_PROJECT_ID"

navigator ops secrets apply --deployment "$name"
navigator ops observability --deployment "$name"
navigator ops ship --deployment "$name" --tag "$tag" --dry-run
navigator ops ship --deployment "$name" --tag "$tag"

curl --fail --show-error --silent "https://${host}/readyz" >/dev/null
curl --fail --show-error --silent "https://${host}/version" |
  jq --exit-status --arg tag "$tag" '.release == $tag'

Change only name and tag; the host follows from the config. The current tree maps:

  • neon-law-stg → www.neonlaw.com
  • neon-law-prod → www.neonlaw.com
  • neon-law-prod → www.neonlaw.com

All three hosts resolve to their own deployment's gateway IP, so all three answer /readyz and /version directly.

39. Post the verified handoff in `#navigator`

Point your domain at the instance (optional)

navigator ops gcp setup reserves a static gateway IP but deliberately does not touch DNS. Keep this boundary: do not put the DNSimple token in any deployment's tree, and do not make DNS a side effect of GCP provisioning. Apply this one reviewed transaction directly with the DNSimple CLI.

The exact one-time transaction below is the three-deployment record set: one public and one workflow address per deployment. Review current state before applying it and omit any create whose exact record already exists. The apex continues to redirect neonlaw.com to https://www.neonlaw.com.

This block is the pre-cutover neonlaw.com record set and has not been rewritten for the host map above. It records the single-zone state live in DNSimple today, including the exact record ids its preflight compares against, so it is reproduced verbatim rather than machine-edited. Moving the firm's production to www.neonlaw.com and Neon production to www.neonlaw.com splits this one zone into three, and each new zone needs its own registration, records, managed certificate, and OAuth redirect URI before any record here is deleted. Treat the block below as the state to migrate from.


export DNS_ACCT=174981
export DNSIMPLE_TOKEN="$DNS_SIMPLE"

dnsimple records list neonlaw.com --account "$DNS_ACCT" --json |
  jq --exit-status '
    .data as $records |
      ([$records[] | select(.name == "" and .type == "URL")] == [{
        id: 80303423,
        zone_id: "neonlaw.com",
        type: "URL",
        name: "",
        content: "https://www.neonlaw.com",
        ttl: 300,
        regions: ["global"],
        created_at: "2026-07-23T22:08:37Z",
        updated_at: "2026-07-23T22:08:37Z"
      }]) and
      ([$records[] |
        select(
          .name == "staging" or
          .name == "workflows-staging" or
          .name == "neon" or
          .name == "workflows-neon-law-prod" or
          .name == "www" or
          .name == "workflows"
        )
      ] == [{
        id: 80303569,
        zone_id: "neonlaw.com",
        type: "URL",
        name: "www",
        content: "https://www.neonlaw.com",
        ttl: 300,
        regions: ["global"],
        created_at: "2026-07-23T22:14:28Z",
        updated_at: "2026-07-23T22:14:28Z"
      }])
  '

dnsimple records delete neonlaw.com 80303569 --account "$DNS_ACCT" --yes

dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name staging \
  --content 34.160.169.219 --ttl 300
dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name workflows-staging \
  --content 34.160.169.219 --ttl 300
dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name neon \
  --content 34.149.196.255 --ttl 300
dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name workflows-neon-law-prod \
  --content 34.149.196.255 --ttl 300
dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name www \
  --content 8.233.220.29 --ttl 300
dnsimple records create neonlaw.com --account "$DNS_ACCT" --type A --name workflows \
  --content 8.233.220.29 --ttl 300

The provider-side record list and a public resolver must return all six paired addresses after the transaction. The apex returned 301 https://www.neonlaw.com/. The temporary token was then removed from the shell and can be revoked in DNSimple; record serving and propagation do not depend on it.

Run the mail record groups once for the neonlaw.com zone—not once per deployment—using --google-workspace, --sendgrid, DKIM/link-branding targets, SPF includes, and DMARC settings. The command is additive and never deletes unrelated records. The full record ownership and Google Workspace forwarding recipe is in docs/dns.md.

40. Point your domain at the instance (optional)

Drive it from the CLI

Once your instance answers /readyz, the navigator CLI runs the firm's whole matter flow against it from your terminal. It authenticates like gcloud auth login and lands a short-lived (~8h) token at ~/.navigator.json:


cargo install --path cli          # installs `navigator` on your PATH
# …or skip installing — run it straight from the source tree:
cargo run -p cli -- login --host www.your-domain.example   # `cargo run -p cli -- <args>` == `navigator <args>`
41. Drive it from the CLI

Make it yours — white-label under your own brand

Neon Law Navigator runs two brands from one binary. A deployment operator can ship it under another identity without forking source by describing the organization once in a private navigator.yaml bundle:


cp navigator.example.yaml navigator.yaml   # then edit: names, emails, domain, logos
cargo run -p cli -- ops rebrand build --out .devx/brand-bundle
cargo run -p cli -- ops rebrand verify --dir .devx/brand-bundle
set -a; source .devx/env; set +a                # object storage and the rest of the runtime env
NAVIGATOR_CUSTOM_BRANDING=.devx/brand-bundle cargo run -p neon
42. Make it yours — white-label under your own brand

This is how we set up Neon Law Foundation

Everything above is the recipe. This is the log of us following it for the Foundation's own deployment, neon-law-prod in the neon-law-prod project, serving www.neonlaw.com. It is written down because the first install into a cold cluster went differently from the happy path, and the difference is worth knowing before you hit it.

The Foundation's deployment is the third row in our matrix and the one that holds real pro bono matters, so it went last — after the same release had proven itself on neon-law-stg and neon-law-prod. navigator ops gcp setup had already built the project's half of the world: the neon-law-prod GKE Autopilot cluster, the navigator-secrets KMS keyring, five storage buckets, and the reserved global address neon-law-prod-gateway-ip.

Then the first ops ship failed, and kept failing. Three things were in the way, in the order we hit them.

The object list was a superset. ops secrets apply --deployment <row> --deployments-dir . --dry-run failed closed naming nine DocuSign objects. The Foundation's deployment executes no documents: it supplies no DOCUSIGN_BASE_URL, declines the integration, and runs StubSignatureProvider. But the shared SecretProviderClass referenced all nine anyway, plus the three engineering-webhook objects scoped to the automation home that this project must never hold. A CSI mount fails the whole volume on one object it cannot read, so the only way past was a placeholder credential — and a placeholder boots the real provider, because DocuSignSignatureProvider::from_env returns Some for any non-empty value. A green deploy that fails on its first signature request, on the deployment holding real matters.

That was a genuine defect rather than a configuration mistake, and the fix was to render the object list per deployment so the class references exactly what the deployment writes. It is described in docs/deployment-secrets.md. If your own deployment declines an integration the manifest names, this is the machinery that lets it.

Secret Manager was empty. With the object list correct, one command filled it:


navigator ops secrets apply --deployment <row> --deployments-dir .

Twenty-five objects written, twelve reported as skipped with their reasons. Read that skipped line — it is also the list of what this deployment's mount will not ask for.

A cold cluster has no namespace, and no Secret to preflight against. This is the one to plan around. ops ship confirms the deployment's Secret satisfies the running binary's boot invariants before it reconciles anything, which is the right order for every ship after the first — an unsatisfied requirement aborts a ship that has touched nothing. On a first ship it is a standoff: the namespace is created by the apply in step 5, and the Secret is projected by the CSI driver only while a pod mounts the volume, so neither exists when step 4 goes looking. The failure reads:


Error: kubectl get secret neon-law-prod-web-secrets failed:
Error from server (NotFound): namespaces "neon-law-prod" not found

We broke the standoff by hand: created the namespace, then seeded a plain neon-law-prod-web-secrets Secret from the Secret Manager objects ops secrets apply had just written, so the preflight had something real to read. The projected Secret takes the same name, so once the pods are up and the driver owns it, the seeded one is retired exactly as "Retire the plain Secret" describes.

Do not read that paragraph as a runbook. Doing it by hand is off the invariant this whole workshop rests on — the navigator CLI orchestrates every machine-bound flow, and reaching around it with ad hoc commands is precisely what leaves the next operator without a path. The bootstrap belongs in the CLI, and the shape it should take is a preflight that falls back to the key set the rendered SecretProviderClass will project when no live Secret exists yet: those objects are already proven to resolve to an ENABLED version before the reconcile, so it checks the same property from the authoritative source instead of from a cluster that has nothing in it. Until that lands, a first install into a cold cluster needs a human, and you should expect it.

A cold Autopilot cluster has no nodes, and that stops the reconcile. With the Secret readable, ops ship rendered and diffed, then failed on an admission webhook:


Error from server (InternalError): failed calling webhook
"validate.rules.gmp-operator.gke-gmp-system.monitoring.googleapis.com":
no endpoints available for service "gmp-operator"

The chain is circular and worth recognising on sight. Autopilot provisions nodes for workloads; a cluster nobody has shipped to has none. With no nodes, the managed Prometheus operator cannot schedule — ours had been Pending for 44 hours — so its admission webhook has no endpoints, and the Rules object in the exports overlay cannot be validated. The ship is blocked by the absence of the very workload it is trying to create.

ops observability is what breaks it, and this is exactly why the first-install order puts that command before ops ship rather than after. It applies the collector Deployment first — a workload, which makes Autopilot provision a node, which lets the operator schedule — and only then does it wait for the webhook and apply the monitoring manifests. Run it and watch a node appear:


navigator ops observability --deployment <row> --deployments-dir .

Skipping it, as we did, turns a documented ordering into forty minutes of diagnosis. Run the order as written.

Then the ship works. Every manifest applied, navigator-web and workflows-service both rolled out, and the five trigger CronJobs pinned to the tag. ops ship still exited non-zero, at the last step: it re-registers the worker with Restate and refuses to call a ship complete when that fails, because a stale handler list means webhook submissions fail silently later. That is the check behaving correctly — RESTATE_ADMIN_URL and RESTATE_ADMIN_TOKEN live in the deployment's encrypted tree for the operator, and CI supplies them from secrets.

The DNS cutover, and the gap it costs. The Ingress carries the annotation kubernetes.io/ingress.global-static-ip-name: neon-law-prod-gateway-ip, so it claims the address reserved during provisioning — wait for it to appear before touching DNS, because until GKE finishes the load balancer there is nothing behind that IP:


NAVIGATOR_GATEWAY_IP=$(gcloud compute addresses describe neon-law-prod-gateway-ip \
  --global --format='value(address)')
navigator ops dns setup --domain neonlaw.com --gateway-ip "$NAVIGATOR_GATEWAY_IP" --dry-run

Two calls: www patched off the old address, workflows created. The command never deletes. Then the wait described in Pointing a hostname at a deployment begins, and it is a real outage — the hostname serves neither the old site nor the new one while Google validates. Ours ran about twelve minutes for www. FAILED_NOT_VISIBLE on the way through is normal: it records validation attempts made while DNS still pointed elsewhere, and clears itself.

Retire the old certificate after the cutover has settled, not during it. www.neonlaw.com was previously served by a Certificate Manager certificate authorized through an _acme-challenge.www CNAME. That record is inert once the hostname points at GKE — managed certificates validate through the load balancer, not the ACME DNS challenge — but it is also what keeps the old certificate renewable, and the old certificate is what makes a DNS rollback instant. Retire the certificate, its authorization, and the record together, once you have decided not to roll back.

The lesson we would give another deployer is the one the release order already encodes: the deployment that carries real client matters goes last, and it goes last precisely because the first install is where you learn what the recipe assumes. Ours assumed a cluster that had already been shipped to once — and every gap above is one the two earlier deployments could never have found, because by the time they shipped, someone had already shipped to them.

43. This is how we set up Neon Law Foundation
7

Chapter 7

Wrap Up

Canonical references

This workshop is the narrative; these docs are the source of truth and stay current — prefer them when they disagree:

  • docs/oss-install.md — the full end-to-end install (env, Secret, overlay, image, verify). docs/deployment-secrets.md — production secret rendering. docs/third-party-integrations.md — the per-environment vendor-account convention. docs/docusign-esignature.md — e-signature setup and the one-app, two-environment model.
44. Canonical references

You finished — claim your certificate

Enter your name and email and the Neon Law Foundation will send a PDF certificate of completion.

We use your email only to send this certificate.

Neon Law
BlogContactFoundationNavigatorPresentationsWorkshops
Contact us — contact@neonlaw.com+1 510 800 2080
  • Nevada
    5150 Mae Anne AveSte 405-9002Reno, NV 89523
  • New York
    12 E 49th St18th FloorNew York, NY 10017
  • Washington
    720 Seneca StSte 107-715Seattle, WA 98101

© 2026 Shook Law PLLC and Neon Law Foundation

This is attorney advertisement. Nothing on this site is legal advice. Neon Law is the trade name of Shook Law PLLC, and an attorney-client relationship begins only with a signed retainer between you and Shook Law PLLC. Published flat fees cover the scope each one names and do not include third-party filing fees. Every legal matter is different, and past results do not guarantee a similar result.

Shook Law PLLC is a proud supporter of the Neon Law Foundation , a 501(c)(3) nonprofit.

Neon Law Foundation is a Nevada nonprofit corporation and a 501(c)(3) tax-exempt organization. It does not practice law and cannot represent you.

Nothing on this site is legal advice, and nothing here creates an attorney-client relationship.

5150 Mae Anne Ave Ste 405-9999, Reno, NV 89523
support@neonlaw.orgTransparency & public disclosures

Powered by Neon Law Navigator #26.8.20-hotfix.4

Open source — neon-law-foundation/navigator GitHub stars 2