II.
Page JSON
Structured · livepage:docs-specs-platform-gaps-phase0-publish-guards
WP-RN-04 record: publish-isolation guards G1–G6 json
Inspect the normalized record payload exactly as the atlas UI reads it.
{
"id": "page:docs-specs-platform-gaps-phase0-publish-guards",
"_kind": "Page",
"_file": "wiki/docs/specs/platform-gaps/phase0/publish-guards.md",
"_cluster": "wiki",
"attributes": {
"nodeKind": "Page",
"sourcePath": "docs/specs/platform-gaps/phase0/publish-guards.md",
"sourceKind": "repo-docs",
"title": "WP-RN-04 record: publish-isolation guards G1–G6",
"displayName": "WP-RN-04 record: publish-isolation guards G1–G6",
"slug": "docs/specs/platform-gaps/phase0/publish-guards",
"articlePath": "wiki/docs/specs/platform-gaps/phase0/publish-guards.md",
"article": "\n# WP-RN-04 record: publish-isolation guards G1–G6\n\n> **Scope re-locked to `@fabricstack` on 2026-09-28, see `scope-relock.md`.** This record was produced under the previous private scope `@agentfabric`; the scope strings below were rewritten mechanically at the re-lock (the quoted guard output originally showed the old name). The G4 collision that triggered the re-lock is kept in the G4 row and the \"Scope collision\" section as history.\n\nExecuted 2026-09-28 on branch `phase0/hard-split-registry-guards` of `a5c-ai/fabric`. Spec: `docs/specs/platform-gaps/15-private-isolation-rename.md` §4.3 (guards, binding) and \"WP-RN-04\"; intent `00-intent.md` D17–D21 and acceptance criterion 7. Prior work this builds on: WP-RN-02 (`scripts/ci/public-push-guard.cjs`), WP-RN-03 (`docs/specs/platform-gaps/phase0/ar-provisioning.md`, root `.npmrc`, `.github/workflows/ar-smoke.yml`). The public repository `a5c-ai/babysitter` was never written to (`pushed_at` stayed `2026-09-16T19:59:50Z`; only `gh api` GET calls were issued) and nothing was published anywhere: every `npm pack` in the tests is a local tarball and no `npm publish` ran.\n\n## One constant\n\nThe private scope and the Artifact Registry URL are declared once, in the root `package.json`:\n\n```json\n\"fabric\": {\n \"privateScope\": \"@fabricstack\",\n \"privateRegistry\": \"https://us-central1-npm.pkg.dev/boot-464019/fabric-npm/\",\n \"legacyPublicScope\": \"@a5c-ai\"\n}\n```\n\n`scripts/lib/registry-pin.cjs` reads it (`readPrivateRegistryConfig`) and every guard, the inventory selector and the release verifier consume it from there. `scripts/ci/publish-guards.test.mjs` pins that the committed `.npmrc` routing line equals `<privateScope>:registry=<privateRegistry>`. Re-locking the scope at Gate B (spec 15 §2) is therefore: change the three values, change the `.npmrc` line, regenerate the fixtures under `scripts/ci/fixtures/publish-guards/` (they name the scope literally), and rename the smoke fixture — the guards follow.\n\n## Guard table\n\n| Guard | Mechanism | Files | Negative fixture (must trip) | Status |\n|---|---|---|---|---|\n| **G1** manifest-level pinning | Every publishable `@fabricstack/*` manifest pins `publishConfig.registry` to the exact AR URL and carries no `publishConfig.access: \"public\"`; nothing outside the scope pins AR. Rules `private-scope-registry`, `private-scope-public-access`, `ar-registry-foreign-scope`. Private (non-publishable) private-scope workspaces need no pin but may not declare public access or npmjs. | `scripts/lib/registry-pin.cjs` (`manifestProblems`), `scripts/ci/publish-isolation-guard.cjs` (scans root `workspaces` manifests plus `scripts/ci/fixtures/**` and `scripts/__tests__/fixtures/**` package.json files) | `g1/negative/public-npmjs` (`access: public` + npmjs registry), `g1/negative/missing-registry`, `g1/negative/foreign-scope-on-ar` | **Done**; red on fixtures, green on the tree (101 files, 0 findings). Selector switch deferred — see below |\n| **G2** CI enforcement job | Required job `publish-isolation` in `.github/workflows/ci.yml` runs the guard self-test, `npm run test:publish-guards`, `npm run test:binary-renames` and the informational G4 probe. (a) = G1 over the workspace; (b) no publishable manifest carries `@a5c-ai/*` — `legacy-scope-name`, private train only; (c) no workflow touching the private scope has `registry.npmjs.org` in a publish context (`registry-url:` for npmjs combined with a publish command or token, `npm publish --registry`, `//registry.npmjs.org/:_authToken`) — `npmjs-publish-context-private-scope`; private train: any such context, `npmjs-publish-context`; (d) `verify-manifests` fails on any internal dependency referencing `@a5c-ai/*` — private train only | `.github/workflows/ci.yml` job `publish-isolation`; `scripts/ci/publish-isolation-guard.cjs`; `scripts/lib/release-version.cjs` (`collectVersionDivergences`, `privateTrain`) | `g2/negative/npmjs-publish-private-scope`, `g2/negative/npmjs-explicit-registry`, `g2/negative/legacy-publish-private-train` (flag), `g1/negative/legacy-scope-private-train` (flag); G2(d) synthetic root in the test | **Done**; the job runs before `npm ci` (no dependencies) |\n| **G3** credential starvation | No workflow touching the private scope references an npmjs token secret (`secrets.NPM_TOKEN`/`NPMJS_TOKEN`/`NPM_PUBLISH_TOKEN`/`NPM_AUTH_TOKEN`/`NODE_AUTH_TOKEN` next to npmjs, `//registry.npmjs.org/:_authToken`) — `npmjs-credential-private-scope`; no committed `.npmrc` (root, `packages/`, `apps/`, `scripts/`) carries an npmjs auth line — `npmrc-npmjs-token`; private train: any npmjs secret reference, `npmjs-credential`. Plus the read-only secret audit below | `scripts/ci/publish-isolation-guard.cjs` | `g3/negative/authtoken-line`, `g3/negative/npmrc-token` | **Done** for the CI assertion; **audit finding**: the org-level `NPM_TOKEN` is visible to this repository (below). Owner decided \"Restrict now\" at breakpoint `phase0.npmjs-org`; the restriction is **PENDING OWNER UI ACTION** — record, procedure, verification and rollback in `g3-npm-token-restriction.md` |\n| **G4** defensive scope registration | External human action. Runbook + read-only status probe (`--g4-status`: registry search, unscoped package, `/-/org/<scope>/user`, org page) printing one of `unregistered` / `third-party-packages-present` / `registered-no-packages` / `unknown`; never fails the job | `docs/specs/platform-gaps/phase0/g4-npmjs-org-runbook.md`; `scripts/ci/publish-isolation-guard.cjs --g4-status`; `classifyG4` unit-tested | — (informational) | **PENDING OWNER ACTION — scope re-locked to `@fabricstack` on 2026-09-28 (`scope-relock.md`); the `fabricstack` org registration is the owner's, per the runbook.** History: the first probe on 2026-09-28 found the original scope `@agentfabric` third-party-owned (`third-party-packages-present`: its scoped package `sdk` 1.0.1 by `defimessiah`, created 2026-03-18; unscoped `agentfabric` 0.0.2 by `mdirshadengineer`, created 2026-04-25 — the only place in this record where the old name is spelled out, as history), which the owner resolved at breakpoint `phase0.npmjs-org` by re-locking. Probe after the re-lock: `unregistered` for `@fabricstack`. Nothing was created or published |\n| **G5** bin-collision gate | `npm run test:binary-renames` (`scripts/check-binary-renames.cjs`) now also runs `scripts/ci/bin-collision-guard.cjs` over every workspace manifest: `duplicate-bin` (exactly one owner per bin; only the four pinned legacy `@a5c-ai` collisions `babysitter`, `adapters`, `adapters-hooks`, `kradle-server` are tolerated, with their exact owner sets, until the private train), `public-train-bin` (no private-scope package declares one of the 26 bins the public train publishes — spec 15 §3.1 current column, pinned as `PUBLIC_TRAIN_BINS` and cross-checked against the live tree by `public-train-bins-drift`), `bin-not-in-spec` (every private-scope bin is in the §3.1/§3.3 bin column), `spec-bins-missing` (private train: every §3.1 bin is shipped) | `scripts/ci/bin-collision-guard.cjs`, `scripts/check-binary-renames.cjs` | `g5/negative/duplicate-bin`, `g5/negative/public-train-bin` | **Done**; red on fixtures, green on the tree (32 workspace bin names, 0 errors); `--private-train` on today's tree reports the 4 legacy collisions + 18 missing §3.1 bins, which is the RN-1 end-state WP-RN-06 must reach |\n| **G6** packed-artifact scope check | `scripts/verify-release-artifacts.mjs` gained step `scope` right after `pack`: it extracts `package/package.json` from the exact tarball and applies the G1 rules (`packedManifestProblems`; private train additionally refuses any non-private-scope tarball). A failure skips every later step (no install of a mis-scoped artifact). `.github/workflows/release-artifact-verifier.yml` first runs the guard self-test and `npm run test:publish-guards` so the gate is proven red on its fixtures in the same lane | `scripts/verify-release-artifacts.mjs`, `scripts/lib/registry-pin.cjs`, `.github/workflows/release-artifact-verifier.yml` | `g6/negative/private-scope-npmjs` (`@fabricstack/leak` → npmjs, public), `g6/negative/legacy-scope-on-ar` (`@a5c-ai/mis-scoped` → AR); both packed with `npm pack` in the test and run through the real verifier | **Done**; verifier exits 1 with `steps.scope.status = failed` and `install = skipped` on both; `g6/positive/pinned` passes the full chain (`scope`, `surfaces`, `install`, `imports` = passed) |\n\nAll fixtures live under `scripts/ci/fixtures/publish-guards/<guard>/{negative,positive}/<case>/` with an `expect.json` naming the consuming guard, the mode and the rules; `scripts/ci/publish-guards.test.mjs` (`npm run test:publish-guards`, node:test, no dependencies beyond `npm` and `tar`) asserts every negative trips, every positive passes and the tree passes. The real-tree scans exclude the fixture directory; the inventory-equivalence oracle in `scripts/__tests__/publishable-packages.test.mjs` skips fixture manifests for the same reason.\n\n## Selector deferral (binding; spec 15 §4.3 G1, WP-RN-05/07)\n\nSpec 15 says the inventory selector in `scripts/lib/publishable-packages.cjs` must change \"in the same commit\" as the manifests are pinned, keying on `publishConfig.registry` instead of `publishConfig.access === \"public\"`. Today no manifest is renamed, so flipping the selector would make the current `@a5c-ai` release train see **zero** packages. The switch is therefore implemented but gated:\n\n- `FABRIC_PRIVATE_TRAIN=1` (env; also `--private-train` on the guard CLIs) selects the RN-1 end-state everywhere: the inventory keys on `publishConfig.registry === <AR URL>` plus scope; G2(b), G2(d), the whole-tree npmjs rules and the G5 tolerance retirement become active; G6 refuses any non-private-scope tarball.\n- Default (flag unset) is byte-identical to the pre-WP-RN-04 behaviour. `scripts/ci/publish-guards.test.mjs` re-implements the old predicate as an oracle and asserts the CLI output of `node scripts/lib/publishable-packages.cjs` is byte-for-byte equal (43 `@a5c-ai/*` entries); it also asserts the flag yields `[]` on today's tree and exactly the pinned package on a synthetic renamed tree.\n- WP-RN-05/07 flip the default by setting the flag in the release workflows (or by making the flag the default once the rename lands) and delete the legacy branch of `selectsForPublish`. Until then `scripts/__tests__/publishable-packages.test.mjs` keeps asserting 43 `@a5c-ai/*` packages.\n\n## Scope collision risk (added after the G4 probe; resolved by the re-lock)\n\n**Resolved 2026-09-28:** the original scope was found third-party-owned and the owner re-locked to `@fabricstack`, which is free on npmjs (`scope-relock.md`). The analysis below is kept because it applies to *any* private scope that is also claimed on the public registry, and it is why G4 (registering the `fabricstack` org) still matters. At the time it read: the original scope was already a third-party scope on the public registry (a scoped `sdk` package, \"Decentralized cloud for AI agents\", MIT, maintainer `defimessiah`; the unscoped CLI of the same name belonged to a different maintainer). Consequences:\n\n- A developer or CI job **without** the committed `.npmrc` scope routing that runs `npm install @fabricstack/sdk` (or any future private name that the stranger also publishes) gets the stranger's package from npmjs — a dependency-confusion vector. Inside this repository the routing line `@fabricstack:registry=<AR URL>` takes precedence for the scope (WP-RN-03 caveat), so in-tree installs resolve against AR only.\n- Mitigations in place: the committed `.npmrc` routing, G1 (no private manifest can point at npmjs), G2/G3 (no workflow can publish or authenticate there for the scope), G6 (no mis-scoped tarball passes the release gate), and `docs/user-guide/private-install.md` (consumers must carry the routing line). None of these stops an out-of-tree consumer from installing the stranger's package under a name we also use.\n- Recommendation (as made): decide the scope re-lock at breakpoint `phase0.npmjs-org` — either keep the colliding scope private-only and accept the public collision (document it in `private-install.md` and require the routing line in every consumer), or pick a free scope (probe with `node scripts/ci/publish-isolation-guard.cjs --g4-status` after changing the constant) and register its org per the runbook. The owner chose the second option; the re-lock was the one-constant change described above plus the mechanical sweep recorded in `scope-relock.md`.\n\n## Secret audit (read-only, 2026-09-28)\n\n`gh secret list -R a5c-ai/fabric`:\n\n```text\nKRADLE_TEST_AUTH_SECRET 2026-09-16T16:27:42Z\n```\n\nNo npmjs credential at repository level — the G3 invariant holds there.\n\n`gh api orgs/a5c-ai/actions/secrets` (28 secrets): `A5C_AGENT_GITHUB_TOKEN`, `ANTHROPIC_API_KEY`, `AUTH_GITHUB_CLIENT_ID`, `AUTH_GITHUB_CLIENT_SECRET`, `AZURE_APPLICATION_CLIENT_ID`, `AZURE_APPLICATION_CLIENT_SECRET`, `AZURE_OPENAI_API_KEY`, `DISCORD_TOKEN`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CLOUD_PROJECT`, `KRADLE_TEST_AUTH_SECRET` (visibility `private`), `KRATE_GITHUB_CLIENT_SECRET`, `KUBE_CONFIG`, `MY_GH_TOKEN` (`private`), **`NPM_TOKEN` (visibility `all`, updated 2026-05-20)**, `OPENAI_API_KEY`, `RUNNER_GITHUB_TOKEN`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_DB_PASSWORD`, `TAL_GH_TOKEN`, `TRELLO_API_KEY`, `TRELLO_SECRET`, `TRELLO_TOKEN`, `VERCEL_TOKEN`, `X_API_ACCESS_TOKEN`, `X_API_ACCESS_TOKEN_SECRET`, `X_BEARER_TOKEN` (`private`); all others visibility `all`.\n\n**Finding:** an organization secret with visibility `all` is readable by every repository in the organization, including `a5c-ai/fabric`. Credential starvation (spec 15 G3, \"fabric's repo secrets contain no npmjs token\") therefore does **not** hold at organization level: the inherited public-train workflows `.github/workflows/publish-packages-from-tag.yml` (lines 329, 413, 425) and `.github/workflows/fix-npm-tags.yml` (lines 35, 39) reference `secrets.NPM_TOKEN` and would receive a working npmjs token. Both are legacy `@a5c-ai` train workflows scheduled for retargeting to AR by WP-RN-07 and neither mentions the private scope, so the CI assertion classifies them as legacy-train notices (8 notice lines, 0 findings); `.github/workflows/live-stack-published.yml` sets `registry-url` for npmjs but only installs (no publish command, no token) and is read-only. Owner options, to be decided at the next breakpoint (no infra change was made here): (a) restrict the org secret `NPM_TOKEN` to selected repositories that exclude `fabric`, or (b) accept until WP-RN-07 removes the references, at which point `--private-train` makes any remaining reference a hard finding.\n\n## Verification transcript\n\nCommands run from `C:/work/fabric` on 2026-09-28 (outputs abbreviated to the decisive lines):\n\n```text\n$ node scripts/ci/publish-isolation-guard.cjs --root scripts/ci/fixtures/publish-guards/g1/negative/public-npmjs\npackages/leak/package.json:1: [private-scope-public-access] @fabricstack/leak declares publishConfig.access \"public\"; ...\npackages/leak/package.json:1: [private-scope-registry] @fabricstack/leak pins publishConfig.registry to the public npm registry (https://registry.npmjs.org/); ...\npublish-isolation-guard: FAIL — @fabricstack/* packages must never reach registry.npmjs.org (spec 15 §4.3, intent D19) (exit 1)\n\n$ node scripts/ci/publish-isolation-guard.cjs --root scripts/ci/fixtures/publish-guards/g2/negative/npmjs-publish-private-scope\n.github/workflows/leak.yml:14: [npmjs-publish-context-private-scope] ... > registry-url: https://registry.npmjs.org/\n.github/workflows/leak.yml:17: [npmjs-credential-private-scope] ... > NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} (exit 1)\n\n$ node scripts/ci/publish-isolation-guard.cjs --root scripts/ci/fixtures/publish-guards/g3/negative/npmrc-token\n.npmrc:2: [npmrc-npmjs-token] committed .npmrc carries an npmjs auth line (exit 1)\n\n$ node scripts/ci/publish-isolation-guard.cjs --self-test\npublish-isolation-guard self-test: 14/14 assertions passed\npublish-isolation-guard: self-test passed; repository scan clean (101 files, 0 findings, 8 legacy-train notice(s)) (exit 0)\n\n$ node scripts/ci/bin-collision-guard.cjs --root scripts/ci/fixtures/publish-guards/g5/negative/duplicate-bin\n[duplicate-bin] bin \"orchestrator\" is declared by @fabricstack/orchestrator and @fabricstack/orchestrator-sdk ... (exit 1)\n$ node scripts/ci/bin-collision-guard.cjs --root scripts/ci/fixtures/publish-guards/g5/negative/public-train-bin\n[public-train-bin] @fabricstack/orchestrator declares bin \"babysitter\", which the public @a5c-ai train publishes ...\n[bin-not-in-spec] @fabricstack/orchestrator declares bin \"babysitter\", which is not in the spec 15 §3.1/§3.3 bin column ... (exit 1)\n$ npm run test:binary-renames\nBinary rename guardrail passed for 6 renamed binaries and 8 emitted bin targets; single-bin policy (G5) holds over 32 workspace bin names.\n\n$ node scripts/verify-release-artifacts.mjs --package-dir scripts/ci/fixtures/publish-guards/g6/negative/private-scope-npmjs --skip-build --report-dir <tmp>\n[FAILED] @fabricstack/leak: scope: packed package.json violates publish isolation (spec 15 §4.3 G6): [private-scope-public-access] ...; [private-scope-registry] ... (exit 1)\nreport steps: build=skipped pack=passed scope=failed surfaces=skipped shebangs=skipped install=skipped ...\n$ node scripts/verify-release-artifacts.mjs --package-dir scripts/ci/fixtures/publish-guards/g6/positive/pinned --skip-build --report-dir <tmp>\n[verify] @fabricstack/pinned: passed (steps: pack=passed scope=passed surfaces=passed install=passed imports=passed)\n\n$ node scripts/ci/publish-isolation-guard.cjs --g4-status\npublish-isolation-guard G4 status: third-party-packages-present — @fabricstack: third-party packages exist under this scope on registry.npmjs.org ... (exit 0)\n\n$ npm run test:publish-guards && npm run test:binary-renames && echo GUARDS-PASS\n# tests 17 / pass 17 / fail 0\nBinary rename guardrail passed ...; single-bin policy (G5) holds over 32 workspace bin names.\nGUARDS-PASS\n```\n\nOther checks: `node scripts/ci/public-push-guard.cjs` → 179 files, 0 findings; both edited workflows load with js-yaml; `npm run guard:packages` and `npm run verify:metadata` pass (43 public packages); `node --test scripts/__tests__/publishable-packages.test.mjs` passes. Pre-existing failures, identical on the pristine tree and unrelated to this WP: 8 tests in `release-matrix.test.mjs`/`release-version.test.mjs` (they read `publish.yml`, which WP-RN-01 retargeted, and stub `npm` on PATH, impossible on Windows), and the Windows-only `bins` step (`.cmd` shim spawn) plus two `publish.yml` text assertions in `verify-release-artifacts.test.mjs` (13 failures on the pristine tree, 6 with this WP: the verifier now spawns `npm.cmd` through a shell and extracts tarballs with relative paths so GNU tar no longer reads `C:\\` as a host).\n\n## Not done here (by design)\n\n- G4 registration itself (was blocked by the scope collision; after the re-lock it is the owner's action per `g4-npmjs-org-runbook.md`).\n- Retargeting the 43 `@a5c-ai` manifests, the release workflows and the selector default (WP-RN-05/06/07).\n- Any change to organization secrets or repository settings (the `NPM_TOKEN` restriction is pending an owner UI action, `g3-npm-token-restriction.md`).\n",
"documents": []
},
"outgoingEdges": [],
"incomingEdges": [
{
"from": "page:docs",
"to": "page:docs-specs-platform-gaps-phase0-publish-guards",
"kind": "contains_page"
}
]
}