fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다

기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 15:59:42 +09:00
co-authored by Claude Opus 5
parent ab59130196
commit 024362d096
197 changed files with 8336 additions and 960 deletions
@@ -61,11 +61,18 @@ aside[class*="studio-document-status"] ← 이것이 안 보이면 인증 실
### 1. 기록을 읽고 무엇을 넣을지 정한다
`kind` 로 칸 목록이 정해진다. 어떤 `##` 제목이 Studio 의 어느 칸인지는
[references/studio-form-map.md](references/studio-form-map.md).
[references/studio-form-map.md](references/studio-form-map.md), 그 칸이 서버에서 어떤 이름과
자료형인지는 [references/studio-api.md](references/studio-api.md).
**frontmatter 는 메타데이터고, 본문의 `##` 가 Studio 의 칸이며, 제목 아래 첫 문단이 `요약`
이다.** 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다.
**본문은 이제 화면으로 넣지 않는다.** 편집 화면의 본문이 블록 편집기로 바뀌어 블록 하나가
`<textarea>` 하나다 — 기록 한 편이 블록 171개인 것을 실제로 봤다. 그런데 저장이 서버로 보내는
것은 여전히 `bodyMarkdown` 이라는 마크다운 문자열 하나다. 그래서 **옮기는 일은 화면이 아니라
`PUT /api/v1/studio/documents/{id}` 로 한다** — 로그인된 그 페이지 안에서 `fetch` 로 부르므로
세션과 CSRF 가 그대로 실린다. 화면은 읽고 대조하는 데 쓴다.
### 2. 그림이 있으면 Asset 을 먼저 올린다
frontmatter `assets:``file:` 이 올릴 파일이고 `key:` 는 저장소 쪽 이름이다. 올리면 서버가
@@ -101,6 +108,12 @@ python3 scripts/studio-body.py <기록.md> --key <저장소 key>=<서버가 준
### 4. 저장한다
API 로 넣었으면 `PUT` 이 200 을 내는 것이 저장이다. **`expectedVersion` 은 저장 직전에 `GET`
으로 읽은 `document.version` 이다** — 지어내지 않는다. 보내지 않은 칸은 비워지므로 그 종류의
칸을 전부 담는다.
화면으로 고쳤으면 버튼을 누른다.
```
aside[class*="studio-document-status"] 안의 `저장` 버튼
→ 같은 aside 의 글자가 `저장됨` 으로 바뀔 때까지 기다린다 (최대 30초)
@@ -141,12 +154,12 @@ python3 scripts/verify-tech-log-tree.py <프로젝트>
| 종류 | 경로 | 본문 |
|---|---|---|
| Case | `DELETE /v1/studio/cases/{id}` (`:72`) | `{"expectedVersion": <저장 버전>}` |
| Reference | `DELETE /v1/studio/references/{id}` (`:81`) | `{"expectedVersion": <저장 버전>}` |
| Concept | `DELETE /v1/studio/concepts/{id}` (`:95`) | `{"expectedVersion": <저장 버전>}` |
| Case | `DELETE /api/v1/studio/cases/{id}` (`:72`) | `{"expectedVersion": <저장 버전>}` |
| Reference | `DELETE /api/v1/studio/references/{id}` (`:81`) | `{"expectedVersion": <저장 버전>}` |
| Concept | `DELETE /api/v1/studio/concepts/{id}` (`:95`) | `{"expectedVersion": <저장 버전>}` |
| Setup | `DELETE /api/v1/studio/setups/{id}` | `{"expectedVersion": <저장 버전>}` |
| Question | `DELETE /v1/studio/questions/{id}` (`:114`) | `{"expectedVersion": <저장 버전>}` |
| Decision | `DELETE /v1/studio/projects/{id}/decisions/{decisionId}` (`:123`) | `{"expectedVersion": <저장 버전>}` |
| Question | `DELETE /api/v1/studio/questions/{id}` (`:114`) | `{"expectedVersion": <저장 버전>}` |
| Decision | `DELETE /api/v1/studio/projects/{id}/decisions/{decisionId}` (`:123`) | `{"expectedVersion": <저장 버전>}` |
**본문 없이 부르면 204 가 아니라 422 다.** `ExpectedVersionRequest` 가 없으면
`REQUEST_VALIDATION_FAILED` / `Request body is malformed` 로 거절된다. 헤더에는
@@ -114,31 +114,49 @@ while (cur < want) { // 모자라면 더한다
const labels = await fs.locator('label span').allInnerTexts();
```
## 칸 DOM 이 두 가지다
## 칸은 `aria-label` 로 찾는다 (2026-09-17 에 다시 잼)
위 xpath 는 절반만 통한다. 실제 편집 화면에는 모양이 둘이다.
```html
<label class="studio-field"><span>문제</span><textarea></textarea></label> <!-- A -->
<div class="studio-field"><label for="studio-field-summary">요약</label>
<textarea id="studio-field-summary"></textarea></div> <!-- B -->
```
`요약` · `본문 Markdown` · `관계 N 이유` 가 B형이다. A형만 찾으면 이 칸들이 조용히 안 채워진다.
**둘 다 본다.**
**위의 `label` 두 모양 이야기는 낡았다.** 지금 `label.studio-field` 가 감싸는 것은 제목과 요약
둘뿐이고, 나머지 칸은 전부 `aria-label` 을 갖는다. xpath 로 `span` 을 뒤질 일이 없다.
```js
async function field(page, name) {
const a = page.locator(`xpath=//label[./span[normalize-space(.)="${name}"]]`)
.locator('textarea, input').first();
if (await a.count()) return a;
const id = await page.locator(`xpath=//label[normalize-space(.)="${name}"]`)
.first().getAttribute('for');
return id ? page.locator('#' + id) : null;
}
document.querySelector('input[aria-label="제목"]')
document.querySelector('textarea[aria-label="요약"]')
document.querySelector('input[aria-label="slug"]')
document.querySelector('input[aria-label="판단 기준 1 제목"]')
document.querySelector('textarea[aria-label="적용할 때 3"]')
```
**본문 칸의 이름은 `본문` 이 아니라 `본문 Markdown` 이다.**
되풀이 칸을 늘리는 버튼은 `+ 판단 기준` · `+ 적용할 때` · `+ 예외와 주의` · `+ 예시` ·
`+ 버전 추가` 다. 줄 수를 먼저 맞추고 값을 넣는 규칙은 그대로다.
## 본문 칸이 사라졌다 — 블록 편집기다
**`본문 Markdown` 이라는 textarea 는 더 없다.** 본문은 `.studio-block-editor` 이고 블록 하나가
`<textarea>` 하나다. 기록 한 편이 블록 171개인 것을 실제로 봤다.
```
.studio-block-editor
├ textarea.studio-markdown-source 본문 전체의 거울. display:none · aria-hidden · readonly
├ .studio-block-editor__blocks
│ └ .studio-authoring-block[data-kind] heading · paragraph · code · bullet · raw
└ button + 블록 추가
```
**코드 블록의 `input.studio-code-language` 에 울타리 뒤 정보 문자열 전체가 들어간다**
`bash label="[kc-lab-1] ① 기동 로그에서 쿠키 설정을 찾는다"` 가 그 입력의 값이었다. 라벨을
넣을 별도 칸은 없다.
합성한 `ClipboardEvent` 로 붙여넣는 것은 **안 먹는다.** 그래서 쓰인 기록을 화면으로 옮기지
않는다 — [studio-api.md](studio-api.md) 의 `PUT` 으로 `bodyMarkdown` 을 통째로 보낸다.
`.studio-markdown-source``readonly` 라 입력 자리가 아니지만, 화면이 지금 무엇을 직렬화할지를
그대로 보여 주므로 **저장 전에 눈으로 대조하기에 좋다.**
## 저장 다음은 게시가 아니라 검증이다
편집 화면의 `aside` 에는 `저장``게시` 둘뿐인데, 저장만 된 기록은 서버가 `nextAction`
**`VALIDATE`** 로 준다. 검증 화면은 `/studio/documents/{id}/validation` 이다. 이 스킬은 저장까지라
거기까지 가지 않는다 — 다만 「저장했는데 왜 게시가 안 되나」의 답이 이것이다.
## Asset 을 올릴 때
@@ -0,0 +1,175 @@
# Studio 가 실제로 주고받는 것
여기 적은 것은 **2026-09-17 에 로그인한 브라우저에서 직접 불러 확인한 것**이다. 문서를 보고
적은 것이 아니라 요청을 보내고 돌아온 것을 옮겼다. 확인한 판이 바뀌면 이 파일도 낡는다.
## 왜 이 파일이 필요해졌나
**편집 화면의 본문이 블록 편집기로 바뀌었다.** 전에는 본문이 `본문 Markdown` 이라는 textarea
하나였고 거기에 통째로 붙여넣으면 됐다. 지금은 블록 하나가 `<textarea>` 하나다 — 기록 한 편이
블록 171개인 것을 실제로 봤다. 화면으로 그것을 채우는 것은 사람이 한 편을 쓸 때의 방식이지
쓰인 기록을 옮기는 방식이 아니다.
그런데 **저장이 서버로 보내는 것은 블록이 아니라 마크다운 문자열 하나**다. 화면은 그 문자열
위의 편집기이고, 저장 요청은 `bodyMarkdown` 한 칸을 보낸다. 그래서 옮기는 일은 화면이 아니라
이 API 로 한다.
## 인증
**자격증명을 이 저장소에 두지 않는다.** 이미 로그인된 브라우저 세션을 쓰고, 요청은 그 페이지
안에서(`fetch`) 보낸다. 쿠키가 그대로 실린다.
CSRF 토큰은 `XSRF-TOKEN` 쿠키에 있고 `x-csrf-token` 헤더로 되돌려 준다.
```js
document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1]
```
로그인이 안 돼 있으면 `/studio` 가 「기록을 쓰려면 로그인이 필요합니다」를 낸다.
**로그인 화면을 자동으로 통과하려 들지 않는다** — 사람에게 로그인해 달라고 말하고 멈춘다.
## 응답 껍데기
성공이든 실패든 같은 모양이다.
```json
{ "success": true, "data": { }, "error": null,
"meta": { "requestId": "…", "traceId": "…", "correlationId": "…", "page": null } }
```
실패하면 `data``null` 이고 `error``{code, category, message, retryable, details}` 가 온다.
`details.supportedMethods` 처럼 고칠 방법을 담아 주는 경우가 있다.
## 경로
| 무엇 | 요청 |
|---|---|
| 목록 | `GET /api/v1/studio/documents?limit=100` |
| 한 편 | `GET /api/v1/studio/documents/{id}` |
| 새로 만들기 | `POST /api/v1/studio/documents` → 201 |
| **저장** | `PUT /api/v1/studio/documents/{id}` → 200 |
| 지우기 | `DELETE /api/v1/studio/{종류}/{id}` → 204 |
**`limit` 은 100 이 상한이다.** 200 을 주면 422 `REQUEST_VALIDATION_FAILED` 다. 더 받으려면
`data.nextCursor``cursor` 로 넘긴다.
**`/documents/{id}``PUT``GET` 만 받는다.** 거기에 `DELETE` 를 보내면 405 이고
`details.supportedMethods``["PUT","GET"]` 을 준다. 삭제는 종류별 경로다 —
`cases` · `references` · `concepts` · `setups` · `questions`, 그리고 Decision 은
`projects/{projectId}/decisions/{decisionId}`.
## 한 편 읽기
```
GET /api/v1/studio/documents/{id}
→ data: { document, currentValidation, latestPreview, currentPublication,
dependencyRevision, nextAction }
```
`nextAction` 이 다음에 할 일을 말한다 — 저장만 된 기록은 `VALIDATE` 다. 검증 화면은
`/studio/documents/{id}/validation` 이고 편집 화면의 `aside` 에는 **`저장``게시` 둘뿐**이라
검증은 그 화면에서 따로 한다.
## 저장
```
PUT /api/v1/studio/documents/{id}
headers: content-type: application/json
accept: application/json
x-csrf-token: <XSRF-TOKEN 쿠키>
idempotency-key: <요청마다 새로>
body: { "expectedVersion": <읽어 온 document.version>, "document": { … } }
```
**`expectedVersion` 은 방금 읽은 `document.version` 이다.** 저장이 끝나면 1 올라간다. 지어내지
말고 저장 직전에 `GET` 으로 읽는다.
`document`**그 종류의 칸을 전부** 담는다. 빠뜨린 칸은 비는 것으로 저장된다 — 부분 갱신이
아니다.
## 종류마다의 칸
여섯이 함께 갖는 것: `kind` · `title` · `slug` · `summary` · `relations` · `projectId` ·
`topicId` · `variantIds`. 서버가 주지만 보내지 않는 것: `id` · `version` · `updatedAt`.
| kind | 그 종류만의 칸 |
|---|---|
| `CASE` | `problem` · `conclusion` · `environment` · `reproduction` · `bodyMarkdown` · `lastVerifiedOn` |
| `CONCEPT` | `bodyMarkdown` · `basisVersion` |
| `SETUP` | `bodyMarkdown` · `pinnedVersions` |
| `REFERENCE` | `purpose` · `rules` · `applyWhen` · `exceptions` · `examples` · `verifiedOn` |
| `QUESTION` | `facts` · `assumptions` · `unknowns` · `constraints` · `options` · `nextValidation` · `resolution` · `questionStatus` |
되풀이되는 칸의 모양이다. `id` 는 서버가 붙이므로 새로 넣을 때는 빼고 `order` 만 0 부터 센다.
| 칸 | 항목 |
|---|---|
| `relations` | `{ targetId, reason, order }` |
| `facts` · `assumptions` · `unknowns` · `constraints` · `applyWhen` · `exceptions` · `examples` | `{ text, order }` |
| `options` | `{ title, description, order }` |
| `rules` | `{ title, body, order }`**제목과 본문이 따로다** |
| `pinnedVersions` | `{ name, version }``order` 가 없다 |
## `bodyMarkdown` 은 바이트 그대로 돌아온다
넣은 것과 읽어 온 것이 같은지 확인했다. **코드 울타리의 정보 문자열이 그대로 살아남는다.**
```
```bash label="[host] ① 친다"
```
넣고 다시 읽었을 때 `label="…"` 까지 한 글자도 안 바뀌었다. 그래서 `studio-body.py` 가 만든
본문을 그대로 실어 보내면 된다.
## 화면에서 블록이 어떻게 생겼나
옮기는 일은 API 로 하지만, 사람이 화면을 읽을 때와 무엇이 어긋났는지 볼 때 필요하다.
```
.studio-block-editor
├ textarea.studio-markdown-source 본문 전체의 거울. display:none · aria-hidden · readonly
├ .studio-block-editor__blocks
│ └ .studio-authoring-block[data-kind] 블록 하나
└ button + 블록 추가
```
`data-kind``heading` · `paragraph` · `code` · `bullet` · `raw` 다. `raw`(화면 이름
**고급 블록**)가 표처럼 블록으로 안 갈리는 것을 그대로 담는다.
**코드 블록의 `input.studio-code-language` 는 언어 이름만 담지 않는다.** 울타리 뒤의 정보
문자열 **전체**가 거기 들어간다 — `bash label="[kc-lab-1] ① 기동 로그에서 쿠키 설정을 찾는다"`
가 그 입력의 값이었다. 라벨을 넣을 별도 칸은 없다.
**`.studio-markdown-source` 는 읽는 데만 쓴다.** `readonly` 이고 `display:none` 이라 입력
자리가 아니다. 다만 화면이 지금 무엇을 직렬화할지를 그대로 보여 주므로, 저장 전에 눈으로
대조하기에 좋다.
합성한 `ClipboardEvent` 로 붙여넣는 것은 **안 먹는다.** 화면으로 본문을 채우려면 블록을 하나씩
만들어야 한다.
## 화면의 칸 셀렉터
칸은 이제 `aria-label` 로 찾는다. `label.studio-field` 는 제목과 요약 둘만 감싼다.
```js
document.querySelector('input[aria-label="제목"]')
document.querySelector('textarea[aria-label="요약"]')
document.querySelector('input[aria-label="slug"]')
document.querySelector('input[aria-label="판단 기준 1 제목"]')
document.querySelector('textarea[aria-label="적용할 때 3"]')
```
되풀이 칸을 늘리는 버튼은 `+ 판단 기준` · `+ 적용할 때` · `+ 예외와 주의` · `+ 예시` ·
`+ 버전 추가` 다.
상태 레일은 그대로다 — `aside[class*="studio-document-status"]`(실제 class 는
`studio-document-status-bar`). 그 안에 **`저장``게시` 둘뿐**이고, 저장된 상태에서는
`저장``disabled` 이며 글자가 `저장됨` 이다.
## 하지 않는 것
- **게시하지 않는다.** 이 스킬은 저장까지다. 한 번이라도 게시한 문서는 게시를 취소해도 삭제가
409 로 거절된다
- **`expectedVersion` 을 지어내지 않는다.** 저장 직전에 읽는다
- **부분 갱신이라고 생각하지 않는다.** 보내지 않은 칸은 비워진다
- **로그인 화면을 자동으로 넘기지 않는다**
@@ -100,8 +100,20 @@ Decision 의 도메인 `ACCEPTED` 가 화면에서는 `ADOPTED` 로 보인다.
`Reference`·`Question`·`Decision` 의 칸은 **평문으로 렌더링된다.**
- 백틱과 파이프가 글자 그대로 보인다. 코드·표를 넣지 않는다
- 줄바꿈은 `<br>` 로만 살아난다
**「평문」이 「전부 글자 그대로」라는 뜻은 아니다.** 렌더러
(`tech-log-frontend``presentation/shared/public-render/prose-text.tsx`)가 셋을 해석한다.
| 무엇 | 평문 칸에서 |
|---|---|
| 백틱 쌍 | **인라인 `<code>` 로 산다.** 빼지 않는다 — 빼면 식별자가 민무늬로 나온다 |
| 빈 줄 | 문단이 갈린다 |
| 한 줄 바꿈 | `<br>` |
| 별표 · 파이프 · `#` · 코드펜스 · 인용 표지 `>` | **글자 그대로 나온다.** 넣지 않는다 |
**전에 이 자리에 「백틱과 파이프가 글자 그대로 보인다」고 적혀 있었고 백틱 쪽은 틀렸다.**
백틱이 글자로 나오던 것은 고쳐진 옛 버그이고 그 파일 주석에 그렇게 적혀 있다. 칸이 어떻게
보이는지는 **렌더러가 정본이다** — 이 문서가 아니다.
- 나열은 쉼표로 잇지 말고 `이름 : 값` 으로 줄을 나눈다
코드·표·그림이 필요하면 짝이 되는 Case·Concept·Setup 에 담고 `관계` 로 가리킨다.
@@ -367,6 +367,163 @@ Not `${NAME}` — guides use `$TOKEN`, `$SID` and friends as real shell
variables, and a reader who reads a placeholder as one will paste it unchanged.
`{{ }}` is not shell syntax, so pasting it fails where the reader can see it.
## The reader cannot see what is not on screen
Everything below came out of reading 35 finished guides in one pass. Each rule
names the accident that produced it. None of them is a style preference — in
every case the guide passed every other rule in this file and still handed the
reader a wrong answer that looked right.
### A value copied into a container is a snapshot, not a reference
`kubectl run --env="K0=$K0"`, `docker run -e`, cloud-init user-data: each bakes
the string as it was at that moment. From then on two spellings mean different
things, and they differ by two characters:
```bash
exec pod -- sh -c '...$K0...' # the value baked into the pod
exec pod -- sh -c '...'"$K0"'...' # the value in the shell typing this
```
**They agree until something restarts.** In one guide a `rollout restart` changed
the pod IP, and a later step still read the baked copy; the request went to an
address that no longer existed, and the failure it produced was a `500` — which
was also that step's expected result. The screen looked correct.
So: when a step replaces the thing a baked value points at — a pod IP, a node
address, a lease — say in that step which spelling the next commands use.
Prefer recreating the container. If state inside it forbids that, show the
live-read form and add one line saying why it differs from the block above.
**Distance is not the test.** A variable defined 700 lines up can be safe, and
one defined nine lines up can be stale. Ask what happened in between.
### A step that only works inside a window says when, not just what
The four-part shape above — what / command / where to look / what it means —
has no *when*. Several steps read correctly only during a restart, only while a
rule is installed, only within sixty seconds. Read late, they do not error;
they print a different, plausible number.
Name the command that opens the window and the one that closes it, and say
**what the reader sees if they type it after it closed** — that is what most
readers will actually get.
### A check must be able to fail
For every line that says "이렇게 나오면 통과", state what would print the same
thing while the condition is false. Three real cases:
- `virsh list --all` used to prove a group membership took effect — it connects
to the per-user URI, which works with no group at all
- a `postmaster.pid` path checked on a fixed node, when the volume may be on the
other one — "no such file" passes either way
- `source ~/.bashrc` on a host whose login shell is zsh — true in that shell,
gone at next login, and the symptom surfaces one guide later
If the check passes when the step was skipped — because it reads a different
scope, host or shell than the step wrote to — it is not a check. Put the scope
in the command, or write one line naming the failure this check cannot see.
### Never ship a command the guide knows is wrong
Three guides printed a command and told the reader, in prose underneath, to
edit it before typing. Prose is not a guard. The worst of them was a **valid**
assignment:
```bash
OLDID=980ee9b7-... # ← copy from the output above
```
Paste it and `OLDID` holds the literal string, and the `delete` two lines later
runs. Every other hardcoded value in those guides failed loudly — `(0 rows)`,
`(nil)`. This one succeeded at the wrong thing.
If prose under a block says "replace X first", the block already says it — or
says `{{X}}`, which is not shell syntax and fails where the reader can see it.
### One block, one machine
A code block is the reader's copy unit. When the machine changes, the block
ends, even when the commands form one logical step. Splitting the explanation
in prose does not help: they copy the block.
And a machine label tells the reader *where* a block runs — it does not put
them there. When consecutive blocks carry different labels, the transition is
its own step with its own command (`ssh host`, `exit`, "open a second
terminal"). Count them per guide: entries and exits must balance. A
verification section that runs somewhere else needs both.
### A shell function defined mid-guide is worse than all three tiers
It leaves no file, so the reader cannot re-read it. It dies with the shell, so
a new window silently breaks every later step. `unset` does not reach it.
One guide defined `R()` on line 188 and used it through line 617 — a one-letter
name for `kubectl -n … exec deploy/redis -- redis-cli`. At the point of use
the reader cannot see what command they are running.
If something is repeated often enough to want a name, make it a script file
with that name. If it must be a function, define it in the same block as its
first use, name it for what it does (`redis`, not `R`), and restate the
definition at the top of any later section more than a screen away.
### Names the reader must already have
A host alias, a directory, a volume, a file that no earlier step created is not
a placeholder — it is an assumption, and the placeholder rule above does not
catch it. Four guides in one set ran entirely on `ssh kc-lab-1` with no stanza
anywhere that creates it; the second guide hits it on its first command.
Show the command that creates it, or name the guide that owns it, in the prose
above the first block that uses it.
### Walk the guide twice
`echo >>` is one case of a larger rule. Anything created under a fixed name — a
pod, a volume, a DHCP reservation, a namespace — says what the second run
prints and how to clear it first. `--rm` only removes on clean exit; Ctrl+C
leaves the pod and the next run dies on `AlreadyExists`.
And anything that destroys and recreates a host says what identity changes with
it — SSH host keys, MAC-bound leases, certificates — and what that breaks the
next time the reader connects.
### The output block belongs to the command above it
If it came from a wider `grep`, an extra `uniq -c`, or a `sed` that rewrote
names, show that command too or say so on the line before. Readers compare
their screen to yours character by character; a silent edit makes them hunt for
a fault that is not there.
### Context selectors are read as a set
`-n`, `--context`, `-h`, `-U`. One guide's `kubectl exec` was missing
`-n keycloak-lab` while the eight commands around it had it. On its own the
line looks fine; next to its neighbours it is obviously running somewhere else.
Read them as a column, not line by line.
### When the same action has several forms, one of them is the step
One guide gave three ways to kill a backend and marked none of them. Show the
step; put the rest under a heading that says they are alternatives. Leaving the
reader to choose asks them to weigh a trade-off the guide has not explained yet.
### A fixed output path makes a block single-use
"Change the variable and run it again" is incomplete when the block writes to a
fixed file: the second run overwrites the first run's baseline, silently, and
the comparison two sections later has nothing to compare against. Name the
output path among the things to change.
### Run the remedy you prescribe
A guide diagnosed lexical sorting and prescribed `sort -g`. Every line in that
file began with the same `200`, so `sort -g` compared equal and fell through to
byte order — its output was identical to plain `sort`. The fix the guide
offered did not fix anything, and the sentence "miss this and you misread the
maximum" stayed true after following it.
## Rationalization table
| Excuse | Reality |
@@ -383,6 +540,11 @@ variables, and a reader who reads a placeholder as one will paste it unchanged.
| "The file ends up the same either way" | Only on the first run. `echo >>` appends again every time someone repeats the guide |
| "So I should use nano for everything" | No. The trigger is authoring a file the reader must understand. `grep`, `virsh`, a long `virt-install` stay as they are |
| "`python3 -c` only checks syntax" | Then it misses the schema. If the format has a validator — `cloud-init schema`, `nginx -t`, `--dry-run` — that is the step |
| "The prose right under it says to change that value" | The reader copies the block. Prose is not a guard — put `{{NAME}}` in the block |
| "The variable is defined earlier in the same shell" | Ask what restarted in between. A pod IP baked at line 877 was stale by line 764 |
| "The label says which machine it runs on" | A label says where, not how to get there. The transition is its own step |
| "It's the same command, just shorter" | A one-letter function hides the command at the exact moment the reader needs to read it |
| "The check passed" | Ask what it would print if the step had been skipped. Three checks in one set passed either way |
## Red flags — stop and rewrite
@@ -398,6 +560,14 @@ variables, and a reader who reads a placeholder as one will paste it unchanged.
- one line stacking connect + redirect + file creation (`ssh host 'cat > f' < f`)
- `python3 -c` checking syntax when the format has its own validator
- a step that appends, so walking the guide twice appends the line twice
- prose under a block telling the reader to edit the command before typing it
- a block whose machine label differs from the one above, with no command between
- a one- or two-letter shell function, or any function defined far from its use
- a host alias, directory or volume that no step in any guide creates
- `--rm` with no line saying what a Ctrl+C leaves behind
- a "통과" line that would print the same thing if the step had been skipped
- a step that reads correctly only during a window, with no word about the window
- a remedy you have not run against the data that made you prescribe it
## References