Part One (the allocation spine) and the Profile serialization mappers.
Design: REGISTRY-DESIGN.md v0.21.
The normative anchor is the CNS/CP 2026 revision, published 16 September 2026 at
github.com/CNSCP/specification. It is pinned by
hash — 072d4435…be6c0e, 136,899 bytes — and npm run verify-spec checks that the copy
beside this repository is still that one. The same bytes are served at
raw.githubusercontent.com/CNSCP/specification/main/cnscp-2026-specification.md,
so the pin can be checked by anyone rather than only by someone holding the file.
The pin stays now that the revision is public, because a published working draft still revises: when the anchor moves, the check fails until the design and code are re-read against the new revision. It did exactly that on 9 September, and design §24.5 records what changed (the Unpublished rename, the Registry holding no unpublished content, Channels, Default). The 16 September publication moved the pin without moving any citation — the same normative sentences, every §1–§10 subsection heading identical.
The December 2022 draft is kept as history under
2022/. This code does not follow
it, and spec2026.ts refuses both superseded Status vocabularies by name ("Active" from
2022, "Draft" from the 26 Aug 2026 draft) — that shape still exists in the world, so it is
refused rather than silently reinterpreted.
Part One answers one question for the rest of the Registry:
Does an authorization exist for this actor to register or publish this name, under an active allocation?
That is spec §7.3's requirement verbatim, and authorizes() in
src/part-one/authorizes.ts is it. Part Two calls that and
nothing else — one query in, events out (design §4.1).
Built here (the spine, design §10.3):
- The §6 ownership tables — organization, member, allocation, authorization_record
authorizes(), the seam, pure over an injected store- The §3.1 name and reference grammar
- The §3.2 reserved and withheld Prefix policy
- The §4.3 append-only hash-chained audit log
- The §10.2 bootstrap: eighteen grandfathered Prefixes, with rationale, audited
The Profile mappers (src/profile/) — what Part Two stores and Part Three
will serve:
- The canonical model — representation-agnostic, per §19.2
- Both serializations: the deployed key-presence shape and the 2026 spec shape
- §9.4 conformance checking, as a report rather than a gate
- The §10.4 import policy for the 70 deployed records
Part Three resolution (src/part-three/) — §19, and the only part
other people deploy:
- Resolution at the root under the dot rule;
/profiles/alias for legacy SDKs - Content negotiation: 2026 shape by default, deployed shape on
application/json, a non-summarizing HTML page for browsers - The §18 caching split — a versioned fetch is
immutable; the unversioned selection surface is always revalidated, because that is what Match reads - Strong ETags and RFC 9530
Content-Digestfrom the stored content hash - The §19.3 allocation page, and the 404-not-an-index rule for interior names
Part Two authoring (src/part-two/) — §15, the Phase 0 publication path:
- The full lifecycle by HTTP method on the same paths resolution reads:
PUT /<name>registers (the name, and nothing else),POST /<name>/publishcarries the document in its body and freezes it — the Registry holds no unpublished content (8 Sept spec §7.3) —POST :n/deprecate,PATCH :n/header(Owner/Website only),DELETEreleases a never-published name - The additivity gate (§23 priority 2): removal, redefinition, and mandatory additions
refused with structured
{ code, gate, property, ... }findings an agent can act on ?dry_run=trueruns every gate and provably writes nothing- Credential scopes
register · steward · release · publish · deprecate · operator(§15.2), one per kind of act — a machine author holds the first three and can prepare and rehearse everything, publish nothing;operatorguards the §9.2 acts - Credentials as rows — a
credentialtable of hashed tokens with scopes, minted and revoked withnpm run operator -- credential mint | revoke | list(plususer add,member add), every act audited; the token is shown once and stored nowhere.deploy/CREDENTIALS.md - Transfers and renames (§8.4 operator form, §9.2) —
npm run operator -- allocation transfermoves a Prefix to another holder with recorded evidence (--createto make the organization);organization renamechanges a display name. Both are public journal events that instances apply; the seam makes the old holder's members and grants lapse without touching a row - Self-service identity (§15.3) — a person signs in with Google or GitHub at
/account(the Registry holds no passwords), is linked to their user by the provider's subject or a verified email, and mints and revokes their own tokens there;operatoris never mintable on the page. Membership — where a token may act — stays an operator act by email (npm run operator -- member add, withuser find --emailbeside it). A session is honoured on/auth/*and/accountonly; no act on the Registry is ever authenticated by a cookie
The MCP server (src/mcp/server.ts) — §15.1's "worth building early",
since hand-authoring by an assistant is the Phase 0 publication path:
- Ten tools — the authoring verbs,
lint_profile, and the operator act — run withnpm run mcp(stdio); configure withCP_REGISTRY_URLandCP_REGISTRY_TOKEN— the token's scopes decide what the tools may do - Deliberately THIN: an HTTP client of the same API every other client uses (§4.4 — no privileged path), so every gate and audit write happens exactly once
- Registry refusals pass through verbatim as structured findings;
publishsays IRREVERSIBLE in its description, and the working document lives with the assistant — check_publishable and publish carry it as a parameter, per the 8 Sept revision lint_profile(§16.1) is advice, not a gate: severitiesrefusal · warning · note, findings that are also refusal grounds markedgate, and a permanence note naming every newly added Property. The same findings ride along withcheck_publishablenpm run authoritativestarts the combined authoring+resolution host it talks to
The signed anchor (§20.2): the operator signs the chain head on their own machine — weekly,
and after anything irreversible — and publishes it at /.well-known/cp-anchor, with the key
list at /.well-known/cp-keys and a copy in a repository the Registry does not control. A
local instance compares the signed head against the entries it verified for itself and stops
on divergence; anyone can check from outside with
npm run verify-journal -- https://cp.cnscp.io --anchor <key id>. The Registry holds no
private key: a key it could use to sign is a key that could sign a forked head.
The root key, for checking by eye — vouched for by nothing, which is what makes it the root:
key_id cp-anchor-2026-09
fingerprint 463f 5b19 07b9 4d22 569a 5710 a1ae 2525 eaf4 a23e 2bf6 52fa 4c94 10ae a843 ca72
Seam isolation (§23 priority 6, §4.1 rule 2): with Part One down, every read keeps working and every write waits, with a structured 503 that says so — spec §7.3 requires the owner's authorization for every act on a name, so nothing is inferred from a name's recorded registrant (that fallback was removed on 12 Sept 2026). An outage is never reported as a denial.
And Part Two's storage layer (§12), enough to hold what the import produces:
profileandprofile_version, with no status column and no content columns on profile — status belongs to a version, and the Registry cannot hold unpublished content because the columns for it do not exist (8 Sept spec §7.3; migration 6 dropped them)- The narrow immutability trigger (§23 priority 3) — content, hash, bytes and version
frozen;
statusmoves published → deprecated one way;OwnerandWebsitestay mutable because spec §6.4 permits it and forbidding them is the opposite non-conformance - Version assignment under a row lock — max+1, assigned by the Registry, never by the author
Distribution and local instances (src/distribution/) — §20, the
piece spec §7.4 makes core ("a conforming Governor SHALL be able to operate from a local
Registry instance"), and the first Phase 1 delivery:
GET /distribution/snapshot— everything published, at one instant (REPEATABLE READ), with the chain head to follow from;GET /distribution/journal?since=&limit=— the §4.3 audit chain projected: every event in order, public acts with the exact preimage of theirevent_hash(so anyone can recompute it), everything else as a redacted link;GET /distribution/status. Part of the resolution profile, so every instance serves them toonpm run instance— the resolution server plus a follower: bootstraps from the snapshot, follows the journal, and refuses to move its cursor past a page whose links, hashes or documents do not verify. Answers byte-identically to the authoritative host (proven in test over the whole corpus,Content-Digestincluded), keeps a verbatim copy of the journal, writes no audit events of its own, and answers every non-GET with405and the authoritative host's URL. Seedeploy/INSTANCE.md; releasing tocp.cnscp.ioitself isdeploy/RELEASING.md- The workspace beside an instance (
src/workspace/) — §20.3, built 21 Sept: an organization's unpublished forms held on its own host next to its mirror, open to read at/<name>:unpublished, saved under a workspace credential withIf-Match, held only for names the organization holds and fortest.*(spec §7.1), never in the feed, and marked on every answer so nothing can mistake a draft for a version. The Registry surface of a host running one stays byte-identical to canon (proven in test). Its table lives in its own migration set (npm run migrate:workspace), so canon's schema never gains it - Forwarding (
src/distribution/forward.ts) — §20.3, built 21 Sept:FORWARD_WRITES=truemakes an instance relay a write on a Profile path to the authoritative host with the caller's own credential and hand the answer back verbatim, so an organization has one URL for its tools. A pipe: it holds no credential of its own, refuses nothing, logs and keeps nothing, and relays no dotless path npm run verify-journal -- https://cp.cnscp.io --resolve— the same verifier as a standalone tool: walks the chain, then checks that every published version the host serves hashes to what its act recorded. No state, no credential — spec §9.3's "independent parties can detect whether copies agree", done by one- Not yet: the signed anchor of the chain head (§25 Q12).
anchor: nullsays so
One §9.2 operator act exists — POST /operator/allocations (and npm run allocate),
allocating a NEW Top Level Prefix as a Phase 0 operator ruling in the §10.2 bootstrap's
mold: evidence named, policy consulted and never overridden, organization + allocation +
optional day-one membership created in one audited transaction, dry_run first-class.
Guarded by the operator scope, which no authoring credential carries by default.
Not built, and not stubbed — the rest of Phase 2 (§25): applications, verification challenges, renewal, redemption, transfers, disputes, releases of withheld Prefixes, and the remainder of the §9.2 operator plane. A route that returns 501 invites a client to be written against it, so those routes are absent instead.
One absence is permanent rather than pending. There is no endpoint anywhere to alter, unpublish, or withhold a published version. Spec §9.3 forbids all three, so the capability does not exist in the codebase — its absence is the enforcement.
npm install
npm test # 410+ tests: unit + against a real Postgres (PGlite)
npm run test:unit # the pure logic, milliseconds
npm run test:integration # migrations, triggers, constraints, the hash chain
npm run typecheck
npm run verify-spec # is the normative anchor still the one we built against?
createdb cp_registry
cp .env.example .env # set DATABASE_URL and INTERNAL_SEAM_TOKEN
npm run migrate up
npm run seed -- --dry-run # print the bootstrap plan, write nothing
npm run seed # apply it, audited
npm run devThe unit tests need no database. authorizes() takes an injected OwnershipStore, and
memory-store.ts is a second real implementation of that
interface rather than a mock — so the whole ownership chain, including every negative, is
exercised in milliseconds.
The integration tests bring their own. PGlite is Postgres compiled to WebAssembly, and
pglite-socket puts it behind the real wire protocol, so node-pg-migrate and the pg
client connect unmodified and the migrations, triggers and plpgsql run as Postgres. No
server to install, no container, nothing to configure.
They exist because two bugs got through review without them. The audit chain shipped
char(31) where chr(31) was meant — every unit test passed and migrate up would have
failed on the first run. And audit_chain_verify had an OUT parameter named found,
which silently shadows plpgsql's built-in FOUND boolean; it only failed on the path that
detects tampering, which is the one path that matters. Anything the database enforces
needs a database to prove it.
One caveat: PGlite here is PostgreSQL 18 and §23 targets 16. Everything the schema uses is PostgreSQL 11 or older, so the gap is narrow — but CI should eventually run a real 16.
Registration and publication are gated differently, and it is a conformance question rather than a preference. §14 lists allocation state under Registration; the Publication rows are content gates only. §7.2 and §14 both say a steward hold "suspends new registration" and "cannot alter, unpublish, or refuse to serve anything already published". So a locked or closed allocation stops new names appearing beneath it and does not stop a version being published on a name already there. The ownership chain applies to both — only the allocation's own state is waived.
cp.padi.io holds 70 records across 18 de-facto Prefixes. Spec §7.1 grandfathers them as
allocated, but not to anyone in particular. Design §10.2 says to whom, and
src/seed/grandfathered.ts is that ruling as data:
| Disposition | Prefixes | |
|---|---|---|
| Operator's own | padi cns haystack dbp kube modbus hello |
Ordinary holdings |
| Operator-held, pending claimant | onuma ibb kubecns skycentrics c4sb novant openjs |
Released to the evident owner on verification (§8.1). Named publicly so nobody can race the Prefix, without asserting an ownership nobody has verified. |
| Withheld | proto acme xyz |
Held by the operator, closed to new registration |
| Spec-reserved | test |
No allocation row is created, ever |
npm run seed # allocations first
npm run import -- --file test/fixtures/cp-padi-io-profiles.json --dry-run
npm run import -- --file test/fixtures/cp-padi-io-profiles.jsonReal Connections bind against the deployed records, so they are imported as published
versions, marked grandfathered, with their §9.4 shortfalls recorded rather than filled.
69 names registered, 69 versions published, 1 excluded. Two names — padi.appliance and
padi.device — are registered with nothing published, which §12.1 describes exactly: "a
registered name with a Draft and nothing else."
Nothing is invented. Where a source record has no Owner, the version is published
without one and missing_header_fields says so. Publication freezes content immutably and
forever (§12.2, spec §6.2), so a plausible-looking substitute inserted at import would be
permanent, and a synthesized Owner is a false statement about who is responsible for a
contract. The owner's remedy is available immediately: a Draft persists alongside published
versions, so anyone can complete their Header and publish a conforming version 2.
Three gaps do get resolved, because they have honest answers: Version by array position
(spec §6.2 makes assignment the Registry's job), Status as Published, and Pub Date from
the record's created date — flagged approximate on every version, since the legacy
format has no publication date.
Counts come in two flavours and both are true: 39 conforming versions across 38
conforming names (padi.game.presence has a complete Header and two versions), and the
per-field shortfall table counts versions, so a two-version record missing a field counts
twice.
Two records do not survive the import:
proto— a bare single-segment record. Spec §7.2: a one-segment reference denotes an allocation and is never a Profile. The grammar refuses it independently of the seed data, so an importer that ignored the exclusion list still could not register it.test.abc— republished aspadi.test.abc, an existing Padi convention.testis spec-reserved and never globally resolvable.
Every seeded allocation carries grandfathered = true and an audit event naming the
ruling that put it there, so the bootstrap is as inspectable as anything that follows it.
migrations/ §6 tables, §4.3 audit chain, immutability triggers
src/
names.ts §3.1 grammar — the ONLY place a name is validated
policy.ts §3.2 reserved and withheld Prefixes
audit.ts §4.3 chain, application side
db.ts pool and inTransaction()
part-one/
authorizes.ts THE SEAM (§9.3)
types.ts OwnershipStore — everything the seam may read
memory-store.ts in-memory implementation, for tests and seed data
pg-store.ts Postgres implementation
routes.ts §9.1 subset + the seam over HTTP
seed/
grandfathered.ts §10.2 as data
run.ts idempotent, audited loader
distribution/
journal.ts §20.1 wire types and the PURE verifier — shared by instance, CLI and tests
store.ts snapshot and journal reads (audit projection / instance copy)
routes.ts /distribution/*, and the instance's 405 refusals
forward.ts §20.3 relay: the caller's own credential to the authoritative host
follower.ts bootstrap + sync: verify, apply, copy, advance — one transaction per page
verify-cli.ts npm run verify-journal
instance-server.ts a local instance: resolution + follower (§7.4), and the workspace beside it (§20.3)
workspace/
config.ts WORKSPACE_ORGS / WORKSPACE_TEST
credential.ts the workspace credential (environment form; one function, so the next form is a drop-in)
store.ts qualifies · save/get/remove with If-Match · presentForm · movedOnSince · darkForms (reports; never deletes)
routes.ts GET/PUT/DELETE /<name>:unpublished, /workspace, the pill and the banner
test/ 563 tests
-
Governance state must never reach the read path. A suspended organization, a locked allocation, a dispute in flight — none of it may affect resolution of published versions, which spec §9.3 answers to any party regardless.
authorizes()is a write-path check only. The moment a resolution query reaches it, the Registry is non-conforming (§4.1 rule 1). -
No relationship may be inferred from a name's shape (spec §7.1, §7.7).
acme.meter.flowis registrable whether or notacme.meterexists.names.tsoffers noparent(), nochildren(), no tree walk. Authorization scopes are string prefixes, andscopeCovers('ashrae.135', 'ashrae.1350')is false — the segment boundary is tested, because getting it wrong is a one-character bug that grants somebody else's namespace. -
Names compare exactly (spec §7.2). Nothing normalises, case-folds, or trims an input on its way to storage. Uppercase is refused with a message, never silently lowercased.