Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# AGENTS.md

This file is the public source of truth for AI coding agents (Cursor, Claude
Code, GitHub Copilot, Codex, etc.) and human contributors who want to know
**what is binding** when modifying this codebase.

It is the contributor-friendly distillation of our internal SSOT policies.
The deep, regression-grade rationale lives in [`docs/internals/`](docs/internals/);
the user-facing concepts live in [`docs/concepts/`](docs/concepts/).

> **For setup instructions** see [CONTRIBUTING.md](CONTRIBUTING.md). This
> document is about *what to write* once you are set up.

---

## Quick Map

| You want to…                          | Read                                                |
|---------------------------------------|-----------------------------------------------------|
| Understand the system in 5 minutes    | [docs/concepts/architecture.md](docs/concepts/architecture.md) — user-facing explanations of agents, jobs, tiers live alongside it |
| Set up, build, test                   | [CONTRIBUTING.md](CONTRIBUTING.md) · [docs/develop.md](docs/develop.md) |
| Add a new agent / job / phase node    | This file, then [docs/internals/](docs/internals/) — incident-driven rationale, enforcement greps, test names |
| Touch the universal / custom-agent runtime | [Universal Runtime](#universal-runtime--one-jobtype-file-defined-jobs), then [docs/internals/44-universal-job.md](docs/internals/44-universal-job.md) |
| Touch the pipeline scheduler (cron, chaining, approval gates) | [docs/internals/46-pipeline-scheduling.md](docs/internals/46-pipeline-scheduling.md) |
| Author or edit a prompt template      | [Prompt Engineering](#prompt-engineering)           |
| Touch the LangGraph state machine     | [LangGraph State Management](#langgraph-state-management) |
| Make a change that crosses BE↔FE      | [Cross-Package Contracts](#cross-package-contracts-antshared) |
| Touch a security boundary (paths, origins, child processes, resource budgets) | [`docs/internals/security-posture.md`](docs/internals/security-posture.md) — the seven-axis SSOT |
| Self-host, design input, custom prompts | [docs/guides/](docs/guides/)                      |
| Run the custom-agent + MCP example end-to-end | [examples/README.md](examples/README.md) — not product code. The one runtime reader is the builder-handoff composer, which inlines `custom-agents/ops-team` and `pipelines/weekly-ops.yaml` as a bundle's Part 3, so both images ship `examples/` beside `docs/` |

If a rule here contradicts the code, the **code is authoritative** for runtime
behaviour — please file an issue so the document gets fixed.

---

## Architecture in One Page

Ant is a **modular monolith** packaged as four independent processes
(`ant-api` 4100 / `ant-realtime` 4101 / `ant-job` / `ant-preview` 4102) that
communicate **exclusively via Redis** — Pub/Sub, Key-Value, BullMQ. There is no
direct HTTP between processes. The backend is hexagonal: `composition/` entry
points, `core/` domain logic, `agents/` LangGraph graphs, `infrastructure/` and
`periphery/` adapters.

A job flows: HTTP request → BullMQ enqueue → `JobWorker` spawns `job-runner.ts`
as a child → `orchestrator.ts` routes to a LangGraph agent graph → state
broadcasts over Pub/Sub → completion publishes to `job:status:updates`. Jobs are
interruptible and resumable; checkpoints live at
`{featurePath}/sessions/{agent}/{jobType}.json`.

Process table, entry points, and the job-type → agent → output matrix are in
[docs/develop.md](docs/develop.md); the narrative walkthrough is in
[docs/concepts/architecture.md](docs/concepts/architecture.md) and
[docs/concepts/jobs.md](docs/concepts/jobs.md). Read those before changing
topology; the rules below are what binds when you write code.

---

## Unified Distributed System Principle

**Ant is always a distributed system. There is no separate "local mode"
implementation.** Local and Cloud differ only in where the infrastructure
runs and in two narrow fork points (auth tenant resolution and Figma MCP
transport — desktop is local-only by nature). The data plane — Redis,
BullMQ, Pub/Sub, separate processes on individual ports — is identical.

### ❌ Forbidden

- `if (mode === 'local') / if (mode === 'cloud')` branches that produce
  divergent business logic.
- In-memory `Map` / `Set` that mirrors Redis SSOT state (job-completion
  flags, user-stopped flags, kanban snapshots) as a "fallback".
- Code paths that assume Redis or BullMQ might not exist.
- Skipping authentication based on mode.

### ✅ Correct

- A single code path that uses Redis, BullMQ, and Pub/Sub unconditionally.
- `StateStore` (Redis) is always available — never `undefined`, never behind
  `if (stateStore)`.
- Tenant resolution is always enforced. Cloud mode authenticates every request
  (JWT); local mode resolves the single `local:local` tenant WITHOUT an HTTP
  auth gate — `ServerConfigurator.setupAuthentication` early-returns, because
  local mode is a single-developer trust boundary. Never widen that fork beyond
  auth-tenant resolution.

If it doesn't work without Redis, **fix it — do not add an in-memory fallback.**

---

## Node Graph Layout — Phases Are Task-Type Blind (R1)

**Phase nodes, routers, parallel orchestrators, and common tool handlers MUST
be blind to `task.type`. Task-specific logic lives only in
`tasks/{taskType}/hooks/`.**

### ❌ Forbidden

- `if (task.type === 'verification')` (or `'error'`, `'feature'`, etc.)
  inside `nodes/`, `routers/`, `parallel/`, `common/tool/handlers/`.
- `{ currentTask: { type: '...' } } as any` to sneak task-type logic into
  state-less contexts.
- Domain state fields (`_verificationTracker`, `_fooHistory`, etc.) on
  `state.ts` — they belong in `tasks/{type}/model/Session.ts`.
- Routers mutating state — routers are pure predicates.

### ✅ Correct

- `hooksIfActive(state)?.{hook}?.(...)` at phase-node call sites (state
  available).
- `hooksForTaskType(ctx.currentTaskType)?.{hook}?.(...)` at tool-handler / 
  orchestrator call sites (no state, just ctx).
- Each task type owns `tasks/{type}/index.ts` exporting `{ hooks: TaskHooks }`
  and registers itself in `tasks/_shared/registry.ts`.

The full 8-axis layout and rules R1–R5 live in
[`docs/internals/NODE_GRAPH_LAYOUT.md`](docs/internals/NODE_GRAPH_LAYOUT.md).

---

## Three-Axis Task Modeling — type / band / priority

Task classification is split across **three orthogonal axes** observed by
different actors:

| Axis            | Observer       | Decides                                             | Domain                                 |
|-----------------|----------------|-----------------------------------------------------|----------------------------------------|
| `task.type`     | LLM            | "What action mode is this task in"                  | `feature` / `error` / `verification` / `ui` / `design-system` / `test-code` / `doc` / `setup` / `explain` |
| `task.band`     | Orchestrator   | Scheduling position by dependency order             | feature: `'foundation'` / `'platform'` / `'integration'` / `undefined` · setup: `'root'` (project/framework/workspace-level, priority 100) / `undefined` (package setup) |
| `task.priority` | TaskQueue      | Sort order in the queue                             | Integer — sort comparison only         |

**`task.priority` may be compared only inside `TaskQueue.push()`'s sort
callback.** Phase nodes, routers, parallel scheduling, classification, and
type decisions MUST NOT compare priority semantically. The single legal
priority-to-meaning translation lives in
`decompose/responseParser.ts::deriveBandFromPriority` — a **strict reverse lookup
over the `TASK_PRIORITY` window map** in `code/state.ts`, so the band→window
forward map and the priority→band reverse lookup cannot drift. Strict means
window-exact: `setup.root` is `100..100` (the unique workspace-level setup, which
dequeues first — `101+` is band-absent package setup), feature bands are
`foundation 220..259` / `platform 260..299` / `integration 600..649`, and the
adjacent `design-system 200..219` window never derives a feature band.
Everything downstream reads `task.band` (feature / setup) or `task.type`
(everything else) — never the integer.

`band` is carried only by `FeatureTask` (`FeatureBand` = foundation / platform
/ integration) and `SetupTask` (`SetupBand` = `'root'`) via discriminated
union — `{ type: 'verification', band: 'root', ... }`, `{ type: 'feature',
band: 'root', ... }`, and `{ type: 'setup', band: 'foundation', ... }` are all
compile errors.

A `band:'root'` setup owns ONLY root-level artifacts (workspace manifest +
member glob + shared config + infra in a monorepo; the lone manifest + tooling
config in a monolith) and creates **no** member directory or name. Every other
member is created and named by its own band-absent setup — the workspace glob
discovers it. This is the structural fix for stranded "husk" package
directories (two setups naming the same boundary differently).

---

## Task Description Authorship — `BaseTask.description`

The fourth task axis is **content**: `description` is the LLM-authored
per-task scope of work — the work statement plan/execute receive, the Kanban
card body, and (when no plan is sealed) the ENTIRE execute instruction.

**Assigning the job-level user directive (`state.directive` /
`state.overrideDirective`) to `task.description` is FORBIDDEN.** The directive
reaches every prompt on its own channel (`# User Directive` / `{{directive}}`);
copying it into tasks makes every task identical, erases the decompose LLM's
per-task reasoning, and double-injects the same text under contradictory
framing. Also forbidden: a second field carrying per-task scope alongside
`description` (the historical `DesignTask.sectionScope` split), and two render
sites for the same field in one prompt.

Legal producers: the decompose/revise LLM verbatim, or a deterministic helper
that *names* the unit of work without pasting the directive
(`specDecompose.buildRevisionScope`, `prdSync` task factories). Floor:
non-empty after trim (`isTaskDescriptionAuthored` in `@ant/shared/task.ts`),
enforced at task creation — code decompose retries with framing
(`MissingTaskDescriptionViolation`), design ui/system/game-art throw into
their repair round, spec synthesizes `buildFullSpecScope()`. Never enforced in
`TaskQueue.from` (checkpoint restores must not throw).

`sectionScope` survives only as a local variable in design
`execute/intent/system.ts` (catalog-computed section assignment) — it is not a
task field.

```bash
rg -n "description:\s*(state\.)?(override)?[dD]irective\b" packages/*/src --type ts  # Expected: 0
```

Regression guard: `tests/policy/task-description-authorship.test.ts`.

---

## Tier × Verification Matrix (Code Job)

The 5-tier execution model is a job-neutral SSOT in
`core/executionTier`. Code jobs follow this matrix at decompose time:

| Tier | Meaning              | Writes  | Tasks at decompose | Verification |
|------|----------------------|---------|--------------------|--------------|
| 0 Reflex      | Read + text answer | Forbidden | 0 | N/A (direct) |
| 1 OneShot     | Limited writes, no verification needed | Allowed | 0 | None (direct) |
| 2 Exploratory | Single unit of work | Allowed | Exactly 1 | Two-cycle: apply → reverify via `_shared/verify/` |
| 3 Task        | Multiple units      | Allowed | ≥ 2 with verification task | Verification task gates via `_shared/verify/` |
| 4 RefsGrounded| Tier 3 + refs-grounded | Allowed | ≥ 2 | Same as Tier 3 |

The verification infrastructure lives in
`tasks/_shared/verify/` as the **single SSOT**. Tier 3/4 dedicated
verification tasks and Tier 2 self-verify tasks share it via `composeBundle`.

For the full background and the runtime-escalation paths used by `batchSplit`,
see [`docs/internals/17-code-verification-task.md`](docs/internals/17-code-verification-task.md).

---

## Retry Authority — `violation.isRetryable`

**Whether a violation is retryable is decided once, at the violation's source
of truth: `violation.isRetryable: boolean`.** `checkTaskStatus` reads this
flag and routes; it never re-judges via score, retry count, or type lookup.

### ❌ Forbidden

- Scoring violations or applying retry-count penalties inside any phase node.
- "critical type" arrays that override `isRetryable: false`.
- Pre-filtering violations sent to plan ("top-N same-type slice").
- Returning a violation with `isRetryable` left `undefined` — it will be
  dropped by the strict equality check.

### ✅ Correct

- Set `isRetryable: true | false` explicitly at every violation creation
  site (`tasks/*/hooks/check.ts`, `nodes/checkTaskStatus/evaluate.ts`).
- `nodes/checkTaskStatus/index.ts` only does
  `violations.filter(v => v.isRetryable === true)`.
- Grouping, prioritisation, and root-cause selection live in the plan
  prompt, not in phase code.

---

## LangGraph State Management

### Channels must be defined

When using a state field, define it in `channels` first.

```typescript
// ❌ Wrong
(state as any).parsedUiDocs = value;

// ✅ Correct
const graph = new StateGraph<GraphState>({
  channels: {
    parsedUiDocs: null as any, // reducer
    // ...
  },
});
state.parsedUiDocs = value;
```

### Derived channels

`state.executionTier?: ExecutionTierId` is derived — decompose writes it once,
after the LLM emits `<executionTier>N</executionTier>`. Phase nodes read it only
through the `getExecutionTier(state)` facade (`executionTier.breadcrumb(...)` /
`.boundary(...)`), and must never inspect `mode` / `complexity` literals
themselves (`if (state.resolvedAction?.mode === 'explain' && …)`). Mode dispatch
lives in exactly one place: `Tier3Task`'s constructor.

---

## Canonical Tag Rendering

Every canonical `<tag>` emitted by a node — LLM-streamed or back-channel —
**must** be registered in
`packages/ant-cli/src/core/streaming/transformers/SpecialTagTransformer.ts`.
Rendering rules live there only.

### Forbidden

- Emitting a new tag without registering a transformer entry.
- Calling `formatRACForChat` / building tag-formatted text outside
  `SpecialTagTransformer`.
- Adding `insideXxx` flags to `XMLStreamParser` to suppress a tag —
  suppression belongs in the transformer (`{ consumed: true }`).
- Silently swallowing chat-emit failures (`try {…} catch {}` with no log).

### Correct

- New canonical tag → add `this.register({pattern, transform})` in
  `SpecialTagTransformer.initializeTransformers()`. Use a `transform*` method
  for formatted output or a `() => ({ consumed: true })` suppressor.
- Locale-dependent labels live in sibling SSOT modules and are imported by
  the transformer — not hard-coded in node files.

The current registered inventory is documented in
[`docs/internals/36-output-tag-matrix.md`](docs/internals/36-output-tag-matrix.md).

---

## state.artifacts is RAC-bound

**`state.artifacts` is always a subset of `resolvedAction.refs ∪ context`.**
Only two functions write to the artifact pool:

1. `loadResolvedArtifacts(resolvedAction, featurePath)` — RAC-based load.
2. `appendOrUpdatePool(pool, task.files)` — design-job intra-job
   self-output.

Phase nodes and resolve must never wholesale-walk `architecture/**`,
`visual/**`, or `plan/**` to populate the pool. If you need a presence flag
that pre-dates RAC (used during triage / detect), use `state.workspaceState`
— that is the SSOT for "does this directory exist on disk".

`read_file` / `list_files` enforce a RAC whitelist for **explicit** pipelines.
The policy SSOT is
`code/nodes/decompose/racGate.ts` — `computeRacScope` (returns a scope only when
`source === 'explicit'` ∧ `hasExplicitFields` ∧ `refs ∪ context` non-empty; infer
pipelines get `undefined` and may discover freely), `decideRacGate`, and
`isWithinRacWhitelist`. It is applied at exactly two symmetric sites: decompose's
inline tool dispatch, and the shared code `tool` node that serves plan and
execute. Two families stay RAC-**orthogonal** and are always reachable: paths
under `codebase/` (classified via `normalizeToCodebasePath`) and the
`assets/{service,game,gen}/**` pools. Never push this gate into the common
`readFile` / `listFiles` handlers — design and planner jobs have no RAC to honor.

### The user's selection outranks the directory allowlist

A file's **location must never decide whether the job is told it exists.** Four
layers used to re-derive "is this a real file I may use?" from a directory
allowlist, and an attached screenshot lost at every one of them
(`near-loading-brace`). The owners now key off the user's selection or the bytes:

| Question | Owner | Keyed on |
|---|---|---|
| Did the user's slots survive an inferred RAC? | `inferRacWithTools` (`metadata` input → `mergeWithMetadata`) | the FE's `actionMetadata`, additively — inference may ADD, never REPLACE |
| Which UiSource wins a merge? | `pickUiSource(paths, preferred)` + `filterToUiSource` | the source the USER selected outranks the static `ant > figma > handoff` |
| Is this artifact existence-only? | `loadResolvedArtifacts` content sniff → `ResolvedArtifact.kind` | the bytes, in any directory |
| Does it ride along past `task.include`? | `ridesAlongRegardlessOfInclude` | `kind === 'binary'` |
| May the job place it? | `effectiveAssetInventory` | domain pool ∪ attached binaries |

### ❌ Forbidden

- Dropping `actionMetadata.refs` / `context` / `target` / `basis` because the
  turn carries no explicit `intent`. Absent intent means "the user did not pick
  an action", not "the user selected no files".
- Deciding a UiSource verdict **per slot** when a caller-supplied preference is
  in play — `refs` and `context` diverge and the RAC comes out mixed. Decide once
  over refs ∪ context, then filter both.
- A new directory allowlist answering "is this a real/placeable file". Sniff the
  bytes (`ResolvedArtifact.kind`) or ask the RAC.
- A prompt surface that tells the model a real file it was given is not usable
  (the handoff caption's "generate placeholders" vs the `[asset]` stub's "copy
  it" — the model obeyed the wrong one and shipped dead placeholders).

```bash
# The infer path must forward the user's slots — dropping this line is the bug.
rg -n "metadata: state\.actionMetadata" packages/ant-cli/src/agents/common/graph/nodes/detect/index.ts  # Expected: 1
# The stub decision is the sniff; the prefix list governs TEXT only.
rg -n "isStubLoadedPath\(" packages/ant-cli/src --type ts  # Expected: 2 (definition + the one text-branch call)
```

Guards: `tests/detect/metadata-supplement-merge.test.ts`,
`tests/policy/{rac-scope-invariant,asset-surface-boundary,uiSourceExclusivity}.test.ts`,
`tests/prompt/attachment-injection-gate.test.ts`.
Rationale: [`docs/internals/47-attachment-awareness.md`](docs/internals/47-attachment-awareness.md).

---

## Codebase Channel — Existing-Project Awareness

`codebase` is a first-class workspace resource. Two job groups consume it
with different authorities:

| Job group × workspace                              | RAC slot                              | Pool load |
|----------------------------------------------------|---------------------------------------|-----------|
| code-anchored (`rev-code` / `explain-code` / `gen-learn`) | `codebaseSlot('ref')` (static)       | yes       |
| plan/design × existing project                     | `codebaseSlot('context', { auto: true })` (dynamic) | no   |
| plan/design × greenfield                           | absent                                | n/a       |

The single `codebaseRole` derivation site is
`deriveCodebaseRole(intent, { hasCodebase })` in
`@ant/shared/action-config-matrix.ts`. The single render is the
`templates/jobs/shared/injections/codebase-channel.md` partial, gated on
`{{#if codebaseRole}}`.

`WorkspaceState.hasCodebase` is computed from a disk walk OR'd with the
in-memory index — never memory-only.

---

## UiSource — Three Hard-Exclusive UI Inputs

UI design input is exactly **one** of three sources per RAC. The three values
have different interpretation contracts and must not be mixed.

| UiSource  | Path                  | Meaning                              | Interpretation |
|-----------|-----------------------|--------------------------------------|----------------|
| `ant`     | `visual/ui/ant/`      | Design-job output (tokens/assets/spec) | Schema-based |
| `figma`   | `visual/ui/figma/figma.json` | Figma file URL reference        | Live MCP exploration |
| `handoff` | `visual/ui/handoff/**` | Free-form bundle (HTML/CSS/MD/PNG/JSON) | Observation-only (FPOP) |

The SSOT funnel for the hard-exclusive rule is
`normalizeUiSourceRefs` in `packages/ant-shared/src/canonical.ts`. Every
RAC-creating site goes through it. Mixed RACs throw at the safety nets
(`ArtifactPoolView.uiSource()` / `validateUiSourceExclusivity`).

`figma.json` carries **only the URL + nodeId**. Variable dumps, frame JSON,
and screenshots are never persisted there — the design source is fetched
live via the Figma MCP at prompt time.

---

## Universal Runtime — One JobType, File-Defined Jobs

Custom agents/jobs (the **workspace** project kind) execute on one generic
runtime. The invariants below exist because each was violated once and cost a
debugging session.

### ❌ Forbidden

- **Minting a JobType for a custom job.** A new JobType costs hand-copied
  unions in 5+ places; a custom job is *data*, not a code path.
- Forking `customJobRef` or `UniversalTurnMeta` onto a second channel — one
  env var each (`ANT_CUSTOM_JOB_REF`, `ANT_UNIVERSAL_TURN_META`), turn meta as
  one JSON rather than a CSV per axis.
- **Inferring credential-ness from a value's shape** (the deleted bare-ALL-CAPS
  heuristic) — it cannot tell a key name from a legitimate literal, and it
  silently killed job starts.
- A `process.env` fallback in credential resolution, or `...process.env` into a
  stdio MCP child — either turns a definition into an exfiltration vector.
- Treating checklist items as tasks: no TaskQueue, no kanban cards, no
  `billableTaskCount`.
- Offering a path in the `@ctx:` picker that the tool sandbox cannot resolve.
  The attachable set is DERIVED from the agent plane
  (`resolveUniversalAgentPlanePath` — artifacts ∪ `pipeline-runs` ∪ `_agents` ∪
  `_pipelines`, never `sessions`); adding a mount without teaching that
  resolver, or the reverse, re-creates the attachable-but-unreadable bug. The
  `_pipelines` splitter (`parseUniversalPipelineRef`) IS its whitelist — only
  the folder and `pipeline.yaml` resolve; `owner.json` / `availability.json`
  are refused on every plane by that one rule.
- Grafting an account-scoped root (agent definitions) into the project file-tree
  endpoint. It is cached per project × feature for 24h and the account-scoped
  write funnel cannot bust that key — the `_agents` / `_pipelines` subtrees are
  picker-side client merges.
- Re-judging the project × jobType gate anywhere other than the truth table,
  or letting a definition error crash the worker child instead of answering 400
  at accept.
- Silently ignoring a removed yaml key — an author concludes the knob works.
- Clearing armed `universalTurnMeta.context` / `plan` on the way to a dispatch.
  An `@ctx` chip is the user's explicit input (the near-loading-brace class: the
  selection outranks any policy layer). The Actions-tab Build button owns the
  intent slot ONLY — `selectUniversalBuildExecuteContext` composes over the
  armed meta and the directive names the attachment
  (`universal.buildDirectiveWithContext`); the one reset is post-dispatch.
- **A per-turn pre-classification LLM pass** (intent, tier, or anything else)
  before the agent call. Unpinned turns resolve deterministically:
  explicit → inherited (clarify continuity) → `general` (there is no catalog
  default); the Intent Catalog rendered into the agent prompt — each intent's
  `infer.md` criterion — is what informs in-turn self-selection. This was
  built twice and removed twice — the latency/cost of a non-streaming call
  per turn buys nothing an informed prompt doesn't.

### ✅ Correct

- `jobType='universal'` for every custom job; the definition rides
  `customJobRef = "{agentId}/{jobId}"`.
- Capability extension has exactly TWO declaration channels: `mcp.servers`
  (MCP servers, stdio/http) and `apis` (declared REST API connections for
  systems with no MCP server — baseUrl + `${secret:}` headers + optional
  method/path `allow`; the runtime synthesizes `api__{name}__get` /
  `api__{name}__request` in-process). Both share the credential store, the
  approval gate, and result spooling. API *knowledge* (endpoints, fields,
  sequences) is never declared — it is prose in `base/`, intent `prompt.md`,
  and `on-demand/**` docs (paths-only index rendered into the system block —
  the bodies never inline, so the channel costs no prompt budget). No
  per-endpoint tool schemas, no OpenAPI→tools import. The ONE declared
  request outside prose is a pipeline's `on.fetch.request` — trigger
  CONFIGURATION (the cron expression's sibling) executed by the control-plane
  poller, never rendered to a model and never a tool; it rides the same
  `buildRestRequest` admission as the tools (doc 46 §2). Its connection is
  either a job's `apis` entry (bound) or the trigger's own inline
  `connection` — the external entry's shape minus `allow`/`self`, compiled by
  the same `compileRestServer`. A trigger is not a capability grant, so a
  queue no step calls never earns a job an `apis` entry.
- An `apis` entry takes one of two mutually exclusive forms: external
  (`baseUrl` + `headers`) or `self: true`, which targets Ant's own API and
  carries NEITHER — the runtime resolves the origin from `ANT_API_URL` and the
  bearer from the token minted at accept. It is the only `apis` form a builtin
  agent may declare (same reason MCP is forbidden there: a shipped definition
  assumes no install URL and no registered credential).
- **A definition's `allow` list is not a security boundary** — the definition is
  user-editable, so the pin is never scoped per definition. For a self entry the
  boundary is `createSelfApiScopeGuard`, mounted after authentication on `/api`
  (ant-api) and on the realtime server. A `scope: 'self-api'` token reaches
  **`/definitions` and nothing else** — the scoped-template family — whose two
  resources carry deliberately OPPOSITE polarity:
  - `/definitions/agents` — **allow-except**: the whole resource is authoring,
    minus `promote` / `editors` (authority spread) and `import` /
    `files/upload` (they skip `gateDefinitionSave`).
  - `/definitions/pipelines` — **deny-except**: most of that resource is
    OPERATIONAL, so only the authoring shapes are listed (`GET|POST` on the
    root, `GET|PUT|DELETE` on `:id`, `GET :id/permissions`,
    `POST preview-fires`, `GET activatable-projects`) and everything else —
    present or added later — is refused. `enable` / `disable` / `activate` /
    `deactivate` / `run-now` / `promote` / `editors` / `approvals` / `runs` are
    a person's decisions; a job drafts, a person publishes. Never invert this
    polarity: an allow-except list here would admit every route added after it.
  A `:id` rule must exclude its resource's reserved literals (`preview-fires`,
  `activatable-projects`, `approvals`, `runs`) — Express disambiguates them by
  registration order, the guard matches independently, so
  `GET /definitions/pipelines/approvals` would otherwise ride the `:id` shape.
  The realtime server has no `/definitions` surface, so the same guard refuses
  the claim there wholesale — a job-minted token must not open its owner's SSE
  stream or `/bridge/*`. Absence of the claim is an ordinary session, never a
  pin. Minting stays in the process holding the JWT private key (C-001).
- **Definition writes have one funnel**: `PUT /definitions/agents/:agentId/file` →
  `gateDefinitionSave` → `loadCustomJob`. A job authors definitions by calling
  it (the builtin `agent-builder`), never by writing the files directly — which
  is also why `run_command`'s containment check runs on every plane, not only
  where the `codebase/` prefix rule applies. Pipelines are the same shape one
  resource over — `POST|PUT /definitions/pipelines` under the same pin — held
  by the builtin `pipeline-builder`. The two builtins split one boundary:
  `agent-builder` authors what an agent DOES and FILTERS calendars and
  cross-intent run order out (doc 44), its `allow` naming only the agents
  resource; `pipeline-builder` composes a finished agent's intents into a
  schedule, its `allow` writing only the pipelines resource (agents GET-only).
  Both lanes are pinned in `tests/customAgents/builtin-agents.test.ts`. A
  user-scope pipeline-authoring agent works identically — the pin admits by
  `declaresSelfApi`, which reads the definition, not the scope, so a
  user-scope agent declaring a self entry gets the same token a builtin would.
- **HTTP groups name a KIND, not an owner.** `/api/definitions/**` is the
  scoped-template family (`user` | `org` | `builtin` roots, closest-wins,
  promotable, project-independent, one shared `orgAclStore.ts`) and holds
  `agents` + `pipelines`; `/api/credentials/**` holds `{org, user}`-keyed secret
  stores. A resource joins the family it belongs to — **"the bare name was free"
  is not a reason to mount at the root**, and that ad-hoc rule is exactly how a
  template ended up spelled `/api/account/agents` in one place and
  `/api/pipelines` in another. `/api/agents` is the PUBLIC canonical job-agent
  catalog and is a different concept from a custom agent definition. The full
  table, including what is still bare, is in
  [docs/reference/api.md](docs/reference/api.md#grouping-rule--by-kind-not-by-owner).
- `decideProjectJobGate` (`core/customAgents/universalContainer.ts`) is the one
  bidirectional truth table; enforcement is HTTP 400 at job-accept.
- `${secret:KEY}` (`MCP_SECRET_REF_PATTERN`) is the only credential marker;
  everything else is a literal. `validateMcpServers` in `@ant/shared` is the one
  rule set, with three failure shapes (throw / 400 / form-disable).
- `buildStdioChildEnv()` allowlist for stdio children; `McpConfigError` →
  `config_invalid`, never `process_crash`. Connect failures are fatal on the
  UNATTENDED lane only (pipeline/scheduled — `failFast`); an attended turn
  DEGRADES per server and runs, with the failure as a fact the agent explains
  (the `Capability Status` band). `core/customAgents/connectionReport.ts` is
  the single owner of that report's shape and every rendering — never add a
  second renderer, and never re-derive the lane anywhere but the runner's
  `failFast` rule (doc 44).
- Phase-node blindness applies here too: the universal graph must not learn
  `task.type` or execution tiers — it has neither.
- **Universal's leftover work is an unsealed TURN, not a task queue.** Resume
  re-runs the interrupted instruction on the saved transcript, so `canResume`
  and its KIND come from one owner — `resumeGranularityOf`
  (`mid-graph` | `turn` | `null`) — and `isMidGraphResumable` keeps its narrow
  "a checkpoint exists" meaning. The resume request carries NO definition ref:
  `resolveUniversalResumeTarget` recovers it from the job's own durable records,
  because a client-held ref names the composer's current selection rather than
  the paused pair. Never word a `turn` resume as continuing from where it
  stopped, and never let a decided refusal reach the client as a code-less 500.
- **Hooks follow the act, not the label.** An intent that also answers
  questions or audits declares `hooks.arm: on-write` — its contract arms only
  once the turn writes anything (a draft in artifacts included), so a turn that
  only reads ends clean; a deliverable intent keeps the default `always`. An
  unpinned (`general`) turn that performs an intent's `action:` hook ADOPTS
  that intent's contract. On a pinned deliverable turn the agent's
  `<contract-deferred>reason</contract-deferred>` is the ONE machine-readable
  "this turn owes nothing / cannot deliver" (honored only attended + no write
  evidence), and an unmet contract ESCALATES (`universal_stop_hook_unmet`) on
  the unattended lane only — attended carries it in the seal to the next
  message (doc 44 hooks rule 4). Never author a hookless "read-only" twin intent to get
  a turn that does not owe a write — read-only is the `@plan` turn, which is
  machine-enforced; a prose "do not write" is not. A review that LEAVES a
  record is not that twin: the builders' `review` intent owes
  `review-report/*.md` through an `artifact:` hook with the default arm, and
  its offline twin for an external agent is `docs/guides/builder-handoff/`
  (channel deltas only — the shipped prose stays the one contract, guarded by
  `tests/policy/builder-handoff-binding.test.ts`). The clone-less form is the
  GENERATED bundle (`GET …/handoff/{jobId}/{intentId}`, `definition handoff`)
  composed by `builderHandoff.ts` from the delta doc + the definition files;
  never hand-edit a bundle, never commit one.

```bash
rg -n "process\.env" packages/ant-cli/src/core/customAgents/McpCredentialResolver.ts  # Expected: 0
rg -n "ExecutionTier|executionTier" packages/ant-cli/src/agents/universal            # Expected: 0
# The attachable set has ONE owner; the gate and the band both go through it.
rg -n "resolveUniversalMergedPath" packages/ant-cli/src/core/scheduling packages/ant-cli/src/agents/universal  # Expected: 0
# Reserved mount names come from @ant/shared (UNIVERSAL_AGENTS_DIRNAME / UNIVERSAL_PIPELINES_DIRNAME), never literals.
rg -n "'_agents'|'_pipelines'" packages/ant-cli/src packages/ant-ui/src  # Expected: 0
# The pin is one rule, mounted whole-surface on each cookie/bearer server — never re-judged per route.
rg -n "createSelfApiScopeGuard\(" packages/ant-cli/src                              # Expected: 3 (definition + api mount + realtime mount)
rg -n "ANT_THREAD_ID|threadPaths|getAgentThreadPath" packages/*/src                  # Expected: 0
# The resume verdict has ONE owner; nobody re-derives it from the job type.
rg -n "isMidGraphResumable\(" packages/ant-cli/src packages/ant-shared/src          # Expected: 2 (the definition + its ONE call, inside resumeGranularityOf)
# A universal session path has ONE run-aware owner (getUniversalSessionFilePath); the runtime and the scheduler never compose the shared one.
rg -n "\bgetSessionFilePath\(" packages/ant-cli/src/infrastructure/scheduling packages/ant-cli/src/agents/universal  # Expected: 0
```

Guards: `tests/customAgents/{custom-agent-loader,builtin-agents,universal-container,universal-tool-policy,universal-prompt-injection,universal-checklist,universal-turn-context,universal-mcp-runtime,mcp-credential-store}.test.ts`,
`tests/lifecycle/dismiss-route-branches.test.ts` (resume routing + typed refusals),
`tests/composition/sigterm-interruption.test.ts` (the granularity table).
Full rationale: [`docs/internals/44-universal-job.md`](docs/internals/44-universal-job.md);
MCP orchestration & capability-server contract:
[`docs/internals/45-mcp-orchestration.md`](docs/internals/45-mcp-orchestration.md).

---

## Authorization Answers Whose, Never How Much

**An authenticated route is not a budgeted one.** Every gate on
`/jobs/:jobId/continue` — access, approval, membership, credit, canonical-project,
pipeline-ownership — proved WHOSE session it was, and none of them bounded the
work: the handler then read that session, unshifted another directive, and wrote
it back with a raw `fs.writeFileSync()`, outside the byte budget every reader
enforces. The same split produced `chat/job-error` (no schema, no cap, no rate
limit) and a job-history scan that parsed 5,000 session files into one response.

Three rules; each later one is why the one before it kept failing:

- **Each authenticated route carries its own field caps, request rate, and
  response-materialization budget.** A per-item cap is not a per-request bound.
- **A seam is adopted by FILE, not by call name.** `atomicWriteFile(` was the
  guard, so three writers reached the same session files by a different shape and
  no test failed. Enforce the offense as "this path reached a write that is not
  the seam", whatever that write is spelled.
- **Seam adoption is enforced in TYPE space, not by name greps.** Four audit
  rounds keyed the guard on names (literal paths → call names → variable names)
  and each round a differently-spelled caller slipped past — names are
  re-spellable by construction. The closed form is a branded type with a single
  mint (`boundActionMetadata` → `BoundedActionMetadata`) that every consumer
  signature between an ingress and a durable/broadcast/env sink requires: a new
  typed ingress fails `pnpm typecheck`, not a regex. Greps survive only for what
  the compiler cannot see — brand fabrication (`as BoundedActionMetadata`, a
  finite spelling) and the `any` trust boundaries (HTTP `req.body`, queue/env
  replay), each of which pairs with a RUNTIME re-check calling the same mint.
  An open-shaped object (`.passthrough()`) is budgeted by its **whole serialized
  size**, never by field enumeration — an unknown field is outside a field-cap
  model by definition.

### ❌ Forbidden

- Writing a session JSON by any route other than `writeSessionBounded()` — raw
  `fs.write*`, or a hand-rolled tmp+rename. A read-modify-write passes
  `{ expect: sessionWriteGuardOf(text) }` built from the bytes it already read;
  it does not re-read, and it does not add a lock.
- Passing action metadata to a durable/broadcast/env consumer as a raw object —
  the consumers take `BoundedActionMetadata`, and the only mint is
  `boundActionMetadata()` (typed 413 / typed job failure on over-budget).
- REFUSING an append to bound a JSONL log. Readers already serve only the newest
  window, so the fix is retention (trim to that window — observably lossless),
  never a rejection that stops chat working. The ONE exception is a single line
  over `JSONL_LINE_MAX_BYTES`: appended, it swallows the whole tail window and
  every bounded reader returns zero lines (the log goes blank), so refusing that
  line — before append AND broadcast — is the only observably-lossless outcome.
- THROWING when an enumeration budget runs out on a display-only path. Degrade
  (less folder compression, a `truncated: true` sibling key); never fail a request
  that would otherwise have succeeded.
- Registering a cross-pod lock on one side only. The API process and the job-runner
  child append to the same logs on the same mount.
- Capping a field that is serialized into a durable line by its per-field length.
  Cap the SERIALIZED value — a join or a `JSON.stringify` amplifies past it.
- A guard that enumerates the routes someone remembered. Enumerate the SET.
- Letting a parser's own limit breach reach the framework's default error
  handler. multer aborts with `next(new MulterError(...))`; with no handler that
  became an HTML 500, so a folder upload the budget legitimately refused was
  indistinguishable from a crash and the FE could only say "upload failed".
- Choosing a multipart `fields` / `parts` cap independently of `files`. Every
  lane pairs each file part with one `relativePaths` field, so `fields: 50`
  refused the 50th file and the advertised count was unreachable — the caps must
  be DERIVED from `files` or the number the client batches on is a lie.

### ✅ Correct

- `writeSessionBounded()` sheds in a fixed order — snapshots → conversations →
  diagnostics → oldest `runs[]` — and only then refuses, leaving the previous valid
  file untouched. The resume core (`taskQueue` / `currentTask` / `completedTasks` /
  `interruption` / `jobId`) is never shed.
- Count and reserve in ONE step: `StateStorePort.reserveSlot` (Redis ZSET + Lua).
  A read-then-compare admits N callers past an N-1 cap — SSE slots (M-005),
  pipeline concurrent runs (L-031), and uploads all share that shape.
- Asymmetric lock failure where the two operations have asymmetric stakes: an
  append is best-effort (never lose a line to a slow lock), a whole-file rewrite
  requires the lock (never race one silently).
- A per-account share of any pod-local ceiling, released on the same
  `finish`/`close`/`aborted` the pod counter uses — a second lifetime is a second
  leak.
- The chat.jsonl append seam owns two field-agnostic invariants: no single line
  past `JSONL_LINE_MAX_BYTES` (typed refusal, no SSE echo of a line no reader
  could return), and a streaming heal that drops pre-cap oversized lines without
  materialising them when retention finds zero complete lines in the window.
- `boundedMultipartUpload(upload, field, deps)` is the ONE way to mount a
  multipart route — it composes the admission gates, multer, and
  `refuseMultipartError` (typed 413/400 `{code, error, message}`, never
  `sendErrorResponse`, whose envelope carries no `code` and is genericised in
  production). Adoption is a property of the call, not of what an author
  remembered: a bare `upload.array(` under `routes/` is the offense.
- A client that would exceed a documented request cap BATCHES to it rather than
  discovering the wall: `@ant/shared/upload-limits` is the one home for the caps
  a client must act on, so `UPLOAD_MAX_FILES_PER_REQUEST` is simultaneously the
  FE batch size and the BE `files` cap and neither side does margin arithmetic.
  The driver (`runUploadBatches`) is strictly sequential — a fan-out would 429
  itself against the cluster-wide per-account slot, and sequencing is what makes
  "a destructive field rides batch 1 only" a guarantee rather than a race for
  the two replace lanes that `fs.rmSync` before writing.

```bash
# The write seam is a property of the file, not of one call name.
rg -n "fs\.(writeFile|writeFileSync)\(\s*sessionPath" packages/ant-cli/src  # Expected: 0
# Every chat POST carries both gates.
rg -c "chatRateLimiter" packages/ant-cli/src/periphery/adapters/http/routes/chat.routes.ts  # Expected: >= 5
# The actionMetadata brand has ONE mint; a cast is the only spelling that bypasses the compiler.
rg -n "as (unknown as )?BoundedActionMetadata" packages/ant-cli/src --type ts  # Expected: 1 (the mint)
# No route mounts multer directly — every multipart lane composes the wrapper.
rg -n "upload\.(array|single|fields)\(" packages/ant-cli/src/periphery/adapters/http/routes  # Expected: 0
```

Guards: `tests/policy/contained-io-adoption.test.ts`,
`tests/policy/resource-admission.test.ts`, `tests/http/resource-admission.test.ts`,
`tests/security/session-namespace-bounds.test.ts`. Rationale:
[`docs/internals/security-posture.md`](docs/internals/security-posture.md) Axis 7.

---

## An Authenticated Route Is Not an Approved One

**Account approval is an identity verdict, so it bounds the whole authenticated
surface — never a list of routes.** `UserRecord.approvalStatus` used to be six
hand-placed pre-flight calls at compute-start handlers (job start/learn/resume/
continue, chat, team create). That blocked starting agent work and nothing else:
a `pending` account still created and deleted projects, uploaded files, booted
preview/deploy children, attached a GitHub PAT, wrote agent definitions, drove a
live IDE pod, opened every SSE stream, and minted a 90-day desktop token. The
list WAS the bug — it grows by whatever the next author remembers.

### ❌ Forbidden

- Re-judging approval inside a route handler. The surface guard has already
  answered; a second owner drifts (the `teams.routes.ts` copy had already
  diverged to a different fail-open posture than the other five).
- Re-listing the public paths as approval exemptions. The guard keys on
  `req.user`, which `createJwtAuthMiddleware` sets only for non-public requests,
  so the exemption is DERIVED from `PUBLIC_PATHS`. A second copy drifts.
- Putting an approval claim in the JWT. The cookie lives days and the desktop
  token 90; an admin approving would not take effect until re-login, which is
  precisely the operator workflow.
- A process-local TTL cache of the verdict. It re-introduces a second answer
  source for a decision whose whole point is immediacy — a `denied` account
  keeps working for the TTL.
- Mounting on ONE plane. Express is not the only way in: the `/ide/*` proxy is
  served before `setupAuthentication` and never calls `next()`, and the bridge
  WebSocket upgrade bypasses Express entirely.

### ✅ Correct

- `createRequireApprovedAccount()` mounted whole-surface on every server that
  authenticates a cookie or bearer, plus the `/ide/` proxy lane; `checkApproval`
  stays the single read of the port and the single fail-open posture.
- Fail-OPEN on a repository error. Redis is the whole system's dependency, so a
  blip that flipped this closed would convert an outage into a total lockout of
  every approved user, reported under a misleading pending code.
- `/admin` exempt: it carries a strictly stronger env-authoritative gate
  (`isSuperAdminEmail`), and `setUserApproval` can stamp a super admin `denied`
  while `syncSuperAdmins` only re-approves at boot — gating it bricks the
  operator out of the screen that undoes the mistake.
- FE: one state-driven branch (`selectShowApprovalGate`) replaces the app shell,
  and `selectIsAuthBlocked` parks every protected fetch, so the screen does not
  sit in front of a 403 storm.

```bash
# One rule, mounted on each plane that admits an identity — never re-judged per route.
rg -n "createRequireApprovedAccount\(\)" packages/ant-cli/src  # Expected: 5 (definition + api + ide + realtime + preview)
# The port read has ONE owner; its callers are the three planes, not handlers.
rg -n "checkApproval\(|getUserApproval\(" packages/ant-cli/src/periphery/adapters/http/routes/*.routes.ts  # Expected: 0
```

Guards: `tests/policy/resource-admission.test.ts` (mount order + the SET, read
from the codebase so a fifth server cannot skip it),
`tests/billing/approval-capability-seam.test.ts` (the guard's decision table),
`tests/auth/lifecycle-guard.test.ts` (FE truth table + branch ordering).

---

## Untrusted Content and the Control Plane Are Different Origins

**A browser origin that serves user-authored content MUST NOT also answer a
cookie-authenticated control-plane API.** `ant-preview` does both jobs — it serves a
public deploy's build output and a user's own dev server, and it exposes
`/projects/*`, which starts previews and writes a feature's `.env`. On one origin,
script inside a deployed SVG or HTML page runs same-origin with that API and drives
it with the viewer's session. The session cookie is `HttpOnly`, which does not
help: the browser attaches it anyway.

### ❌ Forbidden

- Mounting any control-plane route on the content listener (`this.contentApp`).
- "Fixing" this with an SVG/HTML filter, a blanket CSP, or by stripping cookies on
  the proxy hop to the upstream. None of them addresses the sink, which is the
  browser's own origin model.
- Comparing origins by **hostname**. `isSelfOrigin` compares scheme + host + port;
  a hostname-only test silently re-merges the two listeners.
- Accepting a cookie-authenticated state change whose `Sec-Fetch-Site` is
  `same-site` (that is precisely the content listener) — only `same-origin` /
  `none`, or an exact-match registered frontend origin.
- Admitting a request on `Sec-Fetch-Mode: navigate` / `Sec-Fetch-Dest`. A GET
  navigation carries no `Origin`, and in a split-host deployment its
  `Sec-Fetch-Site` is `same-site` — indistinguishable from attacker content. The
  `/ide/*` iframe is admitted by a short-lived capability minted through a
  CSRF-guarded POST instead; see
  [`docs/internals/23-cloud-ide.md`](docs/internals/23-cloud-ide.md).

### ✅ Correct

- Content on `ANT_PREVIEW_CONTENT_PORT`, control plane on `PORT`; the process
  refuses to boot if they are equal.
- `createSameOriginGuard()` after cookie-parser on every server that authenticates
  by cookie. `Authorization: Bearer` callers are exempt — a bearer token is not
  ambient.
- Body parsers mount AFTER authentication: an unauthenticated request must not make
  the process parse a 50 MB body before it is told 401.

```bash
rg -n "contentApp\.(get|post|put|delete)\('/(projects|admin)" packages/ant-cli/src  # Expected: 0
rg -n "sec-fetch-(mode|dest)" packages/ant-cli/src/periphery/adapters/http/middleware/sameOriginGuard.ts  # Expected: 0
```

Guards: `tests/http/preview-origin-split.test.ts`, `tests/http/same-origin-guard.test.ts`,
`tests/http/ide-gate-admission.test.ts`, `tests/cors/cors-matrix.test.ts`. Rationale: [`docs/internals/security-posture.md`](docs/internals/security-posture.md) Axis 5.

---

## Child Process Boundaries — env profile, credential scope, OS identity

Preview, deploy and code-job children run **user-authored code**, and their
stdout/stderr is streamed back to the requester. Four separate rules, each violated
once:

1. **Env profile.** `composeChildEnv()` (preview/deploy) honours
   `ANT_PREVIEW_ENV_PASSTHROUGH`; `composeCommandChildEnv()` (code-job
   `run_command`) honours nothing. An operator naming a host variable so a dev
   server boots did not consent to it reaching every LLM-chosen command and its
   chat transcript.
2. **Namespace denial is by NAME, never by value shape.** The credential-marker
   list is a name test, and a live credential need not look like one —
   `ANT_REDIS_URL` carries the data plane with no `TOKEN`/`SECRET`/`AUTH` in it. The
   inherited/passthrough path refuses the whole `ANT_*` namespace plus bare
   connection-URL names. Project `.env` overlays are a different channel and are
   unaffected. Do **not** add value-shape heuristics (see the universal-runtime
   section for why that was deleted once already).
3. **Credentials are scoped to the step that needs them.** A user's PAT reaches the
   `--ignore-scripts` dependency-FETCH pass only; the lifecycle pass re-runs the
   install without it. Installs that cannot use `GIT_CONFIG_*` at all
   (python/rust/java) never receive it.

4. **The filesystem a child sees is a mount namespace, not the pod.** Env
   scrubbing and the UID drop bound what a child learns from its own process;
   neither bounds the disk — a child under any UID walked `/vault`, `/etc` and
   every other tenant's workspace, and one `cp` moved a Vault-injected secret
   file into the requester-visible artifacts (2026-09 report). Every
   user-authored spawn goes through ONE funnel, `spawnUserChild()`
   (`core/config/childSandbox.ts`; `wrapCommandForUserChild()` where an SDK owns
   the spawn), which composes identity gate → UID drop → bubblewrap in that
   order: the call site names the tenant roots (`sandbox.rwRoots` / `roRoots`),
   the system toolchain is read-only, and what is not bound does not exist. The
   path parsers (`detectReadPathViolations`) stay as guardrails; the namespace
   is the boundary. `ANT_CHILD_SANDBOX` is on by default in cloud and
   fail-CLOSED (non-Linux, no `bwrap`, namespaces refused → the spawn is
   refused); a deployment that cannot sandbox says `off` explicitly. Two
   corollaries: `CommandOptions.sandbox` is required in TYPE space, so a caller
   that cannot name the boundary does not compile; and `kill <server_pid>` is
   served by the `run_command` handler against the handle it holds, because a
   later command's PID namespace cannot see an earlier command's server.

The funnel is also where `childSpawnIdentity()` is spread, so a deployment that
grants the privilege runs those children under their own UID — a same-UID child
can read `/proc/<service-pid>/environ` whatever the composed env says.

```bash
rg -n "composeChildEnv\(\)" packages/ant-cli/src/periphery/adapters/command  # Expected: 0 (command profile)
# User code has ONE spawn funnel; an identity spread into spawn options is a site that skipped it.
rg -n "\.\.\.(childSpawnIdentity|credentialedAcquireIdentity)\(\)" packages/ant-cli/src --type ts  # Expected: 0
rg -n "wrapCommandForChildIdentity\(" packages/ant-cli/src --type ts  # Expected: 2 (definition + its one call, inside wrapCommandForUserChild)
```

Guards: `tests/policy/credential-isolation.test.ts` (funnel adoption, image
launchers), `tests/security/child-sandbox.test.ts` (binding rules, refusals,
live invisibility), `tests/preview/installCommand.test.ts`.

---

## Project / Feature Lifecycle

Project and feature lifecycles share three policies:

1. **`repoType` defaults to `'cloud'`.** `repoType: 'local'` is opt-in only,
   set explicitly through the wizard's advanced options. Auto-mapping from
   `userContext` (`local:local` vs cloud) is forbidden — that pattern caused
   path collisions in past regressions.
2. **`deleteProject` runs a 5-step cascade**: cancel jobs → IDE pod
   cleanup → preview pub/sub ack → Redis state cleanup → fs.rm verification.
   Skipping any step risks leaking EFS handles or stale workers.
3. **Cross-process cleanup uses Redis pub/sub** (`ant:lifecycle:cleanup:*`),
   not in-process method calls. Each process must listen for and ack
   cleanup requests.

`renameProject` shares steps 1–4 with `deleteProject` via the
`stopProjectRuntime` SSOT helper.

---

## Code Style

- **TypeScript strict mode**.
- **ESLint + Prettier**. Format on save.
- **Console logs use emoji prefixes** (`📄 [DocGen]`, `🔧 [Tool]`).
- **Comments are lean.** Don't translate the code line-by-line. New
  comments only for non-obvious invariants, external contracts, or trade-offs
  (one sentence). JSDoc for public APIs and `@deprecated` markers — not
  every function. A patch where comments outnumber executable lines is too
  comment-heavy.
- **Imports are absolute or workspace-relative**. Don't reach across
  packages with `../../../`.

---

## Writing Tests & Regression Guards

The suite is large (≈640 files / ≈6,900 cases, ~35s) and that size is fine —
it runs fast and 70%+ of commits touch it. What degrades it is a specific
authoring habit: turning each incident into a new file that pins prose rather
than behavior. These rules exist to stop that.

### Where tests are gated

| Surface | Gate |
|---|---|
| `packages/ant-cli/tests/**` | `pnpm test:cli` + `pnpm typecheck:tests` (CI) |
| `packages/ant-ui/tests/**`, `src/**/__tests__/**` | `pnpm --filter @ant/ui test` (CI) |
| `packages/cloud/tests/**` (ant-cloud) | `pnpm test:cloud` (ant-cloud PR CI) |
| `tests/e2e-mock/**` | `pnpm test:e2e` — needs a live mock server, excluded from `test:cli` by design |

**The build does NOT run tests** and no `prebuild` hook should be added — CI is
the only gate. Test sources are excluded from `packages/ant-cli/tsconfig.json`
because it ships into the runtime image; `tsconfig.test.json` is what
typechecks them. Never add test globs to the shipped config.

`pnpm typecheck:tests` is **blocking in CI** — keep it at zero. It was added
after tests went un-typechecked long enough to accumulate 185 errors across 57
files (stale import paths, task literals missing required fields, `interface X
extends BaseTask` from before that type became a union, spies with too-small
declared arity, and fields already deleted from the types).

### ❌ Forbidden

- **Pinning prompt prose.** `expect(rendered).toMatch(/ONE store instance/)`
  locks an English sentence, so improving the wording fails the build. Assert
  the **gate** instead: does this partial get injected when the gate is on, and
  not when it is off? If the rule can't be expressed as a gate truth table, it
  isn't a contract — don't test it.
- **Testing a gitignored file.** `CLAUDE.md` / `.cursorrules` are gitignored, so
  a test asserting their prose `it.skip`s in CI and on every fresh clone — it
  looks green while checking nothing. Doc-prose sync is not a test's job.
- **A new file per incident.** Add a row to the existing policy test for that
  axis. Codename-named files (`regression-<codename>.test.ts`) fragment one
  invariant across many near-duplicate regexes that then drift.
- **Duplicating a rule in CI shell and in a test.** One owner. Prefer the test —
  it can strip comments and reason about structure; a `grep -vE` heuristic can't.
- **`expect(true).toBe(true)` as a soft skip**, or asserting a constant against
  its own literal. If the case needs env state, stub it (`vi.stubEnv` +
  `vi.resetModules()` + re-import) so the path actually executes.
- **Reaching into another package** (`import … from '../../../ant-ui/src/…'`).
  FE behavior is owned by the ant-ui suite, BE by ant-cli.

### ✅ Correct

- Table-driven policy tests: one file per axis, one row per case.
- Tombstones (asserting a deleted file stays deleted) are valid **in the
  deletion commit**. They accrue no signal afterwards — prune them once the
  removal is a year old and nothing references the symbol.
- Fixtures a test generates belong in `.gitignore`, not in git. A tracked file
  that a test rewrites on every run produces a spurious diff forever.
- When a test and the source disagree, decide which is stale before editing.
  A test pinning a retired model id or a renamed field is usually the stale
  one — but confirm against the commit that changed the behavior.

---

## Prompt Engineering

Rules for authoring Handlebars prompt templates under
`packages/ant-cli/src/core/prompt/templates/`.

### Enforcement status (read first)

Not all policies below are equal. Some fail a build when broken; some are
only reviewer judgement. Treat that difference as load-bearing — a rule with
no automated guard is a **guideline**, not a contract, and must not be cited
as if it were one.

| Policy | Enforced? | Guard |
|--------|-----------|-------|
| MECE / locality / partition gates | ✅ CI | `domain-branching-locality`, `domain-vocab-locality`, `domain-overlay-locality`, `domain-surface-boundary`, `asset-surface-boundary`, `basis-partial-invariant`, `motion-locality`, `service-virtualization`, … |
| WHAT / HOW split (§1) | ⚠️ Guideline | reviewer judgement (transitional — legacy violations tolerated) |
| FPOP (§4) | ⚠️ Guideline | reviewer judgement only — no CI lock |
| SBS (§5) | ⚠️ Guideline | soft sanity grep only — not a build gate |
| Artifact-language ladder (§3) | ⚠️ Guideline | prompt prose; only the inverse is gated — `builtin-agents` (shipped definitions stay Latin-script) |

The policies below are four orthogonal failure-mode axes: **specificity =
activation scope** (SBS, FPOP "Universal over Specific"), **single home /
collectively-exhaustive partition** (MECE, WHAT/HOW), **falsifiable constraints**
(FPOP "Observable", "Constraints over Instructions"), and **self-contained
runtime** — a prompt must not reference internal vocabulary the model was never
given (acronyms, decision IDs, incident codenames). That last axis and "no rule
without a guard" are unenforced: conscious guidelines, not contracts.

### 1. WHAT / HOW separation

| Prefix       | Role | Content                                    |
|--------------|------|--------------------------------------------|
| `base*.md`   | WHAT | Context, data, current state, dynamic interpolation |
| `rules*.md`  | HOW  | Rules, formats, constraints, prohibitions  |

For new or substantively rewritten files, do not put `⚠️ You MUST` /
`DO NOT` directives in `base*.md`, and do not put `{{{interpolations}}}` in
`rules*.md`.

### 2. Directory layout

```
templates/
├── domain/{d}.md            workspace-level domain identity (service / game)
├── basis/                   tier-gated content + shared partials
├── jobs/{job}/
│   ├── base/{system,user,injections}/   job-level shared blocks
│   ├── domain/{d}.md                    job × domain overlay
│   ├── basis/                           job × tier overlay
│   └── nodes/{node}/
│       ├── {base,rules}.md              default
│       └── variants/{v}/{base,rules}.md variant-specific
├── jobs/shared/nodes/{node}/variants/{v}/{base,rules}.md  cross-job
└── infra/                   infra-level partials (compaction, etc.)
```

Templates are auto-registered as Handlebars partials by `initPartials()` at
server startup. **Files under `templates/basis/**` are intentionally not
registered.** Use `_*-private.md` named partials in `jobs/...` if you need a
private partial inside a basis-adjacent file.

### 3. Language and platform neutrality

Prompt **source shipped with Ant** is English only — everything under
`core/prompt/templates/**` and the builtin agent definitions under
`core/data/agents/**`. No project-specific examples (`Hero.tsx`, `page.tsx`),
no platform-specific terms (`React`, `Tailwind`, `Next.js`). Use generic,
platform-neutral wording (`component`, `container`, `element`).

Ant supports frontend / backend / fullstack across multiple languages.
Prompts must not assume a stack.

**This rule does not reach user-authored universal definitions**
(`.ant/agents/**`). Those are one team's data, read and maintained in the agent
settings screen by the person who owns them, and full of proper nouns with no
English form — so their prose follows the requester's language. The universal
runtime states the precedence once, in
`templates/jobs/universal/nodes/agent/rules.md` (Output Channel): an explicit
user instruction → the definition's stated convention → the language of the file
being revised → the language of the user's request. Rungs 2 and 3 above 4 are
what keep an existing file from flipping language on its own. Structural tokens
(ids, yaml keys, paths, tool names, `${secret:}` refs) never localize.
⚠️ Guideline, not a contract — it is prompt prose and pinning it would violate
the no-prose-pinning rule. What IS guarded is the inverse: shipped definitions
carry no non-Latin script (`tests/customAgents/builtin-agents.test.ts`).
Do not re-derive the authored language from `state.language` — it is a binary
ko/en UI locale and cannot express any other language.

### 4. FPOP — First-Principles Observation Prompting — guideline (not CI-enforced)

State observation targets and constraints as **principles**, not as concrete
examples or methods: principles over examples, what over how, observable over
assumed, universal over specific, constraints over instructions, and an explicit
⚠️ reminder for a blind spot the model reliably misses. Concretely — "each
container decides its direction independently", not "the footer is a column";
"if it is not observed, do NOT add it", not "add an overlay".

### 5. SBS — Scope-Bound Specificity — guideline (soft sanity grep only)

**A fragment's required abstraction level is bounded by its activation scope.**
Gated templates (techTier / intent / taskType / mode / role / artifact-presence)
MUST be specific along the gate's axis; always-on templates MUST stay universal.
`basis/techTier/framework/nextjs.md` MUST name "Next.js" — the gate is the entire
reason the file exists, so citing "Universal over Specific" against a gated file
to strip its discriminator is itself an SBS violation.

Run both checks on every paragraph: is it specific along **this file's** gate
(if not, lift it to a less-gated location), and is it specific along **any other**
axis (if so, that is scope creep — lift it out). A compliant paragraph is
specific exactly along the gate and generic everywhere else. Corollary: if you
cannot tell which variant a paragraph came from with the path hidden, the variant
is SBS-empty.

---

## Cross-Package Contracts (`@ant/shared`)

`@ant/shared` is the single source of truth for every type that crosses BE↔FE —
job classification (`JobType`, `DecomposableJobType`), task queue state
(`BaseTask`, `KanbanData`, `TaskStatus`), SSE payloads (`WorkflowRealtimeState`),
interruption metadata, detection types (`InferredAction`, `Mode`, `IntentGroup`),
and RAC / tier types (`ResolvedActionContext`, `TechTier`, `ResolvedArtifact`).
The full inventory is [docs/reference/shared-types.md](docs/reference/shared-types.md).

Adding a shared type is a **contract change**: land it with both BE and FE
consumers in the same PR where possible, and add a regression test in
`packages/ant-cli/tests/` that exercises the new shape. `@ant/shared` also ships
runtime code (canonical paths, the action-config matrix, tier matrices) — after
editing that, run `pnpm --filter @ant/shared build`.

---

## Where to Read Next

The regression-grade rationale behind every rule above — incident-driven
invariants, enforcement greps, and the test that guards each one — lives in
[`docs/internals/`](docs/internals/). Read it before touching the graph, the
prompt builder, or any SSOT function. See the [Quick Map](#quick-map) for
everything else.