Skip to content

PRD: Squad State — Typed StorageProvider Interface with Contract Conformance #481

Closed

Description

@diberry

Squad State: Typed Interface + MarkdownProvider

Author: Dina Berry
Date: 2026-03-22
Status: Draft — Awaiting Brady's Review
Target Repository: bradygaster/squad (packages/squad-sdk/)
Related: Full research document in PRD-squad-state.md


1. Problem Statement

The Squad SDK has no centralized state management. Every module that reads or writes .squad/ files does it independently — 129 read ops and 87 write ops scattered across 29 modules, all direct fs calls with no shared code, no abstraction, and no type safety.

What's broken:

  • 6 separate directory scans for the same .squad/ tree (LocalAgentSource, CharterCompiler, AgentLifecycleManager (stale path!), nap.ts, doctor.ts, export.ts (wrong path!))
  • Parsers are read-only — markdown → typed objects works, but no serializers exist for the reverse
  • CLI bypasses SDK for writes — raw string concatenation instead of typed APIs
  • Stringly-typed everywhere — agent status duplicated 3×, routing tiers duplicated 4×, model tiers duplicated 3×, WorkType has a | string escape hatch that defeats the union

Why now: The team has converged through three research sessions (2026-03-20 through 2026-03-22). The architecture is validated. Time to build.

Why a StorageProvider interface (two independent arguments)

POV #1 — Backend Flexibility: The interface enables swapping storage backends (markdown, SQLite, GitHub API) without changing SDK code. Markdown is the default; others become mechanical implementations against the same contract.

POV #2 — Contract Conformance: The interface defines a testable contract. A provider-agnostic conformance test suite validates that implementations behave correctly — catching bugs in the existing markdown provider (round-trip data loss, edge cases) even if no second backend is ever built. Value on day one.

POV #2 is arguably stronger: it delivers value immediately without requiring faith that a second backend will be needed.

Scope of this PRD

This PRD covers: The StorageProvider typed interface and the MarkdownStorageProvider implementation (plus InMemoryStorageProvider for tests).

This PRD does NOT cover: Implementation details for SQLite, GitHub API, or other non-filesystem providers. The interface is designed so they CAN be added later — that's a separate future PRD.


2. Goals & Success Metrics

Goals

  1. Define the StorageProvider interface — typed, async, collection-level CRUD with section-level operations
  2. Ship MarkdownStorageProvider — default implementation preserving all current .squad/ behavior
  3. Ship InMemoryStorageProvider — for fast, deterministic, filesystem-free tests
  4. Build SquadState as the typed facade — one entry point, typed collections, Agent Handle pattern, no as casts
  5. Establish conformance test suite — provider-agnostic tests that validate any implementation

Success Metrics

Metric Target
Direct fs calls in SDK 0 outside MarkdownStorageProvider
Directory scan locations 1 (internal to MarkdownStorageProvider)
SDK test execution time <2s for state tests (InMemory)
Round-trip fidelity 100% for all document types
CLI write operations 0 raw string surgery

Non-Goals

  • Build non-filesystem providers (SQLite, GitHub API, etc.)
  • Replace git as the transport/sync mechanism
  • Abstract the communication layer (issues/PRs)
  • Break existing .squad/ directory structure or formats

3. Key User Scenarios

Scenario 1: Read agent charter

// BEFORE: bespoke fs + parsing per module
const raw = fs.readFileSync(path.join(squadRoot, '.squad', 'agents', name, 'charter.md'), 'utf-8');
const parsed = parseCharter(raw);

// AFTER: typed handle — plain string in, typed API out
const mal = await state.agents.get('mal');   // AgentHandle — validates at runtime, throws NotFoundError
const charter = await mal.charter();          // Promise<Charter> — fully typed, no casts
const learnings = await mal.history('learnings');  // Promise<HistoryEntry[]>
await mal.appendHistory('learnings', entry);       // Promise<void>

Scenario 2: CLI appends a decision

// BEFORE: raw string concatenation
const entry = `### ${timestamp}: ${title}\n**By:** ${author}\n\n${body}\n`;
fs.writeFileSync(decisionsPath, existing + '\n' + entry);

// AFTER: typed API handles serialization
await state.decisions.addDecision({ title, by: 'Mal (Lead)', body, timestamp: new Date() });

Scenario 3: Unit testing without filesystem

const store = new InMemoryStorageProvider();
store.seed({
  agents: {
    'test-agent': {
      charter: { name: 'test-agent', role: 'Tester', status: 'active' },
    },
  },
});
const state = new SquadState(store); // fast, deterministic, no cleanup
const agent = await state.agents.get('test-agent'); // AgentHandle — typed, no casts

4. Scope

In Scope

  • StorageProvider interface definition (typed async CRUD at document + section level)
  • MarkdownStorageProvider — wraps current filesystem operations, owns markdown serialization
  • InMemoryStorageProvider — for tests
  • SquadState class — typed facade with collection-specific sub-interfaces
  • Shared types.ts — single source of truth for AgentName, AgentStatus, HistorySection, ModelTier, RoutingTier, CollectionName, domain types
  • Markdown serializers for all document types (charters, decisions, routing, team, history)
  • Conformance test suite — provider-agnostic, validates any StorageProvider implementation
  • Absorption of history-shadow.ts into SquadState as agents.history collection

Out of Scope

  • Non-filesystem provider implementations (SQLite, GitHub API, JSON — interface supports them, future PRD)
  • Communication layer abstraction (issues/PRs — separate concern)
  • Config schema unification (SquadConfig vs SquadSDKConfig — adjacent work)
  • Breaking changes to .squad/ format

5. Approach

Architecture

SquadState (typed facade — typed collections, AgentHandle pattern, domain objects)
  └── StorageProvider (interface — typed async CRUD, collection→entity mapping)
       ├── MarkdownStorageProvider (default — reads/writes .squad/ files)
       │    └── SquadFileSystem (file discovery, path resolution, caching)
       └── InMemoryStorageProvider (for tests)

Key Design Decisions

1. StorageProvider Interface — Type-Safe Collection-Entity Mapping

The generic parameters on read() and write() are linked to the collection name via CollectionEntityMap. The compiler prevents reading a Charter from the 'decisions' collection — a class of bug that unconstrained generics silently permit.

// Maps each collection to its entity type — the compiler enforces correct pairings
interface CollectionEntityMap {
  agents: Charter;
  decisions: Decision;
  routing: RoutingConfig;
  team: TeamConfig;
  skills: SkillDefinition;
  templates: Template;
  log: LogEntry;
  config: SquadConfig;
}

type CollectionName = keyof CollectionEntityMap;
type CollectionEntity = CollectionEntityMap[CollectionName];

interface StorageProvider {
  // Collection-level
  list(collection: CollectionName): Promise<string[]>;

  // Whole-document operations — collection name constrains entity type
  read<C extends CollectionName>(collection: C, id: string): Promise<CollectionEntityMap[C]>;
  write<C extends CollectionName>(collection: C, id: string, entity: CollectionEntityMap[C]): Promise<void>;
  exists(collection: CollectionName, id: string): Promise<boolean>;
  delete(collection: CollectionName, id: string): Promise<void>;

  // Section-level operations (sections are string-typed at this layer;
  // SquadState facade adds typed section names per collection)
  readSection(collection: CollectionName, id: string, section: string): Promise<unknown>;
  appendToSection(collection: CollectionName, id: string, section: string, entry: SectionEntry): Promise<void>;

  // Lifecycle
  initialize(): Promise<void>;
  dispose(): Promise<void>;
}

Design note (River): The old read<T extends CollectionEntity>() signature let callers request any entity type from any collection — read<Charter>('decisions', 'foo') compiled but was always wrong. The CollectionEntityMap pattern (same one the SDK uses for SquadEventPayloadMap) makes invalid states unrepresentable.

2. SquadState — Agent Handle Pattern (No Branded Types, No Casts)

The Agent Handle pattern eliminates branded types entirely. The old AgentName = string & { __brand: 'AgentName' } forced every call site into an as AgentName cast — a type system smell that means the design is wrong, not the caller. Instead, state.agents.get() accepts a plain string, validates at runtime, and returns a typed AgentHandle. Everything downstream is fully typed without casts.

type AgentStatus = 'active' | 'inactive' | 'retired';
type HistorySection = 'context' | 'learnings' | 'decisions' | 'patterns' | 'issues' | 'references';
type ModelTier = 'premium' | 'standard' | 'fast';
type RoutingTier = 'direct' | 'lightweight' | 'standard' | 'full';

// The handle is the typed API surface for a single agent
interface AgentHandle {
  readonly name: string;
  charter(): Promise<Charter>;
  history(): Promise<History>;
  history(section: HistorySection): Promise<HistoryEntry[]>;
  appendHistory(section: HistorySection, entry: HistoryEntry): Promise<void>;
  status(): Promise<AgentStatus>;
}

// The collection manages discovery and handle creation
interface AgentCollection {
  list(): Promise<string[]>;
  get(name: string): Promise<AgentHandle>;    // validates name, throws NotFoundError
  exists(name: string): Promise<boolean>;
}

interface DecisionCollection {
  list(): Promise<Decision[]>;
  add(decision: Decision): Promise<void>;
}

class SquadState {
  readonly agents: AgentCollection;
  readonly team: TeamCollection;
  readonly routing: RoutingCollection;
  readonly decisions: DecisionCollection;
  readonly skills: SkillCollection;

  constructor(provider: StorageProvider) { /* wires typed collections to provider */ }
}

Why no branded AgentName: The SDK's existing AgentRef is string. The codebase uses plain strings for agent names everywhere. A branded type adds friction (casts at every boundary) without adding safety — the real validation is "does this agent exist?" which is a runtime concern handled by AgentHandle. The handle IS the proof of validity.

3. Serialization = Provider's Responsibility

The interface deals in typed objects only. MarkdownProvider serializes to/from markdown. A future SQLiteProvider would map to/from SQL rows. The caller never sees raw strings or format-specific content.

4. Granularity = File + Section Level

Operations work at two levels: whole-document (get/put a Charter) and section (append to History.Learnings). This maps to how .squad/ files use ## headers as logical sections. Non-filesystem backends can map sections to table columns, API endpoints, or document subsections.

5. history-shadow.ts Absorbed Into SquadState

The existing history-shadow.ts proved the pattern (typed CRUD, section-level operations, markdown as storage). It does NOT remain standalone — it becomes state.agents.history. One interface, no parallel APIs.

6. Inbox/Drop-Box = Provider-Specific

The decisions inbox pattern (write to inbox files, Scribe merges) is a MarkdownProvider implementation detail, NOT part of the StorageProvider interface. The interface exposes addDecision(decision: Decision): Promise<void>. How the provider handles concurrent writes is its own concern.

7. Async-Only

All StorageProvider methods return Promises. No sync variant. JavaScript is async-first, and the interface must support remote backends.

Conformance Test Suite (Phase 0 Foundation)

The conformance suite is the specification made executable. Written as function runConformanceSuite(provider: StorageProvider), invoked once per implementation.

Core behaviors tested:

  • write() → read() returns identical typed object (round-trip)
  • write() → exists() returns true; before write() → returns false
  • list() returns all written documents, no extras
  • appendToSection() preserves existing content, adds new content
  • delete() → exists() returns false; read() throws
  • write() twice → read() returns second write (overwrite semantics)
  • Empty collection → list() returns [], not error
  • initialize() → dispose() lifecycle is clean

Why this matters: If both InMemoryStorageProvider and MarkdownStorageProvider pass the same suite, the contract is verified from two independent implementations. Neither can cheat in a way the other also cheats.

Competitive Analysis (Brief)

Pattern LangGraph CrewAI AutoGen Semantic Kernel Squad
Pluggable storage interface ✅ ❌ Partial ✅ ✅
Human-readable state ❌ ❌ ❌ ❌ ✅
Git-diffable state ❌ ❌ ❌ ❌ ✅
Document-level operations ❌ ❌ ❌ ❌ ✅
Contract conformance tests ❌ ❌ ❌ ❌ ✅
State is the product ❌ ❌ ❌ ❌ ✅

Squad's unique position: Every other framework stores opaque blobs or embeddings. Squad stores semantically rich, human-editable documents. The closest analog is an ORM, not a checkpoint system.

Implementation Phases

Phase 0: Conformance Suite + Tests (1 week)

  • Round-trip fidelity tests for existing parsers
  • Conformance test suite (runConformanceSuite(provider)) alongside interface
  • Run against InMemoryStorageProvider as smoke test
  • Deliverable: Living spec + round-trip tests (red — serializers don't exist yet)

Phase 1: Interface + Two Providers (2 weeks)

  • StorageProvider interface + shared types.ts (single source of truth)
  • MarkdownStorageProvider with SquadFileSystem internals
  • InMemoryStorageProvider
  • Both pass conformance suite
  • Deliverable: Interface, two providers, all tests green

Phase 2: SquadState Facade (2 weeks)

  • Typed facade with collection-specific sub-interfaces
  • Wire into existing SDK modules (replace scattered fs calls)
  • Deliverable: SquadState class, SDK modules refactored

Phase 3: CLI Migration (1–2 weeks)

  • Replace all CLI raw string surgery with SquadState APIs
  • Deliverable: CLI fully migrated, zero raw string surgery

6. Risks

Risk Likelihood Impact Mitigation
Interface shape is wrong Medium High Let MarkdownProvider drive the shape; two implementations validate. Refine before shipping.
Round-trip fidelity is harder than expected High Medium Phase 0 catches it early. Known hard cases: YAML frontmatter, inline HTML, comments.
Migration breaks CLI behavior Medium High Conformance suite + incremental migration (one module at a time).
Scope creep into non-filesystem providers Medium Medium Hard boundary: this PRD covers interface + MarkdownProvider only. Other providers = separate PRD.

Dependencies

  • Node.js 18+ (already met), existing parsers in markdown-migration.ts and doc-sync.ts, TypeScript strict mode (enabled), no new npm packages

Edge Cases (Interface-Level)

These are contracts the interface defines; implementation details are provider-specific:

Edge Case Interface Contract
Concurrent appends appendToSection() is the provider's responsibility. MarkdownProvider uses atomic file ops; others use transactions.
Atomicity write() is all-or-nothing. A failed write must not leave partial state.
Error types Discriminated error union — see Error Taxonomy below.
Change detection Not in v1 interface. A future subscribe(collection, callback) is a natural extension.
Human editability supportsExternalModification capability flag. Filesystem providers support it; closed providers can cache aggressively.
Ordering Append-only collections carry timestamps. list() returns chronological order.

Error Taxonomy

Errors use a kind discriminant for exhaustive switch matching, following the SDK's existing discriminated union patterns (see CoordinatorRoutingPayload, AgentMilestonePayload). Each error extends a base StorageError class:

type StorageErrorKind = 'not-found' | 'parse-error' | 'write-conflict' | 'provider-error';

class StorageError extends Error {
  abstract readonly kind: StorageErrorKind;
}

class NotFoundError extends StorageError {
  readonly kind = 'not-found' as const;
  constructor(
    readonly collection: CollectionName,
    readonly id: string,
  ) { super(`${collection}/${id} not found`); }
}

class ParseError extends StorageError {
  readonly kind = 'parse-error' as const;
  constructor(
    readonly collection: CollectionName,
    readonly id: string,
    readonly cause: Error,
  ) { super(`Failed to parse ${collection}/${id}`); }
}

class WriteConflictError extends StorageError {
  readonly kind = 'write-conflict' as const;
  constructor(
    readonly collection: CollectionName,
    readonly id: string,
  ) { super(`Write conflict on ${collection}/${id}`); }
}

class ProviderError extends StorageError {
  readonly kind = 'provider-error' as const;
  constructor(
    message: string,
    readonly cause?: Error,
  ) { super(message); }
}

// Exhaustive handling — compiler catches missing cases
function handleError(err: StorageError): never {
  switch (err.kind) {
    case 'not-found':    throw new UserFacingError('Resource not found');
    case 'parse-error':  throw new UserFacingError('Corrupt data');
    case 'write-conflict': /* retry logic */
    case 'provider-error': /* escalate */
  }
}

Design note (River): Classes + discriminant is the right hybrid here. Classes give you instanceof checks and .message/.stack for logging. The kind discriminant gives you exhaustive switch matching. This aligns with the SDK's existing ErrorFactory pattern while adding the exhaustiveness guarantee that catch blocks currently lack.


7. Architecture Review — River (TypeScript Architect)

Interface Shape Assessment

CollectionEntityMap is the critical addition. The original read<T extends CollectionEntity>() had an unconstrained generic — the T bore no relationship to the collection parameter. This is the TypeScript equivalent of void*: it compiles, it's wrong, and you won't know until runtime. The mapped type CollectionEntityMap makes the compiler enforce valid collection-entity pairings.

AgentHandle eliminates the branded type cascade. Branded types (string & { __brand: 'AgentName' }) are the right tool when you need to distinguish two string-shaped values at the type level (e.g., UserId vs SessionId in the same function signature). They're the wrong tool when there's only one string type and the real validation is "does this thing exist?" — that's a runtime concern. The Agent Handle pattern moves validation to get() and makes the returned handle the proof of validity. Everything downstream is typed without casts.

The readSection return type is intentionally unknown at the StorageProvider layer. Section typing is the SquadState facade's job — it knows that agents have HistorySection subsections. The provider just stores and retrieves opaque section data. This keeps the provider interface simple and pushes domain knowledge to the right layer.

Generic Patterns

Well-used: CollectionEntityMap[C] — conditional type inference from mapped types. Same pattern as the SDK's SquadEventPayloadMap.

Recommendation — overloaded history() on AgentHandle: The history() method has two signatures (full history vs. single section). TypeScript function overloads handle this cleanly:

interface AgentHandle {
  history(): Promise<History>;
  history(section: HistorySection): Promise<HistoryEntry[]>;
}

This is better than two separate methods (getHistory / getHistorySection) because the mental model is "history, optionally scoped."

Where to Use Discriminated Unions

The PRD already uses literal union types (AgentStatus, HistorySection, ModelTier, RoutingTier). These are correct — they're simple enumerations, not variants with different shapes.

Where discriminated unions add value:

  1. Error taxonomy — Done above with StorageErrorKind.
  2. Provider capabilities — If providers need to declare what they support:
    type ProviderCapability =
      | { readonly kind: 'external-modification'; supported: true }
      | { readonly kind: 'transactions'; supported: boolean; maxBatchSize?: number }
      | { readonly kind: 'watch'; supported: boolean };
  3. Future: operation results — If write() needs to return more than void (e.g., created vs. updated), a discriminated result type is cleaner than boolean flags.

Conformance Testing — Type-Level Enforcement

The conformance suite can use TypeScript's type system to guarantee completeness:

// Compile-time: verify a class implements StorageProvider
type AssertProvider<T extends StorageProvider> = T;
type _checkMarkdown = AssertProvider<MarkdownStorageProvider>;  // compile error if missing methods
type _checkInMemory = AssertProvider<InMemoryStorageProvider>;

// Runtime: the conformance suite is a function, not a class
function runConformanceSuite(
  name: string,
  factory: () => Promise<StorageProvider>,  // factory, not instance — fresh state per test
): void {
  describe(`StorageProvider conformance: ${name}`, () => {
    // Round-trip tests for every collection in CollectionEntityMap
    for (const collection of COLLECTION_NAMES) {
      it(`round-trips ${collection}`, async () => {
        const provider = await factory();
        // ... write, read, assert deep equality
      });
    }
  });
}

// Invocation:
runConformanceSuite('MarkdownStorageProvider', () => MarkdownStorageProvider.create(tempDir));
runConformanceSuite('InMemoryStorageProvider', () => Promise.resolve(new InMemoryStorageProvider()));

Key insight: Use a factory function, not an instance. Each test gets a fresh provider. This prevents test pollution and mirrors real initialization.

DX Critique

What I'd enjoy:

  • state.agents.get('mal') → handle pattern is excellent DX. Autocomplete works. No ceremony.
  • await mal.history('learnings') — overloaded method, scoped reads. Clean.
  • state.decisions.add(...) — simple, obvious.

What I'd change:

  • appendToSection(collection, id, section, entry) at the StorageProvider level takes 4 positional string args. Consider a params object for clarity when more than 3 args:
    appendToSection(params: { collection: CollectionName; id: string; section: string; entry: SectionEntry }): Promise<void>;
  • The initialize() / dispose() lifecycle should be hidden from callers. SquadState's constructor should call initialize(). Expose dispose() only for cleanup. Consider Symbol.asyncDispose for using syntax:
    await using state = await SquadState.create(provider);
    // automatically disposed when scope exits

Missing Type Patterns

  1. Template literal types for paths: If the provider ever needs to express file paths:

    type AgentPath = `.squad/agents/${string}/charter.md`;
    type HistoryPath = `.squad/agents/${string}/history.md`;

    Not critical for v1, but useful if paths leak into the public API.

  2. satisfies over as const for seed data: The test fixtures should use satisfies to validate structure while preserving literal types:

    const seed = {
      agents: { 'test-agent': { charter: { name: 'test-agent', role: 'Tester' } } },
    } satisfies SeedData;
  3. NoInfer<T> for provider methods: If a provider method accepts both a value and a type hint, NoInfer prevents the value from widening the inferred type. Not needed in v1 but worth noting for future generic methods.


Activity

  1. bradygaster commented on Mar 22, 2026

    @bradygaster
    Collaborator

    🏗️ Flight triage: StorageProvider is a type system abstraction with contract conformance testing. Routing: CONTROL (type interface design) + EECOM (runtime integration). This fixes the data loss race condition in #479.

  2. github-actions commented on Mar 22, 2026

    @github-actions
    Contributor

    📋 Assigned to EECOM (Core Dev)

    Issue: #481 — PRD: Squad State — Typed StorageProvider Interface with Contract Conformance

    EECOM will pick this up in the next Copilot session.

    For Copilot coding agent: If enabled, this issue will be worked automatically.
    Otherwise, start a Copilot session and say:
    EECOM, work on issue #481

  3. github-actions commented on Mar 22, 2026

    @github-actions
    Contributor

    📋 Assigned to CONTROL (TypeScript Engineer)

    Issue: #481 — PRD: Squad State — Typed StorageProvider Interface with Contract Conformance

    CONTROL will pick this up in the next Copilot session.

    For Copilot coding agent: If enabled, this issue will be worked automatically.
    Otherwise, start a Copilot session and say:
    CONTROL, work on issue #481

  4. github-actions commented on Mar 22, 2026

    @github-actions
    Contributor

    🏗️ Squad Triage — Flight (Lead)

    Issue: #481 — PRD: Squad State — Typed StorageProvider Interface with Contract Conformance
    Assigned to: FIDO (Quality Owner)
    Reason: Issue relates to testing/quality work

    @copilot evaluation: 🔴 Not suitable — issue involves work outside the coding agent's capability profile.

    Team roster:

    • Flight (Lead) → label: squad:flight
    • Procedures (Prompt Engineer) → label: squad:procedures
    • EECOM (Core Dev) → label: squad:eecom
    • FIDO (Quality Owner) → label: squad:fido
    • PAO (DevRel) → label: squad:pao
    • CAPCOM (SDK Expert) → label: squad:capcom
    • CONTROL (TypeScript Engineer) → label: squad:control
    • Surgeon (Release Manager) → label: squad:surgeon
    • Booster (CI/CD Engineer) → label: squad:booster
    • GNC (Node.js Runtime) → label: squad:gnc
    • Network (Distribution) → label: squad:network
    • RETRO (Security) → label: squad:retro
    • INCO (CLI UX & Visual Design) → label: squad:inco
    • GUIDO (VS Code Extension) → label: squad:guido
    • Telemetry (Aspire & Observability) → label: squad:telemetry
    • VOX (REPL & Interactive Shell) → label: squad:vox
    • DSKY (TUI Engineer) → label: squad:dsky
    • Sims (E2E Test Engineer) → label: squad:sims
    • Handbook (SDK Usability) → label: squad:handbook
    • Ralph (Work Monitor) → label: squad:ralph
    • @copilot (Coding Agent) → label: squad:copilot

    To reassign, remove the current squad:* label and add the correct one.

  5. diberry commented on Mar 23, 2026

    @diberry
    ContributorAuthor

    Research Update: StorageProvider Adoption Analysis

    By: squad obo dina

    The Blocker

    Squad is hardcoded to filesystem I/O — 60+ direct \ s\ imports, 200+ call sites scattered across the SDK. This means no cloud storage, no databases, no containers without volume mounts, no enterprise compliance, and a concurrent write race condition in \history-shadow.ts\ (#479).

    Proposed: Minimal StorageProvider Interface

    Starting with the absolute minimum — just enough to abstract the filesystem dependency without boiling the ocean:

    \\ ypescript
    interface StorageProvider {
    read(path: string): Promise;
    write(path: string, content: string): Promise;
    append(path: string, content: string): Promise;
    exists(path: string): Promise;
    list(dir: string): Promise<string[]>;
    delete(path: string): Promise;
    }
    \\

    Six methods. No sync variants (migrate call sites to async). No atomicity helpers yet — that comes in Wave 2 when we fix the #479 race condition properly.

    Phased Approach

    Wave 1: Interface + FSProvider (drop-in replacement, zero behavior change)

    • Define the interface
    • Implement \FSStorageProvider\ that wraps current \ s\ calls
    • Wire it through
      esolution.ts\ as the entry point
    • All existing behavior preserved — just indirected through the provider

    Wave 2: Migrate call sites (module by module)

    • \config/\ → \sharing/\ → \�gents/\ → \skills/\ → \ ools/\
    • Each module gets a PR, each PR has tests

    Wave 3: Alternative providers (unlocks adoption)

    • S3, Azure Blob, Database — community or internal
    • Contract conformance test suite ensures all providers behave identically

    Key Files Affected

    • \packages/squad-sdk/src/resolution.ts\ — provider injection point
    • \packages/squad-sdk/src/config/models.ts\ — 10+ direct fs calls
    • \packages/squad-sdk/src/sharing/consult.ts\ — 20+ fs operations
    • \packages/squad-sdk/src/agents/history-shadow.ts\ — race condition (Bug: history-shadow.ts has read-modify-write race condition (data loss) #479)
    • \packages/squad-sdk/src/skills/skill-loader.ts\ — directory scanning

    Open Questions for Brady

    1. Should the provider be injected via constructor (DI) or resolved from config?
    2. Do we need sync fallbacks for the CLI hot path, or can everything go async?
    3. Is Wave 1 (FSProvider drop-in) worth shipping alone as a foundation PR?
  6. diberry commented on Mar 23, 2026

    @diberry
    ContributorAuthor

    Dina's decisions on the open questions

    1. DI with config override: Constructor injection is primary (
    ew SquadSDK({ storage: myProvider })), but if not provided, Squad resolves from config (\squad.config.ts\ or defaults to \FSStorageProvider). This follows the existing SDK pattern where config seeds defaults but DI overrides.

    2. Both sync and async: Ship both variants now. The interface should have
    ead/
    eadSync, \write/\writeSync, etc. Goal is eventually async-only, but we can't break 200+ sync call sites in Wave 1. Sync variants get deprecated in Wave 2.

    3. Wave 1 ships alone: The interface + \FSStorageProvider\ drop-in is worth its own PR. Gets the foundation in place so we can start building alternative providers.

    Updated minimal interface

    \\ ypescript
    interface StorageProvider {
    // Async (preferred — use these for new code)
    read(path: string): Promise;
    write(path: string, content: string): Promise;
    append(path: string, content: string): Promise;
    exists(path: string): Promise;
    list(dir: string): Promise<string[]>;
    delete(path: string): Promise;

    // Sync (legacy — deprecated after Wave 2 migration)
    readSync(path: string): string;
    writeSync(path: string, content: string): void;
    existsSync(path: string): boolean;
    }
    \\

    9 methods total. Async is the future, sync is the bridge.

    Next step

    Ready to start Wave 1 implementation. Branch: \squad/481-storage-provider\

    squad obo dina

  7. diberry commented on Mar 23, 2026

    @diberry
    ContributorAuthor

    @bradygaster — I'd like to start on Wave 1 (StorageProvider interface + FSStorageProvider drop-in) as an independent PR before the other open work. This is foundation infrastructure that everything else builds on.

    The scope is tight: define the 9-method interface, implement FSStorageProvider wrapping current fs calls, wire it through resolution.ts as the injection point. Zero behavior change — just indirection. Tests verify FSProvider matches current behavior exactly.

    Want to make sure you're good with this before I branch off dev. Let me know if you have any concerns with the interface shape I posted above.

    squad obo dina

  8. 81 remaining items

  9. diberry commented on Mar 31, 2026

    @diberry
    ContributorAuthor

    🔄 Ralph status: PRD — typed StorageProvider interface. Related to state backend work (#678, #680). Backlog.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or improvementgo:needs-researchNeeds investigationsquadSquad triage inbox — Lead will assign to a member

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions