Skip to content

T-80: 換掉一把簽章金鑰之後,車隊真的會跟上(而且數得出來) - #418

Merged
pkyosx merged 20 commits into
mainfrom
t-80/warden-key-convergence
Sep 4, 2026
Merged

pkyosx merged 20 commits into
mainfrom
t-80/warden-key-convergence

Conversation

@pkyosx

@pkyosx pkyosx commented Sep 4, 2026

Copy link
Copy Markdown
Owner

為什麼

輪替簽章金鑰只是「加一把」:新的簽,舊的留在環上繼續驗得過。真正的撤銷是 remove,而它立即、無緩衝

今天沒有人敢按它,因為每一台 warden 手上那顆憑證都是舊金鑰簽的,按下去全站當場失聯。而不敢按,就等於我們從來沒有真正撤銷過任何憑證。

重核推翻了原本票面的兩條敘述

票面原本寫「warden 自己沒有任何換憑證的機制」與「token 檔安裝後沒有東西會回頭改它」。兩條都是假的origin/main @ 6771e831 讀碼):cli/ocwarden/renew.go + renewapply.go 已經是一套完整、而且在 production 接著線的自我換發。

真正的缺口是觸發條件credentialDueForRenewal 只看 exp,而 mintWardenTokenmintJWTWithoutExpiry ⇒ 那個判斷對 warden 憑證永遠回 false ⇒ 整套機制永遠不會啟動。而且 JWT header 是常數、沒有 kid,server 端也沒有任何地方記「這台用的是哪把簽的」⇒ 「換完了沒有」今天無解,那才是唯一要從零做的部分。

做了什麼(owner 裁定 A,rc-0572eaeb0f4d

站台只看見與點名;動手的永遠是機器自己。

  • 看見verifyJWTAnyKey 現在回報是哪一把驗過的(它本來就知道,只是丟掉)。requireAuth(唯一咽喉、所有拒絕之後)把觀察值記到 member.token_key_id這是站台自己驗證看到的,不是機器自報 —— 而且刻意沒有任何管道讓機器自報,因為這個數字要拿去決定一個立即、不可回頭的撤銷。
  • 數得出來GET /api/machines 每一列多 token_key_idtoken_key_current(都 optional、nullable;null = 從沒被觀察過,是第三態)。
  • 點名:觀察到不是現行金鑰時,用既有的 warden-command 持久佇列送 renew那個 frame 不帶憑證、不帶對象、不回 receipt —— 站台說「去」,不送東西。收斂由「下一次請求觀察到的 key id」settle,不由 warden 自報,因為一個禮貌地答應卻什麼都沒做的 warden 偽造不了那個。
  • :warden 把「站台要求」當成到期之外的第二個理由。之後的 sub 檢查、寫檔前先拿新憑證探站台、原子 0600 寫、exec 全部沿用既有那條,一行沒改。需求旗標只在替換憑證真的寫下去之後才清,所以任何失敗都留著、下一輪再試。

舊 warden 收到 renew 是 unknown-rpc log+skip,reader loop 不受影響 ⇒ 送全車隊安全。

🔴 合併前的阻塞

migration 號碼是佔位 00099,未定案。 必須排在 #39400076#38900079 之上。取號要動四個位置,都列在 migration 檔頭。這是 draft 的原因。

已知邊界(照實列)

  • 前端沒有任何畫面在讀 token_key_current —— owner「一眼看出」的最後一哩還缺 SPA 那半,不在本包。
  • renewAskInterval = 5m 是論證出來的,不是量測出來的。 沒有在真站台上量過實際收斂時間。
  • renew 沒有失敗可觀測性:離線 warden 只留一行 log,沒有 receipt、沒有「已催過幾次」。owner 目前分不出「催了但它沒動」與「根本沒催過」。這是刻意的(收斂由觀察 settle),但它是已知邊界、不是已解決。
  • memo 是 process-local,重啟後每台機器多一次冗餘寫入。
  • 沒跑 conformance(跨語言那一跳),也沒跑 frontend 測試。
  • verifySecrets 若有人日後把排序邏輯抄回第二份,沒有測試會紅(設計性質,非行為差異)。

驗證

本機只跑點名測試(owner 紅線:不跑完整套件)。server 端涵蓋 491/warden 端 54。14 顆親手造的 mutant,14 顆都被打紅、而且紅的是預期那一條,包括:刪掉那行 enqueue、把 renew 接成 Kick(會編譯、會跑、什麼都不換)、把需求在進入時就消耗掉、以及把 wire 動詞在站台這一側改名(那顆之前完全沒有守衛:兩個模組各有一份字串、沒有編譯器在中間,改名後兩邊都綠而全車隊靜默停止換發)。

雲端整輪以這個 PR 的 checks 為準。

🤖 Generated with Claude Code

https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4

t64 and others added 11 commits September 4, 2026 19:02
[why] The ring's verify order (signing key first, then the rest) was locked
inside verifySecrets, which hands back bare []byte — so a caller that verified a
token could not learn which key did it. That answer is the only way to tell how
many machines are still on an outgoing key, and therefore the only way to know
whether `remove` is safe to press.

[how] verifyCandidates() returns the signingKey VALUES in that same order, and
verifySecrets() is now DERIVED from it rather than reimplementing the ordering —
one fact, one implementation, per the repo's own accident record. verifySecrets
keeps its signature and behaviour byte for byte (sharesig.go and several tests
depend on it) and takes no second RLock, because RWMutex is not reentrant and
rotate/remove hold the write lock across a DB write. activeKeyID() is the
outside-safe half of signingSecret(): an id, never key material, read per use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
[why] Nothing in the system records which signing key a given machine's
credential is signed by — the JWT header is a constant with no kid — so the
owner cannot tell whether every warden has come back on the new key, and
therefore never presses `remove`, which is immediate and un-grace-periodded.
This is the column that holds that answer.

[how] Member.TokenKeyID / member.token_key_id, plus the three-in-one carve-out
the repo requires: a sole writer (SetMemberTokenKeyID via PatchMember), the
column's constructor marked insertOnly, and a row in singleColumnOwnedFields
(count bumped 17 → 18, deliberately hand-maintained). insertOnly for
memberFromWorker's reason — it rebuilds a Member from zero, so every
PutOutsourceWorker would send "", and "" here does not mean "no key", it means
"never observed", i.e. the fleet would read as LESS migrated than it is.
NOT forwardOnly: key ids have no order, and a machine reinstalled on an older
credential must be free to move the value backwards — that is the fact the owner
needs to see.

Migration number is a PLACEHOLDER (00099) and must be renumbered before landing;
the file says so and names both sweep sources. The filename deliberately avoids
the substring "_token": .gitignore carries a secrets denylist line `*_token*`,
so a file named after the column is silently untracked (measured, not guessed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
[why] Owner ruling: the station only RECORDS and takes attendance — it never
rewrites a file on a machine and never pushes a credential. So the whole feature
is one observation made at one place, plus a way to read the tally.

[how] verifyJWTAnyKey now answers (claims, keyID, err); the id is populated on
SUCCESS ONLY and is "" on every failure path, so no refusal and no error string
ever names a key — the non-disclosure property its header declares is unchanged.
requireAuth takes an observeTokenKey callback and calls it as the LAST thing
before the handler, after every refusal: a credential the gate rejected proves
nothing about which key its machine is on. buildHandler carries the parameter;
buildAPIHandler is the one place it is wired, so a test driving the real handler
exercises the real observation.

The value is the station's OWN observation and there is deliberately no wire
field a machine could report it through — it gates a destructive action, so an
assertable answer would be assertable by the very machines being counted.
noteTokenKeyObservation keeps a process-local memo and writes ONLY on a change:
this runs on every authenticated request and the write pool is one connection
wide. That memo is the ONLY suppression, on purpose — an earlier shape also
re-checked the DB value, which made the memo unguarded (its mutant stayed green).

Wire is spec-first: spec/openapi.json → bin/gen-ocapi → ocapi_gen.go, plus the
regenerated frontend schema.ts. MachineDTO gains two optional nullable fields,
token_key_id and token_key_current; the verdict is computed server-side against
the live ring for hardware_stale's reason. null on both means "never observed",
which is a third state and must not fold into false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
[why] The station could SEE which machines were still on an outgoing key and the
warden could ACT on a renew verb, but nothing ever sent one. The loop was open,
and the failure mode is the worst kind: the owner's count would have been
correct, honest, and frozen at "nobody moved" forever.

[how] On the observation path, when the key that just verified a MACHINE's
credential is not the key that signs now, push `{"rpc":"renew","args":
{"member_id":...}}` down that machine's warden-command downlink through the
existing enqueueToWarden reachability gate. wardenTargetArgs — member_id and
nothing else — is owner ruling A on the wire: the station summons, it never
sends. A one-key ring reaches nobody, because the key that verified is by
construction the active one there.

Suppression is the in-memory memo, and ONLY the memo: PutWardenCommand is
conflict-do-nothing and a pending row is deleted when the frame is written, so
queue depth can never mean "already asked". It re-asks every renewAskInterval
(5m) rather than firing once — warden's "I was asked" flag is process-local and
dies with a restart, so a one-shot ask is lost by exactly the machines that most
need it. The number is argued from both sides at the constant: ~10 heartbeats
for a renew to settle and be observed (there is no receipt), and at most 5
minutes for a rebooted warden to be reminded. claimRenewAsk is a compare-and-
stamp so two concurrent requests cannot both decide the interval has elapsed,
and it stamps the ATTEMPT so an unreachable warden is not retried per request.

Also bounds the memo: only machines are memoised now. It is keyed by token
`sub`, and outsource workers mint a new ow- identity per ticket, so memoising
them grew the map monotonically with every worker the station had ever run. The
price — one extra GetMember per non-machine request, on a row requireAuth
already reads up to three times, on the read pool — is named at the type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…etired

The renewal path (renew.go / renewapply.go) is complete and wired in
production, but its only reason to run is "this credential is close to
expiring" — and mintWardenToken mints without an expiry, so the check answers
"not due" forever. Nothing a warden can read off its own token says which key
signed it (the JWT header is a constant; there is no kid), so it cannot ask the
question that matters either. The result is that removing a retired signing key
would cut every machine off at once, which is why nobody has ever removed one —
and an un-removable key is a credential that was never really revoked.

owner ruled option A (rc-0572eaeb0f4d): the station observes and names, the
machine acts. The station must never write a file on a host.

[how] a `renew` warden-command that carries nothing but "go"

- CommandDeps.Renew + the rpcRenew verb: no credential in the frame, no target,
  no receipt. A frame that could carry a credential would be a channel that
  rewrites any host's identity on the server's say-so.
- updater.RenewNow raises an atomic demand flag and Kicks the existing poll
  loop. The flag is atomic because the SSE reader raises it and the loop reads
  it; every other field on that struct has one goroutine.
- maybeRenewCredential gains the demand as a SECOND reason to renew, beside
  expiry. Everything after that point — the subject check, presenting the
  candidate to the station before touching disk, the atomic 0600 write, the
  exec — is untouched.
- The demand is cleared only once a replacement has been WRITTEN, so every
  failure leaves it standing and the next poll retries. Consumed on entry it
  would be lost by one unreachable station and the machine would stay on the
  retired key with nothing left to make it try again.
- wireUpdaterSeams extracts the three self-update/renew wiring lines out of
  realMain's serve branch, which no test could enter: deleting any of them left
  the package green, and in production a warden that never renews looks exactly
  like one that has nothing to do.

An older warden refuses `renew` as unknown-rpc (logged + skipped, reader loop
unharmed), so the verb is safe to send before the fleet has converged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…e it wrong

The verb list is a closed enumeration a reader uses to know what can arrive on a
warden's connection. A verb the warden accepts and the list does not name is a
doc that answers "no" to a question whose real answer is "yes", and nothing goes
red.

[how] one bullet in the shape of the `update` bullet beside it

It states the two properties that are not visible from the wire shape: the frame
carries no credential and must never be given one (that is what makes the
station unable to rewrite a host's identity), and the absence of a receipt is
the stronger evidence rather than a shortcut — convergence is settled by the key
id the server OBSERVES on the machine's next request, which a warden that
answered politely and did nothing cannot forge.

spec/lifecycle.md §1.3 cut 4 also becomes incomplete once the server half lands
(it says a key removal reaches warden credentials and nothing can get out of its
way); that edit is deliberately held back until the counting side exists to be
described, rather than describing a mechanism that is half-built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…he same loop

It enumerated the producers of updater.Kick() and was, after this change, both
incomplete and actively misleading: the natural reading is that anything wanting
the loop to wake calls Kick, and a renew seam wired that way compiles, runs, and
renews nothing — a warden credential has no expiry, so the woken cycle finds
nothing due. The failure is invisible until somebody removes a signing key.

[how] name the third producer and the one invariant that is not visible in the code

Two lines: RenewNow raises the demand before it kicks (and why Kick is the wrong
target), and the demand is cleared only after a replacement has been written
(and what a demand consumed on entry costs). Both are decisions a reader would
otherwise have to reconstruct from the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…ned it

cli/ocwarden is a separate Go module with its own copy of the string
(rpcRenew), and there is no compiler between the two. Every assertion on the
station side compared against reconcileCmdRenew — the constant under test — so
the expected value renamed along with it. Measured: rename the constant to
`refresh` and the whole package stays green, and so does the warden's, because
its tests pin its own literal. What happens in production is that every renew
frame is refused by every warden as unknown-rpc — logged, skipped, reader loop
unharmed, nothing red anywhere — and the credentials this ticket exists to
rotate never move again.

(An earlier mutant appeared to be caught, by TestTheRenewFrameCarriesNoCredential
AtAll. That was an accident of the name I picked: `renew_credential` contains
the substring "credential", which is what that test forbids. A neutral rename
proves the real coverage was zero.)

[how] one test whose expected value is a hard-coded literal, not the constant

It asserts the literal twice: that the constant equals it, and that the string
actually lands in the `rpc` field the warden reads — a frame whose verb sits
somewhere the warden does not look is the same outage. The comment says why
this one test breaks the file's convention, so the next reader does not
"fix" it into consistency with its neighbours.

The `update` verb has the same two-module shape and no such test; that gap is
named rather than silently inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…move"

The criterion in every one of them was "has every machine reconnected". That
was the best answer available while nothing recorded which key a machine was
on — and it is wrong in the way that costs the most: a machine that reconnects
while still holding a credential signed by the outgoing key reads as converged
and drops off the instant remove is pressed. The confirmation dialog is where
the owner actually acts on it, and docs/design/SPEC.md explicitly requires that
dialog to state the cost.

One of the four was ALREADY false before this ticket, and is corrected rather
than updated: spec/lifecycle.md claimed a finite warden credential "remains
finite until that machine is reinstalled". Self-renewal predates T-80 — the
sentence has simply been describing a world without it. Said out loud in the
file so the next reader does not date the error to this change.

[how] state the real criterion, and say plainly what is NOT built

- spec/lifecycle.md §1.3: cut 4 was, until now, a cut nobody could afford to
  make; what T-80 adds is not a new cut but the convergence that has to precede
  it. Also states what has NOT changed — removal is still immediate, still
  takes every share-link the key signed, still has no undo.
- The dialog copy (zh + en) now names the failure it is protecting against
  instead of a proxy for it, and says an offline machine cannot move.
- settings.md / SPEC.md carry the same correction AND the limit: the count
  lives only in the machines API today, not on any screen. Writing it as though
  the owner can see it would be the more useful sentence and the false one.

Existing assertions on this copy check for "機器" / "warden", both still true.
Frontend tests were not run locally (no node_modules in this worktree); cloud
CI covers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
00080, taken by the OffiCraft developer with a sweep of BOTH number sources
across main AND every open PR rather than the ones anybody remembered: main at
00074, #394 holding 00075/00076, #389 holding 00077-00079.

The number is load-bearing beyond collision. #394 rebuilds the whole member
table with a hand-written column list that cannot mention token_key_id, because
it was written before this column existed. Sitting ABOVE that rebuild is what
makes the hazard structurally impossible rather than merely unlikely — the
column is added after the table is rebuilt. Below it, the rebuild drops this
column for every existing row, silently, with no merge conflict and both
branches CI-green in isolation.

[how] renumber, and write down the two traps that have no error message

- The header now says DO NOT take one of the seven gaps below this file. A
  number picked out of a gap sorts perfectly into the middle, so only the lock's
  append ORDER can see it — migration_lock_t75_judgement_test.go judges that as
  a defect, and nothing else would.
- It also states, by name, that the filename avoids the substring "token"
  because .gitignore's `*_token*` secrets denylist silently untracks such a
  file: no error, no warning, it simply never gets committed. That rule stays as
  it is — narrowing a secrets denylist is the asymmetric direction. Without this
  line the next person re-treads it with no signal at all.

migration.lock regenerated with ./bin/gen-migration-lock; the diff is the roll
plus the tail line, no middle change. The four migration guards pass (not skip)
against a fetched origin/main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…aid so

GET /api/machines sits on principalMachine, the lowest rank, so every
authenticated principal — every agent, every warden — can read every machine's
token_key_id, not just the cockpit. Raised in review as something the field's
documentation should carry.

It is not a leak: key ids come from crypto/rand and are never derived from key
material, so the id discloses nothing about the key. What it does disclose is
how many distinct keys the ring is in use across. Written down as accepted
rather than left to look like nobody noticed.

[how] in the prose spec, not the OpenAPI description

The description in spec/openapi.json round-trips through two generators
(ocapi_gen.go, frontend schema.ts) and this worktree has no node_modules, so
editing it would mean committing a schema.ts I could not regenerate or verify.
It is also the better home on its merits: the audience is a property of the
route's auth floor, not of the field's shape.

The note carries the trap with it — an implementer reading "the client could
just compare ids" would put the active key id on the wire, which is exactly the
second home for one fact that token_key_current exists to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
@pkyosx

pkyosx commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

🔴 DO NOT LAND — independent review found a P0 in the thing this PR exists to provide.

The number lies in the SAFE direction

The warden renewal path probes with the NEW credential before writing it:

renewapply.go:309   u.verify(fresh)  → GET /api/machines   (credentialProbePath)
renewapply.go:340   u.writeTok(...)  → the credential actually lands here

GET /api/machines is authGated (routes.go:1053-1056) ⇒ it goes through requireAuththis PR observes it and records the machine as being on the new key — before the credential is on disk.

If writeTok then fails (read-only fs, disk full), the machine keeps running on the OLD credential while token_key_current reads true. Reproduced by the reviewer with nothing written on the host: token_key_id=k-93b9… token_key_current=true.

That is precisely the failure this ticket exists to eliminate: a number the owner presses an irreversible button on, reading "safe" when it is not.

It also narrows an invariant this PR states in three places (dal.go, migrations/00080, spec/lifecycle.md): "no machine can assert it" is true LITERALLY (a machine cannot supply a value) and false IN EFFECT — renew-credential is zero-argument and self-service, so a warden can mint itself a fresh credential, present it once, and keep running on the old one. It cannot choose the value; it can choose which credential to present.

Practical window is usually ≤30s (the next heartbeat on the old token flips it back) — but 30s is enough for the owner to read all-green and press, and a machine that is shutting down or whose heartbeat is also failing holds the false value indefinitely.

Fix under discussion with the source gate; leading option is to bind the observation to the long-lived SSE connection (the credential the process is actually RUNNING on) rather than to every gated route (which includes a probe of a credential it may never adopt).

Also: the doc sweep in 3e0528e was incomplete

It claimed "four places". Three more are still live, and one of them is in a file this PR already edits:

  • spec/openapi.json:9703 — the remove endpoint description, still says "whether every machine has reconnected" (canonical wire contract)
  • frontend/src/api/generated/schema.ts:392 — generated from the above
  • frontend/src/components/SigningKeysCard.tsx:29 — the header comment of the component that renders the corrected dialog

Cause: that sweep was run with --include='*.md'. The corpus was restricted and the result treated as complete.

CI on this head is green, and that green is not the question — it was green with the P0 in it.

t64 and others added 7 commits September 4, 2026 19:45
…going key

Independent review found it and reproduced it: the station recorded the signing
key at the auth gate, on every gated route. A warden renewing itself PRESENTS
its candidate credential on a gated route before writing it to disk
(cli/ocwarden/renewapply.go: probe at :309, write at :340). So the probe alone
marked the machine converged — and if the write then failed (read-only fs, full
disk) the machine kept running on the outgoing key while token_key_current read
true. The owner reads that number, presses remove — no grace period, no undo —
and cuts off a machine that never adopted the credential at all.

That is the exact failure this ticket exists to eliminate, reintroduced by the
mechanism meant to prevent it.

It also narrows an invariant this package states in three places. "No machine
can assert this" is true LITERALLY — a machine cannot supply a value — and false
IN EFFECT: /api/machines/renew-credential is zero-argument and self-service, so
a warden can mint itself a credential, present it once, and keep running on the
old one. It cannot choose the value; it can choose which credential to present.

[how] observe on the stream, not on every request (owner-gate ruling: option 甲)

requireAuth now hands the verifying key id down on the context and records
nothing — it is the only place that can know WHICH key matched, and emphatically
not the place that can know whether the machine is RUNNING on it. The SSE
connect takes the answer, because that stream is opened with the credential the
process actually loaded: "presented" and "running on" are the same event there
by construction, rather than by everyone remembering to exclude the probe.

Rejected: exempting the probe's route. That needs every future "try the
candidate first" path to remember to opt out, and the failure when someone
forgets is silent and points at SAFE.

The observation sits AFTER hub.Connect, which is load-bearing: enqueueToWarden
is fail-closed on a machine the hub does not hold online, so before Connect a
machine's own summons is refused at the moment it arrives. Measured, not
reasoned — the summons tests went red on that ordering.

Guards, both required by the source gate before this could land:
- TestPresentingACredentialOnAnOrdinaryRouteIsNotEvidenceOfRunningOnIt is the
  regression: present a new-key credential on a gated route, write nothing,
  require the station has not changed its mind — asserted both in the DB and as
  the owner reads it off the wire.
- Mutant: put the observation back on an ordinary gated route (the shape a
  future contributor would add) ⇒ that test goes red, alone. Restored
  byte-identical from a scratchpad copy, not with git checkout.

Test measurement changed with the behaviour: a summons raised during a connect
is drained onto THAT connection and its pending row deleted as the bytes go out,
so popping the queue afterwards reports zero whether it was delivered or never
existed. The tests now read the frames off the socket, which is both the correct
measurement and the stronger one.

Also fixes what the earlier doc sweep missed, and it missed them because that
scan was run with --include='*.md' — the corpus was restricted and the result
treated as complete:
- spec/openapi.json: the remove endpoint still told the owner the criterion was
  "every machine has reconnected". Canonical wire contract, in a file this
  branch already edits.
- SigningKeysCard.tsx: the same claim in the header comment of the component
  that renders the corrected dialog.

⚠️ frontend/src/api/generated/schema.ts is NOT regenerated and still carries the
old sentence: this worktree has no node_modules and openapi-typescript is not
installed, so regenerating it here is impossible. Deliberately left visible for
the drift gate rather than hand-edited — a generated file I cannot verify is
worse than a red check that names it.

Adds the noteTokenKeyObservation kind gate to identityGateLedger. That guard
(TestIdentityGatesAreEachOnTheRecord) failed CI on the previous head and is why
"CI is green" was wrong when I said it: I read individual checks passing and
extrapolated instead of waiting for the verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…nd the

      Connect ordering was guarded but not diagnosable

schema.ts: regenerated by the source gate, who first proved his toolchain by
generating origin/main's spec and getting a byte-identical match against the
schema.ts already committed there — without that control it would have been
another unverified generated file, no better than the hand-edit I declined.
Verified here before use: sha256 matches what he stated, and the diff against
this branch is the single line 392 I changed in spec/openapi.json, with the
other 19,336 lines byte-identical.

Ordering: asked whether the "after hub.Connect" requirement was pinned by a test
or only asserted in a comment. Measured with a mutant rather than answered from
memory — moving the observation above Connect turns four tests red, so it is
guarded. But two of those report PREMISE FAILED, which points a reader at their
setup rather than at the line that moved, and none of the four names the
ordering at all.

[how] the note now maps that red back to this cause

No new test: the behaviour is already covered, and a test whose only job is to
restate an ordering the summons tests already enforce would be a second
representation of one fact. What was missing was not a guard, it was the
diagnosis — so the comment now lists exactly which four tests die and warns that
two of them blame their own premise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…sproved

noteTokenKeyObservation resets the renew-ask throttle whenever the observed key
CHANGES. That reset has no guard of its own, and it is safe today only because a
machine's key is recorded in exactly one place — the stream it is running on. I
argued that made a guard pointless. The source gate refuted it with this ticket:
the warden renewal path was complete, wired and DEAD in production, and T-80
made it live with one added trigger, at which point every pre-existing weakness
on it went from probability zero to non-zero. "Unreachable" is not a property of
the code; it is a sentence about which call sites exist today, and the change
that falsifies it is exactly the small well-meant one.

So the guard goes on the PREMISE, not on the unreachable behaviour — pinning a
scenario nothing can produce would be a test asserting no behaviour.

[how] one AST scan, and its own limit measured rather than asserted

Three mutants were run:
  ① a second call site, written the ordinary way     → red, naming file and line
  ② the only call site deleted                       → red (the zero-sites control,
    which is what stops a rename leaving this green forever)
  ③ a second call via a method value (`f := s.note…`) → GREEN — it does not catch this

③ is the honest limit of any spelling-based scan (the neighbouring
api_chat_attachment_wiring_test.go was defeated three ways and its author wrote
that down). It is recorded in the header as a measurement, not a disclaimer —
and the same mutant ③ turns
TestPresentingACredentialOnAnOrdinaryRouteIsNotEvidenceOfRunningOnIt red,
because that one asserts the outcome and does not care how the second
observation was spelled.

The division of labour is therefore explicit: the outcome test is the guard,
this scan is the diagnosis — it names the offending line in the case a
contributor actually writes, where the outcome test says only that something is
wrong. Its green is not evidence; its red is its whole value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRwRSmgrJpjD8mE4C9HtN4
…ts diff

Moving the observation from the auth gate to SSE connect (82e3c02) was the P0
fix. It also made four existing passages false, and the two rounds of document
confrontation this ticket already did could not see any of them: both scanned
for the words the DIFF touched. A statement that goes stale because you changed
something ELSE is never in the diff. The only query that finds them starts from
what the change made true — "who says auth gate / every request / cannot report
it" — which is how this third round found them and the first two did not.

  1. spec/openapi.json token_key_id — "machines cannot report it, and there is
     deliberately no wire field through which they could". Literally true and
     effectively false, which is the worse kind: a machine cannot choose the
     VALUE, but renew-credential is zero-argument self-service, so it can choose
     WHICH CREDENTIAL IT PRESENTS. A spec invariant whose effect version is false
     is worse than none, because the next person designs on top of it.
  2. server/ocserverd/dal.go — "taken from verifyJWTAnyKey at the auth gate".
     The location itself, simply wrong now.
  3. migrations/00080 — same, and it spells out "verifyJWTAnyKey → requireAuth".
  4. api_auth.go noteTokenKeyObservation — "This runs on EVERY authenticated
     request on every gated route", the PREMISE of the whole paragraph arguing
     why the process-local memo has to exist. The memo still has to exist, but
     for a different reason (the reconnect storm), and a reader who checks the
     stated premise, finds it false and deletes the memo breaks something the
     paragraph never mentions. This one was the most dangerous of the four: not
     a stale sentence, a stale ARGUMENT.

Each replacement now states BOTH layers — cannot choose the value / can choose
which credential is presented — and says why the stream is the observation point
rather than asserting that the hole is gone. It is narrowed, not closed:
presenting a fresh credential on a stream you then keep open IS running on it,
and repeating it costs a whole connection rather than one request.

[how] generated files regenerated, and the generator proved against HEAD first

ocapi_gen.go (bin/gen-ocapi), schema.ts (openapi-typescript 7.13.0, the version
package-lock pins) and migration.lock (bin/gen-migration-lock) are regenerated,
not hand-edited. Before trusting the schema.ts generator I ran a POSITIVE
CONTROL: fed it HEAD's spec/openapi.json and diffed the result against HEAD's
committed schema.ts — byte identical. Without that, what I produced would have
been a second unverified artifact rather than a regeneration. The lock diff is
the expected shape: the roll line plus 00080's own line, no edit to any line
above it.

Named tests, not the full suite (owner's red line): ocserverd 279/2101 green,
plus the migration-lock guards (9 listed) green. Each run preceded by -list as a
positive control, because `go test -run` that matches nothing prints ok and
exits 0, which is indistinguishable from passing.

Editing a migration that is already in migration.lock is a line the OffiCraft
developer drew, and [lock:content] cannot see the crossing: it compares the
lock's hash to the tree's, so editing the file and regenerating the lock leaves
both consistent and green. Human approval is therefore the only guard, and it
was given (Kyle, c-e270622c4a1c), verbatim:

  改 `00080_member_signing_key_observed.sql` 的註解(第 13 行那句「taken at the
  auth gate … verifyJWTAnyKey → requireAuth」),改成描述 SSE connect 那個真正的
  位置;然後跑 `bin/gen-migration-lock` 重產。核可人 Kyle,2026-09-04,理由:該
  migration 尚未 land、尚未在任何站台套用過。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8yoq4BMA2Uip1mGi2ypee
…that does not exist

cli/ocwarden/main.go's wireUpdaterSeams note argues that checking a seam for
non-nil proves nothing, because a seam wired to the WRONG producer is non-nil
too — and then names updater_seams_t80_test.go as the test that calls the seam
instead of inspecting it. That file has never existed. The guard is real and it
is in renew_verb_t80_test.go; the pointer to it was not.

A dangling pointer is worse here than a stale sentence. The whole paragraph
exists to stop a reader from trusting a weak check, and it ends by sending them
to a file they will not find — at which point the most available conclusion is
that the guard is imaginary and the paragraph is folklore. This package wrote
that line (ffd36cd), so it is this package's to fix.

Found by re-running the mutants with logs rather than by reading: the mutant
that wires Renew to Kick goes red in renew_verb_t80_test.go, which is how the
real file name surfaced. The corrected note now names that measurement, so the
claim and its evidence sit in the same sentence.

[how] the reference is now checkable

  $ grep -l TestWireUpdaterSeams cli/ocwarden/*_test.go
  cli/ocwarden/renew_verb_t80_test.go

ocwarden named tests green (43 listed by -list as the positive control, since
`go test -run` that matches nothing prints ok and exits 0). Comment-only change;
no behaviour touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8yoq4BMA2Uip1mGi2ypee
…rgence

# Conflicts:
#	server/ocserverd/lifecycle_identity_gate_t170e_test.go
#	server/ocserverd/migration.lock
@pkyosx
pkyosx marked this pull request as ready for review September 4, 2026 16:36
t64 and others added 2 commits September 5, 2026 01:34
 renumbered

The number-choice paragraph in 00080_member_signing_key_observed.sql records a
sweep taken 2026-09-04: main at 00074, #394 holding 00075/00076, #389 holding
00077-00079, therefore 00080. It is labelled with its date and it was true when
written, so nothing in it is a lie.

Two of its three facts have since moved. #394 landed (00075/00076 are in main
now), and #389 was renumbered to 00081-00083 so it lands AFTER this file — a
change made in the other package, invisible from here, and reported to me in
chat rather than in any file. A later reader re-deriving from that paragraph
concludes #389 still holds 00077-00079, which is the exact class of mistake the
paragraph exists to prevent.

[how] the snapshot keeps its date and gets its correction beside it

- One added block states what moved, on 2026-09-05, and repeats the standing
  instruction: sweep both sources yourself rather than trusting either
  paragraph. The original wording is untouched, because the derivation is still
  the reason 00080 is right.
- migration.lock regenerated (bin/gen-migration-lock): the 00080 line's content
  hash and the roll hash change, and NOTHING above them does — 00080 has not
  shipped, so editing it is legal here and would not be after it lands.

Comment-only change; no schema, no behaviour. Named tests green after merging
origin/main @ 094d1fa: ocserverd 375 listed / ok, ocwarden 104 listed / ok
(-list run first as the positive control, since `go test -run` that matches
nothing prints ok and exits 0). gen-ocapi and frontend gen:api both reproduce
byte-identical output — zero drift on all three generated artefacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFMo9tB9Sv9GtS6KAgVGur
@pkyosx
pkyosx merged commit 33021c9 into main Sep 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant