~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
2094 lines · 65.9 KB Raw History
# Permissioned Data — Phase 1: Primitives Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the cryptographic and addressing primitives for AT Protocol permissioned data — vendored BLAKE3 XOF, LtHash set hashing, signed commits, and space AT-URIs — in a new dependency-free `@pdsjs/spaces` package, verified byte-for-byte against vectors generated from the reference implementation.

**Architecture:** New workspace package `packages/spaces` with no external dependencies. BLAKE3 is hand-vendored (WebCrypto has no BLAKE3); everything else builds on `crypto.subtle`. Signature verification is a port — P-256 ships built in, secp256k1 is injected by the deployment. Nothing in this phase touches `@pdsjs/core` except adding JSDoc typedefs to `ports.js`.

**Tech Stack:** plain ESM JS + JSDoc types, vitest, biome (single quotes, 2-space indent), TDD per task, one commit per task.

## Global Constraints

- Spec: `docs/superpowers/specs/2026-07-30-permissioned-data-design.md`. Reference behavior is authoritative.
- Reference pin: `bluesky-social/atproto` PR #5187, head `24a81bf5ab8d39557da35a4c4c0978c9aa62c45a`. Fetched locally as branch `pr-5187` in `~/code/atproto`.
- `@pdsjs/spaces` has **zero external dependencies**. Workspace-only dependency on `@pdsjs/core`. Do not add anything to `package.json` `dependencies` beyond `"@pdsjs/core": "workspace:*"`.
- `@pdsjs/core` must never import `@pdsjs/spaces`. Dependency direction is one-way.
- All crypto except BLAKE3 uses `crypto.subtle`. Do not vendor a second hash.
- Plain ESM JS with JSDoc type annotations. No TypeScript source files.
- Tests live at `packages/spaces/test/*.test.js` (vitest `include` already covers `packages/*/test/**/*.test.js`).
- Every task: failing test first, verify RED, minimal implementation, verify GREEN, `npx biome check --write .`, commit.
- Branch: `permissioned-data`.

---

### Task 1: Scaffold `@pdsjs/spaces` and vendor BLAKE3 XOF

**Files:**
- Create: `packages/spaces/package.json`
- Create: `packages/spaces/src/blake3.js`
- Create: `packages/spaces/src/index.js`
- Create: `packages/spaces/test/blake3.test.js`
- Create: `test/fixtures/blake3-vectors.json` (downloaded)

**Interfaces:**
- Consumes: nothing.
- Produces: `blake3(input: Uint8Array, outLen?: number) => Uint8Array` — unkeyed BLAKE3 with extendable output. `outLen` defaults to 32. This is the only export of `blake3.js`.

- [ ] **Step 1: Create the package manifest**

Create `packages/spaces/package.json`:

```json
{
  "name": "@pdsjs/spaces",
  "version": "0.8.0",
  "type": "module",
  "main": "./src/index.js",
  "types": "./src/index.d.ts",
  "exports": {
    ".": "./src/index.js",
    "./blake3": "./src/blake3.js",
    "./lthash": "./src/lthash.js",
    "./path": "./src/path.js",
    "./uri": "./src/uri.js",
    "./mac": "./src/mac.js",
    "./verifier": "./src/verifier.js",
    "./commit": "./src/commit.js"
  },
  "dependencies": {
    "@pdsjs/core": "workspace:*"
  }
}
```

Create `packages/spaces/src/index.js` with a single line so the package resolves:

```js
// @pdsjs/spaces - AT Protocol permissioned data (proposal 0016)
export { blake3 } from './blake3.js';
```

Run `pnpm install` to link the workspace package.

- [ ] **Step 2: Download the official BLAKE3 test vectors**

```bash
mkdir -p test/fixtures
curl -sSL -o test/fixtures/blake3-vectors.json \
  https://raw.githubusercontent.com/BLAKE3-team/BLAKE3/master/test_vectors/test_vectors.json
```

The file has shape `{ key, context_string, cases: [{ input_len, hash, keyed_hash, derive_key }] }`. Only `input_len` and `hash` matter here — `hash` is 131 bytes of extended output as hex (262 hex chars), which exercises the XOF path.

The input for a case of length `n` is the byte sequence `i % 251` for `i` in `[0, n)`. This is stated in the BLAKE3 repo's `test_vectors/README.md`.

Verify the download is non-empty and has cases:

```bash
node -e "const v=require('./test/fixtures/blake3-vectors.json'); console.log(v.cases.length, 'cases, lens:', v.cases.map(c=>c.input_len).join(','))"
```

Expected: a list including `0,1,2,3,4,5,6,7,8,63,64,65,127,128,129,1023,1024,1025,2048,2049,3072,3073,4096,4097,5120,6144,7168,8192,16384,31744,102400`.

- [ ] **Step 3: Write the failing test**

Create `packages/spaces/test/blake3.test.js`:

```js
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { blake3 } from '../src/blake3.js';

const vectors = JSON.parse(
  readFileSync(new URL('../../../test/fixtures/blake3-vectors.json', import.meta.url), 'utf8'),
);

/** Official BLAKE3 vector inputs are the repeating byte pattern i % 251. */
function vectorInput(len) {
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) bytes[i] = i % 251;
  return bytes;
}

function toHex(bytes) {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

describe('blake3', () => {
  it('matches the official vectors at 32-byte output', () => {
    for (const c of vectors.cases) {
      const out = blake3(vectorInput(c.input_len), 32);
      expect(toHex(out), `input_len=${c.input_len}`).toBe(c.hash.slice(0, 64));
    }
  });

  it('matches the official vectors at extended output length', () => {
    for (const c of vectors.cases) {
      const outLen = c.hash.length / 2;
      const out = blake3(vectorInput(c.input_len), outLen);
      expect(toHex(out), `input_len=${c.input_len} outLen=${outLen}`).toBe(c.hash);
    }
  });

  it('produces 2048 bytes for LtHash expansion', () => {
    const out = blake3(new TextEncoder().encode('a'), 2048);
    expect(out.length).toBe(2048);
  });

  it('defaults to 32 bytes', () => {
    expect(blake3(new Uint8Array(0)).length).toBe(32);
  });
});
```

- [ ] **Step 4: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/blake3.test.js`
Expected: FAIL — `Failed to resolve import "../src/blake3.js"`.

- [ ] **Step 5: Implement BLAKE3**

Create `packages/spaces/src/blake3.js`. This is the only hand-rolled crypto in the tree; the file must open with a comment saying so and naming the vectors that gate it.

These constants are the error-prone part and must be exact:

```js
// @pdsjs/spaces/blake3 - vendored BLAKE3 with extendable output.
//
// This is the only hand-written cryptographic primitive in pds.js. WebCrypto
// has no BLAKE3, and LtHash (proposal 0016) is defined in terms of it. Verified
// against the official BLAKE3 test vectors in test/fixtures/blake3-vectors.json;
// do not modify without re-running packages/spaces/test/blake3.test.js.

const IV = new Uint32Array([
  0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
  0x1f83d9ab, 0x5be0cd19,
]);

const MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];

const CHUNK_START = 1 << 0;
const CHUNK_END = 1 << 1;
const PARENT = 1 << 2;
const ROOT = 1 << 3;

const BLOCK_LEN = 64;
const CHUNK_LEN = 1024;
```

The rest of the file:

```js
const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;

/** The quarter-round. Rotations are 16, 12, 8, 7. */
function g(s, a, b, c, d, mx, my) {
  s[a] = (s[a] + s[b] + mx) >>> 0;
  s[d] = rotr(s[d] ^ s[a], 16);
  s[c] = (s[c] + s[d]) >>> 0;
  s[b] = rotr(s[b] ^ s[c], 12);
  s[a] = (s[a] + s[b] + my) >>> 0;
  s[d] = rotr(s[d] ^ s[a], 8);
  s[c] = (s[c] + s[d]) >>> 0;
  s[b] = rotr(s[b] ^ s[c], 7);
}

/** Four column mixes then four diagonal mixes. */
function round(s, m) {
  g(s, 0, 4, 8, 12, m[0], m[1]);
  g(s, 1, 5, 9, 13, m[2], m[3]);
  g(s, 2, 6, 10, 14, m[4], m[5]);
  g(s, 3, 7, 11, 15, m[6], m[7]);
  g(s, 0, 5, 10, 15, m[8], m[9]);
  g(s, 1, 6, 11, 12, m[10], m[11]);
  g(s, 2, 7, 8, 13, m[12], m[13]);
  g(s, 3, 4, 9, 14, m[14], m[15]);
}

function permute(m) {
  const out = new Uint32Array(16);
  for (let i = 0; i < 16; i++) out[i] = m[MSG_PERMUTATION[i]];
  return out;
}

/**
 * @returns {Uint32Array} all 16 state words — the first 8 are the chaining
 * value, all 16 are needed for extended output.
 */
function compress(cv, block, counter, blockLen, flags) {
  const s = new Uint32Array(16);
  s.set(cv, 0);
  s[8] = IV[0];
  s[9] = IV[1];
  s[10] = IV[2];
  s[11] = IV[3];
  s[12] = counter >>> 0;
  s[13] = Math.floor(counter / 2 ** 32) >>> 0;
  s[14] = blockLen;
  s[15] = flags;

  let m = block;
  for (let i = 0; i < 7; i++) {
    round(s, m);
    if (i < 6) m = permute(m);
  }
  for (let i = 0; i < 8; i++) {
    s[i] = (s[i] ^ s[i + 8]) >>> 0;
    s[i + 8] = (s[i + 8] ^ cv[i]) >>> 0;
  }
  return s;
}

/** Read 16 little-endian uint32s from a 64-byte block. */
function wordsFromBlock(bytes, offset) {
  const m = new Uint32Array(16);
  for (let i = 0; i < 16; i++) {
    const o = offset + i * 4;
    m[i] =
      (bytes[o] |
        (bytes[o + 1] << 8) |
        (bytes[o + 2] << 16) |
        (bytes[o + 3] << 24)) >>>
      0;
  }
  return m;
}

// An Output is a deferred compression: the final block of a chunk or parent,
// not yet compressed, so the ROOT flag can still be applied to the last one.
function outputChainingValue(o) {
  return compress(o.cv, o.block, o.counter, o.blockLen, o.flags).slice(0, 8);
}

function rootOutputBytes(o, outLen) {
  const out = new Uint8Array(outLen);
  let i = 0;
  let counter = 0;
  while (i < outLen) {
    const words = compress(o.cv, o.block, counter, o.blockLen, o.flags | ROOT);
    for (let w = 0; w < 16 && i < outLen; w++) {
      for (let b = 0; b < 4 && i < outLen; b++) {
        out[i++] = (words[w] >>> (8 * b)) & 0xff;
      }
    }
    counter++;
  }
  return out;
}

function chunkOutput(input, start, len, chunkCounter) {
  let cv = IV.slice(0, 8);
  let blockFlags = CHUNK_START;
  let pos = 0;
  while (len - pos > BLOCK_LEN) {
    const m = wordsFromBlock(input, start + pos);
    cv = compress(cv, m, chunkCounter, BLOCK_LEN, blockFlags).slice(0, 8);
    pos += BLOCK_LEN;
    blockFlags = 0;
  }
  const blockLen = len - pos;
  const padded = new Uint8Array(BLOCK_LEN);
  padded.set(input.subarray(start + pos, start + len));
  return {
    cv,
    block: wordsFromBlock(padded, 0),
    counter: chunkCounter,
    blockLen,
    flags: blockFlags | CHUNK_END,
  };
}

function parentOutput(leftCv, rightCv) {
  const block = new Uint32Array(16);
  block.set(leftCv, 0);
  block.set(rightCv, 8);
  return {
    cv: IV.slice(0, 8),
    block,
    counter: 0,
    blockLen: BLOCK_LEN,
    flags: PARENT,
  };
}

/**
 * Unkeyed BLAKE3 with extendable output.
 * @param {Uint8Array} input
 * @param {number} [outLen] - output length in bytes, default 32
 * @returns {Uint8Array}
 */
export function blake3(input, outLen = 32) {
  const chunks = Math.max(1, Math.ceil(input.length / CHUNK_LEN));
  const stack = [];
  let output;

  for (let i = 0; i < chunks; i++) {
    const start = i * CHUNK_LEN;
    const len = Math.min(CHUNK_LEN, Math.max(0, input.length - start));
    const chunk = chunkOutput(input, start, len, i);

    // The last chunk stays an Output so the ROOT flag can reach it.
    if (i === chunks - 1) {
      output = chunk;
      break;
    }

    // Merge while the completed-chunk count has a trailing zero bit.
    let cv = outputChainingValue(chunk);
    let n = i + 1;
    while ((n & 1) === 0) {
      cv = outputChainingValue(parentOutput(stack.pop(), cv));
      n >>= 1;
    }
    stack.push(cv);
  }

  while (stack.length > 0) {
    output = parentOutput(stack.pop(), outputChainingValue(output));
  }
  return rootOutputBytes(output, outLen);
}
```

The single-chunk shortcut is not sufficient: the vectors include inputs up to 102400 bytes, so the tree path must work.

- [ ] **Step 6: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/blake3.test.js`
Expected: PASS, 4 tests.

If the 32-byte test passes but the extended-output test fails, the bug is in the root output-block counter (step 7). If short inputs pass and inputs ≥ 1024 fail, the bug is in the CV stack merge (step 6).

- [ ] **Step 7: Format and commit**

```bash
npx biome check --write packages/spaces test/fixtures
git add packages/spaces test/fixtures/blake3-vectors.json pnpm-lock.yaml
git commit -m "feat(spaces): scaffold @pdsjs/spaces with vendored BLAKE3 XOF"
```

---

### Task 2: Generate reference vectors

**Files:**
- Create: `scripts/gen-space-vectors.md` (documented procedure)
- Create: `test/fixtures/space-vectors.json` (generated, committed)

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `test/fixtures/space-vectors.json` with this exact shape, consumed by Tasks 3, 6, and 7:

```json
{
  "generatedFrom": {
    "repo": "bluesky-social/atproto",
    "pr": 5187,
    "sha": "24a81bf5ab8d39557da35a4c4c0978c9aa62c45a"
  },
  "lthash": {
    "empty": { "stateHex": "…", "digestHex": "…" },
    "single": [{ "element": "…", "stateHex": "…", "digestHex": "…" }],
    "sets": [{ "elements": ["…"], "digestHex": "…" }]
  },
  "commitCtx": [
    { "space": "…", "author": "…", "rev": "…", "ikmHex": "…", "ctxHex": "…" }
  ],
  "signedCommits": [
    {
      "didKey": "did:key:z…",
      "ctx": { "space": "…", "author": "…", "rev": "…" },
      "records": [{ "collection": "…", "rkey": "…", "cid": "…" }],
      "commit": { "ver": 1, "hashHex": "…", "ikmHex": "…", "sigHex": "…", "macHex": "…", "rev": "…" }
    }
  ]
}
```

- [ ] **Step 1: Create an isolated worktree of the reference**

The `~/code/atproto` checkout has uncommitted work on another branch. Do **not** check out `pr-5187` in place.

```bash
cd ~/code/atproto
git worktree add ~/code/atproto-space-ref pr-5187
cd ~/code/atproto-space-ref
pnpm install --frozen-lockfile
```

Verify the space package is present:

```bash
ls packages/space/src
```

Expected: `index.ts`, `lthash.ts`, `repo-commit.ts`, `types.ts`, `util.ts`, and others.

- [ ] **Step 2: Write the generator as a vitest test inside the worktree**

`packages/space` is a registered vitest project, so a test file runs the TypeScript sources directly with no build step.

Create `~/code/atproto-space-ref/packages/space/tests/gen-vectors.test.ts` (untracked scratch file in the worktree — it is never committed to atproto):

```ts
import { writeFileSync } from 'node:fs'
import { P256Keypair } from '@atproto/crypto'
import { parseCid } from '@atproto/lex-data'
import { it } from 'vitest'
import { LtHash, RepoCommit, encodeCommitCtx } from '../src/index.js'

const OUT = process.env.VECTORS_OUT
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex')

// Substitute two real CIDs — see Step 2a. Placeholder strings will fail to
// parse and the generator will throw rather than emit bad vectors.
const CID_A = process.env.CID_A!
const CID_B = process.env.CID_B!

const ELEMENTS = [
  'a',
  'app.example.post/3kabcdefghi/' + CID_A,
  'com.atmoboards.thread/self/' + CID_B,
]

it('generates vectors', async () => {
  const lthashSingle = ELEMENTS.map((element) => {
    const h = new LtHash().add(element)
    return { element, stateHex: hex(h.state()), digestHex: hex(h.digest()) }
  })

  const sets = [
    { elements: [ELEMENTS[0], ELEMENTS[1]] },
    { elements: [ELEMENTS[1], ELEMENTS[0]] },
    { elements: ELEMENTS },
  ].map(({ elements }) => {
    const h = new LtHash()
    for (const e of elements) h.add(e)
    return { elements, digestHex: hex(h.digest()) }
  })

  const empty = new LtHash()

  const ctxCases = [
    { space: 'at://did:plc:aaaa/space/com.example.type/key1', author: 'did:plc:bbbb', rev: '3lkabcdefgh2x' },
    { space: 'at://did:plc:cccc/space/com.atmoboards.forum/general', author: 'did:plc:dddd', rev: '3lkzzzzzzzz2x' },
  ]
  const commitCtx = ctxCases.map((c, i) => {
    const ikm = new Uint8Array(32).fill(i + 1)
    return { ...c, ikmHex: hex(ikm), ctxHex: hex(encodeCommitCtx(c, ikm)) }
  })

  const keypair = await P256Keypair.create({ exportable: true })
  const records = [
    { collection: 'app.example.post', rkey: '3kabcdefghi', cid: CID_A },
    { collection: 'com.atmoboards.thread', rkey: 'self', cid: CID_B },
  ]
  const repo = RepoCommit.fromRecords(
    records.map((r) => ({ ...r, cid: parseCid(r.cid) })),
  )
  const ctx = ctxCases[0]
  const signed = await repo.sign(ctx, keypair)

  const out = {
    generatedFrom: {
      repo: 'bluesky-social/atproto',
      pr: 5187,
      sha: process.env.REF_SHA,
    },
    lthash: {
      empty: { stateHex: hex(empty.state()), digestHex: hex(empty.digest()) },
      single: lthashSingle,
      sets,
    },
    commitCtx,
    signedCommits: [
      {
        didKey: keypair.did(),
        ctx,
        records,
        commit: {
          ver: signed.ver,
          hashHex: hex(signed.hash),
          ikmHex: hex(signed.ikm),
          sigHex: hex(signed.sig),
          macHex: hex(signed.mac),
          rev: signed.rev,
        },
      },
    ],
  }

  writeFileSync(OUT!, JSON.stringify(out, null, 2) + '\n')
})
```

If `parseCid` is not the correct export name in `@atproto/lex-data`, check `packages/lex/lex-data/src/index.ts` in the worktree and use the exported CID parser. The generator must fail loudly rather than emit placeholder CIDs.

- [ ] **Step 2a: Source two real CIDs**

Do not invent CID strings — an invalid CID either throws in the generator or, worse, produces vectors pds.js can reproduce but the reference cannot. Take two real ones from the reference's own fixtures:

```bash
cd ~/code/atproto-space-ref
grep -rhoE 'bafyrei[a-z2-7]{52}' packages --include='*.ts' --include='*.json' | sort -u | head -2
```

If that yields fewer than two, generate them in the worktree instead:

```bash
npx vitest run --reporter=basic --testNamePattern='__cids__' \
  --root . packages/space 2>/dev/null || true
```

and failing that, add a throwaway test in the worktree that CBOR-encodes two small objects with the reference's own helpers and prints the resulting CIDs. Whatever the source, the two values must round-trip through the reference's CID parser before use.

Export them for the next step:

```bash
export CID_A=<first cid>
export CID_B=<second cid>
```

- [ ] **Step 3: Run the generator**

```bash
cd ~/code/atproto-space-ref
REF_SHA=$(git rev-parse HEAD) \
CID_A=$CID_A CID_B=$CID_B \
VECTORS_OUT=~/code/pds.js/test/fixtures/space-vectors.json \
  npx vitest run packages/space/tests/gen-vectors.test.ts
```

Expected: PASS, and the fixture written.

- [ ] **Step 4: Verify the fixture**

```bash
cd ~/code/pds.js
node -e "
const v = require('./test/fixtures/space-vectors.json');
console.log('sha', v.generatedFrom.sha);
console.log('empty state all-zero:', /^0+$/.test(v.lthash.empty.stateHex));
console.log('state bytes:', v.lthash.single[0].stateHex.length / 2);
console.log('order-independent:', v.lthash.sets[0].digestHex === v.lthash.sets[1].digestHex);
console.log('ctx cases:', v.commitCtx.length, 'commits:', v.signedCommits.length);
"
```

Expected: sha is the pinned SHA, empty state is all zeros, state is 2048 bytes, the two reordered sets share a digest, 2 ctx cases, 1 signed commit.

If order-independence is false, the generator is wrong — stop and fix it before proceeding, because every downstream task trusts this file.

- [ ] **Step 5: Document the regeneration procedure**

Create `scripts/gen-space-vectors.md` recording: why the worktree exists, the exact commands from Steps 1–3, and the rule that regenerating this fixture is how pds.js resyncs with upstream. State that `generatedFrom.sha` must be updated in the spec's §0 pin whenever the fixture is regenerated.

- [ ] **Step 6: Commit**

```bash
npx biome check --write scripts test/fixtures
git add test/fixtures/space-vectors.json scripts/gen-space-vectors.md
git commit -m "test(spaces): reference vectors generated from atproto PR #5187"
```

---

### Task 3: LtHash

**Files:**
- Create: `packages/spaces/src/lthash.js`
- Create: `packages/spaces/test/lthash.test.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: `blake3(input, outLen)` from Task 1; `test/fixtures/space-vectors.json` from Task 2.
- Produces:
  - `LTHASH_STATE_BYTES = 2048`
  - `class LtHash` with `constructor(state?: Uint8Array | null)`, `add(element: string): this`, `remove(element: string): this`, `state(): Uint8Array`, `digest(): Promise<Uint8Array>`, `isEmpty(): boolean`, `equals(other: LtHash): boolean`.
  - **`digest()` is async** — it uses `crypto.subtle.digest('SHA-256', …)`. This differs from the reference's synchronous `digest()` and is deliberate (spec §2).

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/lthash.test.js`:

```js
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { LTHASH_STATE_BYTES, LtHash } from '../src/lthash.js';

const vectors = JSON.parse(
  readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'),
);

const toHex = (bytes) =>
  Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');

describe('LtHash', () => {
  it('starts as 2048 zero bytes', () => {
    const h = new LtHash();
    expect(h.state()).toEqual(new Uint8Array(LTHASH_STATE_BYTES));
    expect(h.isEmpty()).toBe(true);
  });

  it('rejects a state of the wrong length', () => {
    expect(() => new LtHash(new Uint8Array(10))).toThrow(/2048/);
  });

  it('add then remove returns to the zero state', () => {
    const h = new LtHash().add('a');
    expect(h.isEmpty()).toBe(false);
    h.remove('a');
    expect(h.isEmpty()).toBe(true);
  });

  it('is order-independent', async () => {
    const a = new LtHash().add('a').add('b');
    const b = new LtHash().add('b').add('a');
    expect(a.equals(b)).toBe(true);
    expect(await a.digest()).toEqual(await b.digest());
  });

  it('distinguishes different elements', () => {
    expect(new LtHash().add('a').equals(new LtHash().add('b'))).toBe(false);
  });

  it('round-trips through state()', () => {
    const h = new LtHash().add('a').add('b');
    expect(new LtHash(h.state()).equals(h)).toBe(true);
  });

  it('matches the reference empty state', async () => {
    const h = new LtHash();
    expect(toHex(h.state())).toBe(vectors.lthash.empty.stateHex);
    expect(toHex(await h.digest())).toBe(vectors.lthash.empty.digestHex);
  });

  it('matches the reference for single elements', async () => {
    for (const c of vectors.lthash.single) {
      const h = new LtHash().add(c.element);
      expect(toHex(h.state()), c.element).toBe(c.stateHex);
      expect(toHex(await h.digest()), c.element).toBe(c.digestHex);
    }
  });

  it('matches the reference for element sets', async () => {
    for (const c of vectors.lthash.sets) {
      const h = new LtHash();
      for (const e of c.elements) h.add(e);
      expect(toHex(await h.digest()), c.elements.join(',')).toBe(c.digestHex);
    }
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/lthash.test.js`
Expected: FAIL — `Failed to resolve import "../src/lthash.js"`.

- [ ] **Step 3: Implement LtHash**

Create `packages/spaces/src/lthash.js`:

```js
// @pdsjs/spaces/lthash - homomorphic set hash for permissioned repo commits.

import { blake3 } from './blake3.js';

const LANES = 1024;
const LANE_BYTES = 2; // cannot change without reworking the Uint16Array impl
export const LTHASH_STATE_BYTES = LANES * LANE_BYTES; // 2048

/**
 * A homomorphic set hash. Each element expands to 1024 little-endian u16 lanes
 * summed into the state mod 2^16. Addition and subtraction commute, so the state
 * depends only on the current set, not on insertion order.
 */
export class LtHash {
  /** @param {Uint8Array|null} [state] */
  constructor(state) {
    if (state && state.length !== LTHASH_STATE_BYTES) {
      throw new Error(
        `LtHash state must be ${LTHASH_STATE_BYTES} bytes, got ${state.length}`,
      );
    }
    const buffer = new ArrayBuffer(LTHASH_STATE_BYTES);
    this.bytes = new Uint8Array(buffer);
    this.lanes = new Uint16Array(buffer);
    if (state) this.bytes.set(state);
  }

  /** @param {string} element @returns {this} */
  add(element) {
    const lanes = expand(element);
    for (let i = 0; i < LANES; i++) this.lanes[i] += lanes[i];
    return this;
  }

  /** @param {string} element @returns {this} */
  remove(element) {
    const lanes = expand(element);
    for (let i = 0; i < LANES; i++) this.lanes[i] -= lanes[i];
    return this;
  }

  /** @returns {Uint8Array} the full state, for persistence */
  state() {
    return new Uint8Array(this.bytes);
  }

  /** @returns {Promise<Uint8Array>} */
  async digest() {
    const hash = await crypto.subtle.digest('SHA-256', this.bytes);
    return new Uint8Array(hash);
  }

  /** @returns {boolean} */
  isEmpty() {
    for (let i = 0; i < LANES; i++) if (this.lanes[i] !== 0) return false;
    return true;
  }

  /** @param {LtHash} other @returns {boolean} */
  equals(other) {
    for (let i = 0; i < LANES; i++) {
      if (this.lanes[i] !== other.lanes[i]) return false;
    }
    return true;
  }
}

/**
 * Expand an element to 1024 little-endian u16 lanes with BLAKE3 in XOF mode.
 * @param {string} element
 * @returns {Uint16Array}
 */
function expand(element) {
  const expanded = blake3(new TextEncoder().encode(element), LTHASH_STATE_BYTES);
  const buffer = new ArrayBuffer(LTHASH_STATE_BYTES);
  new Uint8Array(buffer).set(expanded);
  return new Uint16Array(buffer);
}
```

`Uint16Array` on a little-endian host reads the buffer little-endian, which matches the spec. Do not add byte-swapping.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/lthash.test.js`
Expected: PASS, 9 tests.

If the structural tests pass but the reference-vector tests fail, the bug is in `blake3`, not here — the lane arithmetic has no other degrees of freedom.

- [ ] **Step 5: Export from the package index**

Modify `packages/spaces/src/index.js`:

```js
// @pdsjs/spaces - AT Protocol permissioned data (proposal 0016)
export { blake3 } from './blake3.js';
export { LTHASH_STATE_BYTES, LtHash } from './lthash.js';
```

- [ ] **Step 6: Format and commit**

```bash
npx biome check --write packages/spaces
git add packages/spaces
git commit -m "feat(spaces): LtHash homomorphic set hash"
```

---

### Task 4: Record path and set-hash element helpers

**Files:**
- Create: `packages/spaces/src/path.js`
- Create: `packages/spaces/test/path.test.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `formatRecordPath(collection: string, rkey: string) => string` — `"{collection}/{rkey}"`
  - `parseRecordPath(path: string) => { collection: string, rkey: string }` — throws on a path that is not exactly two segments
  - `formatSetHashElement(collection: string, rkey: string, cid: string) => string` — `"{collection}/{rkey}/{cid}"`, the element folded into LtHash

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/path.test.js`:

```js
import { describe, expect, it } from 'vitest';
import {
  formatRecordPath,
  formatSetHashElement,
  parseRecordPath,
} from '../src/path.js';

describe('record paths', () => {
  it('formats a record path', () => {
    expect(formatRecordPath('app.example.post', '3kabc')).toBe(
      'app.example.post/3kabc',
    );
  });

  it('parses a record path', () => {
    expect(parseRecordPath('app.example.post/3kabc')).toEqual({
      collection: 'app.example.post',
      rkey: '3kabc',
    });
  });

  it('round-trips', () => {
    const path = formatRecordPath('com.atmoboards.thread', 'self');
    expect(formatRecordPath(...Object.values(parseRecordPath(path)))).toBe(path);
  });

  it('rejects paths without exactly two segments', () => {
    expect(() => parseRecordPath('only-one')).toThrow(/invalid record path/);
    expect(() => parseRecordPath('a/b/c')).toThrow(/invalid record path/);
    expect(() => parseRecordPath('')).toThrow(/invalid record path/);
  });

  it('formats a set hash element', () => {
    expect(
      formatSetHashElement('app.example.post', '3kabc', 'bafyreiabc'),
    ).toBe('app.example.post/3kabc/bafyreiabc');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/path.test.js`
Expected: FAIL — `Failed to resolve import "../src/path.js"`.

- [ ] **Step 3: Implement**

Create `packages/spaces/src/path.js`:

```js
// @pdsjs/spaces/path - record path and set hash element formatting.

/**
 * @param {string} collection
 * @param {string} rkey
 * @returns {string}
 */
export function formatRecordPath(collection, rkey) {
  return `${collection}/${rkey}`;
}

/**
 * @param {string} path
 * @returns {{collection: string, rkey: string}}
 */
export function parseRecordPath(path) {
  const parts = path.split('/');
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
    throw new Error(`invalid record path: ${path}`);
  }
  return { collection: parts[0], rkey: parts[1] };
}

/**
 * The element a record contributes to a repo's set hash.
 * @param {string} collection
 * @param {string} rkey
 * @param {string} cid
 * @returns {string}
 */
export function formatSetHashElement(collection, rkey, cid) {
  return `${collection}/${rkey}/${cid}`;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/path.test.js`
Expected: PASS, 5 tests.

- [ ] **Step 5: Export and commit**

Add to `packages/spaces/src/index.js`:

```js
export {
  formatRecordPath,
  formatSetHashElement,
  parseRecordPath,
} from './path.js';
```

```bash
npx biome check --write packages/spaces
git add packages/spaces
git commit -m "feat(spaces): record path and set hash element helpers"
```

---

### Task 5: Space AT-URIs

**Files:**
- Create: `packages/spaces/src/uri.js`
- Create: `packages/spaces/test/uri.test.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `SPACE_MARKER = 'space'`
  - `formatSpaceUri({ spaceDid, spaceType, skey, authorDid?, collection?, rkey? }) => string`
  - `parseSpaceUri(uri: string) => { spaceDid, spaceType, skey, authorDid: string|null, collection: string|null, rkey: string|null }` — throws `Error` on a non-space or malformed URI
  - `isSpaceUri(uri: string) => boolean`

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/uri.test.js`:

```js
import { describe, expect, it } from 'vitest';
import { formatSpaceUri, isSpaceUri, parseSpaceUri } from '../src/uri.js';

const SPACE = 'at://did:plc:aaaa/space/com.atmoboards.forum/general';
const FULL = `${SPACE}/did:plc:bbbb/com.atmoboards.thread/3kabc`;

describe('space URIs', () => {
  it('formats a bare space URI', () => {
    expect(
      formatSpaceUri({
        spaceDid: 'did:plc:aaaa',
        spaceType: 'com.atmoboards.forum',
        skey: 'general',
      }),
    ).toBe(SPACE);
  });

  it('formats a full record URI', () => {
    expect(
      formatSpaceUri({
        spaceDid: 'did:plc:aaaa',
        spaceType: 'com.atmoboards.forum',
        skey: 'general',
        authorDid: 'did:plc:bbbb',
        collection: 'com.atmoboards.thread',
        rkey: '3kabc',
      }),
    ).toBe(FULL);
  });

  it('parses a bare space URI', () => {
    expect(parseSpaceUri(SPACE)).toEqual({
      spaceDid: 'did:plc:aaaa',
      spaceType: 'com.atmoboards.forum',
      skey: 'general',
      authorDid: null,
      collection: null,
      rkey: null,
    });
  });

  it('parses a full record URI', () => {
    expect(parseSpaceUri(FULL)).toEqual({
      spaceDid: 'did:plc:aaaa',
      spaceType: 'com.atmoboards.forum',
      skey: 'general',
      authorDid: 'did:plc:bbbb',
      collection: 'com.atmoboards.thread',
      rkey: '3kabc',
    });
  });

  it('round-trips', () => {
    expect(formatSpaceUri(parseSpaceUri(FULL))).toBe(FULL);
    expect(formatSpaceUri(parseSpaceUri(SPACE))).toBe(SPACE);
  });

  it('recognises space URIs by the marker segment', () => {
    expect(isSpaceUri(SPACE)).toBe(true);
    expect(isSpaceUri(FULL)).toBe(true);
    // Public repo URI: the first path segment is a collection NSID, which
    // always contains dots. The space marker never does.
    expect(isSpaceUri('at://did:plc:aaaa/app.bsky.feed.post/3kabc')).toBe(false);
    expect(isSpaceUri('at://did:plc:aaaa')).toBe(false);
  });

  it('rejects malformed space URIs', () => {
    expect(() => parseSpaceUri('at://did:plc:aaaa/app.bsky.feed.post/3k')).toThrow();
    // missing skey
    expect(() => parseSpaceUri('at://did:plc:aaaa/space/com.example.t')).toThrow();
    // author present but collection/rkey truncated
    expect(() => parseSpaceUri(`${SPACE}/did:plc:bbbb/com.example.c`)).toThrow();
    // non-DID authority
    expect(() => parseSpaceUri('at://alice.test/space/com.example.t/k')).toThrow();
  });

  it('rejects a space type that is not an NSID', () => {
    expect(() => parseSpaceUri('at://did:plc:aaaa/space/nodots/k')).toThrow();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/uri.test.js`
Expected: FAIL — `Failed to resolve import "../src/uri.js"`.

- [ ] **Step 3: Implement**

Create `packages/spaces/src/uri.js`. The addressing scheme is:

```
at://{spaceDid}/space/{spaceType}/{skey}[/{authorDid}/{collection}/{rkey}]
```

Valid segment counts after the host are exactly 3 (bare space) or 6 (full record). Anything else is malformed. Validate that `spaceDid` and `authorDid` start with `did:`, and that `spaceType` and `collection` contain at least one dot (the NSID property the marker relies on).

```js
// @pdsjs/spaces/uri - permissioned space AT-URI addressing.
//
//   at://{spaceDid}/space/{spaceType}/{skey}[/{authorDid}/{collection}/{rkey}]
//
// Space URIs are distinguished from public ones by the literal `space` segment,
// which contains no dots, whereas a collection NSID always contains at least two.

export const SPACE_MARKER = 'space';

/**
 * @param {{spaceDid: string, spaceType: string, skey: string,
 *          authorDid?: string|null, collection?: string|null, rkey?: string|null}} parts
 * @returns {string}
 */
export function formatSpaceUri(parts) {
  const { spaceDid, spaceType, skey, authorDid, collection, rkey } = parts;
  let uri = `at://${spaceDid}/${SPACE_MARKER}/${spaceType}/${skey}`;
  if (authorDid) uri += `/${authorDid}`;
  if (collection) uri += `/${collection}`;
  if (rkey) uri += `/${rkey}`;
  return uri;
}

/**
 * @param {string} uri
 * @returns {boolean}
 */
export function isSpaceUri(uri) {
  const segments = splitUri(uri);
  return segments !== null && segments[1] === SPACE_MARKER;
}

/**
 * @param {string} uri
 * @returns {{spaceDid: string, spaceType: string, skey: string,
 *            authorDid: string|null, collection: string|null, rkey: string|null}}
 */
export function parseSpaceUri(uri) {
  const segments = splitUri(uri);
  if (segments === null || segments[1] !== SPACE_MARKER) {
    throw new Error(`not a space URI: ${uri}`);
  }
  const [spaceDid, , spaceType, skey, authorDid, collection, rkey] = segments;
  if (segments.length !== 4 && segments.length !== 7) {
    throw new Error(`invalid space URI: ${uri}`);
  }
  if (!spaceDid.startsWith('did:')) {
    throw new Error(`space authority must be a DID: ${uri}`);
  }
  if (!spaceType.includes('.')) {
    throw new Error(`space type must be an NSID: ${uri}`);
  }
  if (!skey) throw new Error(`invalid space URI: ${uri}`);
  if (segments.length === 4) {
    return { spaceDid, spaceType, skey, authorDid: null, collection: null, rkey: null };
  }
  if (!authorDid.startsWith('did:')) {
    throw new Error(`space record author must be a DID: ${uri}`);
  }
  if (!collection.includes('.')) {
    throw new Error(`collection must be an NSID: ${uri}`);
  }
  if (!rkey) throw new Error(`invalid space URI: ${uri}`);
  return { spaceDid, spaceType, skey, authorDid, collection, rkey };
}

/**
 * @param {string} uri
 * @returns {string[]|null} host followed by path segments, or null if not an at:// URI
 */
function splitUri(uri) {
  if (!uri.startsWith('at://')) return null;
  const segments = uri.slice('at://'.length).split('/');
  if (segments.length < 2 || segments.some((s) => s === '')) return null;
  return segments;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/uri.test.js`
Expected: PASS, 8 tests.

- [ ] **Step 5: Export and commit**

Add to `packages/spaces/src/index.js`:

```js
export { SPACE_MARKER, formatSpaceUri, isSpaceUri, parseSpaceUri } from './uri.js';
```

```bash
npx biome check --write packages/spaces
git add packages/spaces
git commit -m "feat(spaces): space AT-URI parsing and formatting"
```

---

### Task 6: Commit context encoding and MAC

**Files:**
- Create: `packages/spaces/src/mac.js`
- Create: `packages/spaces/test/mac.test.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: `test/fixtures/space-vectors.json` from Task 2.
- Produces:
  - `COMMIT_VERSION = 1`
  - `encodeCommitCtx(ctx: {space, author, rev}, ikm: Uint8Array) => Uint8Array`
  - `hmacSha256(key: Uint8Array, data: Uint8Array) => Promise<Uint8Array>`
  - `hkdfExpandSha256(ikm: Uint8Array, info: Uint8Array) => Promise<Uint8Array>` — 32 bytes
  - `computeMac(ikm: Uint8Array, ctxBytes: Uint8Array, hash: Uint8Array) => Promise<Uint8Array>`
  - `bytesEqual(a: Uint8Array, b: Uint8Array) => boolean` — length-checked

**Critical detail:** the reference's `hkdfSha256(ikm, info)` is `expand(sha256, ikm, info, 32)` — HKDF-**Expand only**, with `ikm` used directly as the PRK. `crypto.subtle.deriveBits({name:'HKDF'})` performs extract **and** expand and will not match. Because the output length equals the hash length, expand collapses to a single block:

```
hkdfExpandSha256(ikm, info) = HMAC-SHA256(ikm, info || 0x01)
```

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/mac.test.js`:

```js
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
  COMMIT_VERSION,
  bytesEqual,
  computeMac,
  encodeCommitCtx,
  hkdfExpandSha256,
  hmacSha256,
} from '../src/mac.js';

const vectors = JSON.parse(
  readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'),
);

const toHex = (b) =>
  Array.from(b)
    .map((x) => x.toString(16).padStart(2, '0'))
    .join('');
const fromHex = (h) =>
  new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16)));

describe('encodeCommitCtx', () => {
  it('matches the reference vectors byte for byte', () => {
    for (const c of vectors.commitCtx) {
      const out = encodeCommitCtx(
        { space: c.space, author: c.author, rev: c.rev },
        fromHex(c.ikmHex),
      );
      expect(toHex(out), c.space).toBe(c.ctxHex);
    }
  });

  it('starts with the domain prefix', () => {
    const out = encodeCommitCtx(
      { space: 'at://did:plc:a/space/com.e.t/k', author: 'did:plc:b', rev: '3l' },
      new Uint8Array(32),
    );
    expect(new TextDecoder().decode(out.slice(0, 16))).toBe('atproto-space-v1');
  });

  it('uses big-endian uint16 length prefixes', () => {
    const out = encodeCommitCtx(
      { space: 'ab', author: 'c', rev: 'd' },
      new Uint8Array(1),
    );
    // prefix(16) | 0x0000 0x0002 'ab' | 0x0000 0x0001 'c' | ...
    expect(out[16]).toBe(0x00);
    expect(out[17]).toBe(0x02);
    expect(String.fromCharCode(out[18], out[19])).toBe('ab');
  });

  it('rejects a field longer than uint16', () => {
    expect(() =>
      encodeCommitCtx(
        { space: 'x'.repeat(0x10000), author: 'a', rev: 'b' },
        new Uint8Array(0),
      ),
    ).toThrow(/uint16/);
  });
});

describe('mac', () => {
  it('hkdfExpandSha256 is a single HMAC block', async () => {
    const ikm = new Uint8Array(32).fill(7);
    const info = new TextEncoder().encode('info');
    const expected = await hmacSha256(
      ikm,
      new Uint8Array([...info, 0x01]),
    );
    expect(await hkdfExpandSha256(ikm, info)).toEqual(expected);
  });

  it('reproduces the reference commit MAC', async () => {
    for (const c of vectors.signedCommits) {
      const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex));
      const mac = await computeMac(
        fromHex(c.commit.ikmHex),
        ctxBytes,
        fromHex(c.commit.hashHex),
      );
      expect(toHex(mac)).toBe(c.commit.macHex);
    }
  });
});

describe('bytesEqual', () => {
  it('compares by length and content', () => {
    expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true);
    expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false);
    expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
  });
});

describe('COMMIT_VERSION', () => {
  it('is 1', () => {
    expect(COMMIT_VERSION).toBe(1);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/mac.test.js`
Expected: FAIL — `Failed to resolve import "../src/mac.js"`.

- [ ] **Step 3: Implement**

Create `packages/spaces/src/mac.js`:

```js
// @pdsjs/spaces/mac - commit context encoding and deniable-signature MAC.

export const COMMIT_VERSION = 1;

const DOMAIN_PREFIX = new TextEncoder().encode('atproto-space-v1');

/**
 * ctx = "atproto-space-v1"
 *    || uint16be(len(space))  || space
 *    || uint16be(len(author)) || author
 *    || uint16be(len(rev))    || rev
 *    || uint16be(len(ikm))    || ikm
 *
 * Length prefixes are big-endian per the TLS variable-length vector convention,
 * deliberately the opposite byte order from the set hash's lanes.
 *
 * @param {{space: string, author: string, rev: string}} ctx
 * @param {Uint8Array} ikm
 * @returns {Uint8Array}
 */
export function encodeCommitCtx(ctx, ikm) {
  const encoder = new TextEncoder();
  const fields = [
    encoder.encode(ctx.space),
    encoder.encode(ctx.author),
    encoder.encode(ctx.rev),
    ikm,
  ];

  let size = DOMAIN_PREFIX.length;
  for (const field of fields) {
    if (field.length > 0xffff) {
      throw new Error('commit ctx field exceeds uint16 length prefix');
    }
    size += 2 + field.length;
  }

  const out = new Uint8Array(size);
  out.set(DOMAIN_PREFIX);
  let offset = DOMAIN_PREFIX.length;
  for (const field of fields) {
    out[offset++] = (field.length >>> 8) & 0xff;
    out[offset++] = field.length & 0xff;
    out.set(field, offset);
    offset += field.length;
  }
  return out;
}

/**
 * @param {Uint8Array} key
 * @param {Uint8Array} data
 * @returns {Promise<Uint8Array>}
 */
export async function hmacSha256(key, data) {
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    key,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
  return new Uint8Array(sig);
}

/**
 * HKDF-Expand (RFC 5869 §2.3) with SHA-256 and L = 32, matching the reference's
 * `expand(sha256, ikm, info, 32)`. `ikm` is used directly as the PRK — there is
 * no extract step, which is why crypto.subtle's HKDF cannot be used here. With
 * L equal to the hash length this is exactly one block.
 *
 * @param {Uint8Array} ikm
 * @param {Uint8Array} info
 * @returns {Promise<Uint8Array>}
 */
export function hkdfExpandSha256(ikm, info) {
  const block = new Uint8Array(info.length + 1);
  block.set(info);
  block[info.length] = 0x01;
  return hmacSha256(ikm, block);
}

/**
 * @param {Uint8Array} ikm
 * @param {Uint8Array} ctxBytes
 * @param {Uint8Array} hash
 * @returns {Promise<Uint8Array>}
 */
export async function computeMac(ikm, ctxBytes, hash) {
  return hmacSha256(await hkdfExpandSha256(ikm, ctxBytes), hash);
}

/**
 * @param {Uint8Array} a
 * @param {Uint8Array} b
 * @returns {boolean}
 */
export function bytesEqual(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/mac.test.js`
Expected: PASS, 8 tests.

If `encodeCommitCtx` matches but the MAC does not, the HKDF interpretation is wrong — re-read `packages/crypto/src/hmac.ts` in the worktree.

- [ ] **Step 5: Export and commit**

Add to `packages/spaces/src/index.js`:

```js
export {
  COMMIT_VERSION,
  bytesEqual,
  computeMac,
  encodeCommitCtx,
  hkdfExpandSha256,
  hmacSha256,
} from './mac.js';
```

```bash
npx biome check --write packages/spaces
git add packages/spaces
git commit -m "feat(spaces): commit ctx encoding and deniable-signature MAC"
```

---

### Task 7: Signature verifier port

**Files:**
- Create: `packages/spaces/src/verifier.js`
- Create: `packages/spaces/test/verifier.test.js`
- Modify: `packages/core/src/ports.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: `bytesEqual` from Task 6 (not required, but available).
- Produces:
  - `createVerifier({ secp256k1 } = {}) => { verify(didKey: string, data: Uint8Array, sig: Uint8Array): Promise<boolean> }`
  - `parseDidKey(didKey: string) => { curve: 'p256'|'secp256k1', publicKey: Uint8Array }` — throws on unsupported multicodec
  - Typedefs in `packages/core/src/ports.js`: `SignatureVerifierPort`, `DidResolverPort`

The multicodec prefixes are: `0x1200` (varint `0x80 0x24`) for P-256, `0xe7` (varint `0xe7 0x01`) for secp256k1. did:key values are `did:key:z<base58btc(multicodec || compressedPublicKey)>`. `@pdsjs/core/crypto` already exports `base58btcDecode`-equivalent helpers used by `plc.js`; reuse them rather than writing new base58 code. Check `packages/core/src/crypto.js` and `packages/core/src/plc.js:47` for the encode path and add a decode counterpart there if one does not exist.

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/verifier.test.js`:

```js
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { encodeCommitCtx } from '../src/mac.js';
import { createVerifier, parseDidKey } from '../src/verifier.js';

const vectors = JSON.parse(
  readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'),
);

const fromHex = (h) =>
  new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16)));

describe('parseDidKey', () => {
  it('recognises the P-256 multicodec', () => {
    const { didKey } = vectors.signedCommits[0];
    const parsed = parseDidKey(didKey);
    expect(parsed.curve).toBe('p256');
    expect(parsed.publicKey.length).toBe(33);
  });

  it('rejects a non-did:key string', () => {
    expect(() => parseDidKey('did:plc:aaaa')).toThrow(/did:key/);
  });
});

describe('createVerifier', () => {
  it('verifies a reference P-256 commit signature', async () => {
    const verifier = createVerifier();
    const c = vectors.signedCommits[0];
    const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex));
    expect(
      await verifier.verify(c.didKey, ctxBytes, fromHex(c.commit.sigHex)),
    ).toBe(true);
  });

  it('rejects a tampered signature', async () => {
    const verifier = createVerifier();
    const c = vectors.signedCommits[0];
    const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex));
    const sig = fromHex(c.commit.sigHex);
    sig[0] ^= 0xff;
    expect(await verifier.verify(c.didKey, ctxBytes, sig)).toBe(false);
  });

  it('rejects tampered data', async () => {
    const verifier = createVerifier();
    const c = vectors.signedCommits[0];
    const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex));
    ctxBytes[0] ^= 0xff;
    expect(
      await verifier.verify(c.didKey, ctxBytes, fromHex(c.commit.sigHex)),
    ).toBe(false);
  });

  it('rejects secp256k1 when no verifier is injected', async () => {
    const verifier = createVerifier();
    // did:key with the secp256k1 multicodec (zQ3s… prefix)
    await expect(
      verifier.verify(
        'zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme',
        new Uint8Array(1),
        new Uint8Array(64),
      ),
    ).rejects.toThrow(/secp256k1/);
  });

  it('delegates secp256k1 to an injected verifier', async () => {
    let called = null;
    const verifier = createVerifier({
      secp256k1: async (publicKey, data, sig) => {
        called = { publicKey, data, sig };
        return true;
      },
    });
    const didKey = 'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme';
    const data = new Uint8Array([1, 2, 3]);
    const sig = new Uint8Array(64);
    expect(await verifier.verify(didKey, data, sig)).toBe(true);
    expect(called.data).toEqual(data);
    expect(called.publicKey.length).toBe(33);
  });
});
```

The secp256k1 did:key in the last two cases is the well-known example from the did:key spec. If it does not decode to a 33-byte key, substitute any `did:key:zQ3s…` value produced by `Secp256k1Keypair.create().did()` in the reference worktree and record it in the test.

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/verifier.test.js`
Expected: FAIL — `Failed to resolve import "../src/verifier.js"`.

- [ ] **Step 3: Implement**

Create `packages/spaces/src/verifier.js`:

```js
// @pdsjs/spaces/verifier - signature verification port.
//
// P-256 ships built in on crypto.subtle. secp256k1 is injected: hand-rolling
// ECDSA verification is a different risk class from hand-rolling a hash, since
// a subtle bug means accepting a forged credential rather than a failing test.
// Without an injected verifier, spaces interoperate only with P-256 accounts.

import { base58btcDecode } from '@pdsjs/core/crypto';

const P256_MULTICODEC = [0x80, 0x24];
const SECP256K1_MULTICODEC = [0xe7, 0x01];

/**
 * @param {string} didKey - did:key:z… (the did: prefix is optional)
 * @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}}
 */
export function parseDidKey(didKey) {
  const value = didKey.startsWith('did:key:') ? didKey.slice(8) : didKey;
  if (!value.startsWith('z')) {
    throw new Error(`expected a did:key multibase value, got: ${didKey}`);
  }
  const bytes = base58btcDecode(value.slice(1));
  if (bytes[0] === P256_MULTICODEC[0] && bytes[1] === P256_MULTICODEC[1]) {
    return { curve: 'p256', publicKey: bytes.slice(2) };
  }
  if (
    bytes[0] === SECP256K1_MULTICODEC[0] &&
    bytes[1] === SECP256K1_MULTICODEC[1]
  ) {
    return { curve: 'secp256k1', publicKey: bytes.slice(2) };
  }
  throw new Error(`unsupported did:key multicodec: ${didKey}`);
}

/**
 * @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}} [opts]
 * @returns {{verify: (didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}}
 */
export function createVerifier(opts = {}) {
  return {
    async verify(didKey, data, sig) {
      const { curve, publicKey } = parseDidKey(didKey);
      if (curve === 'p256') return verifyP256(publicKey, data, sig);
      if (!opts.secp256k1) {
        throw new Error(
          'secp256k1 verification requires an injected verifier; ' +
            'this deployment supports P-256 keys only',
        );
      }
      return opts.secp256k1(publicKey, data, sig);
    },
  };
}

/**
 * @param {Uint8Array} publicKey - 33-byte compressed point
 * @param {Uint8Array} data
 * @param {Uint8Array} sig - 64-byte raw r||s
 * @returns {Promise<boolean>}
 */
async function verifyP256(publicKey, data, sig) {
  const key = await crypto.subtle.importKey(
    'raw',
    publicKey,
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['verify'],
  );
  return crypto.subtle.verify(
    { name: 'ECDSA', hash: 'SHA-256' },
    key,
    sig,
    data,
  );
}
```

If `@pdsjs/core/crypto` has no `base58btcDecode`, add one next to the existing `base58btcEncode` used by `plc.js:57` and export it. Verify the encode/decode pair round-trips before relying on it.

If `crypto.subtle.importKey('raw', …)` rejects a 33-byte compressed point on the target runtime, decompress to the 65-byte uncompressed form first. Node 22+, Deno, and workerd all accept compressed points; confirm rather than assume.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/verifier.test.js`
Expected: PASS, 7 tests.

- [ ] **Step 5: Declare the ports in core**

Modify `packages/core/src/ports.js`, adding these typedefs next to the existing `LexiconResolverPort`. This file is pure JSDoc ending in `export {}` — it gains type declarations and no runtime coupling.

```js
/**
 * Verifies signatures against did:key identifiers. P-256 is always supported;
 * secp256k1 support depends on an injected implementation.
 *
 * @typedef {Object} SignatureVerifierPort
 * @property {(didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>} verify
 */

/**
 * Resolves a DID to its DID document. Injected rather than imported so that
 * @pdsjs/spaces stays dependency-free.
 *
 * @typedef {Object} DidResolverPort
 * @property {(did: string) => Promise<Object>} resolveDid
 */
```

- [ ] **Step 6: Export and commit**

Add to `packages/spaces/src/index.js`:

```js
export { createVerifier, parseDidKey } from './verifier.js';
```

```bash
npx biome check --write packages/spaces packages/core
git add packages/spaces packages/core/src/ports.js
git commit -m "feat(spaces): signature verifier port with injectable secp256k1"
```

---

### Task 8: RepoCommit

**Files:**
- Create: `packages/spaces/src/commit.js`
- Create: `packages/spaces/test/commit.test.js`
- Modify: `packages/spaces/src/index.js`

**Interfaces:**
- Consumes: `LtHash` (Task 3), `formatSetHashElement` (Task 4), `COMMIT_VERSION`/`encodeCommitCtx`/`computeMac`/`bytesEqual` (Task 6), verifier port (Task 7).
- Produces:
  - `class RepoCommit` with:
    - `constructor(setHash?: LtHash)`
    - `static fromState(state: Uint8Array|null|undefined): RepoCommit`
    - `static fromRecords(records: Iterable<{collection, rkey, cid}>): RepoCommit`
    - `static fromIndex(index: Record<string, string>): RepoCommit` — index maps `"{collection}/{rkey}"` to CID string
    - `add(collection, rkey, cid): this`
    - `remove(collection, rkey, cid): this`
    - `applyOp(op: {collection, rkey, cid: string|null, prev: string|null}): this`
    - `applyOps(ops): this`
    - `matches(commit: SignedCommit): Promise<boolean>`
    - `sign(ctx: {space, author, rev}, signer: {sign(bytes): Promise<Uint8Array>}): Promise<SignedCommit>`
    - `state(): Uint8Array`
  - `verifyCommit(commit, ctx, didKey, verifier): Promise<boolean>`
  - `SignedCommit` shape: `{ ver: number, hash: Uint8Array, ikm: Uint8Array, sig: Uint8Array, mac: Uint8Array, rev: string }`

Note `sign` takes a **signer object** (`{ sign(bytes) }`) rather than raw key material, so `@pdsjs/spaces` never handles private keys. `randomBytes(32)` comes from `crypto.getRandomValues`.

- [ ] **Step 1: Write the failing test**

Create `packages/spaces/test/commit.test.js`:

```js
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { RepoCommit, verifyCommit } from '../src/commit.js';
import { createVerifier } from '../src/verifier.js';

const vectors = JSON.parse(
  readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'),
);

const fromHex = (h) =>
  new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16)));
const toHex = (b) =>
  Array.from(b)
    .map((x) => x.toString(16).padStart(2, '0'))
    .join('');

function referenceCommit() {
  const c = vectors.signedCommits[0];
  return {
    ...c,
    signed: {
      ver: c.commit.ver,
      hash: fromHex(c.commit.hashHex),
      ikm: fromHex(c.commit.ikmHex),
      sig: fromHex(c.commit.sigHex),
      mac: fromHex(c.commit.macHex),
      rev: c.commit.rev,
    },
  };
}

describe('RepoCommit', () => {
  it('reproduces the reference set hash from the same records', async () => {
    const { records, signed } = referenceCommit();
    const repo = RepoCommit.fromRecords(records);
    expect(toHex(await repo.setHash.digest())).toBe(toHex(signed.hash));
  });

  it('matches() compares against a signed commit', async () => {
    const { records, signed } = referenceCommit();
    expect(await RepoCommit.fromRecords(records).matches(signed)).toBe(true);
    expect(await new RepoCommit().matches(signed)).toBe(false);
  });

  it('fromIndex folds in the same records as fromRecords', async () => {
    const { records } = referenceCommit();
    const index = Object.fromEntries(
      records.map((r) => [`${r.collection}/${r.rkey}`, r.cid]),
    );
    const a = RepoCommit.fromRecords(records);
    const b = RepoCommit.fromIndex(index);
    expect(toHex(await a.setHash.digest())).toBe(toHex(await b.setHash.digest()));
  });

  it('fromState round-trips', async () => {
    const { records } = referenceCommit();
    const repo = RepoCommit.fromRecords(records);
    const restored = RepoCommit.fromState(repo.state());
    expect(toHex(await restored.setHash.digest())).toBe(
      toHex(await repo.setHash.digest()),
    );
  });

  it('fromState treats null as an empty repo', () => {
    expect(RepoCommit.fromState(null).setHash.isEmpty()).toBe(true);
  });

  it('applyOp handles create, update, and delete', async () => {
    const { records } = referenceCommit();
    const [a, b] = records;
    const repo = new RepoCommit();
    repo.applyOp({ collection: a.collection, rkey: a.rkey, cid: a.cid, prev: null });
    repo.applyOp({ collection: b.collection, rkey: b.rkey, cid: b.cid, prev: null });
    const full = RepoCommit.fromRecords(records);
    expect(repo.setHash.equals(full.setHash)).toBe(true);

    // delete removes it again
    repo.applyOp({ collection: b.collection, rkey: b.rkey, cid: null, prev: b.cid });
    expect(repo.setHash.equals(RepoCommit.fromRecords([a]).setHash)).toBe(true);
  });

  it('an update is remove-then-add', async () => {
    const { records } = referenceCommit();
    const [a, b] = records;
    const repo = RepoCommit.fromRecords([a]);
    repo.applyOp({ collection: a.collection, rkey: a.rkey, cid: b.cid, prev: a.cid });
    const expected = RepoCommit.fromRecords([
      { collection: a.collection, rkey: a.rkey, cid: b.cid },
    ]);
    expect(repo.setHash.equals(expected.setHash)).toBe(true);
  });
});

describe('verifyCommit', () => {
  const verifier = createVerifier();

  it('accepts the reference commit', async () => {
    const { signed, ctx, didKey } = referenceCommit();
    expect(await verifyCommit(signed, ctx, didKey, verifier)).toBe(true);
  });

  it('rejects a wrong version', async () => {
    const { signed, ctx, didKey } = referenceCommit();
    expect(
      await verifyCommit({ ...signed, ver: 2 }, ctx, didKey, verifier),
    ).toBe(false);
  });

  it('rejects a rev mismatch between commit and ctx', async () => {
    const { signed, ctx, didKey } = referenceCommit();
    expect(
      await verifyCommit(signed, { ...ctx, rev: 'different' }, didKey, verifier),
    ).toBe(false);
  });

  it('rejects a tampered hash', async () => {
    const { signed, ctx, didKey } = referenceCommit();
    const hash = new Uint8Array(signed.hash);
    hash[0] ^= 0xff;
    expect(
      await verifyCommit({ ...signed, hash }, ctx, didKey, verifier),
    ).toBe(false);
  });

  it('rejects a tampered mac', async () => {
    const { signed, ctx, didKey } = referenceCommit();
    const mac = new Uint8Array(signed.mac);
    mac[0] ^= 0xff;
    expect(await verifyCommit({ ...signed, mac }, ctx, didKey, verifier)).toBe(
      false,
    );
  });
});

describe('sign', () => {
  it('produces a commit that verifies against our own verifier', async () => {
    const keys = await crypto.subtle.generateKey(
      { name: 'ECDSA', namedCurve: 'P-256' },
      true,
      ['sign', 'verify'],
    );
    const signer = {
      sign: async (bytes) =>
        new Uint8Array(
          await crypto.subtle.sign(
            { name: 'ECDSA', hash: 'SHA-256' },
            keys.privateKey,
            bytes,
          ),
        ),
    };
    const verifier = {
      verify: async (_didKey, data, sig) =>
        crypto.subtle.verify(
          { name: 'ECDSA', hash: 'SHA-256' },
          keys.publicKey,
          sig,
          data,
        ),
    };

    const { records } = referenceCommit();
    const ctx = {
      space: 'at://did:plc:aaaa/space/com.example.type/k',
      author: 'did:plc:bbbb',
      rev: '3lkabcdefgh2x',
    };
    const repo = RepoCommit.fromRecords(records);
    const signed = await repo.sign(ctx, signer);

    expect(signed.ver).toBe(1);
    expect(signed.rev).toBe(ctx.rev);
    expect(signed.ikm.length).toBe(32);
    expect(await verifyCommit(signed, ctx, 'ignored', verifier)).toBe(true);
  });

  it('uses a fresh ikm per signature', async () => {
    const signer = { sign: async () => new Uint8Array(64) };
    const ctx = { space: 'at://did:plc:a/space/com.e.t/k', author: 'did:plc:b', rev: '3l' };
    const repo = new RepoCommit();
    const a = await repo.sign(ctx, signer);
    const b = await repo.sign(ctx, signer);
    expect(toHex(a.ikm)).not.toBe(toHex(b.ikm));
    expect(toHex(a.mac)).not.toBe(toHex(b.mac));
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run packages/spaces/test/commit.test.js`
Expected: FAIL — `Failed to resolve import "../src/commit.js"`.

- [ ] **Step 3: Implement**

Create `packages/spaces/src/commit.js`:

```js
// @pdsjs/spaces/commit - permissioned repo commits with deniable signatures.

import { LtHash } from './lthash.js';
import {
  COMMIT_VERSION,
  bytesEqual,
  computeMac,
  encodeCommitCtx,
} from './mac.js';
import { formatSetHashElement } from './path.js';

/**
 * @typedef {Object} SignedCommit
 * @property {number} ver
 * @property {Uint8Array} hash
 * @property {Uint8Array} ikm
 * @property {Uint8Array} sig
 * @property {Uint8Array} mac
 * @property {string} rev
 */

export class RepoCommit {
  /** @param {LtHash} [setHash] */
  constructor(setHash = new LtHash()) {
    this.setHash = setHash;
  }

  /** @param {Uint8Array|null|undefined} state */
  static fromState(state) {
    return new RepoCommit(new LtHash(state ?? null));
  }

  /** @param {Iterable<{collection: string, rkey: string, cid: string}>} records */
  static fromRecords(records) {
    const commit = new RepoCommit();
    for (const { collection, rkey, cid } of records) {
      commit.add(collection, rkey, cid);
    }
    return commit;
  }

  /**
   * Fold in every record an index describes, to compare against a commit.
   * @param {Record<string, string>} index - "{collection}/{rkey}" to CID
   */
  static fromIndex(index) {
    const commit = new RepoCommit();
    for (const [path, cid] of Object.entries(index)) {
      commit.setHash.add(`${path}/${cid}`);
    }
    return commit;
  }

  add(collection, rkey, cid) {
    this.setHash.add(formatSetHashElement(collection, rkey, cid));
    return this;
  }

  remove(collection, rkey, cid) {
    this.setHash.remove(formatSetHashElement(collection, rkey, cid));
    return this;
  }

  /** @param {{collection: string, rkey: string, cid: string|null, prev: string|null}} op */
  applyOp(op) {
    if (op.prev) this.remove(op.collection, op.rkey, op.prev);
    if (op.cid) this.add(op.collection, op.rkey, op.cid);
    return this;
  }

  applyOps(ops) {
    for (const op of ops) this.applyOp(op);
    return this;
  }

  state() {
    return this.setHash.state();
  }

  /**
   * Whether this repo's contents match a signed commit. Verify the commit first —
   * on its own this says nothing about authenticity.
   * @param {SignedCommit} commit
   */
  async matches(commit) {
    return bytesEqual(await this.setHash.digest(), commit.hash);
  }

  /**
   * Sign a commit over the current contents.
   *
   * The signature covers only the ctx, never the digest, so a leaked commit
   * proves nothing about what the author wrote. The digest is bound to the ctx
   * by a symmetric MAC instead: readers get integrity, third parties get
   * nothing. A fresh ikm per commit means each reader receives a distinct one.
   *
   * @param {{space: string, author: string, rev: string}} ctx
   * @param {{sign: (bytes: Uint8Array) => Promise<Uint8Array>}} signer
   * @returns {Promise<SignedCommit>}
   */
  async sign(ctx, signer) {
    const hash = await this.setHash.digest();
    const ikm = crypto.getRandomValues(new Uint8Array(32));
    const ctxBytes = encodeCommitCtx(ctx, ikm);
    return {
      ver: COMMIT_VERSION,
      hash,
      ikm,
      mac: await computeMac(ikm, ctxBytes, hash),
      sig: await signer.sign(ctxBytes),
      rev: ctx.rev,
    };
  }
}

/**
 * Verify a commit's signature (authenticity) and MAC (integrity). Once this
 * passes, `hash` is trusted as the author's claim about their repo, which is
 * what makes RepoCommit#matches meaningful.
 *
 * @param {SignedCommit} commit
 * @param {{space: string, author: string, rev: string}} ctx
 * @param {string} didKey
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} verifier
 * @returns {Promise<boolean>}
 */
export async function verifyCommit(commit, ctx, didKey, verifier) {
  if (commit.ver !== COMMIT_VERSION) return false;
  if (commit.rev !== ctx.rev) return false;

  const ctxBytes = encodeCommitCtx(ctx, commit.ikm);
  const mac = await computeMac(commit.ikm, ctxBytes, commit.hash);
  if (!bytesEqual(mac, commit.mac)) return false;

  return verifier.verify(didKey, ctxBytes, commit.sig);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run packages/spaces/test/commit.test.js`
Expected: PASS, 14 tests.

- [ ] **Step 5: Run the whole suite**

Run: `npx vitest run packages/spaces`
Expected: PASS across all six space test files.

Then confirm nothing else regressed:

Run: `npx vitest run packages/core`
Expected: PASS — Task 7 touched only JSDoc in `ports.js`, plus a possible `base58btcDecode` addition to `crypto.js`.

- [ ] **Step 6: Export and commit**

Add to `packages/spaces/src/index.js`:

```js
export { RepoCommit, verifyCommit } from './commit.js';
```

Run the type build to confirm the JSDoc types are coherent:

```bash
npm run build
npm run typecheck
```

Expected: no errors. `packages/spaces/src/*.d.ts` files are generated.

```bash
npx biome check --write packages/spaces
git add packages/spaces
git commit -m "feat(spaces): RepoCommit signing and verification"
```

---

## Phase 1 exit criteria

- [ ] `npx vitest run packages/spaces` passes
- [ ] `npx vitest run` passes (no regressions)
- [ ] `npm run typecheck` introduces no NEW errors relative to the branch
      point (`6a1b716`). `npm run typecheck` failing outright was never a
      realistic bar: the repo carried 91 pre-existing `error TS` lines at
      that commit (measured via `npx tsc --noEmit 2>&1 | grep -c "error TS"`
      in a clean worktree of `6a1b716`), none of which this phase is
      responsible for fixing.
- [ ] `packages/spaces/package.json` `dependencies` contains only `@pdsjs/core`
- [ ] `packages/core` does not import `@pdsjs/spaces` anywhere:
      `grep -rnE "(^|[^*])\s*import .*@pdsjs/spaces|require\(['\"]@pdsjs/spaces" packages/core/src | wc -l`
      returns 0. (A plain `grep -rn "@pdsjs/spaces" packages/core/src` returns
      2, both inside JSDoc comment prose in `ports.js` rather than real
      imports, so the check must target import/require syntax specifically.)
- [ ] `test/fixtures/space-vectors.json` `generatedFrom.sha` matches the pin in the spec's §0