From ae5a908a2195818c922d56c92a1a11c251a361f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:46:59 +0800 Subject: [PATCH 1/5] docs(rfc): propose branded IDs everywhere they belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing Branded machinery (CallId/SessionId/AgentId) to the unbranded cross-boundary IDs that meet the brand.ts policy bar — chiefly the model-facing bash task id (BashTask.id, the `bash-N` counter that shares SessionId's `name-N` shape) and a distinct OwnerToken brand for the bash owner token — and fix the brand erosion where existing brands decay back to `string` at Map keys and method params. Scoped focused per the "not every string needs a brand" policy: ModelId, ToolName, numeric ordinals, and validated construction are listed as deferred extensions, not in-scope work. Filed under proposed/architecture. --- docs/rfc/README.md | 1 + .../architecture/2026-06-20-branded-ids.md | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-20-branded-ids.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6f295957ab..ca71af7578 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -65,6 +65,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md new file mode 100644 index 0000000000..ad992f6a91 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -0,0 +1,68 @@ +# RFC: Branded IDs everywhere they belong + +Status: proposed + +## Problem + +The harness already brands three identifiers — `CallId` ([`packages/llm/src/brand.ts`](../../../../packages/llm/src/brand.ts)), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. + +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. + +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". + +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare`/`get(id?: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the entire ACP session-id surface (`SessionRecord.sessionId: string`, `sessions = new Map()`, `requireSession(sessionId: string)`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. + +## Proposal + +A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. + +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts) (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). + +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts); type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) + +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. + +Illustrative shape (the factory pattern is identical to the three existing brands): + +```ts ignore-check +import type { Branded } from '@deepseek-ai/dsh-llm' + +/** A background bash task handle (generated `bash-N` by the local executor). */ +export type BashTaskId = Branded<'BashTaskId'> +export function BashTaskId(id: string): BashTaskId { + return id as BashTaskId +} + +/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */ +export type OwnerToken = Branded<'OwnerToken'> +export function OwnerToken(id: string): OwnerToken { + return id as OwnerToken +} +``` + +## Why a distinct OwnerToken brand (not SessionId) + +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. + +## Out of scope / possible extensions + +Kept deliberately narrow per the "not every string needs a brand" policy. Each of these is a plausible future brand, deferred with a reason, not a commitment: + +- **`ModelId`** (`GenerateOptions.model`, the `LlmService` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this RFC's blast radius focused. +- **`ToolName`** (the `ToolRegistry` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. +- **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. +- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. +- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own RFC, not bundled into this type-only pass. + +## Acceptance criteria + +- `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end: the executor seam, the `dsh-bash-local` generation site, and the `dsh-tool-bash` model-facing surface all speak the brands; `dsh-bash` gains no dependency on `dsh-session`. +- No `Map` keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) remains; the corresponding public method params take the brand, not `string`. +- Brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`); no `as` casts scattered at call sites. +- `pnpm run typecheck` and `pnpm run doc-sync` are green; the change is observably type-only (no snapshot, no e2e behavioral diff). + +## Risks / what we give up + +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above). +- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. +- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. From 034779d761341e0d16ea89452aa4dbdd08725908 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:05:31 +0800 Subject: [PATCH 2/5] docs(rfc): propose extracting example apps into packages Add a proposed architecture RFC to make the examples folder thin: each example becomes mostly an invocation of an app package. A shared dsh-agent-core bundle owns the providerless spine; dsh-stdio-agent and dsh-acp-agent app packages bake in their coupled front-door cluster (UI + logger/hmr policy + agent pre-creation), turning the ACP stdout-purity footgun into a property of the artifact. Leaf cordis.yml shrinks to backends + config; start.ts is dropped in favor of a package bin. Supersedes the providerless-example-base RFC (cross-linked) and indexes the new RFC under Proposed -> Architecture. --- docs/rfc/README.md | 1 + ...2026-06-20-extract-example-app-packages.md | 44 +++++++++++++++++++ .../2026-06-20-providerless-example-base.md | 2 + 3 files changed, 47 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6f295957ab..f3aea7cf2f 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -65,6 +65,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md new file mode 100644 index 0000000000..c175d93e98 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -0,0 +1,44 @@ +# RFC: Extract example apps into packages + +Status: proposed + +## Problem + +An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`logger`/`timer`/`hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. + +The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. + +## Proposal + +Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). + +- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop` (neutral `agents: []`). This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** the loop, as code instead of a YAML include. +- **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. +- **Drop `start.ts`.** Each app package exposes a `bin`; the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle move into that bin, owned by the app. +- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying model / systemPrompt / persistence root. ~4 entries, no infra preamble. +- **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. +- **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-agent-core`. + +`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. + +## Why not keep the wiring in shared YAML includes? + +The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stays copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. + +## Acceptance criteria + +- Each example directory is `cordis.yml` + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. +- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s. +- `pnpm run test`, `pnpm run test:snapshot` (re-recorded), `pnpm run typecheck`, `pnpm run knip`, `pnpm run publint`, and `pnpm run doc-sync` are green; the new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. + +## What we give up + +- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README must carry that teaching weight. +- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. +- **Migration cost** (the implementing PR, not this one): three new packages, three leaf rewrites, the boot glue moved into bins, re-recorded ACP snapshots, and rewritten example READMEs + [examples/AGENTS.md](../../../../examples/AGENTS.md). + +## Related + +- Supersedes [Make the shared example base providerless](2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. +- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. diff --git a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md index 2061248596..fae44635d0 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md @@ -2,6 +2,8 @@ Status: proposed +> Superseded by [Extract example apps into packages](2026-06-20-extract-example-app-packages.md): moving the spine into a `dsh-agent-core` bundle and deleting the `base*.yml` files makes the rename below moot. Kept for the record. + ## Problem The examples have two shared base files: [examples/base-core.yml](../../../../examples/base-core.yml) is providerless, while [examples/base.yml](../../../../examples/base.yml) includes that core plus the real `llm-deepseek` adapter. Snapshot replay needs the providerless core with `llm-replay`, because loading the real adapter without a key throws. The normal demos need the real adapter. The result is a naming inversion: the file named `base.yml` is not the reusable base for all examples, while the true base is `base-core.yml`. From bf3cfe84c3e481808ef678eaa40b95c48cfae4c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:11:02 +0800 Subject: [PATCH 3/5] docs(rfc): fix Codex review findings on branded-IDs RFC - Correct the SessionStore signature: `get(id: string)` is non-optional, not `get(id?: string)` (only create/prepare take an optional id). - Broaden the ACP brand-erosion description and the acceptance criterion beyond `Map`: the session-id surface also includes the `bySession` WeakMap, the `loadingIds` Set, and the exported `streamSessionEventUpdate(sessionId)` signature. - Use bare inline code spans for code paths instead of markdown links, matching the house style of the other architecture RFCs and removing the link/bare-span inconsistency within this file (doc-to-doc cross-links stay markdown links per docs/AGENTS.md). --- .../architecture/2026-06-20-branded-ids.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md index ad992f6a91..6f92de519a 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/proposed/architecture/2026-06-20-branded-ids.md @@ -4,21 +4,21 @@ Status: proposed ## Problem -The harness already brands three identifiers — `CallId` ([`packages/llm/src/brand.ts`](../../../../packages/llm/src/brand.ts)), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness already brands three identifiers — `CallId` (`packages/llm/src/brand.ts`), `SessionId` (`packages/session/src/types.ts`), and `AgentId` (`packages/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery and a zero-cost cast factory per type. `brand.ts` also states the governing policy: *"Branding is for IDs that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare`/`get(id?: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the entire ACP session-id surface (`SessionRecord.sessionId: string`, `sessions = new Map()`, `requireSession(sessionId: string)`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Proposal A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts) (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-llm` exactly as `SessionId`/`AgentId` already do. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in [`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts); type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) - **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. @@ -42,7 +42,7 @@ export function OwnerToken(id: string): OwnerToken { ## Why a distinct OwnerToken brand (not SessionId) -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" ([`packages/bash/src/types.ts`](../../../../packages/bash/src/types.ts)). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions @@ -57,7 +57,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Acceptance criteria - `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end: the executor seam, the `dsh-bash-local` generation site, and the `dsh-tool-bash` model-facing surface all speak the brands; `dsh-bash` gains no dependency on `dsh-session`. -- No `Map` keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) remains; the corresponding public method params take the brand, not `string`. +- No collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — this covers `Map`, `WeakMap` value slots, and `Set` membership (e.g. the ACP `bySession`/`loadingIds`), not just `Map`; the corresponding public method params and exported function signatures (e.g. `streamSessionEventUpdate`) take the brand, not `string`. - Brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`); no `as` casts scattered at call sites. - `pnpm run typecheck` and `pnpm run doc-sync` are green; the change is observably type-only (no snapshot, no e2e behavioral diff). From d9c9c5d403f2f4c4d3657e83e659291f3e82df0d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:25:11 +0800 Subject: [PATCH 4/5] docs(rfc): sharpen agent-loop, timer, and leaf-config accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review against the actual source surfaced three imprecisions: - agent-loop placement: the core bundle forwards agent-loop's `agents` list as its own config (default []), matching AgentLoop.Config, rather than hardcoding []. This is what lets a shared core coexist with stdio pre-creating `main` and acp pre-creating none — and it directly rebuts the reason base-core.yml gives today for keeping the loop out of core. - timer is universal and stdout-safe, so it lives in the shared spine, not the per-app front-door cluster (only logger + hmr are app-specific). - model/systemPrompt land in different places per app (stdio onto the pre-created agent, acp onto the bridge plugin), routed by the app package's own Config — not a single uniform bundle entry. --- .../2026-06-20-extract-example-app-packages.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index c175d93e98..e129acf64e 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -4,18 +4,18 @@ Status: proposed ## Problem -An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`logger`/`timer`/`hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. +An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Today it is thick. Each example carries a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments, and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — is spread across the leaf and the [base.yml](../../../../examples/base.yml) / [base-core.yml](../../../../examples/base-core.yml) / [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) includes. -The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. +The deeper problem is a **coupled front-door cluster** that lives at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` is not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames), omit `hmr` (the editor owns the subprocess), and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger, `hmr`, and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) Today that coupling is enforced only by prose warnings in [acp-agent/cordis.yml](../../../../examples/acp-agent/cordis.yml) and [base-core.yml](../../../../examples/base-core.yml). A leaf that wires a console logger into the ACP config is a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guard by hand. The three `start.ts` files also duplicate the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. ## Proposal Make each example **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](../../implemented/architecture/2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop` (neutral `agents: []`). This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** the loop, as code instead of a YAML include. +- **`@deepseek-ai/dsh-agent-core`** — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`. This is today's [base-core.yml](../../../../examples/base-core.yml) **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (default `[]`, exactly the existing `AgentLoop.Config` shape in [packages/agent-loop/src/index.ts](../../../../packages/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason [base-core.yml](../../../../examples/base-core.yml) gives today for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. - **`@deepseek-ai/dsh-stdio-agent`** and **`@deepseek-ai/dsh-acp-agent`** — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + `hmr` + a pre-created `main`; acp = the `acp` bridge + **no stdout logger** + no `hmr` + no pre-created agents. The coupling becomes structurally unreachable from the leaf. - **Drop `start.ts`.** Each app package exposes a `bin`; the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle move into that bin, owned by the app. -- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying model / systemPrompt / persistence root. ~4 entries, no infra preamble. +- **Collapse each leaf `cordis.yml`** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), and one app-bundle entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). A handful of entries, no infra preamble. - **Fold echo-agent onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. - **Retire** [base.yml](../../../../examples/base.yml), [base-core.yml](../../../../examples/base-core.yml), and [acp-tail.yml](../../../../examples/acp-agent/acp-tail.yml) — the spine they shared now lives in `dsh-agent-core`. From 94a762658ff53a817de40504907fa6beafa81cd0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:37:41 +0800 Subject: [PATCH 5/5] docs(rfc): reject the superseded providerless-example-base RFC git mv it from proposed/ to rejected/architecture/ (preserving history) and fold the supersede note into a one-line rejected Status. The extract-example-app-packages RFC subsumes it: once the spine moves into dsh-agent-core and the base*.yml files are deleted, there is no shared base YAML left to rename. Move its README row to Rejected -> Architecture and repoint the supersede cross-link to the new path. --- docs/rfc/README.md | 2 +- .../architecture/2026-06-20-extract-example-app-packages.md | 2 +- .../architecture/2026-06-20-providerless-example-base.md | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) rename docs/rfc/{proposed => rejected}/architecture/2026-06-20-providerless-example-base.md (88%) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 83f4a2e6db..997683fab5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -63,7 +63,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Make the shared example base providerless](proposed/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | | [Reorganize packages into a modular hierarchy](proposed/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | @@ -163,3 +162,4 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | +| [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | diff --git a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md index e129acf64e..a99fa80f86 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/proposed/architecture/2026-06-20-extract-example-app-packages.md @@ -39,6 +39,6 @@ The `base*.yml`/`acp-tail.yml` includes already dedupe the *config*, but a YAML ## Related -- Supersedes [Make the shared example base providerless](2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. +- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted. - Builds on the [capability-seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into whatever hierarchy that RFC settles on. diff --git a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md similarity index 88% rename from docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md rename to docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md index fae44635d0..c83824d21c 100644 --- a/docs/rfc/proposed/architecture/2026-06-20-providerless-example-base.md +++ b/docs/rfc/rejected/architecture/2026-06-20-providerless-example-base.md @@ -1,8 +1,6 @@ # RFC: Make the shared example base providerless -Status: proposed - -> Superseded by [Extract example apps into packages](2026-06-20-extract-example-app-packages.md): moving the spine into a `dsh-agent-core` bundle and deleting the `base*.yml` files makes the rename below moot. Kept for the record. +Status: rejected — superseded by [Extract example apps into packages](../../proposed/architecture/2026-06-20-extract-example-app-packages.md), which moves the spine into a `dsh-agent-core` bundle and deletes the `base*.yml` files, so there is no shared base YAML left to rename. ## Problem