# Feature Specification: Agent Management & Investor Logic (SP-05)

**Feature Branch**: `006-agent-investor`

**Created**: 2026-06-15

**Status**: Draft

**Input**: User description: "SP-05 — Agent Management & Investor Logic — derives requirements for agent CRUD, investor share movement, 30-day share log lock, computed investor profit (5% of company margin), permanent investor flag, and referred-customers list. Builds atop existing SP-01..SP-04 artifacts."

---

## 1. Problem Statement

### 1.1 Business Context

The Tamkeen business model is built on three intertwined human roles that share one storage table (`clients`):

- **Customer** — a person who buys a product on installment.
- **Agent** — a person who brings that customer to the company.
- **Investor** — an agent who has also purchased company shares and is entitled to 5% of the company's profit on the agent's contracts.

These three sub-roles are *not mutually exclusive*. The same person can simultaneously be a customer, an agent, an investor, or any subset thereof. Roles are inferred at O(1) cost from a JSONB `client_type_flags` column on the unified `clients` table (BR-001-2, BR-001-3).

### 1.2 Why SP-05 Exists

The product owner requires the system to:

1. **Catalogue the agent population.** Sales and operations need a listable, searchable index of every person who has ever been recorded as an agent, with the financial footprint of their referral activity.
2. **Track share movements permanently and immutably.** Each `add` / `withdraw` transaction is a historical event that must never be physically erased. The "investor" designation must persist on the client record for life, even if share balance is later reduced to zero.
3. **Compute investor profit deterministically.** Investor profit is a *derived* value — 5% of the company's total margin (not 5% of collections) on the agent's `active` and `completed` contracts. The system must produce this number on demand; it must not be stored, snapshotted, or cached beyond the response.
4. **Restrict share-log corrections to a 30-day window.** Mistyped or wrong share entries can be corrected or voided only on the *most recent* `active` record and only within 30 days of its `created_at`. After that the record is frozen for accounting integrity.
5. **Surface the referred customer base on the agent detail page.** Operations needs to see, for any given agent, the list of customers they brought in, with their financial summary.
6. **Enforce data minimization at the API surface.** `client_type_flags` (internal role data) and `reference_number` (paper document) must never leak to mobile clients. The same immutability that protects the customer protects the agent.

### 1.3 Trace to Vision & Scope

| Vision Driver | Trace |
|---|---|
| "Unified human entity" (`01_02_DOMAIN_MODEL.md`) | Agent is a sub-role of the same `clients` table; flag-based inference; never a separate entity |
| "Append-only ledger + 30-day correction window" (`01_03_BUSINESS_RULES.md` BR-005-5..7) | Shares log is a logical (status-driven) ledger; never physically deleted; bounded correction window |
| "Computed, not stored" (`01_03` BR-005-3) | Investor profit is a formula output, not a column; recomputed on every read |
| "Constitutional immutability" (Constitution §V, BR-001-1) | Agents can never be deleted; the DELETE endpoint is non-existent by design |

---

## 2. Goals

Each goal is a measurable, observable outcome.

| # | Goal | Measurable Target |
|---|------|-------------------|
| **G-1** | Admin can list all agents in stable alphabetical order with their financial footprint | `GET /api/v1/agents` returns paged, name-sorted results; p95 ≤ 200ms for 10,000 records |
| **G-2** | Admin can register a new agent and optionally seed the share count at creation | `POST /api/v1/agents` returns 201; transactionally applies `agent` flag; transactionally applies `investor` flag iff `shares_count > 0` |
| **G-3** | Admin can view a single agent with all financial aggregates, computed profit, and referred customers in one response | `GET /api/v1/agents/{id}` returns basic data + 5 financial aggregates + computed_profit + referred_customers array; p95 ≤ 200ms |
| **G-4** | Admin can update an agent's basic contact information (name, phone, description) but NOT shares | `PUT /api/v1/agents/{id}` excludes share-related fields; p95 ≤ 200ms |
| **G-5** | Admin can record an `add` or `withdraw` shares movement | `POST /api/v1/agents/{id}/shares` returns 201; transactionally adds `investor` flag on first `add`; p95 ≤ 200ms |
| **G-6** | Admin can correct the most recent share-log record within 30 days | `PATCH /api/v1/agents/{id}/shares/{shareLogId}` updates the *same* row to `status='modified'`; rejects with 403 if not latest active or if 30 days elapsed |
| **G-7** | Admin can void the most recent share-log record within 30 days | `DELETE /api/v1/agents/{id}/shares/{shareLogId}` updates the *same* row to `status='deleted'`; rejects with 403 if not latest active or if 30 days elapsed; record remains in the table |
| **G-8** | Admin can read the full chronological history of share movements for an agent | `GET /api/v1/agents/{id}/shares-log` returns reverse-chronological paginated history including `active`, `modified`, and `deleted` rows |
| **G-9** | The `investor` flag is set on first share addition and is never removed thereafter | Verified by AC-010; a withdrawal that zeroes the balance leaves the flag intact |
| **G-10** | `client_type_flags` and `reference_number` are never present in any agent API response | Verified by resource contract; structural test for every endpoint |

---

## 3. User Scenarios & Testing *(mandatory)*

### 3.1 User Story 1 — Agent Registration (Priority: P1)

The admin opens the "Add Agent" form, enters a name, a Syrian phone number, an optional description, and an optional initial share count. The system creates the agent record, returns the basic profile, and (when shares > 0) returns the initial share log row and the implicit investor status.

**Why this priority**: Agent creation is the entry point for the entire agent/investor lifecycle. Without it, no other agent operation exists.

**Independent Test**: Two scenarios are testable in isolation:
- (a) Create with `shares_count=0` → response contains only `agent` flag (not returned in body), no share log row, no investor status.
- (b) Create with `shares_count=5` → response contains one active share log row, investor status implicitly set on the client record.

**Acceptance Scenarios**:

1. **Given** unique valid phone, **When** `POST /api/v1/agents` with `name`, `phone`, `shares_count=5`, **Then** response 201, agent data returned, one `active` share log row with `transaction_type='add'` and `shares_count=5` is created, and the `investor` flag is set on the client record.
2. **Given** a phone number already in `clients`, **When** `POST /api/v1/agents`, **Then** response 422 with `errors.client.phone_unique` translation key.
3. **Given** missing `name` or `phone`, **When** `POST /api/v1/agents`, **Then** response 422 with field-specific validation errors.
4. **Given** `shares_count=0` (or omitted), **When** `POST /api/v1/agents`, **Then** response 201, no share log row, no `investor` flag set on the client.
5. **Given** `shares_count` is negative, **When** `POST /api/v1/agents`, **Then** response 422 with `validation.min.numeric` translation key.

---

### 3.2 User Story 2 — Agent Listing with Financial Footprint (Priority: P1)

The admin opens the Agents page and sees a paginated list of all agents, alphabetically sorted by name. Each row shows the total amount remaining on contracts brought by that agent and the total amount already collected.

**Why this priority**: This is the primary working view for the sales/operations team to gauge agent performance and prioritize collections.

**Independent Test**: Seed ≥ 50 agents, ≥ 20 with contracts in mixed states (active, completed, draft). Verify ordering is strictly alphabetical, draft contracts are excluded from financial totals, and pagination is cursor-based.

**Acceptance Scenarios**:

1. **Given** ≥ 1 agent exists, **When** `GET /api/v1/agents`, **Then** response 200, items sorted alphabetically by `name` (asc, case-insensitive), paginated with cursor.
2. **Given** a search term, **When** `GET /api/v1/agents?search=…`, **Then** results filtered by name using PostgreSQL `ILIKE '%term%'`.
3. **Given** agents with mixed-status contracts (active, completed, draft), **When** listing, **Then** `total_remaining_via_agent` and `total_collected_via_agent` sum only `active` and `completed` contracts; draft contracts contribute zero.
4. **Given** no agents exist, **When** `GET /api/v1/agents`, **Then** response 200 with empty `items` array and `has_more=false`.
5. **Given** an agent with zero contracts, **When** listing, **Then** the row still appears with `total_remaining_via_agent=0.00` and `total_collected_via_agent=0.00`.

---

### 3.3 User Story 3 — Agent Detail with Computed Profit and Referred Customers (Priority: P1)

The admin opens an individual agent's detail page. The response combines basic profile data, six financial aggregates (total_shares, total_installments_via_agent, total_collected_via_agent, total_remaining_via_agent, computed_profit), and a `referred_customers` array with each customer's name and financial summary.

**Why this priority**: This is the high-value drill-down view. It concentrates the four key agent stories (referral performance, share balance, investor profit, customer base) in one response.

**Independent Test**: Seed an agent with 2 referred customers, 3 contracts in mixed states, and 8 active shares. Verify: (a) `computed_profit` equals `SUM(total_after_profit − purchase_amount) × 0.05` over active+completed contracts, (b) draft contracts contribute zero profit, (c) `referred_customers` excludes customers the agent did not bring.

**Acceptance Scenarios**:

1. **Given** an agent exists, **When** `GET /api/v1/agents/{id}`, **Then** response 200 with basic data + 6 financial fields + `referred_customers` array + timestamps.
2. **Given** an agent with active+completed+draft contracts, **When** detail is fetched, **Then** `computed_profit` is computed over active and completed contracts only (draft excluded).
3. **Given** an agent with no contracts, **When** detail is fetched, **Then** `computed_profit=0.00`, `total_collected_via_agent=0.00`, `total_remaining_via_agent=0.00`, `referred_customers=[]`.
4. **Given** an agent whose share balance is 0 (all withdrawn), **When** detail is fetched, **Then** `total_shares=0` AND the agent still appears with the implicit investor status (the `investor` flag remains on the client).
5. **Given** a non-existent `{id}`, **When** detail is fetched, **Then** response 404 with `errors.agent.not_found` translation key.
6. **Given** an agent with ≥ 1 referred customer, **When** detail is fetched, **Then** `referred_customers` contains one entry per customer with `name`, `total_installments`, `total_paid`.

---

### 3.4 User Story 4 — Agent Profile Update (Priority: P2)

The admin corrects a typo in an agent's name, updates a phone number, or revises a description. The system applies the change without modifying any share-related data.

**Why this priority**: Correctness of contact information is routine maintenance; not as urgent as lifecycle operations.

**Independent Test**: Send `PUT` with various combinations of fields; verify only the supplied fields are changed; verify share log is untouched.

**Acceptance Scenarios**:

1. **Given** an existing agent, **When** `PUT /api/v1/agents/{id}` with updated `name`, **Then** response 200 with updated agent data; the share log table is not modified.
2. **Given** a new phone number already in use by another client, **When** updating, **Then** response 422 with `errors.agent.phone_unique` translation key (current client's own phone ignored).
3. **Given** the request body contains `shares_count` or any share-related field, **When** updating, **Then** those fields are ignored — share modification only happens through dedicated share endpoints.
4. **Given** a non-existent `{id}`, **When** updating, **Then** response 404.

---

### 3.5 User Story 5 — Share Movement Recording (Priority: P1)

The admin records a new share movement: either an `add` (the agent acquires more shares) or a `withdraw` (the agent returns shares). The system creates a new active row in the shares log. The first `add` permanently marks the client as an investor.

**Why this priority**: Share movement is the core write of the investor subsystem. The first-add-permanence rule is the cornerstone of investor identity.

**Independent Test**: Two scenarios in isolation:
- (a) First `add` of 1 share → active log row created, `investor` flag set on the client record.
- (b) Subsequent `add` and `withdraw` of any size → new active log row created, `investor` flag remains set; subsequent `withdraw` to bring balance to 0 → new active log row, `investor` flag still set.

**Acceptance Scenarios**:

1. **Given** an agent without the `investor` flag, **When** `POST /api/v1/agents/{id}/shares` with `action='add'`, `shares_count=3`, **Then** response 201, one new active log row, `investor` flag set on the client record.
2. **Given** an agent who is already an investor, **When** `POST …/shares` with `action='add'`, `shares_count=5`, **Then** response 201, new active log row, `investor` flag remains set (idempotent).
3. **Given** an agent with positive balance, **When** `POST …/shares` with `action='withdraw'`, `shares_count=2`, **Then** response 201, new active log row with `transaction_type='withdraw'`, balance reduced.
4. **Given** a withdrawal that brings balance to 0, **When** the operation completes, **Then** the `investor` flag remains set on the client (BR-001-6, BR-005-1).
5. **Given** `shares_count < 1` (zero or negative), **When** `POST …/shares`, **Then** response 422 with `validation.min.numeric` translation key.
6. **Given** `action` value is not `add` or `withdraw`, **When** `POST …/shares`, **Then** response 422 with field-specific validation error.
7. **Given** a non-existent agent `{id}`, **When** `POST …/shares`, **Then** response 404 with `errors.agent.not_found` translation key.

---

### 3.6 User Story 6 — Share Log Modification within 30-Day Window (Priority: P1)

The admin notices an error in the most recent share log entry and corrects it. The system updates the same row to reflect the new value and marks it `modified`. If the entry is not the most recent active, or if 30 days have elapsed since its `created_at`, the operation is rejected.

**Why this priority**: Correction is the only way to recover from data-entry errors. Bounded by the 30-day window so old records are frozen.

**Independent Test**: Three scenarios:
- (a) Modify most recent active record within 30 days → row updated, `status='modified'`, `shares_count` replaced.
- (b) Attempt to modify a record that is not the most recent active → 403.
- (c) Attempt to modify a record whose `created_at` is > 30 days ago → 403.

**Acceptance Scenarios**:

1. **Given** a most-recent active share log row within 30 days, **When** `PATCH /api/v1/agents/{id}/shares/{shareLogId}` with new `shares_count`, **Then** response 200, the *same* row updated in place: `shares_count` changed, `status='modified'`, `updated_at` set.
2. **Given** a share log row that is not the most recent active, **When** PATCH is attempted, **Then** response 403 with `errors/shares.not_latest` translation key.
3. **Given** a share log row whose `created_at` is > 30 days old, **When** PATCH is attempted, **Then** response 403 with `errors/shares.lock_period_expired` translation key.
4. **Given** a share log row in `status='modified'` or `status='deleted'`, **When** PATCH is attempted, **Then** response 403 (it is not the "most recent active").
5. **Given** a non-existent `{shareLogId}`, **When** PATCH is attempted, **Then** response 404.
6. **Given** `shares_count < 1`, **When** PATCH is attempted, **Then** response 422.

---

### 3.7 User Story 7 — Share Log Logical Deletion within 30-Day Window (Priority: P1)

The admin voids an erroneous share log entry. The system flips the `status` of the same row to `deleted` and updates `updated_at`. The row is never physically removed.

**Why this priority**: Voiding is a correction primitive. Logical-only deletion protects audit history (BR-005-7) and the append-only ledger invariant.

**Independent Test**: Three scenarios:
- (a) Delete most recent active record within 30 days → row status flips to `deleted`, row remains queryable in shares log.
- (b) Attempt to delete non-latest active → 403.
- (c) Attempt to delete a row > 30 days old → 403.

**Acceptance Scenarios**:

1. **Given** a most-recent active share log row within 30 days, **When** `DELETE /api/v1/agents/{id}/shares/{shareLogId}`, **Then** response 200 (or 204), the same row's `status='deleted'`, `updated_at` set; no physical DELETE statement runs.
2. **Given** a share log row that is not the most recent active, **When** DELETE is attempted, **Then** response 403 with `errors/shares.not_latest` translation key.
3. **Given** a share log row whose `created_at` is > 30 days old, **When** DELETE is attempted, **Then** response 403 with `errors/shares.lock_period_expired` translation key.
4. **Given** a row already in `status='deleted'`, **When** DELETE is attempted, **Then** response 403 (it is not "most recent active").
5. **Given** a non-existent `{shareLogId}`, **When** DELETE is attempted, **Then** response 404.

---

### 3.8 User Story 8 — Share Log History Read (Priority: P1)

The admin opens the agent's "Share Movements" tab and sees the full history of all share transactions, in reverse chronological order, including rows in `active`, `modified`, and `deleted` states.

**Why this priority**: History visibility is the audit primitive for the investor ledger.

**Independent Test**: Seed an agent with 5 share log rows (mix of `add`/`withdraw` and `active`/`modified`/`deleted`). Verify reverse-chronological order, full status visibility, and cursor pagination.

**Acceptance Scenarios**:

1. **Given** an agent with ≥ 1 share log row, **When** `GET /api/v1/agents/{id}/shares-log`, **Then** response 200 with reverse-chronological array, paginated.
2. **Given** rows of mixed statuses, **When** history is read, **Then** all rows appear including those with `status='modified'` and `status='deleted'`.
3. **Given** an agent with zero share log rows, **When** history is read, **Then** response 200 with empty `items` array and `has_more=false`.
4. **Given** a non-existent agent `{id}`, **When** history is read, **Then** response 404 with `errors.agent.not_found` translation key.

---

### 3.9 Agent Deletion Does Not Exist (Out-of-Band Story, Priority: P1)

There is no `DELETE /api/v1/agents/{id}` endpoint. Any attempt to delete an agent — by any code path — must throw a domain exception. This is constitutional (BR-001-1, Constitution §V).

**Why this priority**: It is the strongest invariant in the system. Without it, the immutability of the unified client entity is broken.

**Independent Test**: Programmatically call `Agent::destroy($id)` or `$agent->delete()` — must throw `ClientImmutableException`. The route table must not register any `DELETE` for the agents collection resource.

**Acceptance Scenarios**:

1. **Given** an agent, **When** any code path calls `$agent->delete()` or `Client::destroy($id)`, **Then** a `ClientImmutableException` is thrown and no database mutation occurs.
2. **Given** the API surface, **When** inspecting the route table, **Then** no `DELETE` route exists for `/api/v1/agents/{id}`.

---

### 3.10 Edge Cases

- **Agent with no contracts**: appears in list, appears in detail with zero financial aggregates, appears in shares log normally.
- **Agent with all shares withdrawn**: `total_shares=0`, but the `investor` flag remains on the client record. The agent still appears in any "investors" filter (a future capability, see Open Question OQ-1).
- **Two share log records with identical `created_at`**: 30-day lock must use the *latest* active record; with identical timestamps, the tie-breaker MUST be deterministic (the higher `id` wins). See OQ-2.
- **Modification attempted exactly on the 30th-day boundary**: 30 days is inclusive of the 30th calendar day; the boundary moment is defined as the same wall-clock instant 30 × 24 hours later than `created_at`. Confirmed by clarification 2026-06-15 Q3 (OQ-3 RESOLVED).
- **Withdrawal request that would yield negative balance**: the system **rejects with HTTP 422** (`shares.insufficient_balance`) and does NOT create a share log row. The projected balance is calculated as the current `total_shares` (sum of `active` rows' signed share counts) minus the requested withdrawal amount; if the result would be < 0, the request is refused. Boundary: a withdrawal that leaves the balance exactly at 0 is accepted. Source: BR-005-8 (added 2026-06-15).
- **Update endpoint receives `shares_count`**: silently ignored, never persisted, never returned in the response.
- **Search term in Arabic vs Latin characters**: search uses case-insensitive partial match against `name`; multilingual names follow database collation.
- **Agent whose name changes**: future sort order is affected. (Not a special case — the alphabetical list uses the current `name` value at query time.)
- **New share movement exactly 30 days after the last one**: the prior row is now frozen; the new row starts a new 30-day correction window.

---

## 4. Requirements *(mandatory)*

### 4.1 Functional Requirements

Each requirement ID has the form `SP-05-FR-NNN` and is traceable to a source document (FR = `02_01_FUNCTIONAL_REQUIREMENTS.md`, BR = `01_03_BUSINESS_RULES.md`, ALG = `05_ALGORITHMS/05_0X_*.md`).

#### Agent Lifecycle (FR-3.1, FR-3.2, FR-3.4 from `02_01`)

- **SP-05-FR-001**: System MUST provide `GET /api/v1/agents` returning a cursor-paginated list of clients whose `client_type_flags` contains `agent`, sorted alphabetically by `name` ascending. Source: FR-3.1.
- **SP-05-FR-002**: System MUST support filtering the agents list by a `search` query parameter using case-insensitive partial match against `name`. Source: FR-3.1 + BR-001-2.
- **SP-05-FR-003**: System MUST limit the list `per_page` to a maximum of 100 and default to 20. Source: `07_06_API_CONSTRAINTS.md` (pagination standard).
- **SP-05-FR-004**: System MUST provide `POST /api/v1/agents` that creates a new client record with `agent` added to `client_type_flags` (transactionally). Source: FR-3.2 + BR-001-5.
- **SP-05-FR-005**: System MUST accept `name`, `phone`, `description` (optional), and `shares_count` (optional, default 0, min 0) on agent creation. Source: FR-3.2.
- **SP-05-FR-006**: System MUST enforce phone uniqueness on agent creation (422 on duplicate). Source: FR-3.2 + `01_02` "shared data structure" + `06_08` `agent.phone_unique`.
- **SP-05-FR-007**: When `shares_count > 0` is provided at agent creation, System MUST, within the same transaction, (a) create an `active` row in `agent_shares_logs` with `transaction_type='add'` and `shares_count=…`, and (b) add `investor` to `client_type_flags` permanently. Source: FR-3.2 + BR-001-6 + ALG `05_01_ENTITY_INFERENCE.md` "Investor Flag".
- **SP-05-FR-008**: When `shares_count = 0` (or omitted), System MUST NOT create a share log row and MUST NOT add `investor` to flags. Source: FR-3.2.
- **SP-05-FR-009**: System MUST provide `GET /api/v1/agents/{id}` returning basic agent data plus six financial fields (`total_shares`, `total_installments_via_agent`, `total_collected_via_agent`, `total_remaining_via_agent`, `computed_profit`) and a `referred_customers` array. Source: FR-3.3.
- **SP-05-FR-010**: System MUST provide `PUT /api/v1/agents/{id}` that updates `name`, `phone`, `description` only — and MUST silently ignore any share-related field if present in the body. Source: FR-3.4.
- **SP-05-FR-011**: System MUST NOT expose any `DELETE /api/v1/agents/{id}` route, and any code-level attempt to delete a client MUST throw `ClientImmutableException`. Source: FR-3.4 (Note) + BR-001-1 + Constitution §V "NEVER delete any client entity".

#### Computed Fields on Agent Detail (FR-3.3 + ALG 5.1, 5.4)

- **SP-05-FR-012**: `total_shares` MUST equal `SUM(CASE WHEN transaction_type='add' THEN +shares_count ELSE -shares_count END)` over rows where `client_id=?` AND `status='active'`. Source: ALG 5.2 + BR-005-4.
- **SP-05-FR-013**: `computed_profit` MUST equal `SUM(total_after_profit − purchase_amount) × 0.05` over contracts where `agent_id=?` AND `status IN ('active', 'completed')`. Draft contracts MUST be excluded. Source: ALG 5.1 + BR-005-3.
- **SP-05-FR-014**: All financial arithmetic on the agent detail path MUST use PHP `bcmath` functions and MUST NOT use `float` / `double`. Source: BR-006-1, BR-006-2, Constitution §I "ALWAYS" (bcmath).
- **SP-05-FR-015**: `total_installments_via_agent` MUST equal `SUM(monthly_installment_amount × months)` over contracts where `agent_id=?` AND `status IN ('active', 'completed')`. Source: ALG 5.4.
- **SP-05-FR-016**: `total_collected_via_agent` MUST equal the sum of `amount` for installments of the agent's `active`/`completed` contracts whose `status='paid'`. Source: ALG 5.4 (derived).
- **SP-05-FR-017**: `total_remaining_via_agent` MUST equal the sum of `amount` for installments of the agent's `active`/`completed` contracts whose `status IN ('pending','overdue')`. Source: ALG 5.4 (derived).
- **SP-05-FR-018**: The `referred_customers` array MUST contain one entry per customer where `customer_id` is referenced from a contract with `agent_id=?` AND `status IN ('active','completed')`. Source: FR-3.3 ("referred_customers array").
- **SP-05-FR-019**: Each `referred_customers[i]` entry MUST include `name`, `total_installments` (sum of installment amounts), and `total_paid` (sum of paid installment amounts) for that customer's agent-referred contracts. Source: FR-3.3 + API contract `06_03`.
- **SP-05-FR-020**: If the agent has no referred customers, `referred_customers` MUST be an empty array. Source: API contract.

#### Share Movement Operations (FR-3.5..3.8)

- **SP-05-FR-021**: System MUST provide `POST /api/v1/agents/{id}/shares` accepting `action ∈ {'add','withdraw'}` and `shares_count` (integer ≥ 1) and creating a new `active` row in `agent_shares_logs`. Source: FR-3.5.
- **SP-05-FR-021a**: When `action='withdraw'`, the system MUST compute the projected balance as `current_total_shares − shares_count` (where `current_total_shares` is computed per SP-05-FR-012) and MUST reject the request with HTTP 422 (`shares.insufficient_balance`) when the projected balance would be < 0. No share log row is created on rejection. A projected balance of exactly 0 is accepted. Source: BR-005-8 (added 2026-06-15) + clarification session 2026-06-15 Q2.
- **SP-05-FR-022**: When `action='add'` is the first such operation for this client, System MUST, within the same transaction, add `investor` to `client_type_flags` permanently. Source: BR-001-6 + ALG 5.1 "Investor Flag" + FR-3.5.
- **SP-05-FR-023**: System MUST provide `PATCH /api/v1/agents/{id}/shares/{shareLogId}` that updates the *same* share log row in place: change `shares_count` to the supplied value, change `status` to `'modified'`, and update `updated_at`. Source: FR-3.6 + BR-005-6.
- **SP-05-FR-024**: System MUST reject PATCH with HTTP 403 (`errors/shares.not_latest`) when the target row is not the most recent `active` record for the agent. Source: BR-005-5 + `06_08`.
- **SP-05-FR-025**: System MUST reject PATCH with HTTP 403 (`errors/shares.lock_period_expired`) when the target row's `created_at` is more than 30 days before the current moment. Source: BR-005-5 + `06_08`.
- **SP-05-FR-026**: System MUST provide `DELETE /api/v1/agents/{id}/shares/{shareLogId}` that updates the *same* share log row in place: change `status` to `'deleted'` and update `updated_at`. No physical DELETE may occur. Source: FR-3.7 + BR-005-7 + AC-011.
- **SP-05-FR-027**: System MUST reject DELETE with HTTP 403 (`errors/shares.not_latest`) under the same conditions as PATCH. Source: BR-005-5.
- **SP-05-FR-028**: System MUST reject DELETE with HTTP 403 (`errors/shares.lock_period_expired`) under the same conditions as PATCH. Source: BR-005-5.
- **SP-05-FR-029**: System MUST provide `GET /api/v1/agents/{id}/shares-log` returning a cursor-paginated, reverse-chronological list of *all* share log rows for the agent, including those with `status ∈ {'modified','deleted'}`. Source: FR-3.8.

#### Data Minimization (Constitution §"NEVER", `07_06`)

- **SP-05-FR-030**: No agent API response (list, detail, create, update, shares operations) MAY contain the fields `client_type_flags` or `reference_number` in any form. Source: `07_06_API_CONSTRAINTS.md` "Data Minimization" + Constitution "NEVER" + BR-001-3.
- **SP-05-FR-031**: System MUST NOT accept `client_type_flags` as input on any agent endpoint; submitted values MUST be ignored or rejected (preferred: silently ignored; explicit rejection is acceptable — see OQ-4).

#### Flag-Update Transaction Safety (BR-001-4..6, AC-010)

- **SP-05-FR-032**: Every flag mutation triggered by an agent-domain operation (`agent`, `investor`) MUST occur within the same database transaction as the triggering write. Source: BR-001-5 + BR-001-6 + ALG 5.1 "Flag Update Rules" + AC-010.
- **SP-05-FR-033**: Once `investor` is added to `client_type_flags`, it MUST NEVER be removed by any code path, including: a withdrawal that zeros the balance, a delete of all share logs (which is not possible because the agent cannot be deleted and share log rows are never physically deleted), or any future flag-management operation. Source: BR-001-6 + AC-010.

#### Materialized View Refresh Trigger (existing artifact from SP-04 + spec note)

- **SP-05-FR-034**: The `RefreshCustomerListingJob` (existing artifact from SP-04) MUST be dispatched as a side-effect of an agent flag being added to a `clients.client_type_flags` value that did not previously contain it, when the new flag set also includes `customer` (i.e., when the operation mutates a row whose `customer` flag changes). This preserves consistency for clients who become agents as a side-effect of contract creation (which is owned by a future Spec). For operations that do NOT change the `customer` flag (pure agent/investor operations), the refresh is not strictly required, but the implementation MAY dispatch the job unconditionally for safety. Source: derived from SP-04 refresh trigger semantics + FR-4.1 in `02_01` (which describes contract creation as a flag-update trigger). See OQ-5 for clarification.

#### Architectural Compliance (Constitution §I, §II, §III)

- **SP-05-FR-035**: All agent endpoints MUST be registered under `/api/v1/agents*` and loaded by a dedicated `AgentServiceProvider` from `app/Domains/Agent/Routes/v1/api.php`. The central `routes/api.php` MUST remain empty. Source: Constitution §II + `07_01` "Domain Routing".
- **SP-05-FR-036**: All input validation MUST occur in a FormRequest class per endpoint. No validation logic may be in the Controller. Source: `07_07` "FormRequest mandatory" + Constitution §I.
- **SP-05-FR-037**: All responses MUST use the unified response shape via `ApiResponseHelper::success()` / `ErrorResponseHelper::*` helpers — no inline `response()->json()`. Source: `07_06` Unified Response Format.
- **SP-05-FR-038**: Resources extending `BaseResource` (or a `BaseAgentResource` for shared fields) are permitted to keep list/detail resources DRY. Source: `07_06` Resource Inheritance.
- **SP-05-FR-039**: All user-facing strings (success messages, error messages, validation messages, exception defaults) MUST be resolved via `__()` translation keys; no hardcoded Arabic or English strings in code. Source: `07_02` + `07_07`.
- **SP-05-FR-040**: All new domain exceptions MUST register a mapping in `app/Exceptions/ExceptionMappings.php`. Source: `07_01` Exception Mapping.

### 4.2 Non-Functional Requirements

#### Performance (per endpoint, derived from `03_03`)

| Endpoint | NFR ID | Target | Strategy |
|----------|--------|--------|----------|
| `GET /api/v1/agents` | **SP-05-NFR-P-001** | p95 ≤ 200ms for 10,000 agents | GIN index `idx_clients_type_flags` + btree `idx_clients_name` |
| `POST /api/v1/agents` | **SP-05-NFR-P-002** | p95 ≤ 300ms (single transaction: insert + flag update) | Single transaction; no fan-out |
| `GET /api/v1/agents/{id}` | **SP-05-NFR-P-003** | p95 ≤ 200ms (live query with computed aggregates) | Index-backed joins on `contracts(agent_id, status)` + `installments(contract_id, status)` |
| `PUT /api/v1/agents/{id}` | **SP-05-NFR-P-004** | p95 ≤ 200ms | Single row update + optional avatar file write |
| `POST /api/v1/agents/{id}/shares` | **SP-05-NFR-P-005** | p95 ≤ 200ms | Single insert + flag check (idempotent) |
| `PATCH /api/v1/agents/{id}/shares/{shareLogId}` | **SP-05-NFR-P-006** | p95 ≤ 200ms | Index `idx_shares_logs_client_status_created` for "latest active" lookup |
| `DELETE /api/v1/agents/{id}/shares/{shareLogId}` | **SP-05-NFR-P-007** | p95 ≤ 200ms | Same as PATCH |
| `GET /api/v1/agents/{id}/shares-log` | **SP-05-NFR-P-008** | p95 ≤ 200ms | `idx_shares_logs_client_id` for paginated history |

**General baseline**: every read endpoint ≤ 500ms (Constitution performance posture, `03_03`).

#### Security & Authorization (per endpoint, source `07_07`)

| Endpoint | NFR ID | Required Permission | Auth |
|----------|--------|---------------------|------|
| `GET /api/v1/agents` | **SP-05-NFR-S-001** | `agents.view` | `auth:sanctum` + Spatie permission |
| `POST /api/v1/agents` | **SP-05-NFR-S-002** | `agents.create` | `auth:sanctum` + Spatie permission |
| `GET /api/v1/agents/{id}` | **SP-05-NFR-S-003** | `agents.view` | `auth:sanctum` + Spatie permission |
| `PUT /api/v1/agents/{id}` | **SP-05-NFR-S-004** | `agents.update` | `auth:sanctum` + Spatie permission |
| `POST /api/v1/agents/{id}/shares` | **SP-05-NFR-S-005** | `agents.manage_shares` | `auth:sanctum` + Spatie permission |
| `PATCH /api/v1/agents/{id}/shares/{shareLogId}` | **SP-05-NFR-S-006** | `agents.manage_shares` | `auth:sanctum` + Spatie permission |
| `DELETE /api/v1/agents/{id}/shares/{shareLogId}` | **SP-05-NFR-S-007** | `agents.manage_shares` | `auth:sanctum` + Spatie permission |
| `GET /api/v1/agents/{id}/shares-log` | **SP-05-NFR-S-008** | `agents.view_shares_log` | `auth:sanctum` + Spatie permission |

Additional security mandates:

- **SP-05-NFR-S-009**: No mass-assignment shortcut. Every Model MUST declare an explicit `$fillable` whitelist. Source: `07_07` Mass Assignment Protection.
- **SP-05-NFR-S-010**: No credential, API key, or `.env`-sourced value in any source file. Source: `07_07` + Constitution "NEVER commit credentials".
- **SP-05-NFR-S-011**: All exception responses returned to the API client MUST be in the unified `{success, message, errors}` shape with translation-key messages. Source: `06_08` + `07_07`.
- **SP-05-NFR-S-012** (clarification 2026-06-15 Q5): Agent endpoints inherit the **project-wide authenticated rate-limit policy** (NFR-003-6 in `02_02_NON_FUNCTIONAL_REQUIREMENTS.md`, ratified in the Constitution ALWAYS section). The policy is enforced at the throttle-middleware layer, NOT inside SP-05 service code:
  - Read endpoints (`GET /api/v1/agents`, `GET /api/v1/agents/{id}`, `GET /api/v1/agents/{id}/shares-log`): **120 requests/minute per admin user**
  - Write endpoints (`POST /api/v1/agents`, `PUT /api/v1/agents/{id}`, `POST /api/v1/agents/{id}/shares`, `PATCH /api/v1/agents/{id}/shares/{shareLogId}`, `DELETE /api/v1/agents/{id}/shares/{shareLogId}`): **60 requests/minute per admin user**
  - Throttle key is the **admin user ID** (not IP), to support co-located admins behind shared NAT.
  - 429 responses use the `errors/general.too_many_requests` translation key (see `07_02_LOCALIZATION.md`).
  - This rule is project-wide; SP-05 spec is intentionally silent on implementation details (which is the SP-03 architecture concern).

#### Data Integrity (transactional boundaries)

- **SP-05-NFR-D-001**: Agent creation (`POST /api/v1/agents`) and any share-movement write (`POST /shares`, `PATCH /shares/{id}`, `DELETE /shares/{id}`) that triggers a flag mutation MUST be wrapped in a single `DB::transaction()`. Flag writes and the primary write commit together or both roll back. Source: `07_01` Service Layer Conventions + AC-010.
- **SP-05-NFR-D-001a**: The guard evaluation order on `POST /api/v1/agents/{id}/shares` MUST be: (1) target agent exists (`errors.agent.not_found` 404); (2) `action` enum + `shares_count ≥ 1` validation (422); (3) `action='withdraw'` → projected balance ≥ 0 (422 `shares.insufficient_balance`); (4) on first `add` for this client, `investor` flag mutation (transactional). This order produces the most informative error response for each failure mode. Source: clarification session 2026-06-15 Q2.
- **SP-05-NFR-D-002**: The `markAsInvestor()` invocation on the client record (when triggered) MUST use the regular `save()` lifecycle (events fire) — NEVER `saveQuietly()`. Source: Constitution "NEVER Using `saveQuietly()` for flag updates" + `07_01` Model Conventions.
- **SP-05-NFR-D-003**: The 30-day window MUST be computed at the moment of the request, using the application's configured timezone (`Asia/Damascus`). A row whose `created_at` is exactly 30 × 24 hours in the past is considered within the window (inclusive). Confirmed by clarification 2026-06-15 Q3 (OQ-3 RESOLVED).
- **SP-05-NFR-D-004**: The "most recent active record" for a given agent MUST be the row with the largest `created_at` AND `status='active'`. Ties on `created_at` MUST be broken by the largest `id` (deterministic). See OQ-2.
- **SP-05-NFR-D-005**: No physical DELETE statement may run against the `agent_shares_logs` table under any code path. Source: BR-005-7 + AC-011.
- **SP-05-NFR-D-006**: The PATCH and DELETE share-log endpoints MUST acquire a pessimistic row lock (`lockForUpdate()` in Eloquent, mapping to PostgreSQL `SELECT ... FOR UPDATE`) on the target share log row at the start of the transaction, before evaluating the "most recent active" and "30-day window" guards. Concurrent attempts on the same row MUST serialize: the second request blocks until the first commits, then re-evaluates the guards and is rejected with HTTP 403 if the row's status or age no longer satisfies them. Source: clarification session 2026-06-15 Q1, BR-005-5 + SP-05-FR-024..028.

---

## 5. Acceptance Criteria *(mandatory)*

Each criterion ID has the form `SP-05-AC-NNN` and is traceable to a BR/FR/NFR.

### 5.1 Happy Path Acceptance

- **SP-05-AC-001**: `GET /api/v1/agents` with no params returns HTTP 200, an `items` array of agents, and a `pagination` meta object; sorted alphabetically by `name` ASC. Validates: FR-3.1, SP-05-FR-001.
- **SP-05-AC-002**: `POST /api/v1/agents` with `{name, phone, shares_count: 5}` returns HTTP 201, an `agent` resource object, and (transactionally) creates one active `agent_shares_logs` row with `transaction_type='add'`. The created client record contains `investor` in `client_type_flags` (verified by `Client::find($id)->isInvestor() === true`). Validates: FR-3.2, SP-05-FR-007, BR-001-6, AC-010.
- **SP-05-AC-003**: `POST /api/v1/agents` with `{name, phone}` (no `shares_count`) returns HTTP 201 with an `agent` resource; no share log row is created; `isInvestor() === false`. Validates: SP-05-FR-008.
- **SP-05-AC-004**: `GET /api/v1/agents/{id}` for an agent with 2 active+completed contracts and 1 draft contract returns `computed_profit` = `(sum of total_after_profit − purchase_amount for active+completed) × 0.05`; draft contributes 0. Validates: SP-05-FR-013, BR-005-3.
- **SP-05-AC-005**: `GET /api/v1/agents/{id}` for an agent with mixed share log rows (some `add`, some `withdraw`, some `modified`, some `deleted`) returns `total_shares` = sum of `(+add, -withdraw)` over rows where `status='active'` only. Validates: SP-05-FR-012, BR-005-4.
- **SP-05-AC-006**: `GET /api/v1/agents/{id}` returns `referred_customers` containing one entry per distinct `customer_id` referenced by a non-draft contract with `agent_id={id}`. Each entry contains `name`, `total_installments`, `total_paid`. Validates: SP-05-FR-018, SP-05-FR-019.
- **SP-05-AC-007**: `PUT /api/v1/agents/{id}` with `{name: "new"}` returns HTTP 200 with the updated agent; the `agent_shares_logs` table is unchanged (zero modifications). Validates: SP-05-FR-010, FR-3.4.
- **SP-05-AC-008**: `POST /api/v1/agents/{id}/shares` with `{action: "add", shares_count: 3}` returns HTTP 201, creates one active log row, and adds `investor` to the client record (if not already present). Validates: SP-05-FR-021, SP-05-FR-022, FR-3.5, AC-010.
- **SP-05-AC-009**: ~~`POST /api/v1/agents/{id}/shares` with `{action: "withdraw", shares_count: 100}` that brings the balance negative STILL returns HTTP 201 (no business-rule refusal on negative balance).~~ **SUPERSEDED by SP-05-AC-009a (clarification 2026-06-15 Q2) — withdrawals that would yield a negative balance are now rejected with HTTP 422.**
- **SP-05-AC-010**: `PATCH /api/v1/agents/{id}/shares/{shareLogId}` for the most recent active row within 30 days returns HTTP 200; the *same* row's `shares_count` is changed, `status='modified'`, `updated_at` is set. Validates: SP-05-FR-023, BR-005-6.
- **SP-05-AC-011**: `DELETE /api/v1/agents/{id}/shares/{shareLogId}` for the most recent active row within 30 days returns HTTP 200 (or 204); the *same* row's `status='deleted'`, `updated_at` is set; the row still exists in the table. Validates: SP-05-FR-026, BR-005-7, AC-011.
- **SP-05-AC-012**: `GET /api/v1/agents/{id}/shares-log` returns the rows in reverse chronological order; the row updated in AC-010 and the row logically deleted in AC-011 BOTH appear with their respective statuses. Validates: SP-05-FR-029, FR-3.8.

### 5.2 Error Path Acceptance

- **SP-05-AC-013**: `POST /api/v1/agents` with a phone already in `clients` returns HTTP 422 and the body's `errors` object contains the `phone` field with the `agent.phone_unique` translation key. Validates: SP-05-FR-006, `06_08` `agent.phone_unique`.
- **SP-05-AC-014**: `GET /api/v1/agents/{nonexistent-id}` returns HTTP 404 and the body's `message` is the `agent.not_found` translation key. Validates: SP-05-FR-009 + `06_08`.
- **SP-05-AC-015**: `PATCH /api/v1/agents/{id}/shares/{shareLogId}` where the target row is NOT the most recent active row returns HTTP 403 with `message` = `errors/shares.not_latest`. Validates: SP-05-FR-024, BR-005-5, `06_08`.
- **SP-05-AC-016**: `PATCH /api/v1/agents/{id}/shares/{shareLogId}` where the target row's `created_at` is > 30 days before `now()` returns HTTP 403 with `message` = `errors/shares.lock_period_expired`. Validates: SP-05-FR-025, BR-005-5, `06_08`.
- **SP-05-AC-017**: `DELETE /api/v1/agents/{id}/shares/{shareLogId}` mirrors SP-05-AC-015 and SP-05-AC-016 with the same translation keys. Validates: SP-05-FR-027, SP-05-FR-028.
- **SP-05-AC-018**: `POST /api/v1/agents/{id}/shares` with `action="invalid_value"` returns HTTP 422 with a field-level validation error on `action`. Validates: SP-05-FR-021.
- **SP-05-AC-019**: `POST /api/v1/agents/{id}/shares` with `shares_count=0` returns HTTP 422 with `validation.min.numeric` (or equivalent) on `shares_count`. Validates: SP-05-FR-021.
- **SP-05-AC-009a** (clarification 2026-06-15 Q2, BR-005-8): `POST /api/v1/agents/{id}/shares` with `{action: "withdraw", shares_count: 100}` when the current `total_shares` is less than 100 returns HTTP 422 with `message = errors/shares.insufficient_balance`; **no share log row is created**; the share count table is unchanged; `isInvestor()` status is unchanged. Validates: SP-05-FR-021a, BR-005-8.
- **SP-05-AC-009b** (clarification 2026-06-15 Q2, BR-005-8 boundary case): `POST /api/v1/agents/{id}/shares` with `{action: "withdraw", shares_count: N}` where the current `total_shares` equals `N` exactly returns HTTP 201, creates one active log row, and the resulting balance is `0`. Validates: SP-05-FR-021a, BR-005-8.
- **SP-05-AC-020**: A request to a non-existent route `DELETE /api/v1/agents/{id}` returns HTTP 404. Validates: SP-05-FR-011, BR-001-1.

### 5.3 Data Integrity & Transaction Safety

- **SP-05-AC-021**: If the share-log insert in `POST /api/v1/agents/{id}/shares` fails for any reason (e.g., a forced DB error), the flag update on the client record is rolled back atomically — `isInvestor()` remains at its pre-request value. Validates: SP-05-NFR-D-001, AC-010.
- **SP-05-AC-022**: After any number of `add` and `withdraw` operations (including those that bring the balance to 0), the `investor` flag, once set, remains set on the client record. Validates: SP-05-FR-033, BR-001-6, BR-005-1, AC-010.
- **SP-05-AC-023**: After `PATCH` or `DELETE` of a share log row, the row's physical presence in the table is verified (a raw `SELECT count(*)` returns the same number as before; `status` reflects the new value). Validates: SP-05-FR-023, SP-05-FR-026, BR-005-6, BR-005-7, AC-011.
- **SP-05-AC-024**: A programmatic call `$agent->delete()` or `Client::destroy($id)` throws `ClientImmutableException` and produces zero database mutation (verified by row count). Validates: SP-05-FR-011, BR-001-1, AC-004.
- **SP-05-AC-025**: No `DELETE FROM agent_shares_logs` statement ever executes during a PATCH or DELETE on a share log row. Validates: SP-05-NFR-D-005, BR-005-7, AC-011.
- **SP-05-AC-039** (clarification 2026-06-15 Q1): When two PATCH (or two DELETE, or PATCH+DELETE) requests for the *same* share log row arrive within the same millisecond window, the system MUST serialize them: the first request commits successfully; the second request, after acquiring `lockForUpdate()`, MUST re-evaluate the "most recent active" and "30-day" guards and be rejected with HTTP 403 (`errors/shares.not_latest`) because the row's `status` is no longer `'active'`. Verified by a test that issues two parallel PATCH requests against the same row and asserts exactly one 200 and exactly one 403. Validates: SP-05-NFR-D-006, BR-005-5.

### 5.4 Data Minimization

- **SP-05-AC-026**: For every endpoint in §3 (the eight agent endpoints), the response payload, when serialized to JSON, MUST NOT contain a key named `client_type_flags` or `reference_number`. Validates: SP-05-FR-030, Constitution "NEVER Returning `client_type_flags`", `07_06` "Data Minimization".
- **SP-05-AC-027**: No agent endpoint accepts `client_type_flags` in the request body. A submitted value is ignored; no error is raised, no flag is mutated by direct submission. Validates: SP-05-FR-031.

### 5.5 Localization

- **SP-05-AC-028**: All error responses use translation keys resolved via `__()`; no hardcoded Arabic or English strings appear in any error or success response body. Validates: SP-05-FR-039, `07_02`, AC-019.
- **SP-05-AC-029**: All `errors` arrays in 422 validation responses use Laravel's standard `field` → `array of translated messages` shape, and the message strings come from `lang/ar/validation.php` or domain-specific translation files. Validates: `07_02` + `07_07`.

### 5.6 Performance (load-style tests)

- **SP-05-AC-030**: With 10,000 seeded agents, `GET /api/v1/agents` with default pagination completes in p95 ≤ 200ms (measured with 100 requests). Validates: SP-05-NFR-P-001.
- **SP-05-AC-031**: With an agent holding 50 active contracts and 500 installments, `GET /api/v1/agents/{id}` completes in p95 ≤ 200ms. Validates: SP-05-NFR-P-003.

### 5.7 Architecture Compliance

- **SP-05-AC-032**: The route file `routes/api.php` remains empty except for guidance comment. All `/api/v1/agents*` routes are loaded by an `AgentServiceProvider` from `app/Domains/Agent/Routes/v1/api.php`. Validates: SP-05-FR-035, Constitution §II.
- **SP-05-AC-033**: All Controller methods are thin (≤ 5 lines of orchestration). No business logic in Controllers. Validates: Constitution §I "No business logic inside Controller".
- **SP-05-AC-034**: All Models used by the agent domain declare an explicit `$fillable` whitelist. No `protected $guarded = ['*']`. Validates: SP-05-NFR-S-009.
- **SP-05-AC-035**: Every new domain exception thrown by agent services has a mapping in `app/Exceptions/ExceptionMappings.php`. Validates: SP-05-FR-040.

### 5.8 Test Coverage (Constitution §III)

- **SP-05-AC-036**: PHPUnit Unit Tests for all computed fields (`total_shares`, `computed_profit`, agent list aggregates) achieve 100% coverage. Validates: Constitution §III "Calculator tests must be pure Unit Tests".
- **SP-05-AC-037**: Feature Tests cover every endpoint with happy path + at least 2 error paths + at least 1 edge case. Validates: Constitution §III "Feature Tests for every API Endpoint".
- **SP-05-AC-038**: Overall test coverage ≥ 80% for the agent domain. Validates: Constitution §III "80% minimum".

---

## 6. Key Entities

(Entities are described *as business objects*, not as table schemas — the schema is defined in `04_01_TABLES.md`.)

- **Agent (sub-role of Client)**: A person who brings customers to the company. Identified by the `agent` flag in `client_type_flags`. Operates on the unified `clients` table (existing artifact from SP-03/SP-04). Cannot be created-or-deleted as a separate type — only created through the agents endpoint (which adds the flag) or contract creation (which adds the flag in a future Spec).
- **Investor (sub-role of Client)**: A person who is an agent AND has the `investor` flag set. The `investor` flag is permanent — set on first share addition (BR-001-6) and never removed.
- **Share Movement (AgentSharesLog row)**: A single historical event recording either an `add` or `withdraw` of N shares. Carries a `status` field of `active`/`modified`/`deleted`. The record is logically (not physically) mutable for a 30-day window after creation.
- **Referred Customer**: A projection — a customer (`customer` flag set) who appears in a contract with `agent_id` pointing to this agent and `status IN ('active','completed')`. Computed on demand for the agent detail response, not stored.

---

## 7. Success Criteria *(mandatory)*

Technology-agnostic, measurable, business-facing.

- **SC-001**: An admin can register a new agent and receive a successful response within 1 second of submission, regardless of whether shares are provided.
- **SC-002**: An admin can view the agent list and identify any specific agent within 2 seconds (search + paginated scan).
- **SC-003**: An admin can view an agent's full financial profile — share balance, computed investor profit, and referred customer list — in a single response, with all values reconciling to underlying contract and share-log data.
- **SC-004**: An erroneous share entry can be corrected (PATCH) or voided (DELETE) within the 30-day window, by any admin with `agents.manage_shares` permission, in under 1 second.
- **SC-005**: Once an agent has made at least one share addition, they are *permanently* recognized as an investor — verifiable by `isInvestor() === true` regardless of subsequent withdrawals.
- **SC-006**: The investor profit displayed on the agent detail page always equals 5% of `SUM(total_after_profit − purchase_amount)` over the agent's non-draft contracts — never derived from collections, never stored as a column.
- **SC-007**: No agent API response ever leaks the internal role flags or the paper-only reference number to the mobile client.
- **SC-008**: Under load (10,000 agents), the agent list and detail endpoints maintain p95 ≤ 200ms.
- **SC-009**: A withdrawal that zeros the share balance does NOT remove the investor flag, does NOT remove the agent from lists, and does NOT lose the historical share log.
- **SC-010**: 100% of acceptance criteria SP-05-AC-001..SP-05-AC-038 pass in the test suite.

---

## 8. Open Questions

These are ambiguities found in the source documents that may require explicit product-owner / lead-engineer clarification before or during implementation. They are tracked here rather than guessed.

| ID | Question | Source | Default if Unanswered |
|----|----------|--------|-----------------------|
| **OQ-1** | Is there a dedicated "Investors" listing endpoint, or is investor status implicitly derivable from the existing agent list (filter `client_type_flags @> '["investor"]'`)? `02_01` FR-3 covers only "List Agents". | `02_01` FR-3.1 | Treat as out-of-scope for SP-05; future Spec may add a filter. |
| **OQ-2** | When two share log rows share an identical `created_at`, how is "most recent active" determined? The source documents do not specify a tie-breaker. | `06_03` (implicit), BR-005-5 | Default: largest `id` wins (monotonic with insertion order). This is a SPEC-level decision; the implementation will follow. |
| **OQ-3** | ~~Is the 30-day window measured in calendar days (midnight-to-midnight) or in 24-hour durations?~~ **RESOLVED 2026-06-15 (clarification Q3)** — 24-hour duration, inclusive of the `created_at + 30 × 24h` exact instant. The boundary instant is treated as within the window. Confirmed against existing SP-05-NFR-D-003. | `06_03` (shares endpoints) | Resolved — see SP-05-NFR-D-003. |
| **OQ-4** | When `PUT /api/v1/agents/{id}` is sent with `client_type_flags` in the body, must the system reject with 422 or silently ignore? The source documents say "MUST NOT" return it in responses but do not say what to do on input. | `07_06` + Constitution "NEVER" | Default: silently ignore (consistent with PUT `shares_count` semantics in FR-3.4). |
| **OQ-5** | Which agent-domain operations exactly trigger `RefreshCustomerListingJob`? The source documents state the job is triggered by flag changes that affect the customer listing MV, but the precise trigger set is not enumerated. | SP-04 (existing artifact) + `02_01` FR-4.1 | Default: dispatch the job whenever a `client_type_flags` mutation occurs in the agent domain, regardless of whether `customer` is in the flag set. This is conservative; it does not miss any actual invalidation. |
| **OQ-6** | ~~When a withdrawal would result in a negative share balance, should the system (a) accept the negative result and let humans investigate, (b) reject with 422, or (c) reject with 409?~~ **RESOLVED 2026-06-15 (clarification Q2)** — system MUST reject with HTTP 422 (`shares.insufficient_balance`); no share log row is created. See SP-05-FR-021a, SP-05-AC-009a, SP-05-AC-009b, BR-005-8 (added in source documents 2026-06-15). | `02_01` FR-3.5, BR-005-8 (new) | Resolved — no default needed. |
| **OQ-7** | Should `total_installments_via_agent` in the list response be the same as in the detail response? `06_03` shows the list response includes only `total_remaining_via_agent` and `total_collected_via_agent`, while detail adds more fields. | `06_03` GET list vs detail | Default: list shows only the two fields shown in `06_03`; detail shows the full set. No contradiction. |
| **OQ-8** | Does the `avatar` field apply to agents? The source documents define `avatar` on the `clients` table but do not list it in the `POST /api/v1/agents` body in `06_03`. | `06_03` POST agents (no `avatar`) | Default: `avatar` is not in the create-agent body; if a future requirement surfaces, it can be added. Out of scope for SP-05. |
| **OQ-9** | What is the policy on negative `shares_count`? The CHECK constraint in `04_01` says `shares_count > 0`, and FR-3.5 says `min: 1`. Are these consistent? | `04_01` + `02_01` FR-3.5 | Yes — the column constraint and the form validation both enforce `> 0`/`min 1`. No conflict. (Recorded for completeness.) |

---

## 9. Assumptions

(Assumptions made to fill reasonable defaults; each is defensible from the source documents and the Constitution.)

- The `Client` model and `ClientFactory` already exist (SP-03) and expose `markAsAgent()`, `markAsInvestor()`, `isInvestor()`, and the immutability machinery.
- The `HasReferenceNumber` trait (SP-03) generates `CUS-XXXXXXXXXX` for clients, including agents. The reference number is generated at client creation (when the agent is created) and never changes.
- The `idx_shares_logs_client_status_created` index (SP-02) supports the "most recent active" lookup in O(1).
- Spatie permissions `agents.view`, `agents.create`, `agents.update`, `agents.manage_shares`, `agents.view_shares_log` are seeded in the `RolePermissionSeeder` (SP-04 established the pattern).
- `RefreshCustomerListingJob` (SP-04) accepts a client ID or operates system-wide; the SP-05 implementation will dispatch it at the same call sites used by SP-04.
- All financial aggregates use `bcmath` with 2 decimal places; precision is fixed at scale 2 for monetary values.
- The shares log table has a `CHECK (shares_count > 0)` constraint and a foreign key `client_id REFERENCES clients(id) ON DELETE RESTRICT`.
- The default `per_page` is 20 and the maximum is 100, consistent with `07_06` Pagination.
- The `errors/shares.not_latest` and `errors/shares.lock_period_expired` translation keys are present in `lang/ar/errors/shares.php` and `lang/en/errors/shares.php` (validation by the plan-phase to confirm).
- **Observability is intentionally minimal** (clarification 2026-06-15 Q4): no application-level logging of share movements or flag mutations beyond Laravel's default exception/error logging. The `agent_shares_logs` table is the authoritative audit trail for share movements (per BR-005-6/7); flag mutations are rare and captured by the JSONB column on `clients`. If operational telemetry becomes a requirement in the future, a new Spec can introduce Laravel Events + listeners without invalidating SP-05.
- A `BaseAgentResource` may be created to share common fields between the list resource and the detail resource; the spec permits this per `07_06` Resource Inheritance.

---

## 10. Out of Scope (for SP-05)

- Customer-facing read APIs (owned by SP-04).
- Contract creation, signing, deletion (owned by a future Spec — they currently exist as documented in `01_03` but have no implementation in this Spec).
- Payment recording, modification, deletion (future Spec).
- Analytics (future Spec).
- Notification templates (future Spec).
- "List investors" filtering — see OQ-1.
- Agent exports (PDF / Excel) — not mentioned in `02_01` or `06_03` for agents; customer exports exist (SP-04) but the agent equivalent is not specified.

---

## 11. Cross-Reference Summary

| This Spec Section | Primary Source |
|---|---|
| Problem Statement §1 | `01_02_DOMAIN_MODEL.md`, `01_03_BUSINESS_RULES.md` |
| Goals §2 | `01_03` BR-005-1..7, `02_01` FR-3.1..3.8 |
| User Stories §3 | `02_01` FR-3.1..3.8, `06_03_AGENTS_ENDPOINTS.md` |
| Functional Requirements §4.1 | `02_01` FR-3.1..3.8, `01_03` BR-005-1..7, `05_04` ALG 5.1/5.2/5.4 |
| Non-Functional Requirements §4.2 | `07_06`, `07_07`, `03_03` |
| Acceptance Criteria §5 | `02_03` AC-010, AC-011, `01_03` BR-001-1..7, BR-005-1..7 |
| Open Questions §8 | Internal inconsistencies found in `01_03`, `06_03`, `02_01` |
| Success Criteria §7 | `03_03` performance targets + business outcomes |

---

## 12. Clarifications

### Session 2026-06-15

- Q: Concurrency control on share-log PATCH/DELETE — what prevents two simultaneous modifications on the same share log row from both passing the "most recent active" guard? → A: Pessimistic row lock via `lockForUpdate()` inside the transaction; second writer blocks then re-evaluates the guards. (Integrated as SP-05-NFR-D-006 and SP-05-AC-039.)
- Q: Withdrawal policy when projected balance would be negative (OQ-6)? → A: Reject with HTTP 422 (`shares.insufficient_balance`); no share log row is created; boundary case (projected balance = exactly 0) is accepted. Guard evaluation order on `POST /shares` is (1) existence, (2) validation, (3) negative-balance, (4) flag mutation. New source rule BR-005-8. (Integrated as SP-05-FR-021a, SP-05-NFR-D-001a, SP-05-AC-009a/009b; OQ-6 marked RESOLVED.)
- Q: 30-day window boundary semantics (OQ-3) — calendar days or 24-hour durations? → A: 24-hour duration, inclusive of the `created_at + 30 × 24h` exact instant. Confirms existing SP-05-NFR-D-003 default. (Integrated; OQ-3 marked RESOLVED.)
- Q: Observability / logging requirements for the agent domain? → A: No additional application-level logging beyond Laravel's default exception/error logging. The `agent_shares_logs` table is the authoritative audit trail for share movements. (Integrated as Assumption note; future telemetry Spec can extend.)
- Q: Rate limiting strategy for agent endpoints? → A: Inherit the **project-wide authenticated rate-limit policy** (NFR-003-6): 120 req/min/admin for reads, 60 req/min/admin for writes, throttle key = admin user ID (not IP). This is enforced at the throttle-middleware layer (SP-03 architecture concern), not in SP-05 service code. SP-05 spec is silent on implementation; all future specs (SP-06..SP-11) inherit the same policy by reference. (Integrated as SP-05-NFR-S-012; no per-SP-05 rate-limit logic required.)
