Skip to content

An external workspace is not a provider: the term, the dependency posture, and where synced content lives

An external workspace is not a provider: the term, the dependency posture, and where synced content lives

majordomus://adr/adr-0032proposed architectureworkspace

it references

Nothing. This object declares no reference.

referenced by

25. An external workspace is not a provider: the term, the dependency posture, and where synced content lives

Context

The operator wants machine-readable access to his own ChatGPT Projects — enumerate them, read their conversations, sync incrementally, and see the result through the surfaces this repository already has. Official coverage is incomplete, so any implementation reaches for an authenticated browser and observed private traffic for the part the vendor does not document. That is a legitimate thing to build over one's own authorised account, and the gray zone is not what this record is about.

What this record is about is that the design as it arrives collides with three things the repository has already settled, and each collision is one a session would otherwise resolve silently, in passing, in the middle of a phase that was supposed to be about something else.

The first is the word. provider is spent: ADR 0024 defines it as a tool that works in this repository — a bootstrap file it reads, a client configuration that starts the shared MCP server for it, and the hooks through which it hands Majordomus a prompt — and share/providers.yaml is the table with agents, claude, codex, gemini and bb in it. A ChatGPT Project is the opposite direction of the same arrow: content held elsewhere that this repository reads. Both are named after the same vendors, and a session told to "extend an existing equivalent invariant rather than duplicate it" would merge them. The two neighbouring nouns are spent too. project is a declared kind, majordomus.project/v1, and it is this repository's own record; source is the knowledge compiler's class in share/knowledge-sources.yaml.

The second is the dependency posture. majordomus-cli has eleven dependencies and the manifest argues for each one: no YAML crate because the layer's YAML is the subset docs/SCHEMAS.md defines, jsonschema with default-features = false because nothing may resolve over the network, tiny_http because the loopback server is synchronous and there is no async runtime and no framework. There is no HTTP client and no TLS anywhere in the tree. CDP is a WebSocket carrying JSON-RPC and private HTTP is TLS; adding either to this crate is not a choice of crates but a change of category, and it lands on the one executable bin/majordomus-mcp builds on demand — already, per ADR 0024, slower than the two seconds a settings-loaded MCP server is given before the first turn.

The third is mutability. Everything the tool does today is a read-only projection of files that are in the repository. A workspace sync is a stateful network client with a durable store, holding content the repository did not write and may not be entitled to publish. ADR 0005 already draws the line it needs — .ai/local/** is checkout state, never a source — but nothing in the incoming design says which side of it the store is on.

An audit taken before this record was accepted established two things that a first reading of the layer gets wrong, and both change the answer.

discovery: state is not the shared mechanism it looks like. share/knowledge-sources.yaml has exactly one reader, lib/knowledge.sh:30, the shell knowledge compiler; the dispatch is lib/knowledge.sh:99-103 and the reader mj_kdisc_state is a non-recursive ls -1 over one directory (lib/knowledge.sh:130-146). The Rust index — the one that feeds objects.list, every MCP resource, every HTTP route, OpenAPI and the Cockpit — knows a single discovery, DiscoveryKind::Vcs, tracked files through the git index (apps/majordomus-cli/src/discovery/mod.rs:33-37), and excludes .ai/local at three independent points (app.rs:82-84, repository.rs:176-178, discovery/mod.rs:283-285). A state class therefore buys visibility in majordomus knowledge and nowhere else. search and context do not read it either: both hardcode their record kinds and paths (lib/search.sh:44-51, lib/context.sh:176-262). The one precedent for local state reaching a capability at all is continuity, a hand-written reader over a hardcoded STATE_DIR (apps/majordomus-cli/src/capability/builtin/continuity.rs:51) exposed as exactly one resource that is, in its own words, served and never published.

The repository already drives a browser, and it is not Rust. scripts/lib/ui-audit.mjs:262 and scripts/lib/cockpit-probe.mjs:436 both call chromium.launch({ channel: 'chrome' }) — Playwright over the system-installed Chrome, deliberately never a downloaded one — with playwright and axe-core as root devDependencies, wired into validate.yml for the site and cockpit jobs, and skipping cleanly with a defined exit code where Chrome is absent. Playwright's transport to that browser is CDP.

Decision

A workspace is not a provider. The external thing gets its own noun: a workspace is a body of content held by another vendor over which the operator is already authenticated, and which this repository reads. The code that reaches one is a workspace adapter; the ways it reaches it are transports. provider keeps the meaning ADR 0024 gave it and no adapter takes that word, no row in share/providers.yaml, and no field of the provider table. That Claude Code is a provider and a Claude Project would be a workspace is the distinction, not an ambiguity to be smoothed over.

The network code does not enter majordomus-cli, and it is not a second crate either. The transport and the sync live in the Node tooling layer that already drives Chrome: Playwright over the system browser, the posture scripts/lib/*.mjs established and CI already runs. majordomus-cli acquires no dependency from this work — not an async runtime, not an HTTP client, not TLS, not a CDP client — and a gate proves it. apps/ stays a single crate. Writing a CDP client in Rust would reimplement, in the language with the strictest dependency budget in this repository, the one thing the repository already has a working, CI-wired implementation of.

The CLI's share of the subsystem is one read capability. It is written in the shape continuity established: a hand-written reader over the state directory, one capability, served and never published, with no docs/generated/ or site projection. There is no free ride: the kind pipeline reaches tracked files only, and nothing under .ai/local is indexed by construction and by three explicit guards.

Synced content is checkout state. A workspace's content lands under .ai/local/workspaces/<workspace>/, which is what ADR 0005 already says it is: state, never a source, git-ignored at .gitignore:55 and gated by mj_validate_ai_layout (lib/doctor.sh:431-436) which fails the pre-commit hook if a file there is ever tracked. It is not an input to derive, not an input to the site, and never served on a public surface.

It is deliberately not declared as a class in share/knowledge-sources.yaml. That would buy visibility in one shell command and charge for it in the worst possible currency: the knowledge compiler hashes the full content of every file it discovers (lib/knowledge.sh:80, lib/common.sh:465-470) and re-parses each one afterwards, so a synced corpus of thousands of conversations would make majordomus knowledge linear in the size of a body of text that command has no reason to read.

Its retention is its own. mj_validate_retention (lib/doctor.sh:303-316) is three hand-written stanzas over the ledger, handovers and checkpoints; a new store gets no cap and no doctor line unless one is written for it. One is written for it, because an unbounded store that nothing measures is the defect this repository keeps finding in itself.

Content becomes the repository's own statement only when a person promotes it into .ai/repo/, in the shape the layer already uses for a decision recorded while working and later written down as an ADR. No sync writes to the tracked tree.

Credentials never touch the repository. The browser adapter attaches to an already-authenticated browser profile named by configuration outside the tree. No cookie, token or authorisation header is copied into a file the repository can read, and none reaches a fixture, a log, a snapshot or the state directory.

Alternatives rejected

A row in share/providers.yaml. The provider table knows three things about a tool — its bootstrap, its client configuration, its hooks — and not one of them means anything for a corpus held elsewhere. Filling the table with a member that answers none of its questions costs the table its meaning, and buys a shared word that was already the source of the confusion.

Async, an HTTP client and a CDP crate inside majordomus-cli. It is the shorter path and it spends the property the crate was built around. Every agent session pays the build, every doctor run pays the link, and the argument in the manifest for refusing a YAML crate stops being true the moment a TLS stack is in the tree for an unrelated feature.

A second Rust crate in a Cargo workspace. This record proposed it before the audit and the audit withdrew it. It keeps the dependency boundary but pays for it twice: a hand-written CDP client beside a working Playwright one, and a second binary to build, ship and explain, for a subsystem whose browser half the repository can already perform.

A discovery: state class for the synced corpus. It reads as the reuse the doctrine demands and is not: one shell command gains it, no surface does, and the compiler behind that command hashes and re-parses every byte it is given.

Synced content under .ai/repo/. It is not this repository's statement, it is machine-local, it is potentially large and potentially private, and ADR 0005 has already answered the question in general terms.

A separate repository. The canonical model, the schemas, the kinds and every surface are here; a split would recreate across two repositories the duplication the layer forbids inside one.

Consequences

The incoming plan changes shape in three places. Its "canonical provider core" is a Node module beside the existing browser tooling, not a trait inside the Rust binary and not a crate beside it. Its "one application layer" projecting the corpus through CLI, REST, OpenAPI, MCP and Cockpit is not available at all: the machinery that would have carried it reaches tracked files only, so the CLI's share is one continuity-shaped capability and the rest of the plan's surface list is a thing that would have to be built, deliberately, against the grain, for content the operator has not said should be public. Its sync engine gets a retention cap written by hand, because nothing generic would ever notice the store.

The subsystem is therefore two halves in two languages with a directory between them: Node writes .ai/local/workspaces/, Rust reads it, and neither links the other. That boundary is the file system, which is the same boundary continuity already lives on.

The distribution model gains a question it does not yet have an answer to: the Node tooling is a devDependency of this repository, not something the installed tool carries, so a workspace sync is an operator's instrument in a checkout rather than a feature of the released binary. Whether that changes is left open; it is decided when there is something to ship.

A gate must assert that majordomus-cli's dependency list did not grow, or the boundary is a sentence rather than a constraint.

Nothing synced is public until a person promotes it, which means the first useful output of this subsystem is visible to the operator's own tooling and to no one else. That is the intended asymmetry.

Where this comes from

canonical file
.ai/repo/adrs/0032-an-external-workspace-is-not-a-provider-the-term-the-depende.md
identity
adr-0032
also served as
MCP resource majordomus://adr/adr-0032 · majordomus entity show adr/adr-0032 · GET /api/v1/entity?uri=majordomus://adr/adr-0032
in the Cockpit
/cockpit/objects/adr/adr-0032

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

Searches the titles and descriptions of every page this site publishes.