Skip to main content
Decision 10 amended 2026-10-27 (STA-1355) by 0461 decision 3: inside the @stackra-pro scope a per-name internal row covers @stackra-pro/ui, so only Figentra’s own internal tokens read it and a customer grant, names: "*" included, never covers it. The two scope rows and the rest of decision 10 stand.
Accepted 2026-10-27 (STA-1333, batch stackra-181): the owner decided the registry in the questionnaire of 2026-10-27 (Stackra finish plan, decision D5). Supersedes decisions 1 to 3 of 0177 and the registry decisions of its amendment of 2026-10-21. Amends 0025 (decisions 1 and 2), 0077 (decision 2), 0178 (decisions 1 to 3, for registry access), 0175 (decision 3), 0290 (decisions 16 and 17), 0316 (decisions 1 and 2, the channel only), 0168 (decision 6) and 0349 (decision 4, the channel only).

Context

0177 sent @stackra/* to npmjs and @stackra-pro/* to Keygen Cloud. Neither is available or wanted now: the npmjs @stackra organisation was deleted and its name is locked until about 2026-11-26, and the owner decided on 2026-10-27 to use no paid service and no Keygen. Stackra also ships more than packages: skills, agents, MCP servers and plugins, which today have no versioned home. The analysis of 2026-10-20 (notes/pk2-registry-analysis.md) scored a Worker with R2 best on cost and licence fit, worst on the code we must own, and left one question open since 0074: whether a registry can hold per-customer, revocable, expiring tokens. The owner chose that option: our own npm-compatible registry on Cloudflare from day 1, one store for every kind of item, and our own licence engine; the registry is on the 0.1.0 finish line. Alternatives weighed: npmjs for free and a managed registry (Cloudsmith) for Pro, rejected as a paid service and two stores; Verdaccio on a VM, rejected for its single writer and the ops burden; GitHub Packages, rejected because every customer would need a GitHub account; waiting for the npmjs scope to unlock, rejected because the finish line does not wait and our registry does not need npmjs.

Decision

Host and member

  1. Host: npm.stackra.figentra.com. The name the docs already use (0284, workers/static/README.md) is kept, and 0290 decision 17 (delete the name) and 0175 decision 3 (point it at Keygen) are reversed. One host serves the npm protocol and the catalogue API; there is no second host for Pro, for licences or for the catalogue. The Worker takes the name as a Worker Custom Domain, as static. does (0284 decision 9).
  2. Member: workers/registry, package @figentra/worker-registry, Worker script stackra-registry, under every rule of 0284 decision 1 (tier:app, UNLICENSED, tests in __tests__/). Its wrangler.jsonc is hand-written under the carve-out of 0284 decision 8 until gen writes Workers config. It deploys only from CI (.github/workflows/deploy-registry.yml, GitHub environment registry), with the Cloudflare token read through Infisical OIDC as 0284 decision 7 says; nobody runs wrangler deploy from a workstation. The R2 bucket, the D1 database, the KV namespace and the custom domain are Pulumi resources in @stackra/infra-cloudflare.

Storage

  1. R2 holds bytes, D1 holds truth, KV holds caches. The licence-token check is cached in the Workers Cache API of each data centre for registry.tokenCacheSeconds (default 60), not in KV, so the cache costs no KV writes and a revocation is effective everywhere within that time. Backups are D1 Time Travel; tarballs are immutable and rebuildable from the tagged release.

npm protocol surface

  1. The registry speaks the subset of the npm registry protocol that npm, pnpm and Yarn need to install and publish scoped packages, and nothing else. Everything else is refused: npm login and adduser (PUT /-/user/*) answer 403 with a pointer to the licence docs, unpublish (DELETE of a package or version) answers 405, search and audit endpoints answer 404 (a consumer maps only the Stackra scopes to this host, so audits of third-party packages stay with npmjs). The registry never proxies an upstream: an unknown name is 404, never fetched from npmjs, so no one can inject a package by name.
  2. Caching headers. Open tarballs: Cache-Control: public, max-age=31536000, immutable. Licensed tarballs and packuments: private, no-store. Open packuments: public, max-age=60.

Publishing

  1. Only the release workflows publish, through GitHub OIDC. The publishers table names, per scope, the trusted publisher: @stackra is figentra/stackra, @stackra-pro is figentra/stackra-pro, each with workflow .github/workflows/release.yml, ref refs/heads/main and environment release, matched on the numeric repository_id and repository_owner_id claims as well as the names, so a renamed or recreated repository does not inherit the right. The job requests a GitHub ID token with audience npm:npm.stackra.figentra.com and exchanges it at the endpoint of decision 4 for a publish token bound to that one package name, valid registry.publishTokenSeconds (default 900), stored hashed. This is the exchange shape npm’s trusted publishing uses; where the client does not run it against a host other than npmjs, the release workflow performs the exchange in one step and passes the token as NODE_AUTH_TOKEN for that job. There is no long-lived publish token, no human publish and no break-glass account; a fix is a new run of the workflow. 0289 decision 11 (a 7-day npm token for the first publish) does not apply here, because a trusted publisher is a scope row, not a per-package npmjs setting.
  2. What a publish must pass. The Worker refuses a version that already exists (versions are immutable, also after a deprecation, 0013); a tarball over registry.maxTarballBytes (default 50 MiB); a tarball whose package.json name or version differs from the request; a stackra.kind outside decision 11; a scope whose licence rule fails (@stackra-pro/* must name the commercial licence and carry its LICENSE, and must contain no src and no source maps, 0316 decision 2; @stackra/* must not name the commercial licence, and is MIT or, for a vendored third-party item, its upstream open-source licence with a NOTICE, 0024 decision 5); a missing provenance bundle; and a client integrity or shasum that differs from the one the Worker computes. Dist-tag changes and deprecations need the same publish token. Every publish, tag change and deprecation is an audit_events row naming the workflow run.

Integrity and signing

  1. Integrity. The Worker computes the SHA-512 dist.integrity (SRI form), the SHA-1 dist.shasum and the SHA-256 digest of each tarball at publish and stores them; installs verify integrity as npm clients always do.
  2. Registry signatures and provenance. Each version carries dist.signatures: [{ keyid, sig }], an ECDSA P-256 signature over <name>@<version>:<integrity>, the format npm’s registry signatures use, so npm audit signatures can verify our packages against the keys at /-/npm/v1/keys (keytype: ecdsa-sha2-nistp256, keyid = SHA256:<base64 of the SHA-256 of the public key's SPKI DER>, expires). The private key is the Worker secret REGISTRY_SIGNING_KEY, sourced from Infisical prod /registry (0173); it rotates yearly, and a rotated key stays listed with its expires so old signatures still verify. Provenance: the release workflow attests each packed tarball with GitHub artifact attestations (Sigstore, actions/attest-build-provenance) and sends the bundle with the publish. The Worker checks that the bundle’s subject digest equals the tarball’s SHA-256 and that its certificate names the trusted publisher’s repository and workflow, stores it in R2 and serves it (decision 4). Full Sigstore chain verification is done by consumers (gh attestation verify) and by the release smoke job, not by the Worker.

Scopes and kinds

  1. Read rules are scope rows, not code. The scopes table ships with two rows: A third rule, internal, exists for the restricted @figentra/* channel of 0349 decision 4: its row is added by the first OS batch that publishes, with the OS release workflow as its trusted publisher; it is not part of 0.1.0. No other scope is added without an ADR. A consumer’s .npmrc maps the scopes to the host and sends the licence token for it:
  2. stackra.kind and the catalogue. Every item is an npm tarball whose package.json may carry "stackra": { "kind": <kind>, ... }. The kinds are exactly package, skill, agent, mcp and plugin; a missing stackra.kind means package. A non-package item is named @<scope>/<kind>-<name> (for example @stackra/skill-archify), so items and packages never collide. stackra add <kind> <name> (stackra-184) resolves @stackra/<kind>-<name>, then @stackra-pro/<kind>-<name>, and installs: Non-package installs are pinned by name, version and integrity in stackra-items.lock.yaml at the workspace root (its schema is stackra-184’s). The stackra field’s schema per kind is a contract of @stackra/skills (./contracts: StackraKind, StackraManifest, CatalogueItem, CataloguePage), which the Worker and the CLI both import. The package kind’s tier, category, runtimes and summary were added by the amendment of 2026-10-27 at the end of this page.
  3. The catalogue API is read-only and lists every item of the open and licensed scopes to anyone, metadata only (no tarball, no manifest file contents beyond stackra):
    • GET /-/stackra/v1/catalogue?kind=&scope=&q=&cursor=&limit= returns CataloguePage: { items: CatalogueItem[], next: string | null }, newest publish first, limit at most 100.
    • GET /-/stackra/v1/catalogue/@scope/name returns one CatalogueItem with versions.
    • CatalogueItem is an object of name, scope, kind, description, latest, distTags, access ("open" or "licensed"), licence, keywords, repository, homepage, publishedAt, deprecated and stackra.
    • GET /-/stackra/v1/entitlements with a licence token returns what it may read: { holder, grants, expiresAt, status }; the CLI shows it as stackra whoami.

Licences

  1. Our own licence engine, in D1. A licence is { id, holderRef, grants, notBefore, expiresAt, status }, where holderRef is the opaque reference of the customer account in Figentra OS, status is active, suspended or revoked, and each grant is { scope: "@stackra-pro", names: "*" | string[] } (an item of any kind is a name). A licence has one or more tokens (one per CI system or machine, each with a label), up to registry.licence.maxTokens (default 20). A token is stkl_ followed by 43 base64url characters (32 random bytes), shown once at issue and stored only as its SHA-256 hash; a fast hash is enough because the token is random, and the prefix lets gitleaks find a leaked one. Each token and each licence can be revoked on its own. Reads of a licensed scope are refused once the licence is suspended or revoked, and registry.licence.graceDays (default 14) after expiresAt; a suspension or an expiry never touches anything a customer already installed (0178 decision 4). Downloads count per licence and day in downloads_daily; no IP address is stored.
  2. The licence API and the OS billing hook. The licence API is /-/stackra/v1/licences: POST issues a licence (with its first token), PATCH /:id renews (expiresAt) or sets the grants, POST /:id/suspend, POST /:id/resume, POST /:id/revoke, POST /:id/tokens and DELETE /:id/tokens/:tokenId. Its caller is the Figentra OS billing and licensing service, authenticated by an issuer token (role issuer, stored hashed in issuer_tokens, its value in Infisical prod /registry, rotated by the owner). Every call carries Idempotency-Key, the OS event id, so a retried billing webhook is applied once. The contract is the command and result types in @stackra/licensing ./contracts (RegistryLicenceCommand, RegistryLicence, RegistryLicenceGrant): OS maps a subscription start to issue, a renewal to renew, a failed payment to suspend, a cancellation or refund to revoke, and a plan change (plans from @stackra/entitlements) to set the grants. The registry holds no prices and no plans.
  3. The LicenceIssuer port gets an own-issuer driver, the default. @stackra/licensing adds the registry driver of LicenceIssuer over the licence API (stackra-183) and makes it the default; the keygen driver stays, optional, for an on-prem customer who runs Keygen. A signed offline licence file for on-prem (checkOut) is signed with its own Ed25519 key, LICENCE_SIGNING_KEY, never with the registry signing key.

npmjs and npm.figentra.com

  1. The npmjs mirror is optional and off. When the @stackra name unlocks on npmjs (about 2026-11-26), the owner re-registers the organisation anyway, so no one else can claim it and confuse a consumer whose .npmrc lacks the scope mapping. If the owner switches the mirror on (repository variable STACKRA_NPM_MIRROR=on), the release workflow publishes the same @stackra/* tarballs, byte for byte, to npmjs with npm trusted publishing and provenance, after they are on our registry. Our registry stays the source of truth: same versions, no npmjs-only version, never @stackra-pro/*, never a non-package kind unless the owner says so. A failed mirror publish is retried with the same tarball, never rebuilt.
  2. npm.figentra.com is retired. The npflared Worker npm-registry gives up the npm.stackra.figentra.com name before stackra-registry takes it, and keeps serving only npm.figentra.com. Its remaining consumers (@figentra/cli and Academorix) move to this registry: a package that still has a consumer is republished from its source through a release workflow (its tarball is not copied, because only CI publishes), under the internal row of decision 10. When the last consumer has moved, npm.figentra.com answers 410 Gone for 30 days; then the owner deletes the npm-registry Worker, its R2 bucket and its DNS record (through Pulumi once the zone is imported, 0290 decision 15). The NPM_FIGENTRA_TOKEN secret is deleted from Infisical the same day.

Versions and settings

  1. One shared version. @stackra/* and @stackra-pro/* release at one exact semver, first 0.1.0 (amending 0177 decision 5, which let Pro follow within a week). Decisions 4 and 6 to 8 of 0177 stand: release checks, deprecation, canaries (dist-tag canary) and the @stackra/source condition.
  2. Every limit is a setting. registry.maxTarballBytes, registry.publishTokenSeconds, registry.tokenCacheSeconds, registry.licence.graceDays and registry.licence.maxTokens are Worker vars with the defaults above; scopes and publishers are D1 rows seeded by a migration. No limit or scope is a constant in code.

Consequences

  • Free installs need no account and no token; Pro installs need one licence token in .npmrc; publishing needs no secret at all.
  • We own an npm registry. Its protocol subset, its tests and its incidents are ours (stackra-182), and a client change in npm or pnpm can break it; the release smoke test installs from it with npm, pnpm and Yarn.
  • Supply chain: no upstream proxy, immutable versions, registry signatures that npm audit signatures checks, and a provenance bundle per version. npm does not show our provenance on a package page; that needs the mirror.
  • Cost: the Workers Free plan covers launch (100,000 requests a day, D1 5 million reads a day, 10 GB of R2); the owner moves to Workers Paid (5 USD a month) when daily requests pass about 80,000.
  • 0178 no longer needs Keygen for registry access; Keygen is an optional on-prem driver.
  • The open question of 0074 and 0077 (per-customer, revocable tokens) is closed by decision 13.
  • Chores: the owner creates the registry GitHub environment, the Infisical /registry path with REGISTRY_SIGNING_KEY and the issuer token, re-registers the npmjs organisation when it unlocks, and switches off npm.figentra.com (decision 17).

Enforcement

planned — every rule of decisions 4 to 14 is a check in workers/registry with its tests in __tests__/ beside the code (stackra-182, stackra-183): the refused routes, OIDC claim matching, the publish checks of decision 7, signatures against /-/npm/v1/keys, scope read rules and licence status. The release workflows (stackra-185) publish only through the exchange of decision 6; zizmor checks their id-token: write scope. tools/scripts/validate-repository.mjs keeps refusing a member here that depends on @stackra-pro/*. A gitleaks rule for the stkl_ prefix is planned. Nothing checks the deletions of decision 17; they are owner steps logged in Linear.

Decided, with triggers

  1. Third-party publishers. Only Figentra’s release workflows publish. Opening the catalogue to outside authors needs its own ADR, when the first outside item is requested.
  2. Perpetual fallback. An expired licence does not keep access to versions released before its expiry. Revisit at the first customer contract that asks for it.
  3. Mirror. Decision 16 stays off until the owner switches it on.

Amendment 2026-10-27 (owner decision): the package fields of stackra and the member metadata

Context. Every member’s package.json carried a description, licence and exports, but no keywords, homepage, bugs, author or engines, and nothing an agent could read to place a member without opening its code: its tier, its category, where it runs and when to reach for it. The owner decided on 2026-10-27 that every member of this repository carries both, in one checked shape, and that decision 11’s stackra field is where the agent-readable part lives. Decision. This amends decision 11; the kinds and the other kinds’ fields are unchanged.
  1. The package kind carries four more keys. "stackra": { "kind": "package", "tier", "category", "runtimes", "summary" }. tier is lib, app, service or config (the turbo tier tag without tier:); category is the packages/<category> folder (tooling included) and is absent for apps, services and Workers; runtimes is a non-empty set from the file contract’s Runtime: vocabulary (isomorphic, node, browser, worker, react-native), read from the headers of the files its export entries name; summary is one sentence for agents on when to reach for the member, never a copy of description. STACKRA_MANIFEST_SCHEMA (@stackra/skills/contracts, with the vocabularies StackraTier, StackraCategory and StackraRuntime) checks them and keeps them optional, so a package item published from elsewhere still parses; exports and description are never repeated there.
  2. Every member of this repository writes "kind": "package" and all four keys, and the npm fields keywords ("stackra", the category, then 3 to 8 domain terms; no tier or runtime words), repository with its directory, homepage (https://github.com/figentra/stackra/tree/main/<dir>#readme), bugs (https://github.com/figentra/stackra/issues), author (Figentra (https://figentra.com)) and engines ({ "node": ">=24.0.0" }). Versions, private and publishConfig are not part of this amendment.
  3. Turbo tags follow the field. A member’s turbo.jsonc tags are exactly tier:<tier>, then category:<category> (packages only), then runtime:<r> for each runtime in runtimes order. Exactly one tier tag stays the rule, and the boundary rules of the root turbo.jsonc still name tier tags only.
  4. pnpm validate enforces it (checkMemberMetadata of @stackra/repo-checks, run in the Stackra repository): it refuses a missing field, a stackra field the built STACKRA_MANIFEST_SCHEMA refuses (CI builds before it validates), a tier or category that disagrees with the folder, and turbo tags that differ from the field. pnpm new and stackra new write the fields, so a new member passes.
Consequences. Agents and the catalogue read a member’s tier, category, runtimes and purpose from package.json alone, and turbo run --filter can select by category or runtime tag. Each new member needs a --summary sentence.

Amendment 2026-10-31 (owner decision): the @figentra-oss scope

Figentra Flow is MIT, so it cannot publish to @figentra, which is internal with a commercial licence rule. The owner added an open scope for Figentra’s open products (migration 0005_figentra_oss.sql); this amendment is the ADR decision 10 asks for before another scope is added. The scope rows are now: Every publisher uses .github/workflows/release.yml on refs/heads/main and is matched on its numeric repository and owner ids (decision 6).

Amendment 2026-11-01: contracts, the contract kind and branch prerelease

Written with 0511 (adr/0511-cross-repo-service-contracts-and-cross-app-events), which amends decisions 6, 10 and 11 as follows.
  • Decision 6 (publishers). A publisher row names one producer repository, its workflow .github/workflows/release.yml and environment release, so a scope may hold one row per repository, as the @figentra rows of the 2026-10-31 table do. A contract name’s row admits a prerelease version from any ref of that workflow, and a stable version only from refs/heads/main.
  • Decision 10 (read rules). The @figentra row of the 2026-10-31 table is the internal read rule that contracts are read through. Its publishers are the producer repositories of 0511 decision 5.
  • Decision 11 (kinds). The kinds are exactly package, skill, agent, mcp, plugin and contract. A contract is named @figentra/<service>-contract (0511 decision 1), not @<scope>/contract-<name>, and stackra add does not install it. Its fields in the stackra object are set by the schema change that adds the kind.
The other decisions stand.

Amendment 2026-11-01 (owner decision): sources in an internal scope

Decision 7’s ban on src and source maps comes from 0316 decision 2, which keeps sources out of what customers install. It now applies only to a commercial scope whose read rule is licensed (@stackra-pro). An internal scope (@figentra) may publish src/ and .map files, because only a Figentra internal licence reads it. The template lines of 0481 are their sources: @figentra/os-mobile ships the Expo project EAS builds from. Every other commercial check still applies there: the scope’s licence string and a LICENSE file. The read rule is the scope row’s own, so a per-name internal rule inside @stackra-pro (@stackra-pro/ui) keeps the ban. The OS release workflow publishes those lines as a trusted publisher of @figentra (migration 0006_figentra_os.sql, decision 10). Linear STA-1828.

Supersedes and sources

Supersedes: 0177 decisions 1 to 3 and decisions 1 to 3 of its amendment of 2026-10-21 (trusted publishing to npmjs); its other decisions and amendments stand, decision 5 as amended by decision 18. Amends: 0025 decisions 1 and 2, 0077 decision 2, 0178 decisions 1 to 3, 0175 decision 3, 0290 decisions 16 and 17, 0316 decisions 1 and 2 (the channel), 0168 decision 6 and 0349 decision 4 (the channel). Related: 0024, 0074, 0113, 0173, 0284, 0289. Sources: owner decisions of 2026-10-27 (questionnaire; Stackra finish plan decision D5); Linear STA-1333; notes/pk2-registry-analysis.md (2026-10-20); npm registry documentation for packuments, dist-tags, registry signatures and trusted publishing.