# Agentic Orchestrator: Scope-Aware Run Lease

**Feature Branch**: `002-scope-aware-lease`
**Status**: Ready for Implementation
**Priority**: P1 (US1 + US2) / P2 (US3 + US4)

---

## Overview

Currently, when an orchestrator instance acquires a run lease it creates a global exclusive lock that prevents any other orchestrator from starting — regardless of whether the two would actually touch the same files or plan sections. This serializes all orchestration work even when runs target entirely different parts of the codebase.

This feature replaces that global execution barrier with a scope-aware one. When a new execution attempts to start, the system compares its declared codebase scope against the scopes of all currently active leases. Execution is only blocked when an overlap exists. Runs that target different sections of the codebase proceed concurrently.

---

## User Stories

### User Story 1 — Parallel Execution of Non-Overlapping Runs (Priority: P1)

An operator triggers two orchestrator runs simultaneously. One targets the authentication subsystem; the other targets the payment processing subsystem. Because these runs touch disjoint parts of the codebase, both should be allowed to proceed in parallel.

**Acceptance Scenarios**:

1. **Given** no active leases exist, **When** Run A declares scope `[packages/auth/]` and acquires a lease, **Then** Run A is granted the lease immediately.
2. **Given** Run A holds a lease for scope `[packages/auth/]`, **When** Run B attempts to acquire a lease for scope `[packages/payments/]`, **Then** Run B is granted its lease without waiting.
3. **Given** Run A and Run B are both active with non-overlapping scopes, **When** both complete, **Then** each releases its own lease without affecting the other.

---

### User Story 2 — Blocking of Overlapping Runs (Priority: P1)

Two orchestrator runs that both target the same directory or plan section — the second run must not be allowed to proceed until the first completes.

**Acceptance Scenarios**:

1. **Given** Run A holds a lease for scope `[packages/auth/]`, **When** Run B attempts to acquire a lease for scope `[packages/auth/]`, **Then** Run B is blocked until Run A releases its lease.
2. **Given** Run A holds a lease for scope `[packages/]`, **When** Run B attempts to acquire a lease for scope `[packages/auth/]`, **Then** Run B is blocked (path prefix containment).
3. **Given** Run A holds a lease for scope `[packages/auth/, packages/shared/]`, **When** Run B declares scope `[packages/shared/, packages/payments/]`, **Then** Run B is blocked because `packages/shared/` appears in both scopes.

---

### User Story 3 — Conservative Fallback for Unknown Scope (Priority: P2)

A run started before a plan is established has no declared scope. The system must not allow an unknown-scope run to proceed concurrently with any other active run.

**Acceptance Scenarios**:

1. **Given** Run A holds a lease for any declared scope, **When** Run B attempts to acquire a lease with no declared scope, **Then** Run B is blocked.
2. **Given** Run A holds a lease with no declared scope, **When** Run B attempts to acquire a lease for any scope, **Then** Run B is blocked.
3. **Given** no active leases exist and Run A has no declared scope, **When** Run A acquires the lease, **Then** Run A is granted the lease immediately.

---

### User Story 4 — Scope Declaration & Persistence at Acquisition (Priority: P2)

Orchestrators can supply a set of codebase paths or plan section identifiers when requesting a lease. This declared scope is persisted in the lease record and used for conflict detection.

**Acceptance Scenarios**:

1. **Given** an orchestrator supplies directory paths when requesting a lease, **When** the lease is granted, **Then** the lease record contains exactly those paths.
2. **Given** an orchestrator supplies plan section identifiers, **When** the lease is granted, **Then** the record contains those identifiers alongside any path-based scope.
3. **Given** a stale lease is taken over by a new orchestrator, **When** the takeover completes, **Then** the new orchestrator's scope replaces the previous lease's scope in the record.

---

## Functional Requirements

- **FR-001**: The system MUST allow two orchestrator runs to hold active leases simultaneously when their declared codebase scopes do not overlap.
- **FR-002**: The system MUST deny or defer lease acquisition when the requesting run's scope overlaps with any currently active lease's scope.
- **FR-003**: Scope overlap MUST be evaluated using path-prefix containment — a scope entry overlaps another if one is a prefix of (or equal to) the other.
- **FR-004**: The system MUST treat a run with an undeclared or unknown scope as conflicting with all other active leases (conservative blocking).
- **FR-005**: The system MUST persist the declared scope as part of the lease record so it survives across heartbeat renewals and instance restarts.
- **FR-006**: The system MUST support scope declarations consisting of file-system paths, feature/plan section identifiers, or both.
- **FR-007**: An active orchestrator MUST be able to expand its declared scope during a run (e.g., after discovering additional affected files), and the updated scope MUST be used for subsequent conflict evaluations.
- **FR-008**: When a stale lease is taken over, the system MUST replace the expired scope with the new orchestrator's declared scope before evaluating conflicts.
- **FR-009**: The system MUST continue to use short-duration atomic locks for mutating the lease registry itself, ensuring conflict comparison and lease grant/deny decisions are race-condition free.
- **FR-010**: The system MUST produce an observable record (log entry or status field) when a lease acquisition is deferred due to scope conflict, identifying which active lease caused the block.

---

## Data Model

### New Types (`apps/control-plane/src/core/runtime-sessions.ts`)

```typescript
export interface LeaseScope {
  paths: string[]; // file-system paths relative to repoRoot (prefix-matched)
  plan_section_ids: string[]; // opaque plan section identifiers (equality-matched)
}

export type LeaseScopeDeclaration = LeaseScope | null;
// null                              → unknown/undeclared → conflicts with ALL active leases
// { paths: [], plan_section_ids: [] } → explicitly empty  → conflicts with NOTHING
// { paths: [...], ... }             → declared scope     → prefix-overlap / equality check

export interface ActiveLeaseRecord {
  run_id: string;
  owner_instance_id: string;
  instance_id: string;
  scope: LeaseScopeDeclaration;
  lease_expires_at: string; // ISO-8601; entries with expiry < now are pruned on read
  acquired_at: string; // ISO-8601
}

export interface LeaseRegistry {
  leases: ActiveLeaseRecord[];
  updated_at: string; // ISO-8601; last write timestamp (informational)
}
```

### Modified Types

**`RuntimeSessionsSnapshot`** — add `scope?: LeaseScopeDeclaration` (optional, backward-compatible; missing field normalizes to `null`).

**`AcquireRunLeaseInput`** — add `scope?: LeaseScopeDeclaration` (optional; `undefined` normalizes to `null`).

**New: `ExpandRunLeaseScopeInput`**:

```typescript
export interface ExpandRunLeaseScopeInput {
  run_id: string;
  owner_instance_id: string;
  additional_paths?: string[];
  additional_plan_section_ids?: string[];
}
```

### New Error Code (`apps/control-plane/src/core/error-codes.ts`)

```typescript
LEASE_SCOPE_CONFLICT: 'lease_scope_conflict',
```

Error details shape:

```typescript
{
  incoming_scope: LeaseScopeDeclaration;
  blocking_run_id: string;
  blocking_owner_instance_id: string;
  conflicting_scope_entry: string; // first conflicting path or plan_section_id
  retryable: true;
  requires_human: false;
}
```

### Persistence

| Artifact                | Path                                       | Protected By                |
| ----------------------- | ------------------------------------------ | --------------------------- |
| Per-instance lease file | `.aop/runtime/<instanceId>/run-lease.json` | `withIndexLock`             |
| Global lease registry   | `.aop/runtime/lease-registry.json`         | `withIndexLock` (same lock) |

---

## Architecture

### Conflict Detection Logic (Scope Overlap Rules)

```
acquireRunLease(input):
  scope = input.scope ?? null   ← normalize undefined → null

  active = registry.leases (pruned of expired entries)

  for each activeLease in active (excluding same run_id re-acquire):
    if activeLease.scope === null OR scope === null  → CONFLICT
    if scope is empty AND activeLease.scope is empty → no conflict
    if scope is empty                               → no conflict (touches nothing)
    if activeLease.scope is empty                   → no conflict
    if pathsOverlap(scope.paths, activeLease.scope.paths) → CONFLICT
    if planSectionsOverlap(...)                     → CONFLICT
  → GRANT
```

**Path normalization** (R-001): Strip trailing `/`, append exactly one `/`. Empty string after strip → `'/'` (root, overlaps everything). `packages/auth` and `packages/authentication` do NOT overlap.

**Plan section overlap** (R-002): Case-sensitive string equality only. No hierarchy between section IDs.

**Conflict matrix**:

| Incoming                            | Active                      | Result                     |
| ----------------------------------- | --------------------------- | -------------------------- |
| `null`                              | anything (any lease exists) | CONFLICT                   |
| anything                            | `null`                      | CONFLICT                   |
| `{ paths:[], plan_section_ids:[] }` | any                         | No conflict                |
| non-empty                           | `{ paths:[], ... }`         | No conflict                |
| `packages/auth/`                    | `packages/payments/`        | No conflict                |
| `packages/auth/`                    | `packages/auth/`            | CONFLICT                   |
| `packages/`                         | `packages/auth/utils/`      | CONFLICT (parent/child)    |
| `src/`                              | `src/config.ts`             | CONFLICT (file within dir) |
| `src/`                              | `src-old/config.ts`         | No conflict                |

### New Files

- **`apps/control-plane/src/application/services/lease-scope-conflict-detector.ts`** — Pure logic class, zero I/O, zero side effects. Methods: `normalizePath`, `pathsOverlap`, `planSectionsOverlap`, `detectConflict`.
- **`apps/control-plane/test/lease-scope-conflict-detector.spec.ts`** — 26 unit tests.
- **`apps/control-plane/test/run-lease-service.spec.ts`** — 20 integration tests.

### Modified Files

| File                                        | Change                                                                                 |
| ------------------------------------------- | -------------------------------------------------------------------------------------- |
| `core/runtime-sessions.ts`                  | 4 new types, `scope?` on snapshot                                                      |
| `core/error-codes.ts`                       | `LEASE_SCOPE_CONFLICT` added                                                           |
| `core/path-layout.ts`                       | `leaseRegistryPath` getter                                                             |
| `application/services/run-lease-service.ts` | Scope conflict integration, `expandRunLeaseScope`, port interface extensions           |
| `core/kernel.ts`                            | `readLeaseRegistry`, `writeLeaseRegistry`, `emptyLeaseRegistry`, `expandRunLeaseScope` |
| `supervisor/types.ts`                       | `scope?` on `FeatureInput`                                                             |
| `supervisor/run-coordinator.ts`             | `deriveRunScope()`, `expandScopesFromPlans()`, scope wired to `acquireRunLease`        |
| `test/run-coordinator.spec.ts`              | 2 scope propagation tests                                                              |
| `test/supervisor.spec.ts`                   | 1 `LEASE_SCOPE_CONFLICT` propagation test                                              |
| `test/kernel.spec.ts`                       | 2 registry round-trip tests                                                            |
| `docs/core/DOMAIN-LOGIC.md`                 | Rule 19 — Lease Scope Semantics                                                        |
| `docs/core/DECISIONS.md`                    | ADR: Scope-Aware Lease Registry                                                        |

### Key Design Decisions

- **Existing index lock reused**: The new Lease Registry (`lease-registry.json`) uses the existing `withIndexLock` (`.aop/features/.index.lock`). No new lock primitives, no priority-inversion risk.
- **Prune-on-read**: Expired entries are removed inside every `withIndexLock` callback. Self-healing, no background GC process.
- **Conservative by default**: Any caller that doesn't pass `scope` gets `null` (conservative behavior), which is identical to the current global-lock behavior. Parallelism must be explicitly opted into.
- **Pure conflict detector**: `LeaseScopeConflictDetector` has zero I/O and is independently unit-testable.
- **Scope expansion is additive**: Paths can only be added to a live lease, never removed.

---

## Implementation Tasks

### Phase 1: Core Types & Infrastructure

- **T001** — Add `LeaseScope`, `LeaseScopeDeclaration`, `ActiveLeaseRecord`, `LeaseRegistry` to `core/runtime-sessions.ts`
- **T002** [P] — Add `LEASE_SCOPE_CONFLICT` to `core/error-codes.ts`
- **T003** [P] — Add `leaseRegistryPath` getter to `core/path-layout.ts`

### Phase 2: Pure Conflict Detection Service

- **T004** — Create `application/services/lease-scope-conflict-detector.ts` — pure `LeaseScopeConflictDetector` class with `normalizePath`, `pathsOverlap`, `planSectionsOverlap`, `detectConflict`

### Phase 3: Scope Declaration & Persistence (US4)

- **T005** [US4] — Add `scope?: LeaseScopeDeclaration` to `RuntimeSessionsSnapshot`
- **T006** [P] [US4] — Add `scope?` to `AcquireRunLeaseInput` and add `ExpandRunLeaseScopeInput` interface

### Phase 4: Registry-Backed Lease Operations (US1 & US2)

- **T007** [US1/US2] — Extend `RunLeaseServicePort` with `readLeaseRegistry`, `writeLeaseRegistry`, `emptyLeaseRegistry`
- **T008** [US1/US2] — Modify `acquireRunLease` to consult registry, run conflict detection, write registry entry on grant, emit `LEASE_SCOPE_CONFLICT` on deny
- **T009** [US2] — Modify `releaseRunLease` to remove entry from registry
- **T010** [P] [US1] — Modify `renewRunLease` to update `lease_expires_at` in registry
- **T011** — Implement `readLeaseRegistry`, `writeLeaseRegistry`, `emptyLeaseRegistry` on `AopKernel`

### Phase 5: Scope Expansion (FR-007)

- **T012** — Add `expandRunLeaseScope` to `RunLeaseService` (additive merge, ownership check, registry update)
- **T013** [P] — Add `expandRunLeaseScope` to `RunLeaseServicePort` and implement in `AopKernel`

### Phase 6: Supervisor Wiring

- **T014** [P] — Add `scope?: LeaseScopeDeclaration` to `FeatureInput` in `supervisor/types.ts`
- **T015** — Add `deriveRunScope()` to `RunCoordinator` (aggregate feature scopes; `null` if any unknown); wire to `acquireRunLease`
- **T016** — Add `expandScopesFromPlans()` to `RunCoordinator` (call `expandRunLeaseScope` after planning wave with plan's `affected_files`)

### Phase 7: New Test Files

- **T017** — Create `test/lease-scope-conflict-detector.spec.ts` — 26 unit tests covering all conflict matrix quadrants:
  - `normalizePath` (4 tests: T-01→T-04)
  - `pathsOverlap` (9 tests: T-05→T-13, including prefix/suffix false-positive guards)
  - `planSectionsOverlap` (4 tests: T-14→T-17)
  - `detectConflict` full matrix (9 tests: T-18→T-26, including `excludeRunId`)
- **T018** — Create `test/run-lease-service.spec.ts` — 20 integration tests with mocked port:
  - Scope persistence (T-30→T-33)
  - Parallel non-overlapping runs (T-34→T-35)
  - Overlapping runs blocked (T-36→T-38)
  - Conservative fallback (T-39→T-40)
  - Stale takeover + scope replacement (T-41→T-42)
  - `expandRunLeaseScope` (T-43→T-45)
  - `renewRunLease` registry TTL update (T-46→T-47)
  - `releaseRunLease` registry removal (T-48→T-49)

### Phase 8: Existing Test Updates

- **T019** [P] — `test/run-coordinator.spec.ts` — 2 scope-derivation tests
- **T020** [P] — `test/supervisor.spec.ts` — 1 `LEASE_SCOPE_CONFLICT` propagation test
- **T021** [P] — `test/kernel.spec.ts` — 2 registry round-trip tests

### Phase 9: Documentation

- **T022** [P] — Add Rule 19 (Lease Scope Semantics) to `docs/core/DOMAIN-LOGIC.md`
- **T023** [P] — Add Scope-Aware Lease Registry ADR to `docs/core/DECISIONS.md`
- **T024** — Append Entry 193 to `spec-files/progress.md` after all tests pass

### Task Dependency Graph

```
T001 (types)
 ├── T002 [P] (error code)
 ├── T003 [P] (path layout)
 ├── T004 (conflict detector)
 │    └── T017 (detector unit tests)
 ├── T005 [P] (scope on snapshot)
 └── T006 [P] (scope on AcquireInput + ExpandInput)
      ├── T007 (port interface)
      │    ├── T008 (acquireRunLease)
      │    │    ├── T009 [P] (releaseRunLease)
      │    │    ├── T010 [P] (renewRunLease)
      │    │    └── T018 (integration tests)
      │    └── T011 (kernel port impl)
      │         └── T021 (kernel tests)
      └── T012 (expandRunLeaseScope service)
           └── T013 [P] (port + kernel method)
                ├── T014 [P] (FeatureInput)
                ├── T015 (deriveRunScope)
                │    ├── T019 (run-coordinator tests)
                │    └── T020 [P] (supervisor tests)
                └── T016 (expandScopesFromPlans)

T022 [P], T023 [P] (docs — independent)
T024 (progress.md — after all above)
```

### MVP Cut

Complete **T001–T011 + T017** first. This delivers US1 + US2 (P1 stories) with full unit-test coverage and a passing typecheck before touching supervisor or expansion paths.

---

## Observability

### Warning log on conflict

```
[run-lease] scope conflict: run run_123 blocked by run_456 on packages/auth/
```

### Structured error response

```json
{
  "ok": false,
  "error": {
    "code": "lease_scope_conflict",
    "message": "Lease acquisition deferred: scope overlaps with active run run_456",
    "details": {
      "incoming_scope": { "paths": ["packages/auth/"], "plan_section_ids": [] },
      "blocking_run_id": "run_456",
      "blocking_owner_instance_id": "supervisor:12345:abc",
      "conflicting_scope_entry": "packages/auth/",
      "retryable": true,
      "requires_human": false
    }
  }
}
```

### Registry inspection

```bash
cat .aop/runtime/lease-registry.json | jq '.leases[] | {run_id, scope, lease_expires_at}'
```

---

## Edge Cases

- **Nested path prefixes**: `packages/` and `packages/auth/utils/` — prefix containment is detected; these conflict.
- **No false positive on similar names**: `src/` and `src-old/` do NOT overlap (trailing-slash normalization prevents this).
- **Scope expansion mid-run**: Expansion is additive; runs that started with `null` scope cannot become non-conflicting by expanding (initial acquisition is the binding decision).
- **Empty scope vs. unknown**: Explicitly `{ paths: [], plan_section_ids: [] }` (touches nothing) is distinct from `null` (unknown). Empty never conflicts; null always conflicts.
- **Simultaneous acquisition race**: Protected by `withIndexLock`; only one writer at a time.
- **Stale lease takeover**: Expired entries are pruned on every registry read; stale scope is replaced by new owner's scope.

---

## Success Criteria

- **SC-001**: Two runs with non-overlapping scopes both reach active state within the same elapsed time as a single run — true parallelism with no artificial serialization.
- **SC-002**: Zero instances of concurrent lease holders with overlapping scopes in any test scenario.
- **SC-003**: A run with unknown/undeclared scope is never permitted to run concurrently with any other active run.
- **SC-004**: Declared scope is accurately reflected in the lease record immediately after acquisition and after any scope expansion.
- **SC-005**: Scope conflict detection adds negligible overhead to lease acquisition when zero competing leases exist.
- **SC-006**: Prefix-overlap blocking is correct in 100% of cases, including nested path relationships.

---

## Definition of Done

- [ ] `npm run typecheck` passes clean
- [ ] `npm run lint` passes with zero warnings (`--max-warnings 0`)
- [ ] `npm run test` passes at ≥90% lines / branches / functions / statements
- [ ] `lease-scope-conflict-detector.spec.ts` — all 26 tests pass (T-01→T-26)
- [ ] `run-lease-service.spec.ts` — all 20 tests pass (T-30→T-49)
- [ ] `run-coordinator.spec.ts` — 2 new scope tests passing
- [ ] `supervisor.spec.ts` — 1 new `LEASE_SCOPE_CONFLICT` propagation test passing
- [ ] `kernel.spec.ts` — 2 new registry round-trip tests passing
- [ ] `docs/core/DOMAIN-LOGIC.md` — Rule 19 added
- [ ] `docs/core/DECISIONS.md` — Scope-Aware Lease Registry ADR added
- [ ] `spec-files/progress.md` — Entry 193 appended with final test counts

---

## Assumptions

- The codebase sections a run will touch are partially knowable before execution begins (derivable from the feature plan or spec file paths). Runs that genuinely cannot declare any scope use the conservative unknown-scope path.
- Path-prefix matching is sufficient for scope overlap detection; symbolic links and aliased paths are out of scope.
- Scope expansion is infrequent. Frequent mid-run expansion is not an expected pattern and does not need high-throughput optimization.
- The lease registry remains small (single-digit concurrent runs); exhaustive pairwise comparison is acceptable without an indexing strategy.
- Plan section identifiers are opaque strings; two identifiers overlap only if equal (no hierarchy between plan sections).
