Capabilities: the product adapts to what is on
OpenSwitchboard is installed, not forked: a Slack bot with one provider and nothing else is a complete installation, and so is the four-Worker deployment with residents, memory, run history, MCP, costs and schedules. What separates them is one value — Capabilities, computed once at startup from the config and the environment (Fowler's feature toggles resolved once) and handed down through CoreDeps. Every surface reads that value: the command registry hides what is off, the web seed carries it so the dashboard paints only the tabs that exist, the self-description block describes only what is wired, the deploy plan iterates the profile's Workers. No surface reads config.memory?.enabled for itself; a scattered if (config.x) is the smell this feature removes (capabilities are computed once, with a Null Object for every off-state). The value's rules and the Null Object for every off-state are routing-and-config.md item 16; the registry's hiding is command-registry.md item 28.
The proof is a suite that runs the whole surface under three installations expressed as configurations — minimal, local-full, cloud-full — and snapshots each, plus a capability axis on the command conformance suite: a capability changing must change exactly the commands whose enabledWhen depends on it, on every surface, and nothing else.
- Code:
src/core/capabilities.ts(Capabilities,capabilitiesFrom,ALL_CAPABILITIES,NO_CAPABILITIES);src/core/capabilityGating.ts(CAPABILITY_KEYS,isEnabled,dependsOn,visibleUnder,withOn/withOff);src/core/commandRegistry.ts(CommandDef.enabledWhen,CommandRegistryOptions.capabilities);src/core/dispatcher.ts(CoreDeps.capabilities);src/index.ts+src/cli.ts(the one computation per process);src/channels/webShell.ts+src/channels/webSeed.ts(the shell stampscapabilitieson every page seed);src/core/selfDescription.ts(the About block from the capabilities and the resident Worker's cap);src/deploy/plan.ts+src/deploy/profile.ts(the plan iterates the profile's Workers);src/core/testing/commandConformance.ts(the matrix'sneedscolumn);src/docs/reference.ts(renderCapabilityCommands, the docs matrix's generated column). - Tests:
src/core/capabilitySurfaces.test.tsover the fixtures insrc/core/testing/capabilityFixtures.tsand the world insrc/core/testing/conformanceFixture.ts; the capability axis insrc/core/commandConformance.test.ts; the docs matrix's generated column insrc/docs/reference.test.ts. The value's own rules:src/core/capabilities.test.ts; the registry's hiding per surface:src/core/commandCapabilities.test.ts. - Docs: Turn features on and off (the matrix: capability, the block or env that turns it on, what appears, what disappears, what it costs; its command column is a generated region — docs-site.md item 6), Reference: configuration.
Behavior
One value, computed once.
capabilitiesFrom(config, env)is pure and is called exactly once per process —src/index.tsfor the bot,src/cli.tsfor the CLI — and the result ridesCoreDeps.capabilities. Its axes, each with the rule that turns it on and the value it takes when off:Axis On when Off value executionexecution.type(local|e2b|cloudflare)localresidentsexecution.resident.baseUrlnames the resident Workerfalsememorymemory.enabled: truefalserunHistorya runHistoryblock that selects a store:store: file, or aworkerwhose bearer (tokenEnv, defaultMEMORY_TOKEN) is in the envfalserunLedgerrun history on a state Worker (a file store has no ledger) falsemcpan mcpblockfalsecostsa costsblock AND its Cloudflare analytics token in the envfalseschedulesschedules.worker.baseUrlwith its bearer in the envfalsegithubthe App triple ( GITHUB_APP_ID+GITHUB_APP_PRIVATE_KEY+GITHUB_APP_INSTALLATION_ID) orGH_TOKENfalseingressSWITCHBOARD_INGRESS_TOKENSnames at least one bearerfalsereadingDiffAbridgethe meatbinary resolves on the bot host's PATH (meatOnPath, probed once by the caller and handed in as theHostFactsthird argument — the computation stays pure), AND the Anthropic provider's credential is set (anthropicApiKey, the provider's ownapiKeyEnv), ANDreview.readingDiff.provider— env override included — is notoff(reading-diff.md item 1)falsedashboardAuthconfig.dashboard.authwhen set (access-gate.md); elseaccesswhenACCESS_TEAM_DOMAIN+ACCESS_AUDare set, elsenone(loopback callers of a localhost deployment only) — the sameresolveDashboardAuthModethe verifier runs, so the value names the strategy that gates requestsnoneThe rules mirror the builders that select each subsystem's implementation, and the function throws exactly where they throw (a malformed
costsormcpblock) — a bad config is a startup error here as it is there, never a capability silently read as off.ALL_CAPABILITIESis every axis at its on value (the catalogue as the reference docs render it);NO_CAPABILITIESevery axis at its off value.Off is hidden, not refused. A command declares the capability it needs as
enabledWhen(caps)on its definition (absent → always on). When the predicate is false for the process's capabilities the command is hidden on every surface — absent fromhelp,<group> help, MCPtools/list, the HTTP catalogue (/api/<id>is 404), the CLI catalogue and chat; a directinvokeisnot_found— instead of answeringunavailable(command-registry.md item 28 names the gated commands). The handler's ownunavailable(a dependency missing at call time) stays as the defence in depth; it is never the way a user learns a feature is off.The gate is derived, not restated.
dependsOn(cmd)(src/core/capabilityGating.ts) reads a command's axes off its ownenabledWhen: each axis whose value changes the answer in at least one of the two reference worlds — turned off alone against everything else on, or turned on alone against everything else off. Both directions matter because a predicate may accept more than one backend:repo testis on with residents OR local execution, so it depends on both axes.visibleUnder(cmds, caps)is the catalogue under one value. Every consumer that needs to know which commands a capability decides — the conformance matrix'sDepends on:line, the docs matrix's command column (docs-site.md), the capability suite's expectations — asks these, so a new gated command is covered and documented the moment it is registered, and a hand-written list of "commands behind memory" cannot exist to drift.Three installations, as configurations.
src/core/testing/capabilityFixtures.tsexportsminimal(Slack plus one provider; tools on the bot host; no Access, so dashboard authnone; nothing optional),local-full(everything a laptop can turn on: memory in-process, run history on disk, GitHub viaGH_TOKEN, anmcpblock, bearer ingress; no Workers, so no residents, costs, schedules or ledger) andcloud-full(everything on: Cloudflare execution with a resident Worker, memory, run history and the ledger on the state Worker, MCP, costs, schedules, the GitHub App, ingress, Access). Each is a completeconfig.yamlplus an environment and theCapabilitiesit must produce; the suite asserts the round trip throughcapabilitiesFrom, so a fixture is a shape an operator can copy, never a hand-typed set of flags.minimalisNO_CAPABILITIES;cloud-fullisALL_CAPABILITIES; strictly more is on in each than the one before.Every surface, under every shape, snapshotted. For each fixture the suite drives the conformance world (
src/core/testing/conformanceFixture.ts,fixture()with the fixture'sCapabilitieshanded to theCommandRegistry) through the real adapters and snapshots:helpon chat, the MCPtools/listnames, the HTTP catalogue (the/api/<id>routes that answer an admin with anything but 404), the CLI catalogue, the/runsand/runs/scheduledseeds assrc/index.tswires them (the shell'scapabilitiesstamp, the retention sentence — the configured window with run history on, "kept about a minute" without — the Scheduled panel's schedules and whether it has a firing store), and the self-description block (with the resident Worker's cap where there is a fleet). The deploy plan (formatPlan(planDeploy(…))) is snapshotted for the full example profile (every Worker in the canonical order) and for a profile with only the bot (one step, no state Worker to push the config to). A snapshot changes when — and only when — a surface changes what it shows for that shape; the diff is the review.The capability axis. For every axis of the contract, turning it on from the minimal world changes exactly the commands whose
enabledWhendepends on it — the ones the predicates say it adds and the ones they say it removes (a sandbox execution removesrepo test/repo buildfrom a world with no residents), on each adapter, judged by what the adapter shows (presenceOf: HTTP not-404, a tool intools/list, the CLI grammar knowing the words, chat recognising the form) — and every command that moved depends on that axis. Every fixture shows exactlyvisibleUnderits capabilities, per adapter, intersected with the surfaces the command exposes. A snapshot of the gating map (command → axes) fences which commands depend on each capability. On the conformance suite (command-registry.md item 25) every command gets a capability row: in every world one axis away from all-on or all-off (and in all-off itself), it exists exactly where its predicate says — hidden is absent on every adapter andnot_foundon a directinvokewith nothing executed, neverunavailable; present is anokinvoke.
Validation criteria
| Criterion | Evidence |
|---|---|
1: readingDiffAbridge is on iff the host fact says the binary is there, the provider's credential (its own apiKeyEnv) is set, and the switch (config or SWITCHBOARD_READING_DIFF) is not off; any one missing is off; meatOnPath answers command -v meat for a PATH | [unit] src/core/capabilities.test.ts::capabilitiesFrom — every axis, on and off::readingDiffAbridge…, src/core/meatProcess.test.ts::meatOnPath::* |
2: review.abridge is hidden without runHistory or without readingDiffAbridge, present with both; the off Null Object answers unavailable naming the facts | [unit] src/core/commands/review.test.ts::review.abridge::is hidden when run history is off…, ::no abridger (the capability's Null Object)… |
Each fixture's config.yaml + env produce its declared Capabilities through capabilitiesFrom (item 4) | [unit] src/core/capabilitySurfaces.test.ts::capability fixtures — real configurations::$name: its config.yaml and env produce its Capabilities through capabilitiesFrom (the round trip) |
minimal is the all-off value with the local-dev dashboard; cloud-full is the all-on value; the three shapes are distinct and monotone; local-full needs no Worker (item 4) | [unit] ::minimal is the contract's all-off value with the local-dev dashboard; cloud-full is its all-on value, ::the three shapes are distinct and strictly more is on in each than the one before, ::local-full turns on nothing that needs a Worker: execution local, no residents, costs, schedules or ledger |
Under each fixture, help on chat, the MCP tool list, the HTTP catalogue, the CLI catalogue, the web seeds and the self-description block are pinned (item 5) | [unit] src/core/capabilitySurfaces.test.ts::capability surfaces — $name::help on chat, ::MCP tools/list names, ::HTTP catalogue: the /api/<id> routes that answer an admin, ::CLI catalogue, ::web seeds: /runs (capabilities, retention sentence) and /runs/scheduled (schedules, firing store), ::self-description block (the resident cap is the Worker's answer — 6 where there is a fleet) |
The /runs seed carries exactly the fixture's capabilities; retention reads the configured window with run history on and the one-minute sentence without; the Scheduled panel has a firing store only with schedules on (item 5) | [unit] src/core/capabilitySurfaces.test.ts::capability surfaces — $name::web seeds: /runs (capabilities, retention sentence) and /runs/scheduled (schedules, firing store) (the seed's capabilities is asserted equal to the fixture's before the snapshot) |
The deploy plan: the full example profile is every Worker in DEPLOY_ORDER; a bot-only profile is one step with no state Worker URL; both texts pinned (item 5) | [unit] src/core/capabilitySurfaces.test.ts::deploy plan — what \deploy plan` prints::the full example profile: every Worker, in the canonical order, ::a bot-only profile is a one-step plan with no state Worker to push the config to` |
Which commands depend on each capability is derived from enabledWhen and fenced (item 3, 6) | [unit] src/core/capabilitySurfaces.test.ts::capability axis — a capability turning on changes exactly the commands its enabledWhen names::which commands each capability turns on, derived from the registry's enabledWhen — update deliberately |
| Every fixture shows exactly the commands enabled under it, per adapter (item 6) | [unit] ::$name: every adapter shows exactly the commands enabled under it |
| Turning one axis on from the minimal world adds and removes exactly the commands the predicates say, on every adapter, and each of them depends on the axis (item 6) | [unit] ::%s: from the minimal world, turning it on changes exactly the commands whose enabledWhen depends on it, on every adapter |
In every world one axis away from all-on or all-off, and in all-off, a command exists exactly where its enabledWhen says: hidden is absent on every adapter and not_found on invoke with nothing executed — never unavailable; present invokes ok (item 2, 6) | [unit] src/core/commandConformance.test.ts::command conformance — $id::capability axis: in every world one axis away from all-on or all-off, the command exists exactly where its enabledWhen says — hidden is absent on every adapter and not_found on invoke, never unavailable; present is ok |
The conformance matrix's Depends on: line is dependsOn (item 3) | [unit] src/core/commandConformance.test.ts::command conformance — catalogue fences::scripts/command-conformance-matrix.ts prints this suite's matrix… |
The docs matrix's command column is generated per axis from dependsOn, and docs:check fails when the committed page and the registry disagree (item 3) | [unit] src/docs/reference.test.ts::renderCapabilityCommands::*, ::the real catalogue::the capability column lists, per axis, exactly the commands dependsOn derives from the registry — a list nobody typed, ::GENERATED_REGIONS::matches the markers actually present in each docs page…; [agent] npm run docs:check → docs:check ok — 10 file(s) match the command registry, the decision records and the diagram sources |