# RFC 0025: Test-mode Registry Namespace

> Source: https://openwop.dev/rfcs/0025-test-mode-registry-namespace.html · OpenWOP, the open-source protocol for multi-agent workflow orchestration. CC BY 4.0.

| Field | Value |
| --- | --- | --- |
| **RFC** | 0025 |
| **Title** | Test-mode registry namespace for conformance publish-error catalog |
| **Status** | `Accepted` |
| **Author(s)** | OpenWOP Working Group |
| **Created** | 2026-05-19 |
| **Updated** | 2026-05-29 (`Active → Accepted`: every acceptance-criteria item is satisfied and the surface is now **verified end-to-end against a running host**. See [Amendment record](#amendment-record). |
| **Affects** | `spec/v1/node-packs.md` (new §"Test-mode registry namespace") · `schemas/capabilities.schema.json` (new `packs.testMode` block) · `api/openapi.yaml` (new `/v1/packs-test/*` endpoints) · `conformance/src/scenarios/pack-registry-publish.test.ts` (25 todos → behavioral; the `manifest_mismatch` aggregate folds the granular `manifest_name_mismatch` + `manifest_version_mismatch` pair) · `conformance/src/scenarios/pack-registry-isolation.test.ts` (new — anchors §C point 1) |
| **Compatibility** | `additive` |
| **Supersedes** | — |
| **Superseded by** | — |

## Summary

Adds an optional `/v1/packs-test/*` registry namespace that mirrors the production `/v1/packs/*` PUT/GET/DELETE surface against an isolated catalog, gated behind a new optional `capabilities.packs.testMode` advertisement. Lets the conformance suite exercise the documented 19-code publish error catalog (`invalid_pack_name`, `invalid_pack_scope`, `tarball_too_large`, `manifest_mismatch`, …) without `packs:publish` scope on the real registry — closes the 25-`it.todo()` gap in `conformance/src/scenarios/pack-registry-publish.test.ts` (the `manifest_mismatch` aggregate scenario covers both that code and the granular `manifest_name_mismatch` / `manifest_version_mismatch` pair).

## Motivation

Per `pack-registry-publish.test.ts` line 5-22, the publish path is gated on `packs:publish` scope plus a binary tarball upload. Round-trip scenarios from a black-box suite have two options:

1. **Super-admin scope** on the host under test. **Rejected** — gives the suite the ability to stomp on the real catalog. Non-starter for v1.
2. **Test-mode namespace** that mirrors the real surface against an isolated catalog. Defined here.

Without option 2, the 19 documented error codes (`node-packs.md` §"PUT /v1/packs/{name}/-/{version}.tgz") have no conformance coverage. Implementers learn the error catalog by reading the spec; cross-host divergence on error-code wording, status codes, and the granular/composite pair (e.g. `manifest_mismatch` vs `manifest_{name,version}_mismatch`) goes undetected.

## Proposal

### §A Capability flag

```diff
   "packs": {
     "type": "object",
     "properties": {
+      "testMode": {
+        "type": "object",
+        "description": "Optional sample-namespaced /v1/packs-test/* surface that mirrors /v1/packs/* against an isolated catalog. Lets conformance exercise the publish error catalog without packs:publish scope on the real registry.",
+        "properties": {
+          "supported": { "type": "boolean" },
+          "isolated": { "type": "boolean", "description": "MUST be true when supported is true — guarantees the test catalog is separate from production." },
+          "catalogResetEndpoint": { "type": "string", "description": "Optional path (e.g. /v1/packs-test/reset) that clears the test catalog. When advertised, suite teardown SHOULD call it." },
+          "scopes": { "type": "array", "items": { "type": "string", "enum": ["core","vendor","community","private","local"] }, "description": "Which namespace scopes the test catalog accepts. Public test catalogs SHOULD refuse `private` and `local`; private dev catalogs MAY accept all five." }
+        },
+        "required": ["supported"],
+        "additionalProperties": false
+      }
     }
   }
```

### §B New endpoints

```yaml
paths:
  /v1/packs-test/{name}/-/{version}.tgz:
    put:
      operationId: putTestPackTarball
      tags: [packs-test]
      description: |
        Mirror of /v1/packs/{name}/-/{version}.tgz against the
        isolated test catalog. Same request/response shape +
        error-code catalog as the production endpoint.
    get:
      operationId: getTestPackTarball
      tags: [packs-test]
    delete:
      operationId: deleteTestPackVersion
      tags: [packs-test]
      description: Mirror of unpublish-window semantics.
  /v1/packs-test/{name}/-/{version}.sig:
    get:
      operationId: getTestPackSignature
      tags: [packs-test]
```

All endpoints surface the documented 19 error codes verbatim. Path semantics, status codes, error envelope shape MUST match the production `/v1/packs/*` surface so conformance scenarios written against the test namespace prove the production-namespace contract.

### §C Isolation guarantees

A host advertising `packs.testMode.supported: true` MUST:

1. Persist every test-namespace pack to a catalog distinct from the production catalog. A pack PUT'd to `/v1/packs-test/core.openwop.x@1.0.0` MUST NOT appear in `/v1/packs/core.openwop.x`'s catalog listing.
2. Refuse `/v1/packs-test/*` traffic when `OPENWOP_TEST_SEAM_ENABLED` is unset (matches the existing test-seam env-gating pattern). The boot log SHOULD warn when the surface is exposed.
3. NOT serve the test-namespace catalog from the host's public-discovery (`packs.openwop.dev`) endpoints. Test-catalog packs MUST NOT appear in production registry listings.
4. Honor `catalogResetEndpoint` when advertised — calling it MUST clear the entire test catalog (suite teardown).

### §D Conformance scope

The 25 scenarios in `pack-registry-publish.test.ts` are all `it.todo()` today. This RFC converts them to behavioral assertions that:

- Soft-skip when `capabilities.packs.testMode.supported !== true`
- Use `/v1/packs-test/*` instead of `/v1/packs/*` for every assertion
- Verify the spec-documented error code AND HTTP status (4xx range)

The 19-code catalog covered:

| Category                 | Codes                                                                                                                                                                                                                                         |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL / scope (§A point 1) | `invalid_pack_scope`, `invalid_pack_name`, `invalid_version`                                                                                                                                                                                  |
| Body shape (§A point 2)  | `invalid_body`                                                                                                                                                                                                                                |
| Tarball extraction       | `tarball_gunzip_failed`, `tarball_too_large`, `tarball_manifest_missing`, `tarball_manifest_too_large`, `tarball_manifest_not_json`, `tarball_entry_missing`, `tarball_entry_too_large`, `tarball_path_traversal`, `tarball_tar_parse_failed` |
| Manifest contents        | `invalid_manifest`, `manifest_mismatch` (or pair: `manifest_name_mismatch` / `manifest_version_mismatch`), `pack_integrity_failure`, `unsupported_runtime`                                                                                    |
| Auth + conflict          | `forbidden` (403), `conflict` (409, or `version_conflict`), idempotent re-publish (200)                                                                                                                                                       |
| Unpublish                | `unpublish_window_expired`                                                                                                                                                                                                                    |
| Signature pairing        | `signature_not_available` (404 after PUT-without-sig and after YANK)                                                                                                                                                                          |

## Compatibility

**Additive** per `COMPATIBILITY.md §2.1`.

- New optional capability block (`packs.testMode`); old hosts ignore.
- New namespace (`/v1/packs-test/*`); production namespace (`/v1/packs/*`) is unchanged.
- No new required fields in any existing schema.
- No event-type shape changes.
- No existing MUST relaxed.

## Conformance

- 25 existing `it.todo()` scenarios in `pack-registry-publish.test.ts` convert to behavioral assertions against `/v1/packs-test/*`. Each soft-skips when the host doesn't advertise `packs.testMode.supported: true`. (The original RFC drafted "26 scenarios" anticipating a separate test for the granular `manifest_name_mismatch` + `manifest_version_mismatch` pair; the implementation folded both into the aggregate `manifest_mismatch` scenario since either error-code family is spec-conformant per §C.)
- `pack-registry-isolation.test.ts` (added in this RFC's implementation) verifies that a pack PUT'd via test namespace does NOT appear in production-namespace listings — anchors the §C point 1 isolation invariant.

## Alternatives considered

1. **Super-admin scope for the conformance suite.** Rejected — gives the suite the power to mutate the real catalog. Non-starter for any registry shared across operators.
2. **Mock the error catalog at the SDK level.** Rejected — would assert SDK error-mapping rather than host implementation. Misses cross-host divergence (the load-bearing problem).
3. **Embed the test catalog inside the production namespace via a magic name prefix (e.g. `test.*`).** Rejected — magic prefixes leak into discovery listings and confuse third-party clients. A separate namespace is cleaner.
4. **Do nothing.** Rejected — leaves 25 conformance scenarios as documentation-only `it.todo()` forever. The error catalog stays untested across hosts; divergence ships silently.

## Unresolved questions

1. Should `catalogResetEndpoint` be REQUIRED for `packs.testMode.supported: true`? Cleaner suite teardown, but raises the implementation bar.
2. Should the test namespace accept the `private.*` and `local.*` scopes (which the production namespace refuses for public registries)? Current proposal: yes for private dev catalogs (via `packs.testMode.scopes`), no for public test catalogs.
3. Should there be a TTL on test-catalog entries (auto-cleared after N hours)? Useful for shared CI environments; complicates simple in-memory impls. Defer to v1.2.

## Implementation notes (non-normative)

- Reference impl candidate: `apps/workflow-engine/backend/typescript/src/routes/packs-test.ts` — in-memory isolated catalog; env-gated on `OPENWOP_PACKS_TEST_NAMESPACE_ENABLED=true`.
- All 19 error codes can be unit-tested via a single validation pipeline that runs checks in order; first-failing-check wins. Mirrors the production publish handler's check order.
- The signature-endpoint pairing tests assume the host serves `.sig` from the same backing store as the tarball; impl SHOULD use a single record per (name, version) with optional `signature` blob field.

## Acceptance criteria

- [x] RFC 0025 follows the template; 7-day comment window expires 2026-05-26
- [x] `capabilities.packs.testMode` block added to `schemas/capabilities.schema.json`
- [x] `spec/v1/node-packs.md` gains §"Test-mode registry namespace" referencing this RFC
- [x] `api/openapi.yaml` declares the 4 new endpoints with all 19 error responses
- [x] Reference impl at `apps/workflow-engine/.../routes/packs-test.ts` (in-memory)
- [x] 25 scenarios in `pack-registry-publish.test.ts` converted to behavioral (each soft-skips when the seam isn't advertised); `pack-registry-isolation.test.ts` anchors the §C point 1 invariant
- [x] `npm run openwop:check` 9/9 green
- [x] CHANGELOG entry under `[Unreleased]`

## Amendment record

Change history relocated from the `Updated` metadata cell (newest first).

- The reference impl at `apps/workflow-engine/backend/typescript/src/routes/packs-test.ts` (547 lines — `PUT/GET/DELETE /v1/packs-test/{name}/-/{version}[.tgz                                                                                                                                                                       \| .sig]`+`POST /v1/packs-test/reset`, validation pipeline in spec order, isolated in-memory catalog) is mounted + advertised behind`OPENWOP_PACKS_TEST_NAMESPACE_ENABLED=true`, with`discovery.ts`emitting`capabilities.packs.testMode.{supported: true, isolated: true, catalogResetEndpoint, scopes}`.
- Booted locally, all **26 scenarios pass under strict`OPENWOP_REQUIRE_BEHAVIOR=true`** —`pack-registry-publish.test.ts`(the 25-code publish-error catalog) +`pack-registry-isolation.test.ts`(the §C point-1 test-catalog isolation invariant) — i.e. they genuinely run, not soft-skip.
- Per RFC 0001 §3 (implementation landed + conformance updated), both criteria met; no explicit non-steward bar.
- MyndHyve operates a`PackRegistryStorage`surface and has flagged`packs.testMode`as plausibly in scope (pending the`/v1/packs-test/\*` contract) — a future second-host advertisement would strengthen, not gate, this status.).

## References

- `spec/v1/node-packs.md` §"PUT /v1/packs/{name}/-/{version}.tgz" — production endpoint that this mirrors
- `auth.md` §"`packs:publish` scope" — the scope required for production publishing
- `host-extensions.md` §"Canonical prefixes" — the sample-namespace convention
- `conformance/src/scenarios/pack-registry-publish.test.ts` — the 26 deferred scenarios this RFC unblocks
