diff --git a/Client/tests/unit/livekit-e2ee.test.ts b/Client/tests/unit/livekit-e2ee.test.ts index 9933b5ea..e3f0eeb6 100644 --- a/Client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tests/unit/livekit-e2ee.test.ts @@ -1842,3 +1842,149 @@ describe("E2EEManager", () => { } }); }); + +// ── HP-2 question 4: adversarial membership and key-change rules ─────────── +// Each test pins one rule from docs/trust-model.md §"What is end-to-end +// encrypted" that had no dedicated test before HP-2, or records a known gap +// so the fix has a RED waiting for it. + +describe("E2EEManager — HP-2 adversarial membership and key-change rules", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMembers.clear(); + mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" }); + mockVoiceState.voiceUsers.clear(); + vi.mocked(getIdentityPin).mockResolvedValue({ status: "unpinned" }); + vi.mocked(storeIdentityPin).mockResolvedValue("stored"); + }); + + it("[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it", async () => { + // Membership is server-controlled and the client accepts any first-sight + // identity (verifyPeerAnnounce). A server that inserts a member row it + // holds the identity key for, then relays a well-signed announce for it, + // is keyed by the holder like any real peer. The client has no + // independent membership evidence — the voice roster is server state + // too, and here it does not even list the newcomer. + // + // This pins TODAY's behaviour. When authenticated membership (or + // "refuse unrecognised participants") lands, this test goes RED and the + // expectations below invert. docs/trust-model.md §"What beta does not + // claim" names the gap. + const INTRUDER = 99; + mockMembers.set(INTRUDER, { identityPublicKey: "server-supplied-identity-b64" }); + expect(mockVoiceState.voiceUsers.size).toBe(0); + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + ws.send.mockClear(); + + await mgr.handleAnnounce(INTRUDER, "aW50cnVkZXI=", "sig-the-server-can-make"); + + // Today: keyed, pinned, verified — the RED of the desired rule (no offer, + // no pin) is recorded in docs/plans/hp-2-scorecard-2026-08-29.md Q4. + const offers = sendsOfType(ws, "voice_e2ee_offer"); + expect(offers).toHaveLength(1); + expect((offers[0] as any).payload.target_user_id).toBe(INTRUDER); + expect(mgr.peerPublicKeys.has(INTRUDER)).toBe(true); + expect(storeIdentityPin).toHaveBeenCalledWith( + "localhost:7880", + String(INTRUDER), + "server-supplied-identity-b64", + ); + expect(setPeerVerification).toHaveBeenCalledWith( + expect.objectContaining({ userId: INTRUDER, status: "verified" }), + ); + }); + + it("[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches", async () => { + // Pins are one per account ({host}:{userId}) while identity keys are per + // install (identity.ts; migration 017 holds one identity_public_key per + // user). Trusting device 2 therefore evicts device 1's pin, and device + // 1's next announce is blocked as a mismatch. docs/trust-model.md + // §"What is end-to-end encrypted" states the flip-flop; this pins it. + const DEVICE1 = "device1-identity-b64"; + const DEVICE2 = "device2-identity-b64"; + vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE1 }); + mockMembers.set(PEER_ID, { identityPublicKey: DEVICE2 }); // server row: last announcer wins + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + ws.send.mockClear(); + + // Device 2 announces: pinned key differs → blocked, nothing wrapped. + await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMg==", "sig2"); + expect(setPeerVerification).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0); + + // The human clicks "Trust new key": the ONE slot is overwritten and the + // buffered announce replays against the new pin, so device 2 is keyed. + vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE2 }); + expect(await mgr.rePinPeerIdentity(PEER_ID, DEVICE2)).toBe(true); + expect(storeIdentityPin).toHaveBeenCalledTimes(1); + expect(storeIdentityPin).toHaveBeenCalledWith("localhost:7880", String(PEER_ID), DEVICE2); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + const importsBefore = vi.mocked(importPublicKey).mock.calls.length; + + // Device 1 comes back (the server row carries its key again) and is the + // one that mismatches now — no offer, no key imported, no second pin. + mockMembers.set(PEER_ID, { identityPublicKey: DEVICE1 }); + await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMQ==", "sig1"); + expect(setPeerVerification).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: PEER_ID, status: "mismatch" }), + ); + expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1); + expect(vi.mocked(importPublicKey).mock.calls.length).toBe(importsBefore); + expect(storeIdentityPin).toHaveBeenCalledTimes(1); + }); + + it("[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce", async () => { + // Server side: hub.go re-relays the resumed client's stored announce to + // the channel (TestRegisterNow_ReannouncesOwnKeyOnResume). Holder side, + // pinned here: that replay is a duplicate announce, and the offer it + // triggers must carry the CURRENT room key and epoch — not the key the + // peer held before its outage. + // Round-trip import/export so the replayed announce is recognised as the + // SAME ephemeral key (the duplicate path), not a changed one. + vi.mocked(importPublicKey).mockImplementation( + async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) => + (key as unknown as { type: string }).type.replace("peer-key-", ""), + ); + try { + const ws = { send: vi.fn() }; + const mgr = createManager(ws); + await mgr.setupKeyExchange(true, 1); + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + expect(mgr.epoch).toBe(1); + + // Peer's WebSocket drops (media stays up, so no participant-left); the + // periodic rotation fires meanwhile. + await mgr.rotateKeyPeriodically(); + expect(mgr.epoch).toBe(2); + const rotatedKey = (mgr as any)._roomKey as Uint8Array; + ws.send.mockClear(); + vi.mocked(wrapRoomKey).mockClear(); + vi.mocked(importPublicKey).mockClear(); + + // Peer resumes; the server replays its unchanged announce to us. + await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); + + expect(importPublicKey).not.toHaveBeenCalled(); // duplicate, not a new key + const offers = sendsOfType(ws, "voice_e2ee_offer"); + expect(offers).toHaveLength(1); + expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID); + expect(wrapRoomKey).toHaveBeenCalledTimes(1); + const [, , wrappedKey, wrappedEpoch] = vi.mocked(wrapRoomKey).mock.calls[0]!; + expect(wrappedKey).toBe(rotatedKey); + expect(wrappedEpoch).toBe(2); + } finally { + vi.mocked(importPublicKey).mockImplementation( + async () => ({ type: "public" }) as unknown as CryptoKey, + ); + vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA=="); + } + }); +}); diff --git a/docs/plans/README.md b/docs/plans/README.md index eed124bc..e466e463 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -20,7 +20,8 @@ authority**. | [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. | | [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. | | [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. | -| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 in progress from 2026-08-28.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 done 2026-08-29 (PR #1438, B2-3 and B2-4 folded in). **B2-5 is next**, serialized; B2-6/7/9 in parallel. Ends at HP-2. | +| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 execution complete 2026-08-29, pending HP-2 signature.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. | +| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 awaiting owner signature (written 2026-08-29).** Seven questions answered with commands; B2 exit gate, nine conditions; one E2EE gap recorded and disclaimed. Owner fills the reader, review and signature lines. | | [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. | ## Partially implemented diff --git a/docs/plans/b2-protocol-trust-compat-2026-08-28.md b/docs/plans/b2-protocol-trust-compat-2026-08-28.md index f0856c5f..c258a4a8 100644 --- a/docs/plans/b2-protocol-trust-compat-2026-08-28.md +++ b/docs/plans/b2-protocol-trust-compat-2026-08-28.md @@ -5,7 +5,7 @@ `v1.2.0-alpha.4` — claims verified at `64d2e108`; the branch was rebased onto `dd7ed091` (#1432) before merge **Status:** in progress — entry gate 1 of 3 met at draft time (see below); B2-0, -B2-1 and B2-8 landed 2026-08-28, B2-2 (with B2-3 and B2-4 folded in) and B2-5 on 2026-08-29 (evidence in their sections); B2-6 landed 2026-08-29 (PR #1441); B2-7 is in review 2026-08-29 (evidence in its section). +B2-1 and B2-8 landed 2026-08-28, B2-2 (with B2-3 and B2-4 folded in) and B2-5 on 2026-08-29 (evidence in their sections); B2-6 landed 2026-08-29 (PR #1441); B2-7 landed 2026-08-29 (PR #1443 = `88c7a824`); B2-9 done and the HP-2 scorecard written 2026-08-29, in review — **HP-2 awaits the owner's signature** ([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)). Update this line, not only the step table, when a step lands. Primary inputs: @@ -42,11 +42,11 @@ one to one and a half weeks with agents working steps in parallel. | **B2-3** | Server-first updates through the signed manifest — folded into B2-2 | ½ day | after B2-2 | | **B2-4** | Compatibility matrix — folded into B2-2 | ½ day | after B2-2 | | **B2-5** | One permission predicate per security property — **DONE 2026-08-29 (PR #1440)** | 1–2 days | serialized | -| **B2-6** | Safe audit coverage | ½ day | B2-1, B2-7 | -| **B2-7** | Trust model, absence proofs, plugin boundary | 1 day | B2-1, B2-6 | -| **B2-8** | The nine B2-tagged findings | 1 day | before B2-2 | -| **B2-9** | Security owners and acceptance tests | spread | — | -| **HP-2** | Protocol and threat-model sign-off | — | — | +| **B2-6** | Safe audit coverage — **DONE 2026-08-29 (PR #1441)** | ½ day | B2-1, B2-7 | +| **B2-7** | Trust model, absence proofs, plugin boundary — **DONE 2026-08-29 (PR #1443)** | 1 day | B2-1, B2-6 | +| **B2-8** | The nine B2-tagged findings — **DONE 2026-08-28 (PR #1436)** | 1 day | before B2-2 | +| **B2-9** | Security owners and acceptance tests — **DONE 2026-08-29** (SEC-03 → B5; table closed) | spread | — | +| **HP-2** | Protocol and threat-model sign-off — **scorecard written 2026-08-29, awaiting signature** | — | — | Order: B2-0, B2-1, B2-8, then B2-2 → B2-3 → B2-4; B2-5 is serialized on its own; B2-6, B2-7 and B2-9 run in parallel where the table allows. @@ -571,7 +571,9 @@ Runs in parallel with B2-1 and B2-6. neither is not an option. **Evidence, 2026-08-29** — branch `feat/b2-7-trust-model` from `dev` -`2b2d58ab`; PR #1443 to `dev`. HP-2 questions 3 and 6 cite this block. +`2b2d58ab`; PR #1443 to `dev`, squash-merged 2026-08-29 as `88c7a824` +(16 commits; 11 Codex rounds, all documentation-only). HP-2 questions 3 and 6 +cite this block. - Pre-squash SHAs, one commit per item: `a4cd077b` (item 1, trust model + links), `083d87d9` (item 2, absence test + outbound-host table), `cbfcf702` @@ -837,15 +839,15 @@ The seven local reports in `docs/security-findings/` (gitignored, never committed; the directory-to-row mapping lives in its local README) and where each goes: -| Public row | Owner phase | Acceptance test lives | Lands with | -| ---------- | ------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------- | -| S-01 | **B2** | landed with B2-5 (`Server/service/predicate_parity_test.go`) | B2-5 (PR #1440) — done | -| SEC-02 | **B2** (server half) | landed with B2-5 (`Server/ws/voice_moderation_overrides_test.go`) | B2-5 (PR #1440) — done; UI half in B5 | -| C-09 | **B2** (contract) / B7 (client) | beside the report | contract in B2-7 docs; code in B7 | -| SEC-03 | B2 if small, else **B5** | beside the report | B2-9 or B5 item 11 | -| SEC-01 | **B4** | private GitHub advisory (owner creates it) | B4 | -| SEC-04 | **B3/B6** | private GitHub advisory (owner creates it) | B6 | -| OC-0324 | **B4** | beside the report; no advisory — the tracked ledger already carries this finding in full | B4 | +| Public row | Owner phase | Acceptance test lives | Lands with | +| ---------- | ------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| S-01 | **B2** | landed with B2-5 (`Server/service/predicate_parity_test.go`) | B2-5 (PR #1440, `67fdd18d`) — done | +| SEC-02 | **B2** (server half) | landed with B2-5 (`Server/ws/voice_moderation_overrides_test.go`) | B2-5 (PR #1440, `67fdd18d`) — done; UI half in B5 | +| C-09 | **B2** (contract) / B7 (client) | beside the report | contract in B2-7 docs (PR #1443, `88c7a824`) — done; code in B7 | +| SEC-03 | **B5** (decided 2026-08-29) | beside the report | B5 item 11 — see evidence below | +| SEC-01 | **B4** | private GitHub advisory (owner creates it) | B4 | +| SEC-04 | **B3/B6** | private GitHub advisory (owner creates it) | B6 | +| OC-0324 | **B4** | beside the report; no advisory — the tracked ledger already carries this finding in full | B4 | An acceptance test demonstrates the defect, so it is exploit detail: it stays local until its fix lands, then lands publicly in the same PR. The two @@ -854,7 +856,43 @@ New draft), not by CLI with the report text; their IDs are recorded in `docs/security-findings/README.md`, which is local. Public commits, issues and PR bodies never name the mechanism (`docs/security.md`). -## HP-2 — Protocol and threat-model sign-off +**Evidence, 2026-08-29** — branch `feat/b2-9-hp2` from `dev` `88c7a824`; +PR to `dev` recorded below. HP-2 cites this block for exit-gate condition 7. + +- **SEC-03 verdict: B5, not B2.** Sized against the code the local report + cites at `88c7a824` before deciding. What the register's closure line + requires — streaming limits enforced before any buffering, aggregate + memory and concurrency budgets, timeout, cancellation, adversarial + boundary tests — is not one guard: it is a shared bounded reader behind + every automatic fetch the desktop renderer makes (four call sites across + three client modules, ~1.1k lines), byte-weighted budgets at more than + one level, cache eviction by bytes instead of entry count, plus server + support (bounded thumbnails, range-capable media) that does not exist. + A per-response cap alone is a few dozen lines but would not close the + row, and it would land in the per-call-site TypeScript layer that the + C-09 contract (`docs/trust-model.md` §"Desktop preview destination + policy", clause 6: "bound time, bytes and concurrency … enforced while + reading") exists to retire. Doing it twice is the wrong kind of small. + So: register row re-tagged `B2/B5` → `B5`; roadmap B5 item 11 loses its + "unless B2-9 already landed it" clause and names clause 6 as the shape + the fix takes, so B5 implements the byte accounting once, where B7's + broker will own it. No acceptance test lands here — it stays local + beside the report until the fix, per the rule above. +- **Table closed.** B2-owned rows, all done with their public acceptance + tests in the tree: S-01 and the SEC-02 server half (B2-5, PR #1440 → + `67fdd18d`), the C-09 contract (B2-7, PR #1443 → `88c7a824`; the client + code is B7's). Rows that stay where the table puts them: SEC-03 → B5 + (above), OC-0324 and SEC-01 → B4, SEC-04 → B6. Exit-gate condition 7 ("no + unresolved B2 security advisory remains") is therefore met on the B2-owned + set: zero B2-owned rows open, and no advisory was needed for any of them. +- **Advisories for the deferred rows** — created by the owner in the GitHub + UI (Security → Advisories → New draft), never by CLI, report text never + leaves the local package. IDs, once created (the local + `docs/security-findings/README.md` is the record of which report each maps + to): SEC-01 — `GHSA-____-____-____`; SEC-04 — `GHSA-____-____-____`. +- Pre-squash SHAs: `355b1fc1` (records #1443's squash SHA in the B2-7 + block), `be8454d0` (item 1, SEC-03 verdict), the commit carrying this + bullet (item 2, table closure); HP-2's commits are listed in its own block. `docs/plans/hp-2-scorecard-.md`, in the HP-1 shape. Questions it must answer with commands, not assertions: @@ -880,6 +918,29 @@ answer with commands, not assertions: The owner signs. Acceptance authorises B3 and claims nothing about beta readiness. +**Evidence, 2026-08-29** — the scorecard is +[hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md), measured at +`83a535c3` on `feat/b2-9-hp2` (same PR as B2-9). + +- All seven questions answered with commands and their output; the B2 exit + gate walked, nine conditions, all met (condition 1 at the slim one-epoch + scope, condition 4 with one gap recorded). Every pre-squash SHA the + questions cite was resolved on the fetched PR refs. +- Question 4 added three adversarial tests (`a51e2e89`, + `Client/tests/unit/livekit-e2ee.test.ts`): the modified-server unknown + member at first contact (a known gap — pinned as today's behaviour, its RED + against the desired rule recorded in the scorecard), the second device + overwriting the one-per-account pin, and the holder side of OC-0316 + (resumed peer re-keyed with the rotated key). The last two were proven able + to fail by temporary code mutation, restored with `git checkout`. +- `docs/plans/hp-2-trust-model-anchors.py` is the Question 3 check: 117 + `path:line` anchors in `trust-model.md`, 0 unresolvable at HEAD. +- Pre-squash SHAs: `a51e2e89` (Q4 tests), the commit carrying this block + (scorecard, this block, plan index, roadmap slice); B2-9's are in its block. +- **Owner lines, left blank on purpose:** the BPR-051 reader line in the B2-7 + block (Question 3 quotes it), the owner-review date in Question 3, and the + decision and signature lines at the top and bottom of the scorecard. + ## Exit gate The roadmap's seven conditions, plus two: diff --git a/docs/plans/hp-2-scorecard-2026-08-29.md b/docs/plans/hp-2-scorecard-2026-08-29.md new file mode 100644 index 00000000..81275ed9 --- /dev/null +++ b/docs/plans/hp-2-scorecard-2026-08-29.md @@ -0,0 +1,452 @@ +# HP-2 — Protocol and threat-model sign-off scorecard + +**Hold point:** HP-2, defined in +[repo-health-roadmap-2026-08-23.md](repo-health-roadmap-2026-08-23.md) +**Commits reviewed:** the pre-squash commits of #1435, #1438, #1440, #1443 and +the B2-9/HP-2 branch (table below) +**Measured at:** `83a535c3`, the `feat/b2-9-hp2` branch off `dev` `88c7a824` +**Measured:** 2026-08-29 +**Evidence base:** +[b2-protocol-trust-compat-2026-08-28.md](b2-protocol-trust-compat-2026-08-28.md) +(every B2-_n_ evidence block), [hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md) +(open items carried in) + +**Decision: \_\_\_\_ — 2026-08-\_\_ by J3vb (repository owner).** + +HP-2 asks seven questions. Each is answered below with the command that +produces the evidence and what it printed on the measured tree, not with an +assertion. Then the B2 exit gate's nine conditions are walked. It follows the +shape of [hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md). + +Acceptance authorises B3 to begin. It claims nothing about beta readiness. + +## The commits under review are not on `dev` + +`dev` is squash-merge only. The commit structure HP-2 reviews — fixtures +captured _before_ the first protocol change, one commit per permission +property, one commit per trust-model item — survives only on the pull-request +refs: + +```bash +git fetch origin 'refs/pull/1435/head:pr-1435' 'refs/pull/1438/head:pr-1438' \ + 'refs/pull/1440/head:pr-1440' 'refs/pull/1443/head:pr-1443' +``` + +| PR | On `dev` | Pre-squash commits | +| ----------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| #1435 — B2-1 epoch-1 fixtures | `1fe3df79` | `dd638f1c` retirement, `54cae614` capture, `c0719519` barriers, `d5fe06e5` null forms; head `069412db` | +| #1438 — B2-2 negotiation | `9c9b8be6` | `2ac9b5ba` schema + constants, `77051648` server handshake, `41ef091d` client handshake, `899c956f` server-first updates | +| #1440 — B2-5 predicates | `67fdd18d` | `00761523` predicates, `94aba833` send, `0271cbbe` view, `802101a0` voice join, `aeee37e8` voice moderation, `fdd2a3ff` Codex fix | +| #1443 — B2-7 trust model | `88c7a824` | `a4cd077b` trust model, `083d87d9` absence proofs, `cbfcf702` plugin boundary, `56f23a36` L-08 decision; 11 Codex rounds after, all documentation-only | +| this PR — B2-9 + HP-2 | — | `355b1fc1` records #1443's SHA, `be8454d0` SEC-03 verdict, `a51e2e89` Q4 tests, `83a535c3` table closure, and the commit carrying this file | + +Every SHA above resolves on the fetched refs (`git log -1 ` for each of +the nineteen, 2026-08-29; dates in Question 1). + +## Question 1 — is the epoch wire frozen? + +| Element | Where | Value on the measured tree | +| ------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| The number | `protocol/schema.json:4` | `"protocol_epoch": 1` | +| Go constant (generated) | `Server/ws/message_types.go:11` | `const ProtocolEpoch = 1` | +| TS constant (generated) | `Client/src/lib/protocolTypes.ts:12` | `export const PROTOCOL_EPOCH = 1;` | +| Constant ↔ schema pin | `Server/ws/protocol_contract_test.go:234` | `TestProtocolEpochMatchesSchema` — PASS | +| `auth` shape | client sends `epoch: PROTOCOL_EPOCH` (`Client/src/lib/ws.ts:452`); absent = 0 | server accepts `[minClientEpoch, ProtocolEpoch]` = `[0, 1]` (`Server/ws/serve_auth.go:62`, `messages.go:385`) | +| `auth_error` shape | `Server/ws/messages.go:389-408` | `code: "protocol_epoch_unsupported"`, `client_epoch`, `server_epoch`, `min_epoch`, message naming which side | +| Client reads the refusal | `Client/src/lib/dispatcher.ts:295-298` | `server_epoch > PROTOCOL_EPOCH` → `updateRequiredHost` → Update Now banner on the connect page | +| `ready` shape | unchanged by B2-2 | the epoch-1 fixtures replay verbatim (Question 2) | +| Close code | 1008, the same as every handshake failure | no 4426 — nothing reads close codes (B2-2 evidence block, "not shipped") | +| Update metadata | signed manifest `protocol_epoch`; `GET /api/v1/client-update` answers 204 for a newer epoch | `Server/updater/release_epoch_test.go:27` `TestReleaseProtocolEpoch` — 4/4 PASS | + +```bash +grep -n protocol_epoch protocol/schema.json # 4: "protocol_epoch": 1, +grep -n ProtocolEpoch Server/ws/message_types.go # 11:const ProtocolEpoch = 1 +grep -n PROTOCOL_EPOCH Client/src/lib/protocolTypes.ts # 12:export const PROTOCOL_EPOCH = 1; +git grep -nE 'minClientEpoch|protocol_epoch_unsupported' -- 'Server/ws/*.go' ':!*_test.go' +``` + +**Ordering — fixtures before the first protocol change (exit condition 8):** + +```bash +for s in 54cae614 c0719519 d5fe06e5 069412db 2ac9b5ba 77051648; do git log -1 --format='%h %ci %s' $s; done +``` + +``` +54cae614 2026-08-28 12:37:34 +0200 test(ws): capture the epoch-1 wire fixtures +c0719519 2026-08-28 13:19:23 +0200 test(ws): harden the epoch-1 fixtures against trailing frames and absent op +d5fe06e5 2026-08-28 14:25:11 +0200 test(ws): freeze the null forms of auth_ok and member_join user fields +069412db 2026-08-28 15:31:43 +0200 test(client): compare auth-frame key sets order-independently +2ac9b5ba 2026-08-29 06:32:50 +0200 feat(b2-2): declare protocol_epoch in the schema and generate both constants +77051648 2026-08-29 06:35:14 +0200 feat(b2-2): check the client's protocol epoch in the auth handshake +``` + +#1435 merged 2026-08-28 13:43 UTC as `1fe3df79`; #1438 merged 2026-08-29 +05:23 UTC as `9c9b8be6`. The capture precedes the first negotiation commit by +eighteen hours and one merge, in its own PR. + +**Verdict: PASS.** One number, generated into both consumers, pinned to the +schema by a test; every handshake shape anchored; fixtures captured first and +their pre-squash SHAs recorded (exit condition 9). + +## Question 2 — does downgrade behave? + +By the owner's 2026-08-29 decision B2-2 shipped **one accepted epoch**: the +window is `[minClientEpoch, ProtocolEpoch]` = `[0, 1]`, where 0 is "absent +`epoch`" — the alpha.4 client. So the roadmap's N, N-1, N-2 rows collapse to +two: this epoch and the epoch-less client before it. The matrix is +`TestAuth_ProtocolEpoch`, driven over a real socket, plus the fixture replay +that proves the accepted client is byte-compatible: + +```bash +cd Server && go test -count=1 -run 'TestAuth_ProtocolEpoch$|TestEpoch1Fixtures$|TestProtocolEpochMatchesSchema$' -v ./ws/ +``` + +``` +--- PASS: TestProtocolEpochMatchesSchema (0.00s) +--- PASS: TestEpoch1Fixtures (6.17s) + --- PASS: TestEpoch1Fixtures/fresh-connect (0.62s) + --- PASS: TestEpoch1Fixtures/auth-failure (0.01s) + --- PASS: TestEpoch1Fixtures/ping (0.31s) + --- PASS: TestEpoch1Fixtures/chat-send-fanout (0.62s) + --- PASS: TestEpoch1Fixtures/chat-edit-delete (0.61s) + --- PASS: TestEpoch1Fixtures/reaction-add-remove (0.61s) + --- PASS: TestEpoch1Fixtures/typing (0.61s) + --- PASS: TestEpoch1Fixtures/mark-read (0.31s) + --- PASS: TestEpoch1Fixtures/dm-send (0.62s) + --- PASS: TestEpoch1Fixtures/resume-replay (1.22s) + --- PASS: TestEpoch1Fixtures/voice-join-e2ee-leave (0.62s) +--- PASS: TestAuth_ProtocolEpoch (0.06s) + --- PASS: TestAuth_ProtocolEpoch/absent (0.01s) + --- PASS: TestAuth_ProtocolEpoch/zero (0.01s) + --- PASS: TestAuth_ProtocolEpoch/current (0.01s) + --- PASS: TestAuth_ProtocolEpoch/newer (0.01s) + --- PASS: TestAuth_ProtocolEpoch/negative (0.01s) +ok github.com/J3vb/OwnCord/Server/ws 7.446s +``` + +| Client epoch | Case | Outcome | Actionable? | +| ---------------- | ---------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ | +| absent (alpha.4) | `absent` | accepted as 0; every epoch-1 fixture replays unchanged | n/a — it works | +| 0 | `zero` | accepted | n/a | +| 1 (= N) | `current` | accepted | n/a | +| 2 (newer) | `newer` | `auth_error` `protocol_epoch_unsupported`, `server_epoch: 1`, close 1008 | yes — message says update the server; client keeps its login | +| −1 | `negative` | `auth_error` `protocol_epoch_unsupported`, close 1008 | yes — message says update the client; Update Now banner | +| browser client | — | **n/a until B8** — no bundled browser client exists | exit condition 1 records this as n/a, as the plan does | + +The update side of downgrade — a client release newer than the server's epoch +is withheld, a tampered manifest is refused — is `TestReleaseProtocolEpoch` +(`signed_manifest_declares_the_epoch`, `manifest_without_the_field_is_epoch_0`, +`release_without_a_manifest_is_epoch_0`, `tampered_manifest_is_an_error`), all +PASS. + +**Verdict: PASS, at the slim scope the owner chose.** Both accepted client +generations are tested on a real socket, the refused ones fail with a coded +frame that names the side to update, and the client turns that frame into the +same Update Now flow it already had. A three-wide window and its matrix are +one constant away (`minClientEpoch`) if a future epoch bump needs them. + +## Question 3 — are the trust claims true? + +`docs/trust-model.md` carries **117** `path:line` anchors and names **20** +distinct Go tests plus the vitest cases in its E2EE table. The mechanical half +of the question is whether every anchor still resolves on the measured tree: + +```bash +python docs/plans/hp-2-trust-model-anchors.py +``` + +``` +117 path:line anchors checked (24 short-form resolved by unique basename), 0 unresolvable +``` + +(The 24 short forms are the document's `file.go:NN` after a full path in the +same sentence; each resolves to exactly one tracked file.) Line-range drift +after a future edit is not caught by this check — it proves the file and the +line exist, not that the line still says what the sentence claims; the 11 +Codex rounds on #1443 were the line-by-line read, every finding accepted and +fixed in the document (B2-7 evidence block). + +The human half: + +- **Owner review** of `docs/trust-model.md` at `88c7a824`: **\_\_\_\_** (date). +- **Non-developer read** of "The short answer" — BPR-051's exit evidence. + Reader: **\_\_\_\_**. Date: **\_\_\_\_**. Answer given to "who can read my + messages?": **\_\_\_\_**. (Quoted from the B2-7 evidence block, where the + owner fills it in; both places must agree.) + +**Verdict: PASS on the mechanical half; the human half is the signature.** +Two claims in the document are absences with no positive test — the server +holds no room key; text is not encrypted — and the document says so rather +than inventing a test for nothing. + +## Question 4 — are the E2EE membership and key-change rules stated and tested? + +The rules are the table in `docs/trust-model.md` §"What is end-to-end +encrypted". Inventory of what pinned each before HP-2, and the three +adversarial cases HP-2 added (`a51e2e89`, all in +`Client/tests/unit/livekit-e2ee.test.ts` under "HP-2 adversarial membership +and key-change rules"): + +| Rule | Pinned by (before HP-2) | Added by HP-2 | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Room key made on a participant's machine | `livekit-e2ee.test.ts` "setupKeyExchange as key holder generates the room key…" | — | +| Key holder wraps for each peer (ECDH + AES-GCM) | `e2eeCrypto.test.ts` "round-trips a room key between two keypairs" | — | +| Frames encrypted before leaving; SFU relays ciphertext | `livekit-session.test.ts` "enables E2EE on the room created by createRoom (OC-0095)" | — | +| Server relays wrapped keys opaquely, to channel members only | `TestE2EE_Offer_KeyHolderCanSend`, `_RejectsNonKeyHolder`, `_TargetChannelCheckAtomicWithLookup` | — | +| Server holds no room key | absence — stated, no positive test | — | +| Identity TOFU: pin on first sight, block on change | `livekit-session.test.ts` "pins the peer identity key on first sight…", "blocks and emits identity-tofu when the pinned identity key changed" | — | +| Re-pin TOCTOU: re-pin the key the human saw | `livekit-session.test.ts` "re-pins the verified key, not a store re-read a malicious server mutated (TOCTOU)" | — | +| Rekey on leave (forward secrecy) | `livekit-session.test.ts` "rotates the room key when a keyed peer leaves while I stay key holder" | — | +| Timed rotation | `livekit-e2ee.test.ts` "[T-47] arms the periodic rotation timer…" | — | +| Rotation during outage (OC-0316), server side | `TestRegisterNow_ReannouncesOwnKeyOnResume` | — | +| Rotation during outage (OC-0316), **holder side** | receiver side only (`[OC-0007] confirms the room key after a reconnect re-announce…`) | "[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key…" | +| Second device overwrites the one-per-account pin; first device then mismatches | stated in the document (Codex round 6), untested | "[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches" | +| **Known gap:** modified server adds an unknown member at first contact | stated under "What beta does not claim" (Codex round 7), untested | "[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key…" | +| Server decides who may join and publish | `TestE2EE_VoiceToken_IncludesIsKeyHolder`; `voice_moderation_overrides_test.go` | — | + +All three were written test-first. The RED each produced: + +**Known gap** — written first against the _desired_ rule (no offer, no pin +for a first-sight peer the client has no independent membership evidence for; +the voice roster does not even list them): + +``` +× [HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it +AssertionError: expected [ { type: 'voice_e2ee_offer', …(1) } ] to have a length of +0 but got 1 +``` + +That is the gap, measured: the holder wraps the room key to whoever the server +says is a member, because membership is server-controlled and the client +accepts any first-sight identity (`livekitE2EE.ts` `verifyPeerAnnounce`). The +test now pins **today's** behaviour — offer sent, key pinned, peer "verified" +— with a comment saying it inverts when authenticated membership (or "refuse +unrecognised participants") lands. That fix has its RED waiting. + +**Second device** — proven able to fail by temporarily disabling the pinned-key +mismatch block (`if (false && pin !== null && …)`), then restored with +`git checkout`: + +``` +× [HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches +AssertionError: expected last "vi.fn()" call to have been called with [ ObjectContaining{…} ] +``` + +**Rotation during outage, holder side** — proven able to fail by temporarily +making a duplicate announce skip the re-offer (`!isDuplicate && this._isKeyHolder …`), +then restored. The first version of this test did not fail under that +mutation: the file's `exportPublicKey` mock returns one fixed string, so the +replayed announce took the "key changed" path instead of the duplicate path +the rule is about. Fixed by round-tripping import/export as the OC-0001 test +does, after which: + +``` +× [HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce +AssertionError: expected [] to have a length of 1 but got +0 +``` + +Restored tree: `Tests 3 passed | 59 skipped (62)` for the `-t "HP-2"` filter; +the full client gate is in "The gate run" below. + +**Verdict: PASS with one known gap recorded, not hidden.** Every rule the +document states has a test; the one adversarial case the model cannot defend +against today — a modified server inserting a member at first contact — is +pinned as current behaviour so the fix cannot land silently, and stays under +"What beta does not claim". It is not assigned a phase: the owner decides at +signature whether beta claims it (then B4 or B6 needs an authenticated +membership item) or keeps the disclaimer. The recommendation is to keep the +disclaimer for beta — the fix needs a server-side membership proof the client +can verify, which is a protocol change and belongs after B3. + +## Question 5 — is there one predicate per property? + +Before: the B2-5 evidence block's inventory table, thirteen decision sites +across `permissions/checker.go`, `service/` and `ws/`, each deciding view, +send, type, admit, join or moderate by hand. After: six predicates in +`Server/permissions/predicates.go`, each site delegating, with parity tables +(`Server/service/predicate_parity_test.go`, +`Server/ws/predicate_parity_internal_test.go`) that ran the old logic and the +predicate over the same fixture before the old logic was deleted. + +Residue — direct bit-helper calls outside `Server/permissions`, non-test, at +the measured tree: + +```bash +git grep -nE 'HasPerm|HasAnyPerm|HasServerPerm|EffectivePerms|EffectiveChannelPerms|HasAdmin' \ + -- 'Server/*.go' ':!Server/*_test.go' ':!Server/permissions/*' +``` + +28 lines print; 7 are comments (`admin/handlers_channel_perms.go:143,230,430`, +`admin/middleware.go:99`, `api/middleware.go:186`, `db/channel_queries.go:158`, +`service/mentions.go:286`). The **21 code hits**, each in a class the B2-5 +block already lists with its reason: + +| Class | Hits | Why it is not a channel predicate | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Server-scoped permission, no channel to resolve a `Subject` for | `HasServerPerm` ×6: `api/middleware.go:200`, `admin/middleware.go:109`, `service/emoji.go:95`, `service/moderation.go:51`, `service/role.go:82`; `HasAnyPerm` `admin/middleware.go:84` | these _are_ the canonical server-wide predicate | +| `HasAdmin` as a fetch short-circuit (skip the override query) | `service/channel.go:59`, `service/message_perms.go:25`, `service/permission.go:224`, `ws/serve.go:780`, `ws/serve_ready.go:169`, `ws/voice_join.go:355` | optimisation before the predicate, not a decision | +| `HasAdmin` as an authorization input | `admin/handlers_channel_perms.go:95,325`, `admin/logstream.go:452`, `api/upload_handler.go:404`, `service/role.go:104` | role hierarchy / admin perimeter — the 2026-08-18 measurement's "no `Outranks`" class | +| Bulk @everyone reader walk | `service/mentions.go:262,263,266` | per-role layer walk; owner declined the mechanical conversion 2026-08-18 | +| Base-bit early rejection ahead of `CanModerateVoice` | `ws/voice_moderation.go:65` | never admits; keeps FORBIDDEN ahead of the voice-state lookup (B2-5 decision) | + +Zero hits outside those five classes. The plan's step 5 condition ("no file +outside `Server/permissions` calls the bit helpers") is **not met** and was +not expected to be — the `authz-chokepoint` invariant rule stays with B3 item +15, where the residue above is the allowlist it starts from. + +**Verdict: PASS as "listed residue with reasons".** Every channel-scoped +security property has exactly one predicate; what remains is server-scoped, +an optimisation, or a declined conversion, each named. + +## Question 6 — are the deferred systems bounded? + +**Absence.** Three contract tests walk the mounted router, the WebSocket wire +types in `protocol/schema.json`, and every `koanf` key of `config.Config`, +failing on `(?i)federat|directory|discover|listing`: + +```bash +cd Server && go test -count=1 -run TestAbsenceContract -v ./api/ +``` + +``` +--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingRoutes (0.02s) +--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingWireTypes (0.00s) +--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys (0.00s) +ok github.com/J3vb/OwnCord/Server/api 1.352s +``` + +Each was proven able to fail in B2-7 (a temporary `/api/v1/directory` route; a +temporary `directory_list` wire type — outputs in the B2-7 evidence block). +The bound is stated in `trust-model.md`: vocabulary at three boundaries, +network by the outbound-host table (ten server rows, each with trigger, +purpose, condition-plus-control and anchor — B6's capture checklist), +semantics by review. + +**Plugins.** `docs/architecture/plugins.md` §"Status: off, twice — and not in +the release at all" is the configuration audit: + +| Shape | Verdict | Why | +| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Fresh install | off | default `false` (`config.go:352`); generated `config.yaml` block commented out (`:452-457`) | +| Upgraded install | off | no `plugins` key in an older file; koanf loads defaults first; a misspelled key is warned and ignored (`:520`) | +| Docker | off | image built without the tag (`Dockerfile:13`); the env flag can flip `plugins.enabled` but the binary cannot execute a module | +| Standalone release binary | off | no tag (`release.yml:261`, `:268`) | +| Source build with the flag | **on** | `go build -tags wazero` **and** `plugins.enabled: true` together — the developer path and the only one | + +Read back on the measured tree: `release.yml:261` and `:268` are plain +`go build -o chatserver… -ldflags "-s -w -X main.version=$VERSION" .`; +`Server/Dockerfile:12-13` is `CGO_ENABLED=0 GOOS=linux go build -o /chatserver`. +No `-tags wazero` on any shipped path. "No API promise" is the document's +second section; the beta release-notes paragraph is in the same file. + +**L-08** (WASM example build gate): re-tagged B1/B10 by the B2-7 decision, +reason recorded there — compare-in-CI cannot pass in principle (TinyGo embeds +host paths, no `-trimpath`), compile-only needs a second Go SDK on every PR, +and the subsystem is compiled out of every artifact a release contains. + +**Verdict: PASS.** Federation, directory and listing are absent by test at +three boundaries; WASM is off by default, off on upgrade, and cannot execute +in any shipped artifact. + +## Question 7 — is `dev` strict? + +```bash +gh api repos/J3vb/OwnCord/branches/dev/protection --jq '{strict: .required_status_checks.strict, checks: (.required_status_checks.contexts|length), enforce_admins: .enforce_admins.enabled, approvals: .required_pull_request_reviews.required_approving_review_count, force: .allow_force_pushes.enabled, del: .allow_deletions.enabled}' +``` + +```json +{ + "approvals": 0, + "checks": 12, + "del": false, + "enforce_admins": true, + "force": false, + "strict": true +} +``` + +The twelve required checks: Server Build & Test (ubuntu-latest), Server Build +& Test (windows-latest), Client Static Checks, Client Unit Tests, Rust Unit +Tests, Client E2E (Playwright), Client E2E (parity subset, blocking), Analyze +(go), Analyze (javascript-typescript), Analyze (actions), Repository Hygiene, +Docs & Ledger Consistency. + +**Verdict: PASS.** This closes HP-1's condition 6, accepted there as a stated +limitation: with `strict: true` every squash that lands on `dev` was tested +against the base it lands on. The cost HP-1 predicted — an "Update branch" on +every open PR when another merges — has been paid on every B2 PR since B2-0 +and is the reason the plan keeps one PR in flight. + +## B2 exit gate + +| # | Condition | Status | Evidence | +| --- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 1 | Clients from epochs N, N-1, N-2 pass the matrix; N-3 fails safely and actionably; the bundled browser client matches the server epoch | **met at the slim scope** | Question 2 — one accepted epoch by owner decision; both accepted generations tested; browser client n/a until B8 | +| 2 | Protocol and update-metadata changes are generated, documented, and downgrade-tested | **met** | Question 1 (`npm run generate` drift gate in `check:server`), `TestReleaseProtocolEpoch`, `docs/protocol.md` § Compatibility | +| 3 | Effective-permission and resource-existence sibling cases have parity tests | **met** | Question 5 — two parity tables, red before delegation for S-01 and SEC-02 | +| 4 | Voice/video/screen E2EE membership and key-change behaviour pass adversarial tests | **met, one gap recorded** | Question 4 — every stated rule pinned by a test; the first-contact membership gap pinned as current behaviour and disclaimed | +| 5 | No central identity, directory, federation path, or required external service exists | **met** | Question 6 — three absence tests + outbound-host table | +| 6 | WASM disabled by default; release artifacts do not imply API stability | **met** | Question 6 — configuration audit, no `-tags wazero` in any shipped build, "No API promise" | +| 7 | No unresolved B2 security advisory remains | **met** | B2-9 evidence block — zero B2-owned rows open; SEC-01/SEC-04 advisories are B4/B6 rows the owner creates | +| 8 | _(added)_ Epoch-1 fixtures were captured before the first protocol change, in a separate commit | **met** | Question 1 — `54cae614` (08-28 12:37) precedes `2ac9b5ba` (08-29 06:32), separate PRs | +| 9 | _(added)_ Pre-squash SHAs recorded for the fixture and negotiation commits | **met** | The table at the top; every SHA resolved on the fetched refs | + +### The gate run + +Measured 2026-08-29 on the branch, before each commit, per the `ci-check` +skill. Every step exited 0. + +| Step | Result | +| ------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `npm run check:client` (vitest, `tsc --noEmit`, eslint) | pass — **193 files, 5273 tests**, the three Q4 cases included | +| `npx knip` (blocking in CI, not part of `check:client`) | pass — four configuration hints, exit 0 | +| `npm run check:docs` | pass — 21 claims across 9 watched documents agree with the ledger | +| `npm run check:hygiene` | pass — prettier clean; shellcheck/actionlint skipped locally, CI runs them | +| `go test ./ws/ ./api/ ./updater/` (Questions 2 and 6 subsets) | pass — outputs above | + +No Go or Rust source changed on this branch, so the four build-tag variants, +`-race`, `-tags deadlock` and `golangci-lint` were not re-run locally; CI's +required checks run them on the PR. + +## Open items carried past B2 + +Recorded, not fixed. None blocks B3's entry. + +| Item | State | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| E2EE first-contact membership gap | **open, disclaimed.** Question 4. Pinned as current behaviour; owner decides at signature whether beta claims it (needs a phase) or keeps the disclaimer. | +| SEC-03 — bounded preview/media reads | **B5 item 11**, re-tagged by B2-9 with the sizing reason; shape is the C-09 contract clause 6. | +| SEC-01, OC-0324 | **B4.** SEC-01's private advisory is the owner's to create; ID line in the B2-9 block. | +| SEC-04 | **B6.** Same — advisory ID line in the B2-9 block. | +| C-09 client code | **B7** — the native fetch broker implements the contract written in B2-7. | +| `authz-chokepoint` invariant rule | **B3 item 15**, allowlist = the Question 5 residue. | +| L-08 WASM example build gate | **B10**, by the B2-7 decision. | +| OC-0349 `voice_join` ordering hazard | **open, low** — ledger; a later fix batch. | +| BPR-051 non-developer read | **pending owner** — the `____` line in the B2-7 block and Question 3. | +| `bughunt-fix` workflow gate list | tooling — phantom `gate: FAIL` (`format:check` gone, `knip` missing); observation logged, not B2. | +| Release binaries lack `-tags wazero` | **by design**, now stated in `plugins.md`; revisit only if a release is ever meant to run a plugin. | + +## Hand-off to B3 + +The roadmap's current slice: when HP-2 closes, B3 opens with the "First +actionable slice" of +[developer-experience-layout-refactor-2026-08-29.md](developer-experience-layout-refactor-2026-08-29.md) +— inventory, before-state graph, auth characterization tests, the auth +vertical slice, HP-3. Nothing from that plan touches the client before B7. +B3 item 15 (`authz-chokepoint`) starts from Question 5's residue table. + +## What acceptance does and does not authorise + +Accepting HP-2 authorises B3 to begin. It does **not** claim: + +- that OwnCord is beta-ready — every B2 exit condition is a contract frozen, + not a product qualified; +- that the E2EE model resists a modified server — it resists an operator who + reads; the first-contact membership gap is pinned and disclaimed (Question 4); +- that a three-wide epoch window exists — one epoch is accepted by policy + (Question 2); +- that every direct bit-helper call is gone — the residue is listed with + reasons (Question 5); +- that the deferred security rows are fixed — they are owned and scheduled + (B2-9 table), not resolved. + +**Signed:** **\_\_\_\_** (J3vb, repository owner), 2026-08-\_\_. diff --git a/docs/plans/hp-2-trust-model-anchors.py b/docs/plans/hp-2-trust-model-anchors.py new file mode 100644 index 00000000..4802aea8 --- /dev/null +++ b/docs/plans/hp-2-trust-model-anchors.py @@ -0,0 +1,31 @@ +"""Every `path:line[-line]` anchor in docs/trust-model.md must name a tracked +file whose line count covers the cited line. A bare basename (the document's +short form after a full path in the same sentence) resolves when exactly one +tracked file has that basename. Prints the misses; exit 1 if any.""" +import os, re, subprocess, sys + +doc = open("docs/trust-model.md", encoding="utf-8").read() +tracked = [p for p in subprocess.check_output(["git", "ls-files"], text=True).split("\n") if p] +tracked_set = set(tracked) +by_base = {} +for p in tracked: + by_base.setdefault(os.path.basename(p), []).append(p) +pat = re.compile(r"`([A-Za-z0-9_./-]+\.(?:go|ts|rs|md|json|yml|toml|sql)):(\d+)(?:-(\d+))?") +seen, short, bad = 0, 0, [] +for m in pat.finditer(doc): + path, lo, hi = m.group(1), int(m.group(2)), int(m.group(3) or m.group(2)) + seen += 1 + if path not in tracked_set: + cands = by_base.get(path, []) + if len(cands) != 1: + bad.append(f"{path}:{lo} -- {'ambiguous ' + str(cands) if cands else 'not a tracked file'}") + continue + path = cands[0] + short += 1 + n = sum(1 for _ in open(path, encoding="utf-8", errors="replace")) + if hi > n: + bad.append(f"{path}:{lo}-{hi} -- file has {n} lines") +print(f"{seen} path:line anchors checked ({short} short-form resolved by unique basename), {len(bad)} unresolvable") +for b in bad: + print(" " + b) +sys.exit(1 if bad else 0) diff --git a/docs/plans/repo-health-issue-register-2026-08-23.md b/docs/plans/repo-health-issue-register-2026-08-23.md index 9238ad60..94d16bd8 100644 --- a/docs/plans/repo-health-issue-register-2026-08-23.md +++ b/docs/plans/repo-health-issue-register-2026-08-23.md @@ -151,12 +151,12 @@ This register carries only non-sensitive security properties and opaque remediation families; an apparently related engineering row is not evidence that any private report is fixed. -| ID | Pri | State | Opaque remediation family | Phase | Public closure evidence | -| ------ | --: | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| SEC-01 | P1 | confirmed | Atomic concurrent password-confirmation admission. | B4 | One server-owned admission decision, bounded concurrent attempts, and race/load regression coverage. | -| SEC-02 | P1 | resolved/superseded | Effective channel-level voice moderation permissions. Server half landed in B2-5 (PR #1440): voice moderation decides on the effective permission in the target channel, with override and denial tests. | B5 | UI half only: the moderation controls surface effective permissions (B5 item 11). | -| SEC-03 | P1 | confirmed | Bounded per-response and aggregate preview/media reads. | B2/B5 | Streaming limits are enforced before buffering; aggregate memory/concurrency budgets, timeout, cancellation, and adversarial boundary tests pass. | -| SEC-04 | P1 | confirmed | Durable per-user/server storage quotas and disk headroom. | B3/B6 | Transaction-safe quotas cover files and cumulative storage; low-disk behavior fails safely and is exercised by restart/concurrency tests. | +| ID | Pri | State | Opaque remediation family | Phase | Public closure evidence | +| ------ | --: | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| SEC-01 | P1 | confirmed | Atomic concurrent password-confirmation admission. | B4 | One server-owned admission decision, bounded concurrent attempts, and race/load regression coverage. | +| SEC-02 | P1 | resolved/superseded | Effective channel-level voice moderation permissions. Server half landed in B2-5 (PR #1440): voice moderation decides on the effective permission in the target channel, with override and denial tests. | B5 | UI half only: the moderation controls surface effective permissions (B5 item 11). | +| SEC-03 | P1 | confirmed | Bounded per-response and aggregate preview/media reads. B2-9 (2026-08-29) sized it and re-tagged it to B5: the closure line below is the whole boundary, not one guard, and the C-09 contract names where it lives. | B5 | Streaming limits are enforced before buffering; aggregate memory/concurrency budgets, timeout, cancellation, and adversarial boundary tests pass. | +| SEC-04 | P1 | confirmed | Durable per-user/server storage quotas and disk headroom. | B3/B6 | Transaction-safe quotas cover files and cumulative storage; low-disk behavior fails safely and is exercised by restart/concurrency tests. | ## Client engineering issues diff --git a/docs/plans/repo-health-roadmap-2026-08-23.md b/docs/plans/repo-health-roadmap-2026-08-23.md index a82a4ef7..5e289aeb 100644 --- a/docs/plans/repo-health-roadmap-2026-08-23.md +++ b/docs/plans/repo-health-roadmap-2026-08-23.md @@ -656,9 +656,12 @@ operation before building their full cross-client experience. rotation, and stale-subscription cleanup. There is no OwnCord relay. 10. Keep automation optional. Human moderation authority and audit remain core even if automation becomes a post-beta plugin candidate. -11. _(added 2026-08-28)_ SEC-03 (bounded per-response and aggregate - preview/media reads) is first in line unless B2-9 already landed it. It is - P1 and confirmed. +11. _(added 2026-08-28; re-tagged here by B2-9 on 2026-08-29)_ SEC-03 (bounded + per-response and aggregate preview/media reads) is first in line. It is P1 + and confirmed. Shape: the C-09 contract in `docs/trust-model.md` clause 6 + (time, streaming byte ceiling, content-type list, concurrency cap) plus + aggregate budgets and byte-weighted cache eviction; implement the byte + accounting once, at the boundary B7's native broker will own. ### Hold point HP-5 — Abuse and privacy review @@ -1242,8 +1245,11 @@ in this order: 4. B2-2 — protocol epoch and negotiation, with B2-3 and B2-4 folded in (one accepted epoch, so no matrix) — **done 2026-08-29**, PR #1438 = `9c9b8be6`; -5. B2-5 — serialized, **next**; B2-6, B2-7 and B2-9 in parallel where the - plan allows; then HP-2. +5. B2-5 (PR #1440 = `67fdd18d`), B2-6 (PR #1441 = `2b2d58ab`), B2-7 + (PR #1443 = `88c7a824`) and B2-9 — **done 2026-08-29**; +6. HP-2 — scorecard written 2026-08-29 + ([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)), + **awaiting the owner's signature**. Do not begin B3 domain extraction, client platform extraction, or browser work before HP-2 closes. When it does, B3 opens with the "First actionable slice"