docs(b2-9,hp-2): security owners closed, HP-2 sign-off scorecard (#1444)

* docs(b2-7): record the #1443 squash SHA in the evidence block

* docs(b2-9): SEC-03 sized and re-tagged to B5, verdict recorded

* test(e2ee): HP-2 adversarial membership and key-change cases

Three cases from docs/trust-model.md that had no dedicated test: a modified
server adding an unknown member at first contact (pinned as today's behaviour,
a known gap with its RED recorded in the HP-2 scorecard), a second device's key
overwriting the one-per-account pin so the first device mismatches, and a peer
resumed across a rotation being re-keyed with the rotated key (OC-0316, holder
side). The last two were proven able to fail by temporary code mutation.

* docs(b2-9): close the owner table; advisory placeholders for SEC-01/SEC-04

* docs(hp-2): protocol and threat-model sign-off scorecard

Seven questions answered with commands and their output; B2 exit gate walked
(nine conditions). Adds the trust-model anchor checker beside the scorecard,
the HP-2 evidence block, and the plan index / roadmap slice updates. Owner
lines (reader, review date, decision, signature) are left blank on purpose.
This commit is contained in:
J3vb
2026-08-29 12:54:30 +00:00
committed by GitHub
parent 88c7a8249a
commit 2bfc5e30d6
7 changed files with 726 additions and 29 deletions
+146
View File
@@ -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==");
}
});
});
+2 -1
View File
@@ -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 16 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
## Partially implemented
@@ -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)** | 12 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-<date>.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:
+452
View File
@@ -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 <sha>` 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-\_\_.
+31
View File
@@ -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)
@@ -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
+11 -5
View File
@@ -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"