Single Go binary: MCP server + CLI for managing Zerops PaaS.
CLAUDE.md is a MAP, not a knowledge store. It records where each kind of knowledge authoritatively lives + the disciplines that have no other home. It NEVER caches product knowledge that already lives in a spec / test / code — a cached copy rots invisibly and can't be traced to its source. To answer a question, go to the home.
Source of truth, by authority:
1. Tests (table-driven, executable) ← behavior
2. Code (Go types, interfaces) ← implementation
3. Specs (docs/spec-*.md) ← workflow design
4. Plans (plans/*.md) ← TRANSIENT roadmap (expires; never cite as a source)
5. CLAUDE.md ← this map + the disciplines below
Every kind of knowledge has ONE home — record it there, never here:
| Knowledge | Home |
|---|---|
| Behavior invariant (what the system does) | a test + the spec section |
| Implementation mechanism (how) | the code + its doc-comment |
| Workflow / design decision | docs/spec-*.md (promoted from a plan) |
| Zerops platform fact | ../zerops-docs/ or live verification (never training data) |
| Recipe / framework gotcha | internal/knowledge/recipes/<slug>.md |
| Universal platform mechanic | an atom (internal/content/atoms/) |
| How to work (not a system fact) | CLAUDE.md (here) |
| Dev-loop ops (SSH, eval, flow-eval) | CLAUDE.local.md |
| Transient roadmap / journal | plans/ |
Key specs:
docs/spec-workflows.md— workflow steps, envelope/plan/atom pipeline; deploy + close-mode (§4.3), deploy modes (§8 DM), pipeline (§8 P4), launch-production (§10), export (§9)docs/spec-work-session.md— per-PID Work Session, compaction survival, auto-close (§7.5)docs/spec-knowledge-distribution.md— atom corpus authoring contract (§11)docs/spec-knowledge-architecture.md— knowledge retrieval (tools-only, single fetch)docs/spec-content-surfaces.md— recipe content-quality contract; fact classificationdocs/spec-authoring-boundary.md— maintainer-only authoring domain, ZCP_AUTHORING gatedocs/spec-guided-mode.md— user-onlyzcp init --guided: local marker, AGENTS.md block + skill, authoring mutual-exclusiondocs/spec-architecture.md— per-package map;docs/spec-local-dev.md— local vs containerdocs/spec-capture-inspector.md— capture evidence contract, local UI security, and cold CLI-only inspector boundarydocs/spec-welcome-mode.md— dark command-invoked welcome surface: bridge auth trigger, versioned bootstrap install, guided toggle, curated skillsdocs/spec-scenarios.md— per-phase walkthroughs (pinned byscenarios_test.go)docs/spec-testing-architecture.md— test+eval surface map: tier rule (offline/api/e2e/eval), api/e2e vs behavioral division, drift guards, scenario manifestdocs/schema-integration.md— schema validation ownershipdocs/spec-oss-port-flow.md— gatedzerops_porttool (foreign OSS → curated recipe)docs/spec-dataconsole.md— Managed Data Console: code-isolated managed-service data viewer/editor; caller-bound write-token posture, embed/standalone reach, family taxonomy, value-fidelity wire contract (§7), installdocs/spec-dataconsole-testing.md— Data Console testing architecture: 5-tier map, ServiceProfile proof-coverage rule (declared ⇒ proven), typed manifest + engine×proof matrix gate, dc-live/dc-live-remote lanes, version policy
- Authoring boundary (
internal/authoring/, ZCP_AUTHORING gate, L1/L2 laws + C1-C5 contracts, self-register-inside-gate) →spec-authoring-boundary.md; depguardcore-not-authoring/authoring-allowlist+TestAuthoringBoundary_*. - Guided mode is a LOCAL per-project marker (
.zcp/state/guided), never onruntime.Info; the AGENTS.md guided block + skill render iffguided && !Authoring(user-only, authoring mutual-exclusion) →spec-guided-mode.md;TestBuildAgentsMD_AuthoringExcludesGuided. - Deploy delivery is DERIVED from
GitPushState, not chosen by close-mode:configured⇒ commit+push is the terminal act for every close-mode exceptmanual; legacygit-pushclose-mode folds toauto→spec-workflows.md §4.3/S5;TestResolve_ConfiguredDrivesGitPushDelivery. - Deploy mode asymmetry (self vs cross; DM-2 self-deploy deployFiles=
[.]gate; no client-side source stat-check) →spec-workflows.md §8DM-1…DM-5. - Runtime meta is pair-keyed (one meta per dev/stage pair; index via
ManagedRuntimeIndex/FindServiceMeta, never key onm.Hostnamealone) →spec-workflows.md §8 E8. - Check-before-mutate for non-idempotent platform APIs (read REST-authoritative state, short-circuit) →
spec-workflows.md §8 O3; canonicalops.Subdomain. - Schema validation ownership (schema=client-side source / topology=classification / platform=authority) →
docs/schema-integration.md. - launch-production is pipeline-first: prod import carries NO
buildFromGit; runtimes startstartWithoutCode:true, the first release IS the first build →spec-workflows.md §10. - Single-token launch lifecycle: token =
ZCP_LAUNCH_TOKENstaged service secret (NOTZEROPS_-prefixed;ZEROPS_TOKEN_PRODis the GitHub repo secret) →spec-workflows.md §10.2b/P-LP-14. - Export = single-repo
buildFromGitsnapshot (one runtime + N managed deps; HA in type-variant notmode:; livegit remoteis source of truth; schema-validate before publish) →spec-workflows.md §9/E1-E6. - Lifecycle recovery is
action="status"(envelope = recovery primitive; mutations may be terse; errors stay leaf payloads) →spec-workflows.mdP4. - Data Console write authority is CALLER-BOUND: a new mutating route gates on the per-request
X-Write-Tokenviasafety.Policy.AuthorizeWrite, never on the read bearer orX-Confirmalone →spec-dataconsole.md §5;TestWriteToken_DualClient_CallerBound.
Live schemas (YAML field validation) are fetched host-derived from ZCP_API_HOST
(schema.URLs, pinned to schema.CanonicalAPIHost for dev tooling). Error codes
catalog: internal/platform/errors.go.
The activity/list SEARCHES are Elasticsearch-backed and LAG writes — SearchProcesses,
SearchAppVersions, AND ListServices (all Post*Search) read an ES index that trails
the DB and is slower when ES is under load. A just-imported service / just-started process
can be ABSENT from these searches for sub-second to several seconds; ListServices usually
indexes a new service BEFORE its build process/appVersion show up (live-verified eval 2026-06-30:
List@3.9s, Proc/AppVer@4.3s after a buildFromGit import — variable with load). So absence in
a search is not ground truth right after a mutation/import, and a freshly-imported service
can briefly read "idle"/"adoptable" before its in-flight process is queryable. Never conclude
"no such service" / "service is idle" from a single search taken right after an import — re-query.
By-id GETs (GetService/GetProcess) are direct reads, NOT ES — use them to freshen a search
verdict (the adopt gate's processStillLive does exactly this). Project-level direct reads exist
too — ListServicesDirect (GET /project/{id}/service-stack) + GetProjectProcessesDirect (GET
/project/{id}/process) are lag-free; ops.Discover + ops.ProjectActivity use THESE (not the
searches) so a just-imported service + its live process are visible at-creation. The ES searches
stay for history/timeline (ops.Events) + resolve/poll callers. Spec: spec-workflows.md §3.5.
Strict layering: L1 platform/ (raw Zerops API, no ZCP concepts) → L2
topology/ (ZCP vocabulary: Mode, RuntimeClass, CloseDeployMode,
GitPushState, BuildIntegration + predicates; zero non-stdlib imports) → L3
workflow/ ←peers→ ops/ (orchestration / discrete operations) → L4
cmd/ + server/ + tools/ (MCP + CLI entry points; convert input strings →
typed at the boundary). Per-package map + diagram: docs/spec-architecture.md.
Dependency rule (pinned by .golangci.yaml::depguard + internal/topology/architecture_test.go):
| Rule | Reason |
|---|---|
topology/ imports stdlib only |
Foundational vocabulary |
platform/ imports no internal/ packages |
Bottom of stack |
ops/ does NOT import workflow/, tools/, authoring/ |
Peer/upper |
workflow/ does NOT import ops/, tools/, authoring/ |
Peer/upper |
core does NOT import authoring/ (composition root: server/) |
Authoring boundary L1 |
authoring/ imports core only via allowlist (topology, schema, knowledge, platform, sync) |
Authoring boundary L2 |
New shared type → topology/ first, never workflow/ |
Promotion rule |
Cross-cutting packages live under internal/ (peer, not strict-layered):
auth/ runs pre-engine, authoring/ is the ZCP_AUTHORING-gated maintainer
domain, service/ is exec wrappers. Full list via ls internal/.
RED → GREEN → REFACTOR. Pure refactors skip RED — verify all affected layers stay green.
Change impact — tests at every affected layer must pass:
- Interface/type change in
platform/ops→ unit + tool + integration + e2e - Tool handler change → tool + integration + e2e
- New MCP tool → tool +
annotations_test.go+ integration + e2e
Layers: unit (./internal/...), tool (./internal/tools/...), integration
(./integration/ mock), e2e (./e2e/ -tags e2e real Zerops). Rules:
table-driven; naming Test{Op}_{Scenario}_{Result}; t.Parallel() only where
global state allows (document why not); long tests check testing.Short().
make setup Bootstrap dev env (lint + git hooks)
make lint-fast ~3s native fast linters
make lint-local ~15s full golangci lint
go test ./... -short All tests fast
go test ./... -race All tests with race detector
Knowledge sync (recipe/guide markdown is gitignored — pull before build):
zcp sync pull recipes [<slug>] | pull guides
zcp sync push recipes <slug> [--dry-run] | push guides # → GitHub PR
zcp sync cache-clear [<slug>]
zcp sync recipe {create-repo,push-app,publish,export}
Workflow: pull → edit .md → push → merge → cache-clear → pull. Config:
.sync.yaml + .env STRAPI_API_TOKEN.
Non-trivial changes run through the /flow skill (.claude/skills/flow/):
FRAME → PROVE (load-bearing assumptions proven live on zcp-eval-clean) →
SHAPE (Codex plan gate; owner approves register + spec promotion) → BUILD
(AFK worktree slices, RED replay) → ASSEMBLE (battery + owner retest pack) →
LAND (spec reconciliation, archive). Small safe fixes take the LITE path
(problem-solving + one slice). Details live in the skill, not here.
Code conventions:
- Service by hostname — agents/tools speak hostnames; resolve to ID internally.
- Shell/SQL composition — POSIX single-quote via
shellQuote(); neverfmt.Sprintfto compose shell/SQL. - Error wrapping —
fmt.Errorf("op: %w", err); never barereturn err. - File splits driven by cohesion, not line count.
- English everywhere — code, comments, docs, commits.
- Phased refactors — verify each phase before continuing; no half-finished states.
- Rename safety — no AST-aware tooling; grep calls, types, strings, tests separately.
Knowledge authoring:
- Atoms are single-owner — describe observable state / orchestration / concepts / pitfalls; never spec IDs, handler-behavior verbs, invisible-state fields, plan paths, or env-only title qualifiers. Extend
internal/content/atoms_lint{,_axes}.go, never add per-topic atom tests.TestAtomAuthoringLint. Spec:spec-knowledge-distribution.md §11. - Recipe-specific findings (framework / library / dev-workflow gotchas) → recipe
.md(internal/knowledge/recipes/<slug>.md, thenzcp sync push recipes <slug>), never atoms (platform-mechanics-only) or audits (transient). Classification:docs/spec-content-surfaces.md.
Do NOT:
- Use global mutable state (except
sync.Oncefor init). - Use
replacedirectives ingo.mod. - Use
interface{}/anywhen the concrete type is known. - Use
panic()— return errors. Skip error checks (errcheckenforces). - Write tests + implementation in the same commit without RED first.
- Add
t.Parallel()to packages with global state without thread-safety first. - Hold mutexes during I/O — copy under lock, release, then I/O.
- A corrective
zerops_deployis non-destructive and is NEVER gated — onlyzerops_import override=true(+ DM-2 self-deploy) is a hard gate. A redeploy of a FAILED / READY_TO_DEPLOY-with-failed-history target keeps the prior appVersion serving; gating it deadlocks recovery and forces the agent into the destructive override escape.TestDeployLocal_*Proceeds,TestImport_OverrideOnFailedRequiresAck. - Service "busy" = a LIVE process (
PENDING/RUNNING/ROLLBACKING/CANCELING) referencing it — the SOLE busy-truth, always carrying a cancelableprocessId(ops.ProjectActivity/ops.IsProcessLiveown it; the adopt gate + discoveractivitysteer both read it). The appVersionBUILDING/DEPLOYINGis ONLY a phase LABEL, never an independent busy signal — an appVersion-only gate deadlocks (a stuckBUILDINGhas no process to cancel). A FAILED/terminal build is never busy: never key on mere stack.build presence /WAITING_TO_BUILD(the <1s fast-fail leaves aFAILEDprocess + frozenWAITING_TO_BUILD); gating those deadlocks recovery. The adopt gate freshens viaGetProcessso a stale search row can't deadlock it.TestProjectActivity,TestAdoptGate_*. - Credentials are user-owned —
GIT_TOKEN/ launch token are secrets the agent NEVER fabricates: onGIT_TOKEN_INVALID/MISSINGit surfaces the contract and asks the user (appendCredentialContract); the launch token enters the conversation once, or — on a user-granted one-time platform delegation — zero times (the platform mints it, not the agent), and never crosses response / state / audit surfaces.TestConvertError_CredentialContract. - Subdomain auto-enable is the deploy handler's job — predicate is mode-allowlist +
IsSystem()ONLY; NEVER read DTOSubdomainAccess/Ports[].HTTPSupportas import intent. launch-prod strips it (P-PROD-2).TestServiceEligible_*,TestBuildLaunchBundle_StripsSubdomainAccess. - Telemetry ingest
clientIP()keys on balancer-authoritativeX-Real-IP, NEVER clientX-Forwarded-For— the Zerops L7 balancer appends client XFF left-most (spoofable) and overwrites X-Real-IP; every IP-rate-limited / blocklisted ingest route routes throughclientIP(netip-canonical,RemoteAddrfallback for in-project), andINGEST_BLOCK_IPSentries canonicalize identically. On the shared*.zerops.appsubdomain X-Real-IP is the constant proxy addr (one global bucket) — a v1 test-endpoint tradeoff, custom domain (origin IP) for v2.TestClientIP_SpoofedXFF_Ignored. →docs/spec-telemetry.md §6. - Lifecycle readers MUST use
workflow.IsOpen/DeriveCloseState— never a rawws.ClosedAt == "". Auto-close is derived, not stamped, so a done session keepsClosedAt==""on disk; a raw read reads it as open and stuck-loops the agent.TestNoRawClosedAtReads. - Repo URL
.git/slash suffix is not identity — everybuildFromGitEMIT + every repo-URL identity COMPARE routes throughtopology.CanonicalRepoURL(a raw.git→ clone-preflight FAILED in ~0.3s, no logs); storemeta.RemoteURLverbatim.TestCanonicalRepoURL*. - SSH-error sites must wrap via
tools.withSSHStderr—SSHExecError.Error()drops the distinguishing git stderr (it lives in.Output).TestGitPushSetupContainer_ProbeFailure_SurfacesGitStderr. - Agent-facing markdown: tool-call form only, never a bare backticked
zerops://— ZCP is tools-only (no MCP resources protocol / nocapabilities.resources); emitzerops_knowledge uri="zerops://...".TestNoBareZeropsURIInAgentContent,TestServer_DoesNotAdvertiseResourcesCapability. - Managed-section/REFLOG markers match line-anchored only — every marker lookup routes through
content.IndexMarkerLine, never rawstrings.Index/Contains(guarded byTestNoRawMarkerMatching): a mid-line marker MENTION in prose (agent documenting ZCP in CLAUDE.md) would be cut as structure, truncating user lines at-->. Corollary: a file with NO anchored block (markerless, or a block damaged by the old bug) is PREPENDED, never has content above a REFLOG dropped — the damaged population is exactly who the fix protects.TestInit_MidLine*,TestInit_Damaged*MD_NoDataLoss. - JSON-only stdout — debug to stderr; MCP STDIO depends on it (CLI under
cmd/exempt).TestNoStdoutOutsideJSONPath. tools/eval/cmdnever callclient.{ListServices,GetServiceEnv,GetProjectEnv}directly — go throughops.*so caching/retries/instrumentation land at one site.TestNoDirectClientCallsInToolsEvalCmd.- Editing any
vscode-bootstrap-*template ships ONLY with aBootstrapExtVersionbump — code-server reloads off the extensions.json index version (versioned immutable install dirs), never file mtimes; an unbumped edit builds fine but NEVER reaches a running fleet.TestBootstrapExtVersion_ParityWithManifest.
Default home for durable knowledge is its proper home (table above), NOT here.
Adding knowledge — route, stop at the first match:
- behavior → a test + the spec section · mechanism → code doc-comment · design
decision →
docs/spec-*.md(promote the plan first — never citeplans/*.mdas a source) · platform fact →../zerops-docs/or live · recipe gotcha →recipes/· universal mechanic → atom · how-to-work → here · dev-loop → CLAUDE.local.md. - CLAUDE.md earns a line ONLY when: (a) a cross-cutting trap an agent can violate at a new call site before the test catches it, or (b) it is not test-/spec-shaped (a discipline). Both stay one line and point at the test.
- Never put here: a copy of a sourced value ·
Spec: plans/*.md· a commit hash · a date · a "Pinned by Test…" dump · a journal entry.
Pruning (on every touch + every plan→spec promotion):
- A spec section now states the same invariant → delete the line (the spec wins).
- Archive residue (hash, date, "DELETED 20xx", "fixed in ") → delete on
sight;
git logis the changelog. - A line pointing at an archived plan → promote-or-delete.
Budget: Traps + the subsystem-invariant index stay under ~30 lines. Over budget means a plan→spec promotion is overdue, not that the budget is wrong.
Other: plan completed → git mv plans/X.md plans/archive/X.md. New error code →
declare in internal/platform/errors.go. New global state → document in the
package's seed test as // non-parallel: <reason>.