T-80: 換掉一把簽章金鑰之後,車隊真的會跟上(而且數得出來) - #418
Conversation
[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
|
🔴 DO NOT LAND — independent review found a P0 in the thing this PR exists to provide. The number lies in the SAFE directionThe warden renewal path probes with the NEW credential before writing it:
If 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 — 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 incompleteIt claimed "four places". Three more are still live, and one of them is in a file this PR already edits:
Cause: that sweep was run with CI on this head is green, and that green is not the question — it was green with the P0 in it. |
…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
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
為什麼
輪替簽章金鑰只是「加一把」:新的簽,舊的留在環上繼續驗得過。真正的撤銷是
remove,而它立即、無緩衝。今天沒有人敢按它,因為每一台 warden 手上那顆憑證都是舊金鑰簽的,按下去全站當場失聯。而不敢按,就等於我們從來沒有真正撤銷過任何憑證。
重核推翻了原本票面的兩條敘述
票面原本寫「warden 自己沒有任何換憑證的機制」與「token 檔安裝後沒有東西會回頭改它」。兩條都是假的(
origin/main @ 6771e831讀碼):cli/ocwarden/renew.go+renewapply.go已經是一套完整、而且在 production 接著線的自我換發。真正的缺口是觸發條件:
credentialDueForRenewal只看exp,而mintWardenToken走mintJWTWithoutExpiry⇒ 那個判斷對 warden 憑證永遠回 false ⇒ 整套機制永遠不會啟動。而且 JWT header 是常數、沒有 kid,server 端也沒有任何地方記「這台用的是哪把簽的」⇒ 「換完了沒有」今天無解,那才是唯一要從零做的部分。做了什麼(owner 裁定 A,
rc-0572eaeb0f4d)站台只看見與點名;動手的永遠是機器自己。
verifyJWTAnyKey現在回報是哪一把驗過的(它本來就知道,只是丟掉)。requireAuth(唯一咽喉、所有拒絕之後)把觀察值記到member.token_key_id。這是站台自己驗證看到的,不是機器自報 —— 而且刻意沒有任何管道讓機器自報,因為這個數字要拿去決定一個立即、不可回頭的撤銷。GET /api/machines每一列多token_key_id與token_key_current(都 optional、nullable;null = 從沒被觀察過,是第三態)。renew。那個 frame 不帶憑證、不帶對象、不回 receipt —— 站台說「去」,不送東西。收斂由「下一次請求觀察到的 key id」settle,不由 warden 自報,因為一個禮貌地答應卻什麼都沒做的 warden 偽造不了那個。舊 warden 收到
renew是 unknown-rpc log+skip,reader loop 不受影響 ⇒ 送全車隊安全。🔴 合併前的阻塞
migration 號碼是佔位
00099,未定案。 必須排在 #394 的00076與 #389 的00079之上。取號要動四個位置,都列在 migration 檔頭。這是 draft 的原因。已知邊界(照實列)
token_key_current—— owner「一眼看出」的最後一哩還缺 SPA 那半,不在本包。renewAskInterval = 5m是論證出來的,不是量測出來的。 沒有在真站台上量過實際收斂時間。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