Skip to content

feat(gateway): durable, write-once, verified Capability Kit registry (kits K1 slice 1) - #511

Draft
LamaSu wants to merge 8 commits into
masterfrom
feat/kits-registry-k1
Draft

LamaSu wants to merge 8 commits into
masterfrom
feat/kits-registry-k1

Conversation

@LamaSu

@LamaSu LamaSu commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

Kits K1 slice 1 is the durable Capability Kit registry (technical section 7, MUST-CLOSE 1-2; ledger row 6, "no in-memory-only production catalog").

Stacked on #397 (SHIP 112e, MERGE-READY). It uses #397's CapabilityKitManifestV1, normalizeKitManifest, computeKitDigest and validateKitCompleteness. Merge after #397.

A kit version is the exact canonical bytes of its normalized manifest, keyed by kitDigest = sha256(those bytes). Who published it and when is server metadata, never part of the hashed bytes.

Storage: write-once files on the durable volume

  • <root>/manifests/: the manifest bytes, through the existing FsRegistrySnapshotStore (write-once, collision-verified).
  • <root>/publications/: one publication record per digest, {schema, kitDigest, publisher, publishedAt}. It is created EXCLUSIVELY (a hard link from a temp file), so the first publisher wins even across processes, and a record is never overwritten.
  • Root: PCC_KIT_REGISTRY_DIR, else $RAILWAY_VOLUME_MOUNT_PATH/kit-registry, else ./data/kit-registry. In production with neither variable set, publish answers 503 registry_not_durable.
  • Not in capability_template_store: on master any key can rewrite template rows through the generic template routes (kits D5). fix(gateway): bind job-offer claims/events, tool-catalog entries and templates to their authenticated owners (kits K0 slice 2; merge after WP-A) #395's fix for that waits on WP-A, and a kit row there would also surface in the template list, fork and rate routes. No schema change.

Integrity: verified on every read, fail closed

GET /api/kits/:digest re-reads the bytes and requires all of these:

  • sha256(bytes) equals the digest;
  • the bytes are exactly the canonical form of a valid manifest;
  • a strict, canonical publication record names the same digest.

Anything else answers 500 kit_integrity_failure, and the bytes are not served. The listing is a cache rebuilt from the files at first use; an entry that fails verification is skipped, never listed.

Routes

Route
GET /api/kits filters csdUrl, deviceFamily, interface, q; limit (max 100) and offset; newest first
GET /api/kits/:digest {kit: {kitDigest, manifest, publishedAt}}
POST /api/kits publish: authenticated (401 otherwise); complete kits only (422 incomplete_kit); parent must exist (422 unknown_parent); 256 KiB cap (413); at most PCC_KIT_PUBLISH_DAILY_LIMIT (default 20) new kits per publisher per 24 h (429); re-publishing a digest returns the existing publication (200, created:false)
POST /api/kits/:digest/fork as publish, plus parentKitDigest must equal :digest (400 parent_mismatch; 404 if absent)
  • Scope defaults: POST /api/kits and POST /api/kits/** require template_author, operator or admin, like template authoring. Self-service * keys pass, as everywhere. Deployments that customise endpoint scopes need rows for these two routes.
  • Audit: kit.published and kit.forked events (actor, capability-kit, digest).
  • Privacy: no response carries the publisher, which may be an email or a wallet.

Not in this slice

Tests

packages/gateway/src/__tests__/kits-registry.test.ts (44) and kits-scope.test.ts (7) were written black-box from the spec by a Sonnet test writer, then reviewed and extended by me. They cover:

  • publish and read round-trips, plus order-insensitive identity;
  • integrity: a flipped byte, hash-valid non-canonical bytes, another kit's canonical bytes under a digest, a mismatched or missing record, reload skipping;
  • concurrency: same instance, and two instances on one root (exactly one record);
  • validation: 400/413/422/404, auth (401) and privacy (the publisher is in no response body);
  • quota, including a re-publish at the cap;
  • the durability guard and the isDurableKitRoot environment matrix;
  • list filters, ordering, pagination and bad queries;
  • audit events and scope requirements.
Check Result
gateway vitest 208 files, 3758 passed / 13 skipped
tsc --noEmit (gateway) exit 0
test files typecheck clean
my mutation pass 14/15 killed. The survivor is the route's principal check, equivalent because the service refuses a missing principal too
secret scan over the diff 0

Round 2: astra k1-511 fixes, at fe74533

All 7 findings were reproduced at 47e6eec first.

  • Authorization (both HIGHs): POST /api/kits and /fork check the publishing role themselves: an API key with template_author, operator, admin or *, read with getCallerScopes. This holds whatever the governance table holds. A SIWE session is refused (403 kit_publish_requires_api_key) until sessions have a role model.
  • No cache: list, get, the existence check and the quota read the files every time. Another process's publish is seen at once, and a file that stops verifying stops being listed.
  • No symlinks: files open with O_NOFOLLOW and every registry directory is lstat-checked. The configured root may be a mount symlink, resolved once.
  • Hard-link probe before the first write: a volume without links gets 503, and no orphan manifest.
  • An exact cross-process quota without a lock: claim n under quota/<sha256(publisher)>/ is an exclusive create, and needs claim n − limit to be at least 24 h old.

Evidence: gateway 208 files, 3774 passed / 13 skipped; tsc 0; mutations 20/20; secret scan 0. Review pack: k1b-511-kits-registry-r2-fe745332.

🤖 Generated with Claude Code

https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A

LamaSu and others added 6 commits October 3, 2026 00:50
…(kits K1 slice 1)

A kit version is the exact canonical bytes of its normalized manifest,
keyed by its kitDigest (@pcc/spec capability-kit.ts, #397).

- Authority lives in write-once files on the durable volume:
  - manifests/: through FsRegistrySnapshotStore;
  - publications/: one record per digest, created exclusively (link), so
    the first publisher wins.
  It is NOT a capability_template_store row: on master any key can rewrite
  template rows (kits D5), and #395's fix waits on WP-A.
- Every read re-verifies the bytes against the digest, the canonical form
  and the publication record, and fails closed (500 kit_integrity_failure).
  The index is a cache rebuilt from the files at first use; an entry that
  fails verification is skipped, never listed.
- Routes:
  - GET /api/kits (filters);
  - GET /api/kits/:digest;
  - POST /api/kits (authenticated; complete kits only; a 24h per-publisher
    cap; 503 in production without a durable root);
  - POST /api/kits/:digest/fork (lineage checked).
  Scope defaults match template authoring. Audit events on publish and fork.
- The publisher is never in a response.

Deprecation (publisher ownership, after WP-A), S4 ranking and the tool
catalog as a view over kits are slice 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
…K1 slice 1)

Adds service-unit and Fastify app.inject route tests for KitRegistry and
kitRoutes covering publish/read round-trip, integrity (bit-flip, non-canonical
bytes, mismatched/missing publication records), same-instance and
cross-instance publish concurrency, validation (invalid manifest, 413 size
bound, incomplete kit, unknown/mismatched parent, invalid digest), auth,
publisher privacy, the rolling 24h publish quota, the durability guard
(including isDurableKitRoot env-matrix), list filters/ordering/pagination,
registry reload, and audit log entries. Also adds scope-checker coverage for
the new POST /api/kits and POST /api/kits/** default scope requirements.

All 50 new tests pass against the current implementation; no spec deviations
were asserted away.

Agent: test-writer-kilo

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
…r costs quota (K1 own review)

My mutation pass over test-writer-kilo's black-box suite found four
survivors.

- Read path: the digest recompute was implied by the sha256 check plus the
  canonical-form check, so each made the other an equivalent mutant. It is
  removed. A new test stores another kit's valid canonical manifest under a
  digest, so the hash check is required on its own.
- Re-publish: re-publishing an existing digest must never cost the
  re-publisher quota. A new test has a publisher at their cap re-publish
  another's kit (200, publisher unchanged).
- The route's principal check stays as defense in depth. It is equivalent:
  the service refuses a missing principal itself.

Mutations 14/15 killed (the survivor is that equivalent route check).
Gateway 208 files, 3758 passed / 13 skipped; gateway tsc 0; the kits test
files typecheck clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
…e; the files are the only truth

Every finding was reproduced at 47e6eec first.

HIGH 1 + HIGH 2 (authorization):
- scope-checker replaces its defaults with the governance table whenever
  that table has rows, so the kit rules could be absent and a
  contributor:read key published;
- it also skips callers without an API key, so a SIWE session published.
The kit routes now decide the role themselves: an API key holding
template_author, operator, admin or *. A session carries no scopes and is
refused (403 kit_publish_requires_api_key). getCallerScopes is exported for
this, and is injectable in kitRoutes for tests.

MEDIUM (storage; services/kit-registry.ts rewritten):
- No cache. list(), get(), the existence check and the quota read the files
  every time, so a file that stops verifying stops being listed and another
  process's publish is seen at once.
- No symlinks: every file opens with O_NOFOLLOW and every registry
  directory is lstat-checked. The configured root may be a mount symlink,
  resolved once.
- A hard-link probe runs before the first write, so a volume without links
  gets 503 registry_unsupported_fs and no orphan manifest.
- An exact cross-process quota without a lock: claim n (an exclusive create
  under quota/<sha256(publisher)>/) needs claim n - limit to be at least 24h
  old; a malformed claim counts as recent.
- Manifests now use the same exclusive-link write as records (a second
  writer must carry identical bytes).

Gateway 208 files, 3774 passed / 13 skipped; gateway tsc 0; the kits test
files typecheck clean; mutations 20/20 killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
…fail closed without O_NOFOLLOW

astra k1b (SHIP-WITH-FIXES) kept root confinement open on three points,
each checked at fe74533 first:

(a) REPRODUCED. scan() read <root>/publications before checking it, so a
    symlinked area was listed (nothing was served from it). The area is now
    lstat-checked first; a symlink or non-directory is a KitIntegrityError.
(b) NOT REPRODUCED. dirFor creates and lstat-checks one path component at a
    time. With <root>/manifests or <root>/quota symlinked to an empty outside
    directory, publish fails and the outside directory stays empty. No change;
    the case is now a test.
(c) REPRODUCED by trace: `O_NOFOLLOW ?? 0` silently followed symlinks on a
    runtime without the flag. Now the capability probe refuses to publish
    (503 registry_unsupported_fs) and reads fail closed (KitIntegrityError).
    noFollowFlag is injectable for tests.

Gateway 208 files, 3778 passed / 13 skipped; tsc 0; kits test files clean;
mutations 23/23 killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
Comment-only. The publishRefusal doc comment in routes/kits.ts and the
section comment above the route-only tests in kits-scope.test.ts now say
that the kit routes decide the publishing role themselves, so the check
holds for any deployment configuration. No code, test name or assertion
changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zYzsSSFUHbPV5DNxssX4A
@LamaSu
LamaSu changed the base branch from feat/kits-spec-contracts to master October 7, 2026 00:07
@LamaSu LamaSu closed this Oct 7, 2026
@LamaSu LamaSu reopened this Oct 7, 2026

This branch had an error being deployed

1 failed deployment
trusted-checks — 41864713 Deployed Oct 7, 2026 by LamaSu via post-verdicts #195
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