B4 Network
Deploy the service on the private network and measure the experiment. Tasks marked (human) are performed by the operator; the executor prepares files and records evidence the operator reports.
T4.1 Experiment deployment config
- Wave: 3
- Depends on: T0.4
- Size: S
- Design: network.md
- Touches:
ops/docker-compose.yml,ops/service.env.example,ops/.env.example,kb/setup/index.md(one new section),test/hardening.test.js - Why: the same compose file must run with or without the tunnel sidecar, and the service must validate an experiment environment.
Steps
ops/docker-compose.yml: addprofiles: ["tunnel"]to thecloudflaredservice only. Nothing else changes. Add a comment: without--profile tunnelthe stack is the Tailscale-only experiment and ingress is host nginx.ops/service.env.example: addIDENTITY_MODE=cloudflare-accesswith a comment describinggitlab-oauth, and a commented experiment block:# Experiment (Tailscale-only): IDENTITY_MODE=gitlab-oauth, REQUIRE_ACCESS=false, # ALLOW_ANONYMOUS_READ=true, WRITE_ENABLED=true, features.editing=false.kb/setup/index.md: add a section “Experiment deployment (Tailscale-only)” that links tokb/plans/redline-experiment/design/network.mdand lists the operator inputs table. Do not edit the runbook.test/hardening.test.js: addproduction experiment environment validates in gitlab-oauth mode(NODE_ENV=production,IDENTITY_MODE=gitlab-oauth,REQUIRE_ACCESS=false,ALLOW_ANONYMOUS_READ=true, noACCESS_*,PUBLIC_SERVICE_URL=https://yqa.fio.sh→createNodeServersucceeds)production experiment environment refuses http service url(same withhttp://→ throws)
Acceptance
npm test 2>&1 | grep -c "^✔ production experiment environment" # prints 2
docker compose -f ops/docker-compose.yml config --services # prints only: service
docker compose -f ops/docker-compose.yml --profile tunnel config --services # prints: service, cloudflared
(If Docker is unavailable locally, record that and rely on the two unit tests; the operator runs the compose checks in T4.3.)
T4.2 Smoke script
- Wave: 4
- Depends on: T0.5, T2.4, T0.1
- Size: S
- Touches:
scripts/smoke.js(new),test/smoke.test.js(new),package.json(scriptsmoke) - Why: the operator and the gate need one command that says which edge is broken.
Steps
node scripts/smoke.js <serviceUrl> <siteId> <documentPath>:GET <serviceUrl>/v1/health; on a thrown fetch error printoffline-networkand exit 2.- If
gitlab.reachable === falseprintoffline-gitlaband exit 3. GET <serviceUrl>/v1/sites/<siteId>/documents/state?path=<documentPath>; print one JSON line{ "state": "live" | "connect-required" | …, "commits": n, "layers": n, "comments": n }usingclassifyRedlineFailureandderiveRedlineStatefromclient.js; exit 0 forlive/connect-required, 4 otherwise.
package.jsonscript"smoke": "node scripts/smoke.js".
Acceptance
test/smoke.test.js (starts the mock and the service in-process on ephemeral ports via createNodeServer):
smoke exits 0 and prints live state against a healthy servicesmoke exits 3 when gitlab is downsmoke exits 2 when the service is unreachable(point at a closed port)
npm test 2>&1 | grep -c "^✔ smoke" # prints 3
T4.5 Experiment report
- Wave: 4
- Depends on: none
- Size: S
- Touches:
scripts/experiment-report.js(new),test/fixtures/audit-sample.jsonl(new),test/experiment-report.test.js(new),package.json(scriptreport:experiment) - Why: the kill criterion needs numbers from the audit log, not impressions.
Steps
- The service already prints one JSON line per mutation with
type: "redline-audit",action,actor,gitlabUsername,at,siteId. The script reads JSON lines from stdin (one per line; ignore lines that are not valid JSON or notredline-audit), optional flags--since <ISO>and--until <ISO>, and prints one JSON object:{ "since", "until", "actors": <distinct gitlabUsername or actor>, "writes": <count>, "actions": { "<action>": n }, "documents": <distinct path> }. test/fixtures/audit-sample.jsonl: 12 lines mixing three actors, five actions, two documents, two non-audit lines, one malformed line.
Acceptance
test/experiment-report.test.js:
experiment report counts actors writes actions and documents(exact numbers from the sample)experiment report honours since and until
docker logs fio-redline 2>&1 | node scripts/experiment-report.js # on the host, later
T4.3 Operator setup (human)
- Wave: 5
- Depends on: T4.1
- Size: human
- Design: network.md (operator inputs)
- Touches: nothing in the repository;
STATUS.mdnotes only
The executor sets this task to human and lists in STATUS.md what the
operator must provide. The operator performs:
- DNS:
yqa.fio.shA record to the GitLab host’s Tailscale IP, in the same zone and by the same method asglab.fio.sh. - Host nginx vhost for
yqa.fio.shwith TLS, proxying tofio-redline:8787on the Docker network (or a published loopback port). Same certificate method asglab.fio.sh. - GitLab OAuth application: confidential, scope
api, redirect URI exactlyhttps://yqa.fio.sh/v1/auth/gitlab/callback. - GitLab read token with
read_api. ops/.env,ops/service.envandops/secrets/*perops/*.examplewith the experiment values; no tunnel token.- Run the two
docker compose configchecks from T4.1.
Evidence to paste into STATUS.md notes: the DNS answer from a tailnet device
(dig +short yqa.fio.sh shows a 100. address), the nginx -t result, and
the OAuth application’s redirect URI. No secrets.
T4.4 Deploy and evidence (human)
Deferred (amendment 2026-10-10): replaced by T6.7 in B6, which deploys QA and production and records the same evidence for both.
- Wave: 6
- Depends on: T4.3, and a
:releasedimage that contains every task up to wave 5 (the operator promotes it) - Size: human
The operator runs on the host:
cd ops && docker compose pull && docker compose up -d
docker compose ps # service healthy
Then, from a device on the tailnet, in this repository:
npm run smoke -- https://yqa.fio.sh yyz docs/<an existing document>.md # prints a JSON line with "state":"connect-required" or "live"; exit 0
And from a device off the tailnet (or with Tailscale disconnected):
npm run smoke -- https://yqa.fio.sh yyz docs/<the same document>.md # prints offline-network; exit 2
Evidence for STATUS.md: the two smoke outputs, the image tag from
docker compose ps, and the date. No secrets, no cookies.