fix(#2810): accept the documented effortSurface axis on EoS registry entries (#2813)

* fix(#2810): accept the documented effortSurface axis on EoS registry entries

The EoS registry schema required an exact eight-key `interactions.axes`
object, while `docs/registries/README.md` and `CONTEXT.md` both documented
nine keys including `effortSurface`. An entry that faithfully mirrored its
upstream descriptor's `effortSurface` key was rejected outright.

`effortSurface` reached the runtime-descriptor vocabulary through ADR-1239
amendment #2481 (`HOST_INTEGRATION_AXES`), but the registry's hand-maintained
copy of that vocabulary never picked it up. The runtime-descriptor surface is
guarded by tests/host-integration-validator-parity.test.cjs; the registry copy
had no equivalent guard, which is what let the two drift.

Accept `effortSurface` as an OPTIONAL ninth axis validated against the
canonical ['argv','none'] rather than a required one: registry entries mirror
their upstream registry/eos-entry.json byte-for-byte, so requiring it would
retroactively invalidate every entry published before the amendment.

Adds tests/registry-axes-parity.test.cjs, which asserts that every key shared
between the registry vocabulary and HOST_INTEGRATION_AXES has an identical
enum array, plus limit-1/limit/limit+1 boundary coverage on the axes key set.

Closes #2810

* test(#2810): fail when a canonical axis is added but never mirrored

The enum-equality assertion compares only keys the registry and
HOST_INTEGRATION_AXES already share, so it is blind to the exact drift that
produced #2810: a new canonical axis appears and the registry copy is never
told. Verified by simulation — mutating an enum is caught, adding a new
canonical key is not.

Assert instead that every HOST_INTEGRATION_AXES key is either modeled by the
registry or named in an explicit NOT_MODELLED allowlist (subagentToolkit and
isolation, both dispatch sub-fields the registry collapses into its free-form
dispatch summary). Adding a canonical axis now fails until someone decides
which bucket it belongs in. The allowlist is itself guarded against going
stale.

Refs #2810

* fix(#2810): harden the axis value lookup with the CodeQL barrier pattern

Both orthogonal reviews flagged the same line: `AXES[key] !== undefined`
is not an own-property test, and the bracket reads are shaped like a
prototype-pollution sink even though the unknown-key gate above provably
makes them unreachable.

Switch the presence test to `Object.hasOwn` and add the repo's inline
literal guards (`capability-state.cts:146-155`, "Prototype-pollution guard
(inline literal, CodeQL barrier)"), which CodeQL can follow where it cannot
follow the `.includes()` filter that actually does the work.

Behavior is unchanged — re-verified all five axes key-count shapes plus a
genuine own `__proto__` property built through JSON.parse (the shape a
third-party registry PR would submit): it is rejected as an unknown key and
Object.prototype is untouched.

Refs #2810

* chore(#2810): backfill changeset PR number
This commit is contained in:
Tom Boucher
2026-07-29 07:00:35 -04:00
committed by GitHub
parent 46bae2f9ff
commit 9624167eec
6 changed files with 286 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2813
---
**EoS Registry entries carrying the documented `effortSurface` axis are no longer rejected** — the registry validator required an exact eight-key axes object, so an entry that faithfully mirrored its upstream descriptor's optional ninth `effortSurface` key (`argv` or `none`, added by ADR-1239 amendment #2481) failed validation outright. (#2810)

View File

@@ -43,7 +43,7 @@ Full schema and process: [docs/registries/README.md](../../docs/registries/READM
- [ ] `id`, `name`, `type`, `repo`, `description`, `author`, `license`, `enginesGsd`, `install`, `uninstall`, `interactions`, `discussion` are all present and non-empty
- [ ] **(Capability entries only)** `interactions.loopExtensionPoints` is a non-empty subset of the 12 Loop Extension Points, `interactions.hookKinds` ⊆ `{step, contribution, gate}`, and `interactions.configKeys` / `requires` / `runtimeCompat` / `produces` / `consumes` are present (empty arrays are fine where nothing applies)
- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys
- [ ] **(EoS entries only)** `protocolVersion` is an integer ≥ 1, `interactions.interfacePoints` is a non-empty subset of the six interface points, `interactions.profile` is one of `programmatic-cli` / `declarative-cli` / `ide`, and `interactions.axes` has exactly the eight required axis keys plus, optionally, `effortSurface` (`argv` / `none`)
## Ownership & non-endorsement

View File

@@ -221,7 +221,7 @@ Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that compos
Human-facing discoverability catalog (`docs/registries/capability-registry.md`, generated from `docs/registries/capabilities.json`; issue #2182) listing third-party Feature Capabilities registered by a docs PR so a solo developer can find one before installing it. Distinct from **Capability Registry** (the generated runtime manifest compiled from first-party `capability.json` declarations, ADR-894) and **Capability Registry Overlay** (the runtime seam that merges an installed third-party manifest into that generated registry at load time, ADR-1244 D2): this registry is a static document rendered by `scripts/gen-registry.cjs`, not a runtime data structure or loader. Each entry enumerates the capability's Loop Extension Points and hook kinds so a reader can judge blast radius before running `gsd capability install`, and declares its `engines.gsd` range. Inclusion is an explicit non-endorsement — a maintainer merged a link, nothing more — per `docs/registries/README.md`.
### EoS Registry
Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the nine negotiated axes, and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema.
Human-facing discoverability catalog (`docs/registries/eos-registry.md`, generated from `docs/registries/eos.json`; issue #2182) listing third-party Embeddable Orchestration System (EoS) host integrations — projects that embed GSD as an orchestration engine behind the ADR-1239 six-interface-point Host-Integration Interface. Entries are registered by the same docs-PR process, schema conventions, and non-endorsement stance as the **Community Capability Registry**, but enumerate the six interface points, the eight negotiated axes plus an optional ninth (`effortSurface`), and `protocolVersion` in place of Loop Extension Points and hook kinds. It has no generated-manifest or Capability Registry Overlay counterpart: an ADR-1239 host integration runs inside the third-party host, not inside GSD's own capability loader, so there is nothing for a runtime registry to merge. See `docs/registries/README.md` for the full entry schema.
### Capability Validator
Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`.

View File

@@ -118,7 +118,7 @@ Example:
|---|---|---|
| `interfacePoints` | yes, non-empty | Subset of the six ADR-1239 interface points it binds: `command`, `dispatch`, `model`, `hooks`, `state`, `artifact`. |
| `profile` | yes | One of the three host-capability profiles: `programmatic-cli`, `declarative-cli`, `ide`. |
| `axes` | yes | Object with **exactly** the nine ADR-1239 negotiated axes keys: `embeddingMode`, `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`, `transport`, `runtime`, `effortSurface`. |
| `axes` | yes | Object carrying **all eight** required ADR-1239 negotiated axes keys — `embeddingMode`, `commandSurface`, `dispatch`, `modelMode`, `hookBus`, `stateIO`, `transport`, `runtime` — and **optionally** a ninth, `effortSurface` (`argv` \| `none`). No other key is accepted. `effortSurface` is optional because ADR-1239 amendment #2481 added it after entries already existed; requiring it would retroactively invalidate every entry published before the amendment. |
`axes` value vocabulary:

View File

@@ -36,6 +36,11 @@
* (`{ namedDispatch, nested, maxDepth, background, subagentToolkit }`) —
* this registry accepts a free-form human summary string instead, so it
* carries the `AXES_FREE_STRING` sentinel rather than an enum array.
* `OPTIONAL_AXES` adds one further, OPTIONAL key on top of those eight:
* `effortSurface` (ADR-1239 amendment #2481). An entry may omit it
* (every entry published before the amendment stays valid) or declare
* it as `argv` | `none`, mirroring `HOST_INTEGRATION_AXES.effortSurface`
* in `src/host-integration.cts`.
* - `CAPABILITY_REQUIRED` / `EOS_REQUIRED` mirror the required top-level
* fields for each entry type, including `enginesGsd` (ADR-1244 D1
* "Versioned capability manifest" — the `engines.gsd` semver-range gate,
@@ -90,6 +95,18 @@ const AXES = Object.freeze({
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
});
// ─── ADR-1239 amendment #2481 — one additional, OPTIONAL negotiated axis ─────
// `effortSurface` was added to the runtime-descriptor vocabulary AFTER the
// original eight (`HOST_INTEGRATION_AXES.effortSurface` in
// `src/host-integration.cts`). It is kept OPTIONAL here — not folded into
// `AXES` — because registry entries mirror their upstream
// `registry/eos-entry.json` byte-for-byte, and requiring it would
// retroactively invalidate every entry published before the amendment.
// Values must match `HOST_INTEGRATION_AXES.effortSurface` exactly.
const OPTIONAL_AXES = Object.freeze({
effortSurface: Object.freeze(['argv', 'none']),
});
// ─── Required top-level fields ───────────────────────────────────────────────
const CAPABILITY_REQUIRED = Object.freeze([
'id',
@@ -246,15 +263,39 @@ function validateEosInteractions(interactions, addError) {
if (typeof axes !== 'object' || axes === null || Array.isArray(axes)) {
addError('interactions.axes', 'axes must be an object');
} else {
const expectedKeys = Object.keys(AXES);
const requiredKeys = Object.keys(AXES);
const optionalKeys = Object.keys(OPTIONAL_AXES);
const actualKeys = Object.keys(axes);
const actualKeySet = new Set(actualKeys);
const keysMatch = expectedKeys.length === actualKeys.length && expectedKeys.every((k) => actualKeySet.has(k));
if (!keysMatch) {
addError('interactions.axes', 'axes key set must exactly match the eight negotiated axes');
} else {
for (const key of expectedKeys) {
const allowedValues = AXES[key];
// Every AXES key is mandatory; an extra key is tolerated ONLY when it is
// a recognized OPTIONAL_AXES key (currently just `effortSurface`) — any
// other extra key is still rejected as unknown.
const missingRequiredKeys = requiredKeys.filter((k) => !actualKeySet.has(k));
const unknownKeys = actualKeys.filter((k) => !requiredKeys.includes(k) && !optionalKeys.includes(k));
if (missingRequiredKeys.length > 0) {
addError('interactions.axes', `axes is missing required key(s): ${missingRequiredKeys.join(', ')}`);
}
if (unknownKeys.length > 0) {
addError('interactions.axes', `axes has unknown key(s): ${unknownKeys.join(', ')}`);
}
// Only validate individual values once the key set itself is sound —
// mirrors the original gate (values were never checked against a
// malformed key set either).
if (missingRequiredKeys.length === 0 && unknownKeys.length === 0) {
for (const key of actualKeys) {
// Inline literal guards — CodeQL barrier pattern. Reaching here already
// implies `key` is one of the nine literal axis names (the unknown-key
// gate above rejected everything else), so this is unreachable in
// practice; it is written inline anyway because CodeQL cannot follow
// that gate across the `.includes()` filter and would otherwise flag
// the bracket reads below as prototype-pollution sinks.
if (key === '__proto__') continue;
if (key === 'constructor') continue;
if (key === 'prototype') continue;
const allowedValues = Object.hasOwn(AXES, key) ? AXES[key] : OPTIONAL_AXES[key];
const v = axes[key];
if (allowedValues === AXES_FREE_STRING) {
if (typeof v !== 'string' || v.trim() === '') {
@@ -496,7 +537,14 @@ function renderMarkdown(entries, opts) {
lines.push(`- **Author:** ${mdInline(entry.author)}`);
if (isEos) {
const axesSummary = Object.keys(AXES)
// Required AXES keys always render, in their fixed order; an OPTIONAL_AXES
// key (e.g. `effortSurface`) renders ONLY when the entry actually carries
// it — an entry that omits it must render byte-identical to before
// OPTIONAL_AXES existed (no `effortSurface=undefined` noise).
const presentOptionalKeys = Object.keys(OPTIONAL_AXES).filter(
(key) => interactions.axes && Object.hasOwn(interactions.axes, key),
);
const axesSummary = [...Object.keys(AXES), ...presentOptionalKeys]
.map((key) => `${key}=${interactions.axes ? interactions.axes[key] : undefined}`)
.join(', ');
const summary =
@@ -556,6 +604,7 @@ module.exports = {
INTERFACE_POINTS,
PROFILES,
AXES,
OPTIONAL_AXES,
AXES_FREE_STRING,
CAPABILITY_REQUIRED,
EOS_REQUIRED,

View File

@@ -0,0 +1,221 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* tests/registry-axes-parity.test.cjs — regression coverage for issue #2810.
*
* `scripts/registry-schema.cjs`'s `AXES` (the eight ADR-1239 negotiated axes
* mirrored into the EoS Registry schema) and `src/host-integration.cts`'s
* `HOST_INTEGRATION_AXES` (the canonical runtime vocabulary, compiled to
* `gsd-core/bin/lib/host-integration.cjs`) are two independent, hand-written
* mirrors of the same underlying axis vocabulary. ADR-1239 amendment #2481
* added a ninth axis, `effortSurface`, to the canonical vocabulary but not to
* the registry's closed `AXES` key set — so a registry entry that faithfully
* mirrored its upstream `registry/eos-entry.json` (which DOES carry
* `effortSurface`) was rejected outright by the registry's exact-key-set
* check. `OPTIONAL_AXES` fixes that by adding `effortSurface` as a ninth,
* OPTIONAL registry axis key. This file asserts the two vocabularies stay in
* parity going forward, and pins the boundary behavior of the fix itself.
*
* Entries below are hand-built plain objects — never read from
* `docs/registries/eos.json` on disk, and no source file is read+string
* matched (both would trip `local/no-source-grep` / defeat the point of a
* behavioral regression test).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
AXES,
OPTIONAL_AXES,
AXES_FREE_STRING,
validateEntries,
renderMarkdown,
} = require(path.join(__dirname, '..', 'scripts', 'registry-schema.cjs'));
const { HOST_INTEGRATION_AXES } = require(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'host-integration.cjs'),
);
// ─── Fixtures ─────────────────────────────────────────────────────────────
// A fully-valid eos entry carrying exactly the eight REQUIRED axes keys (no
// `effortSurface`) — same shape as the two real docs/registries/eos.json
// entries (gsd-cursor, gsd-omp), both published before ADR-1239 amendment
// #2481 and neither carrying `effortSurface`.
function baseEosEntry() {
return {
id: 'my-host-plugin',
name: 'My Host Plugin',
type: 'eos',
repo: 'octocat/my-host-plugin',
description: 'Embeds GSD as an orchestration engine in My Host.',
author: 'Octocat',
license: 'MIT',
enginesGsd: '>=1.6.0 <3.0.0',
install: 'See the My Host plugin marketplace listing.',
uninstall: 'Uninstall via the My Host plugin manager.',
protocolVersion: 1,
interactions: {
interfacePoints: ['command', 'state'],
profile: 'programmatic-cli',
axes: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: 'Supports nested background dispatch up to depth 3.',
modelMode: 'active',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
discussion: 'https://github.com/octocat/my-host-plugin/discussions/2',
};
}
// ─── Parity: registry AXES/OPTIONAL_AXES vs canonical HOST_INTEGRATION_AXES ──
describe('registry-axes-parity: AXES/OPTIONAL_AXES vs HOST_INTEGRATION_AXES', () => {
test('every key shared between (AXES ∪ OPTIONAL_AXES) and HOST_INTEGRATION_AXES has an identical enum array', () => {
const registryAxisKeys = new Set([...Object.keys(AXES), ...Object.keys(OPTIONAL_AXES)]);
const canonicalAxisKeys = new Set(Object.keys(HOST_INTEGRATION_AXES));
const shared = [...registryAxisKeys].filter((k) => canonicalAxisKeys.has(k));
// Sanity: the shared set must be non-empty, or the loop below would pass vacuously.
assert.ok(shared.length > 0, 'expected at least one axis key shared between the two vocabularies');
for (const key of shared) {
const registryValue = AXES[key] !== undefined ? AXES[key] : OPTIONAL_AXES[key];
if (registryValue === AXES_FREE_STRING) continue; // dispatch: asserted separately below
assert.deepEqual(
registryValue,
HOST_INTEGRATION_AXES[key],
`registry axis "${key}" enum must match HOST_INTEGRATION_AXES.${key}`,
);
}
});
test('dispatch is registry-only — absent from HOST_INTEGRATION_AXES — and carries the free-string sentinel', () => {
assert.equal(AXES.dispatch, AXES_FREE_STRING);
assert.equal(Array.isArray(AXES.dispatch), false);
assert.equal(Object.hasOwn(HOST_INTEGRATION_AXES, 'dispatch'), false);
});
test('OPTIONAL_AXES.effortSurface equals the canonical HOST_INTEGRATION_AXES.effortSurface exactly', () => {
assert.deepEqual(OPTIONAL_AXES.effortSurface, ['argv', 'none']);
assert.deepEqual(OPTIONAL_AXES.effortSurface, HOST_INTEGRATION_AXES.effortSurface);
});
// The enum-equality test above compares only keys the two vocabularies ALREADY
// share, so it is blind to the drift mode that actually produced #2810: a new
// canonical axis appears and the registry copy is never told. This test closes
// that hole — every canonical axis must be either modeled here or explicitly
// declared out of scope, so adding a canonical axis fails until someone
// decides which it is.
test('every HOST_INTEGRATION_AXES key is either modeled by the registry or explicitly declared out of scope', () => {
// Deliberately not modeled: both are `dispatch` sub-fields hoisted into the
// flat canonical map (CONTEXT.md's Host-Integration Interface entry lists
// dispatch as `{…, subagentToolkit, isolation}`). This registry collapses all
// of dispatch into one free-form human summary string, so they are covered by
// `AXES.dispatch` rather than carried as separate axes.
const NOT_MODELLED = Object.freeze(['subagentToolkit', 'isolation']);
const modelled = new Set([...Object.keys(AXES), ...Object.keys(OPTIONAL_AXES)]);
const unaccounted = Object.keys(HOST_INTEGRATION_AXES).filter(
(key) => !modelled.has(key) && !NOT_MODELLED.includes(key),
);
assert.deepEqual(
unaccounted,
[],
`HOST_INTEGRATION_AXES gained axis key(s) the EoS registry neither models nor excludes: ${unaccounted.join(', ')}. ` +
'Add each to AXES (required) or OPTIONAL_AXES (optional) in scripts/registry-schema.cjs, ' +
'or list it in this test\'s NOT_MODELLED allowlist with the reason it is out of scope.',
);
// Guard the allowlist itself: an entry that no longer exists canonically is
// stale and would silently widen the exemption for a future same-named axis.
for (const key of NOT_MODELLED) {
assert.ok(
Object.hasOwn(HOST_INTEGRATION_AXES, key),
`NOT_MODELLED lists "${key}", which is no longer a HOST_INTEGRATION_AXES key — remove it`,
);
}
});
});
// ─── Boundary coverage: axes key-count limit-1 / limit / limit+1 ──────────
describe('validateEntries: eos axes key-count boundaries (limit-1 / limit / limit+1)', () => {
test('limit-1: 7 keys (one required key missing) is INVALID and names the missing key', () => {
const entry = baseEosEntry();
delete entry.interactions.axes.runtime;
assert.equal(Object.keys(entry.interactions.axes).length, 7);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.axes' && /missing/i.test(e.reason));
assert.ok(err, `expected a missing-key error, got: ${JSON.stringify(verdict.errors)}`);
assert.ok(err.reason.includes('runtime'), `expected the error to name "runtime", got: ${err.reason}`);
});
test('limit: 8 keys (exactly the required axes) is VALID', () => {
const entry = baseEosEntry();
assert.equal(Object.keys(entry.interactions.axes).length, 8);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
test('limit+1: 8 required + effortSurface = 9 keys is VALID', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'argv';
assert.equal(Object.keys(entry.interactions.axes).length, 9);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, true);
assert.deepEqual(verdict.errors, []);
});
test('8 required + an unknown 9th key (bogusAxis) is INVALID and names the unknown key', () => {
const entry = baseEosEntry();
entry.interactions.axes.bogusAxis = 'x';
assert.equal(Object.keys(entry.interactions.axes).length, 9);
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
const err = verdict.errors.find((e) => e.field === 'interactions.axes' && /unknown/i.test(e.reason));
assert.ok(err, `expected an unknown-key error, got: ${JSON.stringify(verdict.errors)}`);
assert.ok(err.reason.includes('bogusAxis'), `expected the error to name "bogusAxis", got: ${err.reason}`);
});
test('8 required + effortSurface with a value outside the enum ("config-file") is INVALID', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'config-file';
const verdict = validateEntries([entry], { type: 'eos' });
assert.equal(verdict.ok, false);
assert.ok(verdict.errors.some((e) => e.field === 'interactions.axes.effortSurface'));
});
});
// ─── renderMarkdown: effortSurface presence/absence ────────────────────────
describe('renderMarkdown: eos effortSurface rendering', () => {
test('an entry carrying effortSurface renders "effortSurface=argv"', () => {
const entry = baseEosEntry();
entry.interactions.axes.effortSurface = 'argv';
const rendered = renderMarkdown([entry], { type: 'eos', sourceFile: 'eos.json' });
assert.ok(rendered.includes('effortSurface=argv'), rendered);
});
test('an entry that omits effortSurface renders no "effortSurface" substring at all', () => {
const entry = baseEosEntry();
const rendered = renderMarkdown([entry], { type: 'eos', sourceFile: 'eos.json' });
assert.ok(!rendered.includes('effortSurface'), rendered);
});
});