# @nodii/role-catalog

Boot-time permission-catalog publisher for Nodii module-services. Type-safe
`definePermissions` + `publishCatalogAtBoot` (cold-start retry, idempotency,
**non-boot-blocking** degrade + background repair) over the tenant-service
`RoleCatalogService` gRPC contract.

Spec: planning-hub feature_doc `serviceId=nodii-libs docKey=role-catalog`,
`07-rbac-doctrine`, **D410(3)**.

> **A publish failure does not kill your process.** Per D410(3) the catalog
> publish is best-effort and non-boot-blocking. `publishCatalogAtBoot` records a
> queryable `degraded` state, logs at ERROR, keeps retrying in the background,
> and lets boot continue. See [Failure behaviour](#failure-behaviour).
>
> Note: `07-rbac-doctrine.md` § 5 and the `role-catalog.md` feature_doc § 5.6
> still contain the pre-D410 sentence "Service refuses to start…". That text is
> stale and contradicts the locked decision — drift
> `b8c94370-0133-40fb-b645-0721c88e4fe7` is open against both. Build to D410(3).

---

## `domain_key` is NOT `module` — read this first

A permission key is `<domain>.<resource>.<verb>[.<qualifier>]`, e.g.
`crm.lead.view`. That leading `crm` is the **domain key**: an RBAC permission
NAMESPACE.

It is **not** the sellable commerce module a tenant buys, even though the two
frequently share a spelling. Two distinct things:

| Concept | What it is | Where it lives |
| --- | --- | --- |
| **`domain_key`** | RBAC permission namespace — first segment of every permission key | this library; `PublishCatalogRequest.domain_key` |
| **module** (commerce) | the sellable module / add-on a tenant subscribes to | the commerce catalog; surfaced here only as `PermissionDef.entitlementKey` |

They are wired together, deliberately and in exactly one place:
`entitlementKey` names the **commerce module** that UNLOCKS a permission.

**Since 0.11.0 it is REQUIRED and there is no default** (D655, implementing
D649 §3). The old rule — *omitted ⇒ defaults to the permission's own
`domain_key`* — is exactly why the two concepts got conflated, and why, before
0.7.0, both were called `module_key`. It is dead: a permission with no commerce
gate now says so explicitly with `SUBSTRATE_ENTITLEMENT` (`"substrate"`), and
`"substrate"` is a reserved namespace so nothing can publish a `substrate.*`
domain that aliases it.

If you are adding a permission, you are choosing a `domain_key`. If you are
deciding what a tenant has to pay for, you are choosing an `entitlementKey`.

## Quick start

```ts
import {
  definePermissions,
  defineStarterRoles,
  publishCatalogAtBoot,
} from "@nodii/role-catalog";

// entitlementKey is REQUIRED since 0.11.0 (D655 / D649 §3): name the sellable
// commerce module/add-on that unlocks the key, or SUBSTRATE_ENTITLEMENT if the
// key has no commerce gate at all. There is no default and no escape hatch.
export const CRM_CATALOG = definePermissions("crm", {
  leadView:   { resource: "lead", verb: "view",   entitlementKey: "crm" },
  leadCreate: { resource: "lead", verb: "create", entitlementKey: "crm" },
  leadDelete: {
    resource: "lead",
    verb: "delete",
    isCritical: true,
    entitlementKey: "crm",
  },
  // Enforced by crm, but UNLOCKED by an add-on a tenant buys separately.
  collectorRun: {
    resource: "collector",
    verb: "run",
    entitlementKey: "crm.collector",
  },
});

export const CRM_STARTER_ROLES = defineStarterRoles("crm", CRM_CATALOG, [
  {
    // `key` is the MACHINE IDENTITY (required since 0.11.0, D654). Rename the
    // display name freely — identity does not move.
    key: "crm_admin",
    // `name` is DISPLAY-ONLY and must be Title Case. A slug here is rejected
    // with a targeted error telling you to put it in `key`.
    name: "CRM Administrator",
    description: "Full access to CRM.",
    // Concrete keys only — wildcards are rejected (NEW-BM-D7).
    permissionKeys: [
      CRM_CATALOG.leadView.key,
      CRM_CATALOG.leadCreate.key,
      CRM_CATALOG.leadDelete.key,
    ],
    roleClass: "admin",
  },
]);

// Does NOT throw on a publish failure. Awaiting it is correct: it awaits ONE
// attempt so a healthy boot is fully published, then degrades + retries in the
// background if that attempt failed.
const state = await publishCatalogAtBoot({
  domainKey: "crm",
  catalogVersion: "1.0.0",
  serviceOwner: "nodii-crm-service",
  permissions: CRM_CATALOG,
  starterRoles: CRM_STARTER_ROLES,
  tenantServiceUrl: process.env.TENANT_SERVICE_GRPC_URL!,
  logger,  // recommended — routes the ERROR lines into your log pipeline
});
// state.status: "published" | "degraded"
```

Scaffold the above into a service with
`bunx @nodii/role-catalog migrate-gen <service-name>`.

---

## Failure behaviour

**Default (`onPublishFailure: "degrade"`)** — a publish failure is loud but
non-fatal, per D410(3):

- the failure is recorded as durable, queryable state,
- every failed attempt logs at **ERROR** (the default logger is console-backed —
  there is no configuration in which this goes quiet),
- a bounded retry ladder (`5s, 15s, 30s, 60s, 120s`) then a slow refresh
  (15 min) keeps re-attempting on `unref`'d timers, so an out-of-band repair
  heals the process **with no restart and no redeploy**,
- boot continues and the service serves.

**Surface the state from `/health`** so a degraded catalog is visible without
scraping boot logs:

```ts
import { getCatalogPublishState } from "@nodii/role-catalog";

const catalog = getCatalogPublishState("crm");
// { status: "published" | "degraded" | "pending", attempts, lastError,
//   terminal, retrying, publishedAt, response }
```

### Why degrading is safe — it is fail-CLOSED

A published catalog is what lets a tenant **hold** a permission key. If the
publish never lands, the domain's keys are absent from tenant-service, the D544
roles→perms resolution yields nothing for them, and guarded routes **403**.
There is no path where skipping the publish grants access that a successful
publish would have withheld.

Crashing instead would convert a partial, fail-closed degradation into a total
outage of surfaces that have nothing to do with RBAC — and it cannot fix a
`FAILED_PRECONDITION`, which is a *data* condition in tenant-service. That is
exactly how `nodii-task-tracking` ended up crash-looping 12 times on ECS with
its deploy pipeline blocked.

**Do not wrap the call in a try/catch that exits.** If you want the old
behaviour, ask for it explicitly:

```ts
await publishCatalogAtBoot({ ...opts, onPublishFailure: "throw" });
// throws CatalogPublishBootFailed (carries the recorded state)
```

A **local authoring error** (bad key grammar, reserved namespace, duplicate key,
oversize catalog) is recorded with `terminal: true` and is **not** retried — the
catalog is compiled into the build, so re-sending identical bytes can never
succeed. Fix it and redeploy.

---

## Migrating 0.8.x → 0.9.0 (non-boot-blocking by default)

`publishCatalogAtBoot` now returns `CatalogPublishState` instead of
`PublishCatalogResponse`, and no longer throws by default. The return-type
change is deliberate: it makes the behaviour flip a **compile-time** migration
rather than a silent runtime one.

| 0.8.x | 0.9.0 |
| --- | --- |
| `const res = await publishCatalogAtBoot(o)` | `const st = await publishCatalogAtBoot(o); st.response` |
| throws on failure (default) | degrades; pass `onPublishFailure: "throw"` to keep throwing |
| `failFastOnVersionMismatch: true` | `onPublishFailure: "throw"` (old spelling still honoured) |
| `failFastOnVersionMismatch: false` — *still threw, with the raw grpc error* | genuinely degrades |
| — | `publishCatalogOnce` — the raw single-shot publish, always throws a typed error |

Passing both `onPublishFailure` and `failFastOnVersionMismatch` with conflicting
meanings **throws** — this library will not guess whether a publish failure
should kill your process.

If your service hand-rolled a try/catch around the publish to keep boot alive,
**delete it** — the library owns that now, and your catch is likely narrower
(most only absorbed cold-start exhaustion, not the `FAILED_PRECONDITION` that
actually took task-tracking down).

---

## 0.11.0 DELETED the 0.6.x → 0.7.0 `module_key` compat layer

`src/compat.ts` is gone, and with it the `{domainKey} | {moduleKey}` option
unions, the warn-once deprecation shim, `resetDeprecationWarnings`, the
`NODII_ROLE_CATALOG_SUPPRESS_DEPRECATIONS` env lever, and the deprecated aliases
(`validateModuleKey`, `isSubstrateModuleKey`, `isModuleKeyReserved`,
`serviceNameToModuleKey`, `SUBSTRATE_MODULE_KEYS`, `SubstrateModuleKey`,
`InvalidModuleKey`).

`domainKey` is the only spelling. This was grep-verified before deletion: every
live publish call site in the fleet already passes it, and the remaining
`moduleKey:` hits are structured-LOG fields with their own deprecation comments
(kyc, notification, task-tracking), not options.

The two 0.7.0 changes that a shim could never have rescued are unchanged and
still worth knowing about, because they change by VALUE and nothing flags them:

1. `ValidationFailureKind` `"invalid_module_key"` → `"invalid_domain_key"` is a
   TELEMETRY LABEL. A panel filtering the old literal renders fine and matches
   nothing. `emitLegacyValidationFailureKind: true` still dual-emits while you
   migrate (off by default — it double-counts).
2. `InvalidPermissionSegment.which` `"module_key"` → `"domain_key"`. The old
   literal is retained in the union so `e.which === "module_key"` still
   *compiles*; it just stops *matching*.

By contrast, the 0.11.0 labels (`invalid_entitlement_key`,
`starter_role_invalid_key`, `starter_role_name_not_title_case`,
`starter_role_duplicate_key`, `namespace_validation_rejected`) are purely
ADDITIVE — nothing is renamed, so no existing panel goes dark.

## Dev commands

```bash
bun run --cwd ts/role-catalog typecheck
bun run --cwd ts/role-catalog test
bun run --cwd ts/role-catalog build

# cross-language byte-equivalence gate (needs the local stack: bun run stack:up)
bash synthetic-consumers/role-catalog/parity-fence/run-parity-fence.sh
```
