# Timebase 2.0 - Docker deployment and security runbook

An executable companion to **Start Here for Docker: How to Deploy and Secure Timebase 2.0**.
Hand this file to a coding agent to deploy and secure a Timebase 2.0 stack on a single
Docker host.

---

## Instructions to the agent

Work through the phases in order. Do not skip ahead: each phase depends on the one before it.

**Rules**

1. Run the verification command at the end of every phase and confirm the expected result
   before continuing. If it does not match, stop and report - do not proceed hopefully.
2. Every command here is non-interactive. Never launch an editor inside a container
   (`docker exec -it ... nano` will hang you).
3. Phases marked **STOP - HUMAN REQUIRED** cannot be completed by you. Print exactly what
   you need, wait for the human to supply it, then continue.
4. **Address services through Compose, never by container name.** This stack sets no
   `container_name`, so Docker generates names like `timebase-pulse-1`. Use
   `docker compose cp`, `docker compose exec`, `docker compose restart`, `docker compose logs`
   and `docker compose ps`, which all take the short service name, and run them from
   `./timebase`. Do not add a `container_name` to the Compose file: a container name is global in
   Docker rather than scoped to a project, so it would collide with any other container of that name.
   Commands here are written `cd ./timebase && ...`, for a fresh shell that starts in the folder
   that holds `./timebase`. A shell that keeps its working directory between commands is already in
   `./timebase` after the first one, and the second `cd ./timebase` fails: run each command in a
   subshell, `(cd ./timebase && ...)`, or write the absolute path in place of `./timebase`. If the
   human named a folder to work in, create it first if it does not exist, and start from inside it.
5. Never invent a secret, an API key, or a password. Never read one out of a log and paste
   it somewhere else without being asked to.
6. Re-running a phase must be safe. Each phase begins with a check for work already done.
7. **When checking whether something is now fixed, read a time window, not a line count.** Use
   `docker compose logs <svc> --since 2m`, never `--tail <n>`: a tail can include lines from
   before the restart you just did, and make a working fix look broken.
8. **Every command you hand to a human must be runnable as pasted.** They are not in your shell
   and do not know your working directory. Resolve `./timebase` to an absolute path once with
   `pwd`, state it plainly, and prefix any command that depends on it with
   `cd <that absolute path> && `. On Windows write `cd <that absolute path>; <command>` instead,
   because Windows PowerShell 5.1 has no `&&`, and give the path in its `C:\...` form (Phase 6
   shows how). Never hand over a bare relative path. Put the command in the message that ends your
   turn, or inside the question you ask: text printed just before a question prompt can be lost, and
   the human then has to ask for it again.
9. **Never write to a component's configuration files.** That covers `settings.config`,
   `historians.config`, `sources.config` and anything else in a component's `settings` or `config`
   folder: do not edit one, and do not copy an edited one back in. When a component needs
   something from a human, the human enters it in that component's own interface. Tell them
   which screen and which fields, give them every value that is not a secret, and wait. The
   files this runbook has you write are the deployment itself: the Compose file and `.env`, and, if
   the human chooses it in Phase 7, a helper script that holds no secret.
10. **Never see a secret.** Each client secret goes from Pulse, through the human's clipboard, into
    that component's own setup form, and nowhere else: not into a file, not into an environment
    variable, not through your shell. The human puts it there themselves, either in the form or
    through the helper script they run in a terminal of their own. Do not ask the human to give you
    one, and do not open a component's `settings.config`, which holds its secret once the form has
    saved it.

**Assumed values.** Substitute nothing unless the human tells you otherwise.

| Value | Use |
|---|---|
| `localhost` | How the human's browser reaches components, **unless step 1b recorded another address**. That address is `<address>` below and `TIMEBASE_HOST` in `.env` |
| `pulse` | The Compose service name: how the containers reach Pulse |
| `https://localhost:4542` | Pulse's public address, `Auth__PublicAddress`: where a browser reaches it, and, with `/auth` added, the token issuer. With another address in step 1b it is `https://<address>:4542` |
| `./timebase` | Working directory for the Compose file, `.env`, the downloaded root CA and, if used, the helper script |
| `Administrator` / `timebase` | The first-run Pulse sign-in, identical on every installation. Needed from Phase 7 onward. Case-sensitive: `Administrator`, not `administrator` |

---

## Phase 1 - Preflight

```bash
docker --version && docker compose version && (python3 --version && echo "use: python3" || (python --version && echo "use: python"))
```

Compose must be v2 or newer (`docker compose`, not `docker-compose`). Python 3 is needed well beyond
the tag check in step 2a: Phases 4, 5 and 7 run it too. On Windows it is usually `python`, not
`python3`: the check ends with `use: python3` or `use: python`, and wherever a command here says
`python3`, use the one it named. If either is missing, **stop and report it**. The human's own
terminal needs Python 3 as well, to run the helper in Phase 7, and you cannot check it from here:
Route 2 in that phase needs none.

Report the CPU count and memory that **Docker** can use, which on Docker Desktop is less than the
host's, and state that the documented minimum is 4 cores and 8 GB for a single-machine deployment of
the full stack:

```bash
docker info --format '{{.NCPU}} CPUs, {{.MemTotal}} bytes'
```

Then look for an earlier stack, because one changes what the rest of this runbook does:

```bash
docker compose ls -a
docker volume ls
```

Look for a project named `timebase`, or for volumes named after it, such as `timebase_pulse`. If either exists, tell the human what you found (whether it is running, and
which volumes) and ask whether to **reuse** it, which is an upgrade, or **start clean**. Wait for the
answer. A reused stack keeps its old root CA, its client secrets and its Administrator password, so
later phases may find their work already done. Starting clean deletes the old volumes and everything
in them: say so, and remove nothing until they have clearly said yes.

Then check that nothing on the host already listens on the fourteen ports this stack publishes,
`4511`-`4512`, `4521`-`4522`, `4531`-`4532`, `4541`-`4542`, `4551`-`4552`, `4561`-`4562` and
`4571`-`4572` (a stack the human chose to reuse holds them itself, and that is expected):

```bash
ss -ltn 2>/dev/null | grep -E ':45[1-7][12] ' || echo "ports free"
```

Where `ss` is not installed, as in Git Bash on Windows, that line prints a false `ports free`. Use the
PowerShell command there.

On Windows, in PowerShell (it lists only the fourteen ports, so an unrelated listener elsewhere
between `4511` and `4572` does not trip it):

```powershell
Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 4511,4512,4521,4522,4531,4532,4541,4542,4551,4552,4561,4562,4571,4572 } | Select-Object LocalPort, OwningProcess
```

**A Windows installation of Timebase uses the same ports.** Its hosts run under the Windows service
`Timebase Service Starter`, and they will hold every one of these ports. Pulse's seeded sign-in
callbacks are fixed to `https://localhost:45x2/callback`, so the Docker stack cannot simply move to
other ports. On Windows, check whether that service is running, in PowerShell (it prints nothing if
the service is not installed):

```powershell
Get-Service 'Timebase Service Starter' -ErrorAction SilentlyContinue
```

If it is running, **STOP - HUMAN REQUIRED**: stopping a service needs administrator rights. Hand
over, for an Administrator PowerShell session:

```powershell
Stop-Service 'Timebase Service Starter'
```

Tell them the service is stopped, not disabled, so it returns on the next reboot and takes the
ports back unless they also set it to Manual (`Set-Service 'Timebase Service Starter' -StartupType Manual`).

**Verify:** Docker responds, Compose v2 or newer is available, Python 3 is available, any earlier
stack has been reported and the human has chosen to reuse it or start clean, and the port check
prints nothing (or `ports free`), or shows only a stack they chose to reuse.

### 1b. Ask how Timebase will be reached

Everything below defaults to one computer, reached as `localhost`. Before you write a file, find out
whether that is what the human wants. **Ask in these words, not in technical terms**, and wait for the
answer:

> Will you use Timebase only on this computer, or will other computers on your network need to
> reach it too?

- **Only this computer.** Also the answer to assume if they are unsure or only trying it out. Nothing
  more to ask: the address is `localhost`. Go on to Phase 2.
- **Other computers too.** Ask the next question.
- **Through a company web address, proxy or load balancer that forwards to this computer.** This
  runbook does not cover that: a component builds its sign-in address from the address the browser
  used, and a proxy changes it. **Stop and tell them so.**

> What name or address will people type to reach this computer? For example
> `timebase.yourcompany.com` or `10.4.2.17`.

Take a DNS name or an IPv4 address. Remove any `https://`, port and path, write it in lower case, and
read it back: "So people will open Timebase at `https://<address>:4542`, and the other pages on their
own ports - is that right?" If they answer `localhost` or `127.0.0.1`, that is the first answer. IPv6
addresses and wildcard names are not covered: ask for another.

Record it as `<address>`. Step 2b writes it to `.env`, and it changes only these things:

| Setting | Only this computer | Other computers, at `<address>` |
|---|---|---|
| Pulse's public address, `Auth__PublicAddress` | `https://localhost:4542` | `https://<address>:4542` |
| Names in Pulse's certificate, `Cert__Names` | `pulse,localhost` | `pulse,<address>` |
| Names in each other component's certificate, `Pulse__Cert__Names` | its service name and `localhost` | its service name and `<address>` |
| Sign-in addresses Pulse accepts | the `localhost` ones it ships with | those, plus six for `<address>`, which the human adds in Phase 7 |
| Pulse's own assistant, `Gage__SelfAddress` | `https://localhost:4542` | `https://localhost:4542` |

Tell them in plain words what to expect. For the first answer, nothing more is needed. For the second,
they will register that address in Pulse, and every computer that opens Timebase must be able to look
the name up and must trust Pulse's certificate.

### 1c. Ask about the AI assistant

Timebase's AI assistant, Gage, answers questions by calling an AI model that **the human supplies**:
Pulse holds the credentials, and every component asks Pulse. Pulse already contains a provider for
Anthropic's Claude, waiting only for a key. Find out now what the human wants, so that their one visit
to Pulse in Phase 7 covers it. **Ask in these words**, and wait for the answer:

> Do you want the AI assistant to be ready to answer questions once this is set up? It needs an account
> with an AI provider. You can use Anthropic's Claude, which is already prepared and only needs your
> API key. You can use another provider, such as OpenAI or Google Gemini, or a model you run yourself.
> Or you can leave it for later.

If they just say yes, ask which: Anthropic, another provider, or later.

- **Anthropic (Claude).** They need an Anthropic API key. Nothing else to decide.
- **Another provider.** Ask which one, and whether it runs in the cloud or on a computer they run. They
  will need its address, an API key if it uses one, and the name of the model to use. A model running on
  the Docker host itself needs care: see *The AI provider* in Phase 7.
- **Later, or not at all.** Also the answer to assume if they are unsure. Nothing breaks: the assistant
  answers nothing until a provider has a key, and Phase 9 offers it as a next step.

Ask them to have the key ready before Phase 7, in a place they can copy it from. You never see it: they
enter it in Pulse themselves, in Phase 7.

---

## Phase 2 - Write the Compose file

Create the working directory and write `./timebase/docker-compose.yml` with exactly the
contents below. It defines seven component services - `pulse`, `historian`, `explorer`, `atlas`,
`connect`, `bridge`, `collector` - with seven named volumes of the same names, plus `volume-init`,
a one-shot service that runs before them and exits.

Do not alter the service names: they are the hostnames the components use to find each
other, and the names their certificates are issued for.

**Every image reads `${TIMEBASE_TAG}`**, which defaults to `latest`, the newest 2.0 release, so the
whole stack moves together. Never edit a tag in this file. To pin a release, set `TIMEBASE_TAG` in
`.env` (step 2a).

`.env` carries one more variable, `TIMEBASE_HOST`: the address from step 1b. It is **optional and
defaults to `localhost`**, so a local run needs no entry for it. Every address-bearing setting in the
file reads it, so a changed answer is one line in `.env` and a `docker compose up -d`, never an edit
to this file.

```bash
mkdir -p ./timebase
```

### 2a. Start the `.env` file

Write `.env` beside the Compose file, where Compose reads it automatically. Its first line is the
release tag. Use `latest` unless the human asked for a particular release, such as `2.0.0`:

```bash
cd ./timebase && echo "TIMEBASE_TAG=latest" > .env
```

`latest` always points at the newest 2.0 release. Docker does not refresh an image it already holds
on its own, which is why every `up` in this runbook passes `--pull always`: keep it.

### 2b. Record how Timebase will be reached

If step 1b gave `localhost`, write nothing: the Compose file defaults to it. Otherwise append the
address to the `.env` that 2a just wrote. 2a replaces the file on every run, so do this after it, every
run:

```bash
cd ./timebase && echo "TIMEBASE_HOST=<address>" >> .env
```

Use the lower-case address from step 1b, with no `https://`, no port and no trailing slash. Then show
the human `.env`, which holds no secret: the tag, and the address if there is one.

### 2c. Write the Compose file

```yaml
# Upgrading an existing stack? Keep YOUR existing project name on the next line: Compose
# names volumes under it, and changing it mounts a fresh, empty set instead of your data.
name: timebase

services:
  # Runs once, as root, before anything else, and exits. From 2.0 the images run as
  # an unprivileged user (uid 1654), so a volume created by an earlier release, which
  # ran as root, has to be handed over first. A volume that is already right costs
  # one stat. `$$` is how Compose writes a literal `$`.
  # Keep it on a fresh installation too: it also hands over a volume first mounted at a
  # path its mounting image does not pre-create; a correct volume costs only that stat.
  volume-init:
    image: timebase/pulse:${TIMEBASE_TAG:-latest}
    user: "0"
    restart: "no"
    network_mode: none
    entrypoint: ["sh", "-c"]
    command:
      - |
        for d in /pulse /historian /explorer /atlas /connect /bridge /collector; do
          if [ "$$(stat -c %u "$$d")" != "1654" ]; then
            echo "volume-init: $$d is owned by uid $$(stat -c %u "$$d"); handing it to uid 1654"
            chown -R 1654:1654 "$$d"
          fi
        done
    volumes:
      - pulse:/pulse
      - historian:/historian
      - explorer:/explorer
      - atlas:/atlas
      - connect:/connect
      - bridge:/bridge
      - collector:/collector

  pulse:
    image: timebase/pulse:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: pulse
    ports:
      - "4541:4541"
      - "4542:4542"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - pulse:/pulse
    environment:
      - Certificates=/pulse/certificates
      - Settings=/pulse/settings
      - Config=/pulse/config
      - Data=/pulse/data
      - Logs=/pulse/logs
      # The names this host's certificate must carry, beyond localhost. Every
      # other container dials Pulse as `pulse`, and a certificate is only good
      # for the names inside it. TIMEBASE_HOST comes from .env and defaults to
      # localhost. See "Certificate names" below.
      - Cert__Names=pulse,${TIMEBASE_HOST:-localhost}
      # Where a BROWSER reaches Pulse. Without it the sign-in redirect sends
      # the human's browser to https://pulse:4542, which resolves only inside
      # this network. See Phase 5.
      - Auth__PublicAddress=https://${TIMEBASE_HOST:-localhost}:4542
      # Where Pulse's own assistant reaches Pulse: always itself, so it never
      # depends on reaching its own public name from inside the container.
      - Gage__SelfAddress=https://localhost:4542

  historian:
    image: timebase/historian:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: historian
    ports:
      - "4511:4511"
      - "4512:4512"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    stop_grace_period: 60s
    volumes:
      - historian:/historian
    environment:
      - Certificates=/historian/certificates
      - Settings=/historian/settings
      - Pulse__Cert__Names=historian,${TIMEBASE_HOST:-localhost}
      - Data=/historian/data
      - Logs=/historian/logs

  explorer:
    image: timebase/explorer:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: explorer
    ports:
      - "4531:4531"
      - "4532:4532"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - explorer:/explorer
    environment:
      - Certificates=/explorer/certificates
      - Settings=/explorer/settings
      - Pulse__Cert__Names=explorer,${TIMEBASE_HOST:-localhost}
      - Config=/explorer/config
      - Data=/explorer/data
      - Logs=/explorer/logs

  atlas:
    image: timebase/atlas:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: atlas
    ports:
      - "4551:4551"
      - "4552:4552"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - atlas:/atlas
    environment:
      - Certificates=/atlas/certificates
      - Settings=/atlas/settings
      - Pulse__Cert__Names=atlas,${TIMEBASE_HOST:-localhost}
      - Config=/atlas/config
      - Logs=/atlas/logs
      - Data=/atlas/data

  connect:
    image: timebase/connect:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: connect
    ports:
      - "4561:4561"
      - "4562:4562"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - connect:/connect
    environment:
      - Certificates=/connect/certificates
      - Settings=/connect/settings
      - Pulse__Cert__Names=connect,${TIMEBASE_HOST:-localhost}
      - Config=/connect/config
      - Logs=/connect/logs
      - Data=/connect/data

  bridge:
    image: timebase/bridge:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 2048m
    hostname: bridge
    ports:
      - "4571:4571"
      - "4572:4572"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - bridge:/bridge
    environment:
      - Certificates=/bridge/certificates
      - Settings=/bridge/settings
      - Pulse__Cert__Names=bridge,${TIMEBASE_HOST:-localhost}
      - Config=/bridge/config
      - Logs=/bridge/logs
      - Data=/bridge/data

  collector:
    image: timebase/collector:${TIMEBASE_TAG:-latest}
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 4096m
    hostname: collector
    ports:
      - "4521:4521"
      - "4522:4522"
    restart: unless-stopped
    depends_on:
      volume-init:
        condition: service_completed_successfully
    volumes:
      - collector:/collector
    environment:
      - Active=false
      - Certificates=/collector/certificates
      - Settings=/collector/settings
      - Pulse__Cert__Names=collector,${TIMEBASE_HOST:-localhost}
      - Config=/collector/config
      - Data=/collector/data
      - Logs=/collector/logs

volumes:
  pulse:
  historian:
  explorer:
  atlas:
  connect:
  bridge:
  collector:
```

**Verify** the file parses *and* that every image resolved to the tag from step 2a. All eight
lines must show the same tag, the one in `.env`, and none may be empty; `volume-init` reuses the Pulse image,
which is why there are eight lines for seven components. Note the image for Connect is
`timebase/connect`: there is no `timebase/connector` repository on Docker Hub.

```bash
cd ./timebase && docker compose config --quiet && docker compose config | grep "image:"
```

If the lines disagree, or any shows a bare `timebase/x:`, the `.env` file is wrong. Go
back to step 2a rather than continuing.

**Verify** the address from step 1b reached every setting that carries it. No secret can match this
filter:

```bash
cd ./timebase && docker compose config | grep -E "Auth__PublicAddress|Cert__Names"
```

Pulse's `Auth__PublicAddress` must read `https://<address>:4542`, its `Cert__Names` must read
`pulse,<address>`, and each of the six `Pulse__Cert__Names` must read `<service>,<address>`, where
`<address>` is `localhost` unless step 1b recorded another.

---

### Certificate names

A certificate is only accepted for the names inside it. Every container here dials its peers by the
Compose service name, so each host's certificate has to carry its own service name or the connection
fails with `RemoteCertificateNameMismatch` however good the trust chain is.

`Cert__Names` on Pulse, `Pulse__Cert__Names` on the other six, is that list. It is **additive**:
`localhost` and `127.0.0.1` are always included and cannot be removed.

> **Do not rely on the container's hostname instead.** A host's own machine name is added when a
> certificate is generated, but it is never treated as *required*, so it never causes one to be
> regenerated. A deployment that already has a `localhost`-only certificate on its volume, which is
> every upgrade, keeps it. A name in `Cert__Names` is required, so listing it forces the certificate
> to be reissued with it.

The address from step 1b is already in every list, through `TIMEBASE_HOST`. A DNS name becomes a DNS
entry in the certificate and an IP address becomes an IP entry, so a browser that opens
`https://<address>:<port>` finds the name it asked for. Pulse adds the host of its own public address by
itself as well; the Compose file lists it anyway.

Add any further name this deployment is reached by - a second DNS name, a second IP address - to the
lists in the Compose file, comma-separated, bare, with no scheme and no port. For example
`Cert__Names=pulse,timebase.plant.example,10.4.2.17`.

---

## Phase 3 - Start Pulse on its own

Pulse comes first and alone, so that everything it needs can be settled before any other component
exists (Phase 7). Naming it also starts `volume-init`, which Pulse depends on.

Check for work already done first. If Phase 1 found an earlier stack and the human chose to reuse it,
the command below updates Pulse in place and keeps its volumes, so its root CA and every secret carry
over. It starts and updates Pulse only: any other component of that stack keeps running as it was.

```bash
cd ./timebase && docker compose -f docker-compose.yml up --detach --pull always pulse
```

The first run pulls the Pulse image and may take a minute or two. The other six start in Phase 7.

**Verify:** Pulse reports `running`, and, on a fresh installation, no other component service does. The `{{.State}}` field emits `running` - it is `{{.Status}}` that reads `Up About a minute`, so do not match on `Up` here.

```bash
cd ./timebase && docker compose ps --format '{{.Name}} {{.State}}'
```

`volume-init` is not listed, and that is correct: it runs once before Pulse and exits, and
`docker compose ps` lists running containers only. **Verify** it exited cleanly - the status must read
`Exited (0)`. Any other exit code means a volume could not be handed to the unprivileged user; read
`docker compose logs volume-init` and **STOP - HUMAN REQUIRED**, because fixing a volume's owner is
done outside the stack.

```bash
cd ./timebase && docker compose ps -a --format '{{.Service}} {{.Status}}' | grep volume-init
```

If Pulse is missing or restarting, read its log before changing anything:

```bash
cd ./timebase && docker compose logs pulse
```

---

## Phase 4 - Check the token issuer

The token issuer is how Pulse names itself: the `iss` claim in every token, and the `issuer` its
discovery document advertises. **It is not a setting.** Pulse derives it from its public address,
`Auth:PublicAddress`, plus `/auth` - and the Compose file in Phase 2 sets that address from
`TIMEBASE_HOST`: `https://localhost:4542` unless step 1b recorded another. A standard client, an MCP
client among them, loads Pulse's discovery
document from the issuer and checks that the document names the same issuer, so the issuer has to be
an address that works, and this is how it stays one.

Confirm what Pulse is publishing:

```bash
curl -sk https://localhost:4542/auth/.well-known/openid-configuration \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print('issuer:', d['issuer']); print('token_endpoint:', d['token_endpoint'])"
```

**Verify:** the command returns without error and `issuer` is `https://<address>:4542/auth`, where
`<address>` is `localhost` unless step 1b recorded another. Pulse writes the host in lower case.
`token_endpoint` names the address you called, which from here is `localhost`: Pulse takes the token
endpoint from the caller's host, always.

Report both values and continue to Phase 5, which settles where a browser is sent - and with it the
issuer.

---

## Phase 5 - Where the browser is sent to sign in

Signing in to any component redirects the human's browser to Pulse. That redirect has to name an
address **their machine** can resolve, and inside this network Pulse is `pulse`, which it cannot.

`Auth:PublicAddress` is the setting for exactly this, and the Compose file in Phase 2 already sets it
from `TIMEBASE_HOST`, to `https://localhost:4542` by default. It is where a browser reaches Pulse, and
nothing else:

| Endpoint | Taken from |
|---|---|
| Sign-in and sign-out redirects | `Auth:PublicAddress`, else the caller's host |
| Token, JWKS, userinfo | The caller's host, always |
| The token issuer, `iss` | `Auth:PublicAddress` plus `/auth`, else Pulse's HTTPS listener plus `/auth` |

That split is why setting it needs no change on any component: the browser goes to the public
address while the component still redeems the code over the back channel, as `pulse`.

**Confirm Pulse is publishing it:**

```bash
curl -sk https://localhost:4542/auth/.well-known/openid-configuration \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['authorization_endpoint'])"
```

**Verify:** the address printed is one the human's machine can open. It is
`https://<address>:4542/auth/authorize`, which is `https://localhost:4542/auth/authorize` unless step
1b recorded another. If it prints `https://pulse:4542/...`, `Auth__PublicAddress` did not reach Pulse:
check the environment line and recreate it.

> **Why it has to be stated.** Without it Pulse falls back to the caller's host, and the caller is
> the component, not the human. So a browser signing in through Atlas was sent to `https://pulse:4542`
> and died on a DNS error, while opening Pulse's own interface directly worked.

> The issuer follows this address, so changing it later is not free: tokens issued under the old
> value stop validating. To change it, edit `TIMEBASE_HOST` in `.env` and run `docker compose up -d`,
> which recreates what changed, then add the matching sign-in addresses in Pulse (Phase 7). Expect
> everyone to sign in again.

### If the human reaches this deployment by another address

The Compose file already carries `<address>` into Pulse's public address and into every certificate,
so there is nothing to set. What is left is whether the address **works from their other computers**.
You are on the Docker host, where `localhost` works whatever `<address>` is, so your own `curl` proves
nothing here.

An IP address needs no lookup. A name does: check that it resolves on this machine, then confirm it
from one of the other computers, as the last step below.

On Linux and macOS:

```bash
getent hosts <address> || ping -c 1 <address>
```

On Windows, in PowerShell:

```powershell
[System.Net.Dns]::GetHostAddresses('<address>') | Select-Object -ExpandProperty IPAddressToString
```

Either check always succeeds for this machine's own hostname, so it proves nothing about the other
computers: each of them must resolve the name to the Docker host's LAN address.

If the name does not resolve, **STOP - HUMAN REQUIRED.** Editing `/etc/hosts` needs root. Ask the
human to map the name to the Docker host, and give them the command for their platform. The IP in it is
`127.0.0.1` when they run it on the Docker host itself, and the Docker host's own IP address when they
run it on another computer. A hosts entry is a last resort for one workstation: DNS is the answer for
anyone else who will use this deployment.

```bash
echo "<ip of the Docker host> <address>" | sudo tee -a /etc/hosts
```

On Windows, in an Administrator PowerShell session:

```powershell
Add-Content -Path $env:SystemRoot\System32\drivers\etc\hosts -Value "`n<ip of the Docker host> <address>"
```

Tell them a hosts entry fixes only the machine it is run on, and that Pulse's own assistant needs no
entry at all, because `Gage__SelfAddress` has it call Pulse on `localhost`. If colleagues will reach
this deployment from their own computers, the name needs to resolve in DNS for all of them.

**STOP - HUMAN REQUIRED.** Only another computer can show that it reaches this deployment. Ask the
human to open `https://<address>:4542` from one of the other computers and tell you what they see. It
should show Pulse's sign-in, with a certificate warning until Phase 6. **Wait for the answer before
Phase 6.** If the page does not load, the usual cause is the Docker host's firewall: the HTTPS ports
`4512`, `4522`, `4532`, `4542`, `4552`, `4562` and `4572` must be reachable from them.

**Verify:** the address resolves, the human has confirmed that Pulse's sign-in loads from another
computer, the CA endpoint answers, and the `authorization_endpoint` above names the address. This
prints the status and size and not the certificate:

```bash
curl -sk -o /dev/null -w '%{http_code} %{size_download}\n' https://<address>:4542/api/ca
```

Expect HTTP `200` and about 1.4 KB (around `1400` bytes), served as `application/x-x509-ca-cert`.

---

## Phase 6 - Trust the Pulse certificate authority

Components obtain their own certificates from Pulse automatically. Nothing to do for them.
The human's machine needs Pulse's root CA so browsers stop warning.

```bash
cd ./timebase && curl -k https://localhost:4542/api/ca -o pulse-ca.cer && ls -l pulse-ca.cer
```

**STOP - HUMAN REQUIRED.** Installing a root CA needs elevated rights.

Resolve the working directory first, and use the result everywhere below in place of `<workdir>`:

```bash
cd ./timebase && pwd
```

On Windows the human needs the `C:\...` form of the path, not Git Bash's `/c/...`: run `pwd -W` in
place of `pwd` (it prints `C:/...`), and write that with backslashes. Use that form wherever a later
phase says to resolve the working directory as in this one.

Tell the human the full path, then give them the command for their platform with the `cd` already
in it - an elevated shell does not inherit your working directory, so a bare relative path will
not find the file:

- macOS: `cd <workdir> && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain pulse-ca.cer`
- Debian/Ubuntu: `cd <workdir> && sudo cp pulse-ca.cer /usr/local/share/ca-certificates/ && sudo update-ca-certificates`
- RHEL/CentOS: `cd <workdir> && sudo cp pulse-ca.cer /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust`
- Windows (Administrator PowerShell): `cd <workdir>; Import-Certificate -FilePath pulse-ca.cer -CertStoreLocation Cert:\LocalMachine\Root`

Tell them they must **restart their browser completely** afterwards. Browsers read the trust
store at startup and cache validation results, so a reload will not pick up a new root CA. On
Windows, closing every window is not enough: Edge and Chrome keep running in the background, and so
keep the trust they read when they started. Have them type `edge://restart` in the address bar (in
Chrome, `chrome://restart`), which restarts the browser and reopens their tabs.

**Verify:** the human confirms, on the Docker host, where the CA was just installed, that
`https://<address>:4542` loads without a certificate warning.

> **Only Pulse is running, and only Pulse is trusted, and that is expected.** The other six start in
> Phase 7. Each serves a self-signed certificate until it holds its client secret, so its page still
> warns, and Explorer cannot reach the Historian over TLS, until its secret is saved a few seconds
> later. Do not treat that as a fault and do not try to fix it here.

> **Keep `-k` on every `curl` here, even once the root CA is trusted.** Windows' own `curl.exe` also
> checks revocation, and Pulse-signed certificates carry no revocation address, so without `-k` it
> fails with `CRYPT_E_NO_REVOCATION_CHECK` (`--ssl-no-revoke` avoids that). That is not a fault, and
> browsers and OpenSSL are not affected.

If `<address>` is not `localhost`, every **other** computer that will open Timebase needs the same
root CA, or its browser warns on every page. They can download it from `https://<address>:4542/api/ca`
and install it with the command for their platform above. That is a next step for the human, not
something to wait for now.

Loading that page presents a sign-in, so give them the credentials now rather than letting them
guess - they may well try to sign in while they are there:

| | |
|---|---|
| Username | `Administrator` |
| Password | `timebase` |

Case-sensitive: `Administrator`, not `administrator`. This is the first-run account and is
identical on every installation, so tell them to change the password as soon as they are in. They
will be asked to do this again at the start of Phase 7.

---

## Phase 7 - Give each component its client secret

Pulse seeded one OAuth client per component on first start. **Do not create any clients** - they
exist. What is missing is the secret: Pulse generates a random one per install and never prints it,
while each component ships with an empty one. The token exchange fails until the secret is copied
across, and nothing recovers on its own.

You cannot read a secret and must not handle one, so the human carries them: all six in one paste,
through a small helper they run in a terminal of their own, or one at a time into each component's own
setup form. Either way the component saves its own secret and is signed in and trusted within
seconds, with no restart.

Only Pulse is running at this point, so the human finishes everything Pulse needs first, and you start
the other six afterwards. They then find the finished configuration the first time they start, and each
comes up showing its setup form.

### The human's part in Pulse

**STOP - HUMAN REQUIRED.** Client secrets are only retrievable through the Pulse interface; there is
no public API for reading them. This is the human's first sign-in, so give them the credentials rather
than assuming they have them. Ask them to:

1. Open `https://<address>:4542` and sign in as **`Administrator`** with the password
   **`timebase`** - the first-run account, case-sensitive, identical on every installation.
2. **Change that password immediately**, from the user administration screen, before going
   further. It controls the identity provider, the certificate authority and the AI gateway.
3. **Only if `<address>` is not `localhost`:** open **Clients** and add a sign-in address to each of
   the six clients in the table below. Choose the client, then **Edit**, add one line to **Redirect
   URIs**, and save. Keep the `https://localhost:...` line that is already there.
4. **Only if they chose a provider in step 1c:** open **Gage**, then **Providers**, and follow *The AI
   provider* below.

| Client | Add this to Redirect URIs |
|---|---|
| `Historian` | `https://<address>:4512/callback` |
| `Collector` | `https://<address>:4522/callback` |
| `Explorer` | `https://<address>:4532/callback` |
| `Atlas` | `https://<address>:4552/callback` |
| `Connect` | `https://<address>:4562/callback` |
| `Bridge` | `https://<address>:4572/callback` |

Pulse accepts a sign-in only for an address registered on the client, and no setting can register one
for you. Without these, signing in to a component at `<address>` fails *after* the Pulse sign-in, on a
page reading *Client 'Explorer' does not support the specified redirect Uri*, naming the client that is
missing its address.

**While they work, pull the other six images**, so that starting them is quick. Tell the human their
steps first, then run this before you wait for them. It pulls one image at a time, which also avoids a
Windows credential-helper failure with parallel pulls. A failure here is not fatal: starting the stack
below pulls again, and reports any real problem.

```bash
cd ./timebase && for s in historian explorer atlas connect bridge collector; do docker compose pull $s; done
```

### The AI provider

Skip this if they chose to leave the assistant for later.

**Anthropic.** Pulse already has a provider named **Anthropic**: Provider Id `anthropic`, model
`claude`, handle `anthropic/claude`, enabled and without a key. Ask them to open **Gage**, then
**Providers**, select it, and under **API key** choose **Set new key**, paste the key and choose **Set
key**. That is the whole step: entering a key never changes whether a provider is enabled.

**Another provider.** Ask them to open **Gage**, then **Providers**, and choose **Add provider**. In
the **New provider** drawer they fill in:

| Field | What to enter |
|---|---|
| **Provider Id** | A short lower-case name of their own, such as `openai`. It becomes the first half of the model's handle and cannot be changed later |
| **Display Name** | A label for people, such as `OpenAI` |
| **Wire** | **OpenAI** for OpenAI, for Google Gemini's OpenAI-compatible address and for servers such as Ollama: the wire is the shape of the requests, not the company. **Anthropic** only for Anthropic's own API |
| **Endpoint** | The provider's base address, such as `https://api.openai.com` |
| **Upstream Path** | Blank, unless the provider serves requests at a non-standard path: *Configuring Gage LLM providers and models* has worked examples |
| **API Key** | The provider's key, or blank for a server that needs none |
| **Logical Id** | Under **Logical models**: a short lower-case name for the model, such as `smart`. The handle is the Provider Id and this, joined by a slash: `openai/smart` |
| **Real Model Id** | The model name the provider expects, such as `gpt-4o` |
| **Default Model** | **Turn this on.** See below |

**Turn on Default Model, or the assistant keeps using the seeded model.** Until a default is marked,
Pulse uses the alphabetically first handle in the catalogue. `anthropic/claude` sorts ahead of almost
anything and has no key, so every question would fail against it. Marking the new model moves the
default for every assistant in the installation.

> **A model on the Docker host itself.** Pulse runs in a container, where `localhost` is the container
> and not the host. Use an address the container can reach: `http://host.docker.internal:<port>` on
> Docker Desktop (Windows and macOS), or the host's own IP address on Linux. The server must also
> listen on more than its own loopback address: for Ollama, set `OLLAMA_HOST=0.0.0.0`. A provider in
> the cloud needs only outbound internet access from the Docker host.

**Confirm it works before they leave Pulse.** The **Ask Gage** button in Pulse's header opens the
assistant, for administrators only. Ask it anything: an answer means the key works, and an
authentication error means it does not. Nothing else in this runbook can test a key, because nothing is
asked of the provider until a question is.

### Start the other six

Once the human says the steps above are done, start the rest. Pulse is already running and is left
alone:

```bash
cd ./timebase && docker compose -f docker-compose.yml up --detach --pull always
```

This pulls anything not already pulled, which may take several minutes.

> On Windows, pulling several images at once can fail with `error getting credentials ... The
> paging file is too small for this operation to complete` from Docker Desktop's credential helper.
> It is transient. Pull one image at a time (`docker compose pull historian`, then the next) and run
> the `up` again without `--pull always`.

**Verify:** all seven component services report `running`.

```bash
cd ./timebase && docker compose ps --format '{{.Name}} {{.State}}'
```

Each of the six now waits for its secret, which is the next step.

### Give each component its secret

Every component serves an anonymous status check at `/app/setup/pulse`, and while Pulse rejects
its client the component's own interface shows a **Pulse setup form** in place of the sign-in.
That form is the only place a secret is ever saved, by whichever route the human takes below: the
component checks the secret with Pulse before it keeps it, writes it into its own configuration,
and re-checks Pulse immediately. Nothing is copied through your shell, so you never handle one.

Before anyone starts, read the status of all six so you know what "done" looks like:

```bash
for p in 4512 4522 4532 4552 4562 4572; do printf "%s: " $p; curl -sk https://localhost:$p/app/setup/pulse; echo; done
```

Each line reads `"state":"rejected"` with `"secretConfigured":false` and `"canApply":true` before the
secret is entered, and `"state":"ok"` with `"secretConfigured":true` afterwards.

If a running component answers with an error page instead of that status, it has no setup form:
its image is older than 2.0, for example a `latest` image from an earlier release still on the host.
**Stop and report it**, and check `TIMEBASE_TAG` in `.env` and that the start pulled with `--pull always`. Do not write the secret into its `settings.config` yourself - Rule 9.

**Do not ask them to rotate a secret**: Pulse generated every one when it first started, so they are
all new and there is nothing to replace. **Do not ask for a secret for the `Pulse` client**: it is a
loopback client for Pulse's own assistant, Pulse reads its secret from its own store, and nothing is
copied anywhere.

**Ask the human, in these words, and wait for the answer:**

> Each of the six components needs its own secret from Pulse. I can give you a small helper to run
> in a terminal of your own: you copy all six from Pulse in one go, paste them once, and it gives
> each one to the right component. Or you can copy and paste them one at a time, each into that
> component's own page. Which would you like?

Assume the helper if they have no preference. Both routes end in the same place, and the second is
always there to fall back on.

### Route 1 - the helper, one paste

The helper is a short Python script. It asks for the secrets when the human runs it, so it holds
none, and it writes none anywhere: not to a file, not to the environment, not to its output. It gives
each secret to that component's own setup form on this machine, waits for any component that is still
starting, and finishes by asking each component whether
Pulse accepts it. It only fills a form that is still empty, so running it again is safe.

Write it to **apply-secrets.py** in `./timebase`, exactly as below. It is the same on every
installation and there is nothing to fill in:

```python
"""Give each Timebase component its Pulse client secret, in one paste.

Run this yourself, in a terminal of your own, on the machine that runs Docker. It asks you to
paste the line that Pulse's Copy all button puts on the clipboard, then hands each secret to the
matching component's own setup form: the same form that component's web page shows. Each
component checks its secret with Pulse before it keeps it.

This script never writes a secret anywhere: not to a file, not to an environment variable, not
to a log, and not to the screen. The secrets exist in this process's memory until it exits, and
they are sent to one place only: the six setup forms on this machine (localhost).
"""

import http.client
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.request

# The client id (the name Pulse's Copy all uses) and the HTTPS port of each component.
COMPONENTS = [
    ("Historian", 4512),
    ("Collector", 4522),
    ("Explorer", 4532),
    ("Atlas", 4552),
    ("Connect", 4562),
    ("Bridge", 4572),
]

GIVE_UP_AFTER = 300     # seconds to wait for the components to come up and take their secret
CHECK_FOR = 90          # seconds to wait for every component to report ok afterwards
REFUSALS_ALLOWED = 5    # Pulse can refuse a good secret for a few seconds after a start

BANNER = """\
Timebase: give each component its Pulse client secret

This window waits for you. Do these two things, in this order:

  1. In Pulse, open Overview. In the card "Connect your Timebase components",
     choose Copy all (open the card first if it is closed).
  2. Come back to this window, paste, and press Enter.

Nothing you paste is shown. The count below only tells you that it arrived,
and it should read 6 secrets before you press Enter.
"""

# A component serves a self-signed certificate until it holds its secret, so its certificate
# cannot be checked yet. Every request goes to this machine only, and never through a proxy.
_TLS = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
_TLS.check_hostname = False
_TLS.verify_mode = ssl.CERT_NONE
_OPENER = urllib.request.build_opener(
    urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=_TLS))


def call(method, port, body=None):
    """Ask, or tell, one component's setup form. Returns (http status, parsed json).

    The status is None when nothing answered. The json is None when the answer was not JSON."""
    request = urllib.request.Request(
        "https://localhost:%d/app/setup/pulse" % port,
        data=None if body is None else json.dumps(body).encode("utf-8"),
        method=method,
        headers={"Content-Type": "application/json"})
    try:
        with _OPENER.open(request, timeout=20) as response:
            status, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        status, raw = error.code, error.read()
    except (urllib.error.URLError, http.client.HTTPException, OSError):
        return None, None
    try:
        return status, json.loads(raw.decode("utf-8") or "null")
    except ValueError:
        return status, None


def read_hidden(prompt):
    """Read one line without showing it. A running count shows that a paste has arrived."""
    typed = []

    def show():
        text = "".join(typed)
        sys.stdout.write("\r%s%d characters, %d secrets   " % (prompt, len(text), text.count(" - ")))
        sys.stdout.flush()

    show()
    if os.name == "nt":
        import msvcrt
        while True:
            ch = msvcrt.getwch()
            if ch in ("\r", "\n"):
                break
            if ch == "\x03":
                raise KeyboardInterrupt
            if ch in ("\x00", "\xe0"):      # a special key: its second code follows
                msvcrt.getwch()
                continue
            if ch in ("\b", "\x7f"):
                if typed:
                    typed.pop()
            elif ch >= " ":
                typed.append(ch)
            show()
    else:
        import termios
        import tty
        fd = sys.stdin.fileno()
        saved = termios.tcgetattr(fd)
        try:
            tty.setcbreak(fd)
            finished = False
            while not finished:
                chunk = os.read(fd, 4096)
                if not chunk:
                    break
                for ch in chunk.decode("utf-8", "ignore"):
                    if ch in ("\r", "\n"):
                        finished = True
                        break
                    if ch in ("\b", "\x7f"):
                        if typed:
                            typed.pop()
                    elif ch >= " ":
                        typed.append(ch)
                show()
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, saved)
    sys.stdout.write("\n")
    return "".join(typed)


def parse(line):
    """Turn 'Historian - <secret> / Collector - <secret> / ...' into {client id: secret}."""
    found = {}
    for entry in line.strip().split(" / "):
        name, divider, secret = entry.partition(" - ")
        name, secret = name.strip(), secret.strip()
        if divider and name and secret:
            found[name] = secret
    return found


def give_secrets(secrets):
    """Hand each secret to its component's form once that component is up. Returns {name: (result, note)}."""
    ports = dict(COMPONENTS)
    pending = [name for name, _ in COMPONENTS if name in secrets]
    results = {}
    refusals = {}
    started = last_notice = time.time()

    def finish(name, result, note=""):
        results[name] = (result, note)
        pending.remove(name)
        print("  %-10s %s" % (name, {"applied": "applied", "kept": "kept: " + note,
                                     "failed": "NOT DONE: " + note}[result]))

    while pending and time.time() - started < GIVE_UP_AFTER:
        for name in list(pending):
            status, form = call("GET", ports[name])
            if status is None:
                continue                                    # not listening yet
            if form is None:
                finish(name, "failed", "it has no setup form (its image is older than 2.0)")
            elif form.get("clientId") not in (None, name):
                finish(name, "failed", "port %d answered as client '%s', not '%s'"
                       % (ports[name], form.get("clientId"), name))
            elif form.get("state") == "disabled":
                finish(name, "kept", "its sign-in is switched off, so it takes no secret")
            elif form.get("overriddenBy"):
                finish(name, "failed", "its secret is set by %s, which this form cannot change"
                       % form["overriddenBy"])
            elif form.get("secretConfigured"):
                if form.get("state") == "ok":
                    finish(name, "kept", "it already holds its secret, so it was left as it is")
                else:
                    finish(name, "failed", "it holds a secret that Pulse no longer accepts, "
                                           "and the form cannot replace it")
            elif form.get("canApply"):
                code, reply = call("POST", ports[name], {"clientSecret": secrets[name]})
                reply = reply if isinstance(reply, dict) else {}
                if code == 200 and reply.get("outcome") == "applied":
                    finish(name, "applied")
                elif reply.get("outcome") == "refused":
                    refusals[name] = refusals.get(name, 0) + 1
                    if refusals[name] >= REFUSALS_ALLOWED:
                        finish(name, "failed", "Pulse refused that secret (%s)"
                               % (reply.get("message") or "no reason given"))
                elif reply.get("outcome") == "invalid":
                    finish(name, "failed", "the form would not take it (%s)"
                           % (reply.get("message") or "no reason given"))
                # Anything else: look again on the next pass.
            # Otherwise the component cannot reach Pulse yet: wait.
        if pending:
            if time.time() - last_notice >= 10:
                print("  still waiting for: %s" % ", ".join(pending))
                last_notice = time.time()
            time.sleep(2)
    for name in list(pending):
        finish(name, "failed", "it did not come up and take its secret within %d seconds" % GIVE_UP_AFTER)
    return results


def confirm(names):
    """Wait until each component's own check with Pulse says ok. Returns the names that did not."""
    ports = dict(COMPONENTS)
    waiting = list(names)
    started = time.time()
    while waiting and time.time() - started < CHECK_FOR:
        for name in list(waiting):
            _, form = call("GET", ports[name])
            if isinstance(form, dict) and form.get("state") == "ok" and form.get("secretConfigured"):
                print("  %-10s ok" % name)
                waiting.remove(name)
        if waiting:
            time.sleep(2)
    for name in waiting:
        print("  %-10s NOT OK: Pulse does not accept it yet" % name)
    return waiting


def main():
    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        print("Run this in a terminal of your own, not through an agent or a pipe.")
        print("It asks you to paste secrets, and nothing else should be able to read them.")
        return 2

    print(BANNER)
    secrets = parse(read_hidden("Paste here, then press Enter: "))
    present = [name for name, _ in COMPONENTS if name in secrets]
    missing = [name for name, _ in COMPONENTS if name not in secrets]
    if not present:
        print("That was not what Copy all produces. It looks like:  Historian - ... / Collector - ... / ...")
        print("Choose Copy all in Pulse again, then run this again.")
        return 1
    print("Got %d secrets: %s." % (len(present), ", ".join(present)))
    if missing:
        print("Not in the paste: %s. Give those through their own setup forms." % ", ".join(missing))

    print("\nGiving each component its secret:")
    results = give_secrets(secrets)
    problems = [name for name in missing]
    problems += [name for name, (result, _) in results.items() if result == "failed"]

    usable = [name for name, (result, _) in results.items() if result in ("applied", "kept")]
    print("\nAsking each component whether Pulse accepts its secret:")
    problems += confirm(usable)

    print()
    if problems:
        print("Not finished: %s." % ", ".join(dict.fromkeys(problems)))
        print("For each, open that component's own page and paste its secret into the setup form instead.")
        print("Tell the agent what is printed above. It will not ask you for any secret.")
        return 1
    print("All six components hold their secret, and Pulse accepts each one.")
    print("Tell the agent this finished. Pulse's Overview should now read 6 of 6 connected.")
    print("Your clipboard still holds the secrets: copy something else over it. If clipboard history")
    print("is on (Windows: Win+V), clear it there as well.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print("\nStopped. Any component listed above as applied keeps its secret.")
        sys.exit(130)
```

**Verify** that it is valid Python. This only parses it; nothing runs. Use `python` in place of
`python3` where Phase 1 found only `python`:

```bash
cd ./timebase && python3 -c "import ast; ast.parse(open('apply-secrets.py').read()); print('syntax ok')"
```

**STOP - HUMAN REQUIRED.** Resolve the working directory as in Phase 6 and give the human these steps
in this order. The order matters: the clipboard holds one thing at a time, so they copy and run the
command first, and copy the secrets last.

1. **Run the helper.** Copy the command for their platform, paste it into a terminal of their own and
   press Enter: PowerShell on Windows (a PowerShell tab in Windows Terminal is fine), not Command
   Prompt, not Git Bash, and not this session. The window then waits for them.
   - Windows: `cd <workdir>; python apply-secrets.py`
   - macOS and Linux: `cd <workdir> && python3 apply-secrets.py`
2. **Copy the secrets.** Only now, in Pulse, open **Overview**. In the card **Connect your Timebase
   components**, choose **Copy all** under **OAuth clients** (open the card first if it is closed).
   That puts one line holding all six secrets on the clipboard. The card's own text also says to paste
   each secret into a configuration file and restart the component. They should not: the helper does
   it through each component's own form.
3. **Paste and press Enter.** In the helper's window, paste and press Enter. If Ctrl+V does nothing,
   right-click in the window to paste. Nothing they paste is shown, only a count, which should read
   `6 secrets` before they press Enter.
4. **Wait for it to finish.** It lists each component as it is done, and ends by saying that all six hold their secret and Pulse accepts each one. Pulse's Overview then reads
   6 of 6 connected. Ask them to tell you when it has finished, and to copy something else afterwards
   so the secrets do not stay on the clipboard (if clipboard history is on, in Windows Win+V, they
   clear it there too).

Do not ask them to read you anything they pasted. The helper prints no secret, so if it reports a
problem they can read you what it says. If it reports a component as not done, or they would rather not use it,
take Route 2 for that component.

**Verify** with the status loop above: every line must read `"state":"ok"` with
`"secretConfigured":true` and `"canApply":false`.

### Route 2 - the setup forms, one at a time

For each of the six components in the table, ask the human to:

1. In Pulse, open **Clients**, choose the client, reveal the secret and copy it.
2. Open the component's address from the Phase 9 table, paste the secret into the form and save.

| Component | Client Id |
|---|---|
| Historian | `Historian` |
| Collector | `Collector` |
| Explorer | `Explorer` |
| Atlas | `Atlas` |
| Connect | `Connect` |
| Bridge | `Bridge` |

The component writes the secret into its own configuration and re-checks Pulse immediately, so it
needs no restart.

If a component's log still prints `Invalid client secret provided` more than a minute after its
form reported `ok`, restart that one: `docker compose restart <service>`.

### Certificates follow the secret

A component requests its Pulse-signed certificate only once its client id and secret are both set,
so Phase 7 is also what moves the six components off their self-signed certificates. Pulse logs
`RootCA signed leaf certificate: CN=Timebase <Component> Pulse-Signed` for each one within a few
seconds of its form reporting `ok`.

**Verify** that every component now presents a certificate issued by the Pulse root CA:

```bash
for p in 4512 4522 4532 4552 4562 4572; do printf "%s: " $p; echo | openssl s_client -connect localhost:$p -servername localhost 2>/dev/null | openssl x509 -noout -issuer; done
```

Every line must name `Timebase Pulse Root CA`. A line still reading `Self-Signed` means that
component's secret has not been applied, or it has not yet reconnected to Pulse; wait a minute and
re-run before doing anything else.

> **Expect errors until the secrets are in, and read nothing into them.** A component that does not
> yet hold its secret logs failed sign-ins to Pulse, and just after a start it can log `The SSL
> connection could not be established` until it has fetched Pulse's certificate authority. Atlas and
> Bridge each log one `Gage model discovery ... no bearer token` warning that they recover from within a
> second. Judge a component by the checks above, and by a log window taken once its secret is in and a
> minute has passed.

### If a component does not say ok

- **`"state":"rejected"` with `"secretConfigured":false` and `"canApply":true`:** the component still
  has no secret. Its form has not been filled in, or the save did not go through: ask the human to
  paste that component's secret again. The form checks the secret with Pulse before it saves anything,
  so a refused secret is simply not saved. If they used the helper, they can run it again: it only
  fills the forms that are still empty. Or take Route 2 for that component.
- **`"state":"rejected"` with `"secretConfigured":true`:** a secret is saved and Pulse no longer
  accepts it, which happens if the secret was rotated in Pulse afterwards. The form fills an empty
  secret only and cannot replace one. **Stop and report it**: do not write the secret into the file
  yourself - Rule 9.
- **`"state":"unreachable"`:** the component cannot reach Pulse at all. Check that `pulse` is `running`
  and read the component's log.

### Nothing is collecting yet, and that is correct

**A running stack is an empty stack.** Every example collector and every example connector ships
**disabled**, so a fresh installation stores nothing until somebody turns one on. Their names all
begin `example-`, which is how you tell a shipped sample from something a human made.

Do not treat an empty Historian as a fault, and do not enable anything yourself. Say so in Phase 9
instead, and point the human at the Collector interface, where switching on `example-simulator` gives
them data to look at within a minute.

### 7a. Check the data connections (change them only if the Historian refuses them)

The secret above lets a component authenticate *to Pulse*. Two components also keep a *data
connection* of their own: the Collector's link to the Historian, and Explorer's Historian and Atlas
sources. Each has its own authentication setting. A connection that names no client of its own
authenticates as its component and needs nothing more. But a shipped connection that names a client and is
switched off counts as the connection's own credentials, so it presents none. Check rather than assume.

Wait two minutes after the last secret went in, so that Explorer has polled
the Historian several times with its secret in place, then read a clean window:

```bash
cd ./timebase && docker compose logs historian --since 1m | grep -c "Authorization header ABSENT"
```

**A count of `0` means there is nothing to do here: go on to 7b.** Note that `grep -c` exits with
status `1` when the count is `0`: that is the pass, not an error. Do not give those connections a
client of their own. It is not needed, and a mistyped secret would break what already works.

A count above `0` is `401 Unauthorized` with `Authorization header ABSENT` in the Historian's log: no
token was attached at all, and Explorer's source reports that it cannot connect. **This is not the
audience problem** in 7b - granting the audience does nothing for it. Do not confuse the two.

**STOP - HUMAN REQUIRED.** The fix is a setting in each component's own interface, so the human makes
it, not you: Rule 9. Both screens need an administrator, which the `Administrator` account is; if
**Edit** is greyed out, or a save is refused, the account they are signed in with is not one. Both
apply on save, so nothing needs restarting. The secrets are the Collector's and Explorer's from Phase 7;
they copy each again from **Clients** in Pulse: select the client, reveal the secret, copy it. Ask them
to make the same six settings in each place:

| Setting | Value |
|---|---|
| **Auth Enabled** in the Collector, **Enabled** in Explorer | on |
| **Client ID** | `Collector`, or `Explorer` |
| **Client Secret** | that component's secret |
| **Identity Provider** > **Host** | `pulse` |
| **Identity Provider** > **Port** | `4542` |
| **Identity Provider** > **Use TLS** | on |

- **Collector**, `https://<address>:4522`: in the sidebar open **Historians**, choose **Edit** on the
  Historian row (it shows when the pointer is over the row), open its **Authentication** tab, make
  the settings and save.
- **Explorer**, `https://<address>:4532`: in the sidebar open **Config** > **Sources**, choose
  **Edit** on the Historian source, open its **Authentication** tab and then the **Authentication**
  row, switch **Enabled** on (the other settings appear once it is on), make the settings and save.
  Do the same for any other source they will use.

**Verify** over a clean window, not a line count. Wait a minute after they save, then:

```bash
cd ./timebase && docker compose logs historian --since 1m | grep -c "Authorization header ABSENT"
```

A count of `0` is the pass condition, and exits `1`, as above. **Use `--since`, never `--tail`, when
checking whether something is now fixed** - a tail can include lines from before the change and make
a working fix look broken.

---

### 7b. Check for authentication failures (act only if any appear)

Wait until every component holds its secret and two minutes have passed, so
the noise described in Phase 7 is behind you, then read the last minute:

```bash
cd ./timebase && for c in historian explorer atlas connect bridge collector; do \
  echo "== $c"; docker compose logs "$c" --since 1m \
    | grep -iE "invalid_client|invalid_request|InvalidClientSecret|Invalid client secret|401|token request failed" \
    || echo "  no auth errors"; done
```

The pattern covers both shapes of failure: a `401`, and the HTTP 400 that a component logs
for a missing or wrong secret, `invalid_request` with `Invalid client secret provided`. Do not
shorten it. A bare `401` can also match a timestamp ending `.401`, so read a match before acting.

**Verify:** no authentication failures in any component's log.

If a component authenticates to Pulse but is rejected by *another* component, that is an
**audience** problem, not a credentials one: to let X call Y, add `Y` to X's audiences in
Pulse, then restart X. Explorer and Collector both need `Historian`.

---

## Phase 8 - Final checks

**STOP - HUMAN REQUIRED.** One question, then wait: **have they changed the administrator password?**
They were asked to in Phase 7. If it is still `timebase`, it must be changed now, from the user
administration screen in Pulse, before the instance is reachable by anyone else. It is their
decision: if they decline, for example on a test machine, do not press and do not stop. Carry on, and
say plainly in the Phase 9 report that the default password is still in place, so anyone who can
reach the instance can sign in as Administrator. Nothing else is required of them here: user
accounts and usage limits are next steps, offered in Phase 9.

**Verify:** the catalogue every component received names the model the human chose. A component logs
it once Pulse has pushed it, which is after that component's client secret is saved and not when it
starts. That can take up to about two minutes, because the component retries on a growing back-off,
so if the command finds nothing, wait and run it again. No secret is involved:

```bash
cd ./timebase && docker compose logs atlas | grep "Gage model catalog pinned" | tail -1
```

The line ends `N model(s), default '<handle>'`. With the seeded provider only, which is also what
*Later* leaves, it reads `1 model(s), default 'anthropic/claude'`. With a provider of their own it must
name that provider's handle, such as `default 'openai/smart'`: any other default means **Default
Model** was not turned on in Phase 7. This does not prove the key. The catalogue itself,
`GET /api/gage/models`, answers only to an authenticated caller, so a bare `curl` gets `401`.

---

## Phase 9 - Report

This is the one message the human reads at the end, so write it for them, not for an engineer.
Plain language, short, and nothing they would have to look up: no container names, image tags,
setting names, commands or log lines, and no account of the checks you ran. If every check passed,
do not mention checks at all.

It has two parts, in this order.

**What was done.** A basic summary in a few sentences: Timebase 2.0 is installed and running on
this machine, and its seven components are up, connected to each other and secured. Say plainly
that nothing is collecting data yet and that this is expected, because the sample data sources that
come with Timebase stay switched off until someone turns one on. Say who can sign in: for now only the
Administrator account, with the password they chose. Say where the AI assistant stands: set up with
the provider they chose, or left for later. Say only what you saw: if you did not check something, do
not claim it works, and the assistant is only known to work if they asked it a question in Phase 7.
Then give them the address of each component, so they can open them, with `<address>` replaced by
`localhost` or by the address from step 1b:

| Component | URL |
|---|---|
| Historian | `https://<address>:4512` |
| Collector | `https://<address>:4522` |
| Explorer | `https://<address>:4532` |
| Pulse | `https://<address>:4542` |
| Atlas | `https://<address>:4552` |
| Connect | `https://<address>:4562` |
| Bridge | `https://<address>:4572` |

If a step did not finish, or a check did not give the expected result, say so here, in one sentence
they can act on: what is not working and what that means for them. Do not hide it, and do not
explain it in developer terms.

**Next steps.** This is the part they will act on, so give it its own heading and make it stand
out. List the **Next steps** below, each as a sentence or two in your own plain words saying what it
gets them rather than how it is done. Offer to help with any of them, and do not start one unless
they ask.

---

## Next steps

None of these is part of this runbook: each is the human's decision. List the ones that apply in your
Phase 9 report, in plain words, and **do none of them unless the human asks**. A step marked *If* is
listed only when its condition holds. The detail under each is for you, in case they ask for help with
it.

1. **See data arrive.** Nothing is collecting yet. In the Collector interface, switching on
   `example-simulator` shows data arriving within a minute, which makes it the quickest way to see
   Timebase work end to end. Every example collector and connector begins `example-` and ships
   disabled on purpose.
2. **Add the people who will use it.** Until they do, only the `Administrator` account can sign in. In
   Pulse, **Users** > **Add User**: Id (an email works), first name, last name, password. **Is
   Administrator** grants admin rights; there are two levels, Admin and Standard.
3. **Switch on the AI assistant.** *If they left it for later.* It answers nothing until a provider has
   a key. In Pulse, under **Gage** and then **Providers**, they add the key as Phase 7, *The AI
   provider*, describes. They enter it themselves, as before, and the assistant is ready to answer once
   it is saved.
4. **Set how much the AI assistant may be used.** *If the assistant is set up*, which for a human who
   left it for later means after the previous step. Limits are imposed by default and should be
   adjusted before many people use it. In Pulse, **Policy** > **User** > edit `[default]` >
   **Rate & budget limits**. Set both **Current session** and the **weekly** limit; `-1` means
   unlimited. Choose an Enforcement setting: **Soft** records overage and never refuses, **Throttle**
   returns HTTP 429 with `Retry-After`, **Block** rejects outright. Start with Soft on a new
   deployment. Repeat under **Client** for components calling on their own behalf.
5. **Use certificates from their own CA - recommended beyond a trial.** Pulse's certificate
   authority keeps the stack working, but every machine that opens an interface has to trust its
   root, as Phase 6 did for this one. Certificates from the organization's own CA are trusted
   already, and their renewal stays with whoever runs that CA. It is not a drop-in swap: the
   certificate must carry `localhost` and every Compose service name, and each component then trusts
   the CA through a bundle the human supplies. The *Bring your own certificate* section of
   *Certificates and trust across Timebase* walks through it, with a Compose override for this stack.
6. **Other computers.** *If they chose this computer only* and colleagues should now use it, the
   address changes in one place and Pulse learns the new sign-in addresses: steps 1b and 2b, Phase 5
   and Phase 7. *If they chose other computers*, each of those computers must install Pulse's root CA
   once, or its browser warns on every page: Phase 6, from `https://<address>:4542/api/ca`.
7. **Back up the volumes.** Every setting, credential and dataset lives in this stack's named
   volumes. *Disaster Recovery*, under *Best Practice: Docker Volumes*, covers what to keep.
8. **Work on Atlas from a coding assistant.** *If they use one*, such as Claude Code. Atlas publishes
   an MCP server at `https://<address>:4552/mcp`. A component token gets its read tools only; when the
   human signs in through the assistant with the `Administrator` account, it also gets the tools that
   build and compile models, and every change is attributed to them. The human registers a client for
   the assistant in Pulse, under **Clients**: grants `authorization_code` and `refresh_token`, scopes
   `openid`, `profile` and `offline_access`, audience `Atlas`, and redirect URI
   `http://localhost:<port>/callback` on a port nothing else on their machine uses. You may write the
   assistant's configuration from the client id they give you and hand them the command that adds the
   secret, but never handle the secret yourself, and the sign-in happens in their browser, not yours.
   On this release the assistant also needs Pulse's discovery document named explicitly:
   `https://<address>:4542/auth/.well-known/openid-configuration`. The field values, the Claude Code
   command and the checks are in *Atlas MCP Server*, under *Gage, the AI harness*.

---

## Known unknowns

One point is not settled in the documentation. Do not assert it as fact.

1. **Whether a Gage call must clear one policy layer or both.** One reference says the layer
   is chosen by caller type; another says both are always enforced.
