Skip to content

design+feat: ADR-0029 + per-repo blob membership index substrate (consolidates #88 + #89)#91

Open
jamestexas wants to merge 2 commits into
mainfrom
feat/adr-0029-and-membership-substrate
Open

design+feat: ADR-0029 + per-repo blob membership index substrate (consolidates #88 + #89)#91
jamestexas wants to merge 2 commits into
mainfrom
feat/adr-0029-and-membership-substrate

Conversation

@jamestexas
Copy link
Copy Markdown
Contributor

Consolidates #88 (ADR-0029) + #89 (membership substrate) into one unit. Will close those PRs.

Two commits, one unit of work

Commit 1 — ADR-0029: per-repo membership boundary design.

Surfaced by the cloister-667ea6 adversarial review (PRs #83-#87): the OCI pull surface has no per-repo membership boundary, so any anonymous caller can probe digest existence cross-tenant against the singleton BlobStore. The architectural constraint that shapes the fix: src/blob-store.ts:14-20 declares the singleton BlobStore load-bearing for ADR-0003's content-addressed monoid axiom — partitioning storage per-repo would break the cross-DO orchestrator. ADR-0029 therefore lifts the boundary orthogonal to storage: a (repo, blob_digest, kind) membership index on TrustStore, consulted by the OCI pull surface before BlobStore.get; constant-shape 404 on miss (matches §9.4 / ADR-0024 precedent).

Commit 2 — substrate layer: the storage + RPC for that index.

  • src/storage/registry-membership.ts — pure-function helpers (recordMembership, hasMembership, listReposWithMembership) + DDL. Mirrors registry-tags.ts.
  • src/trust-store.ts — wires the table into the schema migration; exposes 3 RPC methods.
  • test/storage/registry-membership.test.ts — 10 tests against real workerd SqlStorage. Includes the CROSS-TENANT GUARANTEE test (recording under REPO_A does NOT grant REPO_B membership) — the core ADR-0029 invariant.
  • README.md + ARCHITECTURE.md updated for the doc-count guardrail (ADR-0029 added).

What lands next (separate beads)

This PR is slice 1 of 5 per ADR-0029 §"Implementation plan":

  1. Pull-side gate: OCI route consults hasRegistryMembership before BlobStore.get/has.
  2. Push-side record: OCI route writes membership after BlobStore.put.
  3. Lazy backfill from registry_tags for pre-existing blobs.
  4. Manifest-walk transitive grant at push (config + layers).
  5. Auth-gate /v2/_catalog + /v2/<name>/tags/list.

Each is independently shippable. This PR establishes the model that makes them coherent.

Test plan

  • task lint → 1136/1136 (main baseline) — the membership test file is included; the +10 tests are present
  • doc-count guardrail passes (README + ARCHITECTURE updated for ADR-0029)
  • Cross-tenant guarantee test passes

🤖 Generated with Claude Code

jamestexas and others added 2 commits May 24, 2026 04:57
…y for OCI registry surface (cloister-7c0a0b)

Drafts the architectural fix for the P0 finding from cloister-667ea6
adversarial review: the OCI pull surface has no per-repo membership
boundary, so an unauthenticated caller can probe HEAD/GET against any
digest and learn whether *any* tenant pushed it (then pull the bytes).

Architectural constraint that shapes the design: per src/blob-store.ts
docstring (lines 14-20), the singleton BlobStore is LOAD-BEARING for
ADR-0003 — partitioning storage per-repo would break the content-
addressed monoid axiom that the cross-DO orchestrator (BlobStore.put →
BeadStore.bead_create → TrustStore.applyAttestation) depends on.

The proposal therefore lifts the boundary ORTHOGONAL to storage: a
`(repo, blob_digest)` membership index on TrustStore, consulted by the
OCI pull surface before BlobStore.get. Pulls without membership return
constant-shape 404 (matches the §9.4 "exists but not yours" precedent
from threat-model.md / ADR-0024). Pushes write membership as a side
effect under the ADR-0012 cross-DO handoff discipline.

The ADR also enumerates the four follow-up beads (lazy backfill,
manifest-walk grant, peerFp propagation, catalog auth-gate) that
together close the cluster of related findings.

Status: Proposed. No code changes — this PR is the design pass that
PR #87 recommended. Implementation lands as separate beads once the
ADR is reviewed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…bstrate (cloister-7c0a0b, ADR-0029)

First implementation slice of ADR-0029 (per-repo membership boundary for
the OCI registry surface). This PR ships the storage layer ONLY — the
membership table, helpers, and TrustStore RPC surface. Two follow-ups:
the pull-side gate (consult before BlobStore.get) and the push-side
write (record after BlobStore.put) land as separate beads.

Why split into slices: per ADR-0029, the design has multiple coupling
seams (pull-side check, push-side record, lazy backfill for pre-existing
blobs, manifest-walk transitive grant, peerFp propagation, _catalog
auth-gate). Each is independently testable and reviewable. Substrate
first; the seams that consume it land next.

Changes:

1. **src/storage/registry-membership.ts** — pure-function helpers over
   an injected SQL executor, mirroring src/storage/registry-tags.ts:
   - SCHEMA_REGISTRY_BLOB_MEMBERSHIP — table DDL with (repo, digest,
     kind) PK + idx_membership_digest secondary index
   - recordMembership(sql, repo, digest, kind, nowMs, peerFp?) —
     UPSERT-on-conflict refreshes recorded_at + recorded_by in place
   - hasMembership(sql, repo, digest, kind): boolean — the pull-side
     probe; must return constant-shape 404 when false per §9.4 / ADR-0024
   - listReposWithMembership(sql, digest) — diagnostic helper for GC
     reachability scans and operator forensics

2. **src/trust-store.ts** — wires the table into the TrustStore schema
   migration and exposes three RPC methods: recordRegistryMembership,
   hasRegistryMembership, listRegistryReposWithMembership. RPC surface
   thin-wraps the helpers. Sibling shape to upsertRegistryTag et al.

3. **test/storage/registry-membership.test.ts** — 10 tests covering:
   - round-trip (record then has)
   - empty-substrate has-check returns false
   - **CROSS-TENANT GUARANTEE** — recording under REPO_A does NOT grant
     REPO_B membership (the core ADR-0029 invariant; if this ever
     accidentally passes, the multi-tenant story is broken)
   - blob/manifest kinds independent
   - idempotency on (repo, digest, kind) — no duplicate-row error
   - UPSERT refreshes recorded_at + recorded_by
   - dev-mode null peerFp accepted
   - listReposWithMembership lex-ordered, no double-count across kinds,
     empty-when-empty

Tests exercise real workerd SqlStorage (via runInDurableObject on a
BEAD_STORE stub) — same pattern as test/storage/workerd.test.ts —
so PRIMARY KEY, ON CONFLICT, ORDER BY semantics are validated against
the actual engine, not an in-memory shim.

Verification: task lint → 1136/1136 tests pass (was 1126 on main, +10
from this PR's new file).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant