diff --git a/.env.example b/.env.example index b4cf80c..0965faf 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,9 @@ JARVIS_PORT=8001 JARVIS_ENV=development JARVIS_STORE_PATH=data/jarvis-store.json JARVIS_CORS_ORIGINS=* -# Optional: when set, require Authorization: Bearer or X-API-Key + +# Auth — required by default. Pick ONE: +# Production / any exposed port: # JARVIS_API_KEY= +# Local loopback only (explicit opt-out): +JARVIS_ALLOW_UNAUTHENTICATED=1 diff --git a/README.md b/README.md index cfe1cf4..a3cf400 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - **Service identity:** `jarvis-memoryboard` / schema `continuity-ledger-v1` - **Default URL:** `http://127.0.0.1:8001` -This is **not** a claim of full CCS (Constitutional Continuity Service) readiness. See [docs/RELATIONSHIP_TO_MANDALA.md](docs/RELATIONSHIP_TO_MANDALA.md) and the [maturity scorecard](docs/scorecards/persistence-memory.md). +This is a **ledger of evidence records**, not associative/vector “memory.” See [docs/PLATFORM_LIMITS.md](docs/PLATFORM_LIMITS.md) and [docs/RELATIONSHIP_TO_MANDALA.md](docs/RELATIONSHIP_TO_MANDALA.md). Maturity: [docs/scorecards/persistence-memory.md](docs/scorecards/persistence-memory.md). ## Maturity (evidence-bound) @@ -14,10 +14,11 @@ This is **not** a claim of full CCS (Constitutional Continuity Service) readines |------------|--------|----------| | Continuity (A→B→C same restore) | **enforced** | `tests/test_acceptance.py::TestContinuityAcceptance` | | Replay (why/where/when/session) | **enforced** | `TestReplayAcceptance` + `/api/jarvis/memory/retrieve` | -| Conflict (no silent merge) | **enforced** | `TestConflictAcceptance` + `/api/jarvis/memory/conflicts` | -| Drift (hash fidelity) | **partial** | hash check enforced; multi-day protocol operator-owned | -| Optional API key | **enforced** | `tests/test_auth.py` when `JARVIS_API_KEY` set | +| Conflict (no silent merge) | **enforced** (cross-agent by `subject`) | `TestConflictAcceptance`; groups all agents — not per-agent isolation | +| Drift (hash fidelity) | **partial** | hash check enforced; multi-day semantic protocol operator-owned | +| API key | **enforced** (required by default) | `tests/test_auth.py`; opt-out `JARVIS_ALLOW_UNAUTHENTICATED=1` | | CCS / multi-product authority | **declared** | not implemented here | +| Multi-writer / HA store | **gap** | single JSON file; atomic write ≠ concurrent-safe | ### Drive-G-2 dimensions (summary) @@ -26,15 +27,17 @@ This is **not** a claim of full CCS (Constitutional Continuity Service) readines | Constitutional model | Moderate | | Governance methodology | Moderate | | Reference implementation | Moderate (local vertical slice + acceptance tests) | -| Platform engineering | Moderate (CI + Docker + optional auth; JSON file store, no HA) | +| Platform engineering | Moderate (CI + Docker + auth-by-default; JSON file store, no HA) | | Commercial operations | Not started | -Full table: `docs/scorecards/persistence-memory.md`. Operator deploy: `docs/OPERATOR_DEPLOY_CHECKLIST.md`. Clause V hygiene: `docs/CLAUSE_V_HYGIENE.md` (**partial** / not API-enforced). +Full table: `docs/scorecards/persistence-memory.md`. Limits: `docs/PLATFORM_LIMITS.md`. Deploy: `docs/OPERATOR_DEPLOY_CHECKLIST.md`. Clause V: `docs/CLAUSE_V_HYGIENE.md` (**partial**). -## Quick start +## Quick start (local) ```powershell python -m pip install -e ".[dev]" +# Local open auth (loopback only) — or set JARVIS_API_KEY instead: +$env:JARVIS_ALLOW_UNAUTHENTICATED = "1" python -m app # or uvicorn app.main:app --host 127.0.0.1 --port 8001 @@ -42,9 +45,10 @@ python -m pytest -q .\scripts\smoke-test.ps1 ``` -Docker: +Docker (set a key; do not rely on opt-out when publishing ports): ```bash +export JARVIS_API_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')" docker compose up --build -d ``` @@ -52,23 +56,34 @@ docker compose up --build -d | Method | Path | Purpose | |--------|------|---------| -| GET | `/health` | Liveness + schema | -| GET/POST/PATCH | `/api/jarvis/memory/board` | Board context | +| GET | `/health` | Liveness + schema (public) | +| GET/POST/PATCH | `/api/jarvis/memory/board` | Board context (UI slots ≠ write partitions) | | GET/POST | `/api/jarvis/memory` | List / create ledger rows | -| GET | `/api/jarvis/memory/retrieve` | Memories + selections + conflicts | -| GET | `/api/jarvis/memory/conflicts` | Conflict sets by subject | +| GET | `/api/jarvis/memory/retrieve` | Explicit filters + selections + conflicts | +| GET | `/api/jarvis/memory/conflicts` | Conflict sets by **subject** (all `source_agent`s) | | GET/PATCH/DELETE | `/api/jarvis/memory/{id}` | Row CRUD | Create requires Continuity Ledger fields: `content`, `source_agent`, `session_id`, `type`, plus optional `confidence`, `evidence`, `status`, `subject`, `supersedes`, `tags`. +Retrieval is **filter/query/id** — not embedding similarity. + Legacy on-disk rows (`category` / `truth_status` / …) migrate on load. +## Auth + +| Env | Role | +|-----|------| +| `JARVIS_API_KEY` | Required by default for protected routes | +| `JARVIS_ALLOW_UNAUTHENTICATED=1` | Explicit local-dev opt-out when no key | + +Details: [SECURITY.md](SECURITY.md). + ## Operator notes -- Set `JARVIS_API_KEY` when exposing beyond loopback ([SECURITY.md](SECURITY.md)). +- Prefer `JARVIS_API_KEY` over opt-out whenever the port may leave the machine. - Tighten `JARVIS_CORS_ORIGINS` in shared networks. - `JARVIS_ENV=production` disables uvicorn reload. -- Store path: `JARVIS_STORE_PATH` (atomic JSON writes). +- Store path: `JARVIS_STORE_PATH` (atomic JSON writes; single-writer assumption). ## License diff --git a/SECURITY.md b/SECURITY.md index d7a54bd..d699a8b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,9 +12,29 @@ Open a private security advisory on GitHub or contact the repository owner. Do not commit secrets, API keys, or production store dumps. +## Authentication (required by default) + +| Mode | Env | Behavior | +|------|-----|----------| +| **Default (secure)** | `JARVIS_API_KEY=` | Protected routes require `Authorization: Bearer ` or `X-API-Key` | +| **Local-dev opt-out** | `JARVIS_ALLOW_UNAUTHENTICATED=1` | Open routes when no key is set — **loopback / trusted host only** | +| **Misconfigured** | neither set | Protected routes return **401** (not open) | + +Public paths (no key): `/`, `/health`, `/docs`, `/openapi.json`, `/redoc`. + +If both are set, **`JARVIS_API_KEY` wins** — requests must present the key. + +Do **not** use the opt-out when the port is forwarded, bound on a shared network, or exposed via Docker/publish without another auth layer. + +Generate a key: + +```powershell +python -c "import secrets; print(secrets.token_hex(32))" +``` + ## Operator hardening (baseline) -1. Set `JARVIS_API_KEY` when binding beyond loopback. +1. Set `JARVIS_API_KEY` for any non-throwaway deployment (required by default). 2. Set `JARVIS_CORS_ORIGINS` to explicit origins (never `*` in shared networks). 3. Set `JARVIS_ENV=production` (disables uvicorn reload). 4. Persist `/data` (or `JARVIS_STORE_PATH`) on durable volume; never commit store files. @@ -22,3 +42,4 @@ Do not commit secrets, API keys, or production store dumps. 6. The ledger does **not** enforce multi-tenant isolation — one store per deployment. 7. Prefer `type=decision` (+ evidence) over chat dumps — Clause V hygiene is **partial** / not API-enforced (`docs/CLAUSE_V_HYGIENE.md`). 8. Follow `docs/OPERATOR_DEPLOY_CHECKLIST.md` before shared-network exposure. +9. Treat the JSON store as **single-writer** — atomic writes ≠ multi-writer safety (`docs/PLATFORM_LIMITS.md`). diff --git a/app/auth.py b/app/auth.py index c0227b4..11d4d96 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,7 +1,10 @@ -"""Optional API-key gate for operator deployments. +"""API-key gate for Continuity Ledger deployments. -When JARVIS_API_KEY is unset, all routes remain open (local/dev default). -When set, non-public routes require Bearer or X-API-Key. +Default (secure): JARVIS_API_KEY must be set; protected routes require +Authorization: Bearer or X-API-Key. + +Local-dev opt-out: set JARVIS_ALLOW_UNAUTHENTICATED=1 to serve without a key +(open auth). Do not use the opt-out on shared or port-forwarded hosts. """ from __future__ import annotations @@ -13,12 +16,19 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + def configured_api_key() -> str | None: key = (os.getenv("JARVIS_API_KEY") or "").strip() return key or None +def allow_unauthenticated() -> bool: + raw = (os.getenv("JARVIS_ALLOW_UNAUTHENTICATED") or "").strip().lower() + return raw in _TRUTHY + + def extract_presented_key(request: Request) -> str | None: auth = request.headers.get("Authorization") or "" if auth.lower().startswith("bearer "): @@ -33,16 +43,34 @@ def path_is_public(path: str) -> bool: return path in {"/", "/health", "/docs", "/openapi.json", "/redoc"} -class OptionalApiKeyMiddleware(BaseHTTPMiddleware): +class ApiKeyMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next) -> Response: + if path_is_public(request.url.path): + return await call_next(request) + expected = configured_api_key() - if expected is None or path_is_public(request.url.path): + if expected is not None: + presented = extract_presented_key(request) + if presented is None or not hmac.compare_digest(presented, expected): + return JSONResponse( + status_code=401, + content={"detail": "Invalid or missing API key"}, + ) + return await call_next(request) + + if allow_unauthenticated(): return await call_next(request) - presented = extract_presented_key(request) - if presented is None or not hmac.compare_digest(presented, expected): - return JSONResponse( - status_code=401, - content={"detail": "Invalid or missing API key"}, - ) - return await call_next(request) + return JSONResponse( + status_code=401, + content={ + "detail": ( + "API key required. Set JARVIS_API_KEY, or for local dev only " + "set JARVIS_ALLOW_UNAUTHENTICATED=1." + ) + }, + ) + + +# Backward-compatible alias (middleware renamed from optional → required-by-default). +OptionalApiKeyMiddleware = ApiKeyMiddleware diff --git a/app/continuity.py b/app/continuity.py index bd0974f..7c3922e 100644 --- a/app/continuity.py +++ b/app/continuity.py @@ -110,7 +110,12 @@ def detect_conflicts( *, subject: str | None = None, ) -> list[ConflictSet]: - """Surface disagreeing active memories sharing a subject. Never merges or picks truth.""" + """Surface disagreeing active memories sharing a subject. Never merges or picks truth. + + Grouping key is ``subject`` only — ``source_agent`` / ``session_id`` are NOT + partition keys. Cross-agent contradictory claims on the same subject are + compared (Codex vs Devin, etc.). There are no per-agent write slots. + """ groups: dict[str, list[MemoryRecord]] = {} for rec in records: if not rec.subject: diff --git a/app/main.py b/app/main.py index 1f4a0c8..c3c8e43 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from app.auth import OptionalApiKeyMiddleware +from app.auth import ApiKeyMiddleware from app.continuity import to_selection from app.models import ( BoardUpdate, @@ -36,7 +36,7 @@ allow_methods=["*"], allow_headers=["*"], ) -app.add_middleware(OptionalApiKeyMiddleware) +app.add_middleware(ApiKeyMiddleware) @app.get("/") diff --git a/app/models.py b/app/models.py index e4fa52d..ee92556 100644 --- a/app/models.py +++ b/app/models.py @@ -22,7 +22,8 @@ ] MemoryStatus = Literal["draft", "verified", "archived"] -# Board UI slots (unchanged; board is workspace context, not the ledger record) +# Board UI slots — workspace context / UX only. NOT ledger write partitions. +# Ledger rows live in one shared store; conflicts group by subject across agents. SlotClass = Literal["foundation", "identity", "preference", "operational"] diff --git a/app/store.py b/app/store.py index a7b1d05..9a21b9d 100644 --- a/app/store.py +++ b/app/store.py @@ -79,7 +79,12 @@ def _load(self): self._dirty_migration = False def _save(self): - """Atomic replace: write temp sibling then os.replace.""" + """Atomic replace: write temp sibling then os.replace. + + Atomicity prevents torn files; it does **not** serialize multi-writer + contention. Concurrent processes can still lose updates (last writer + wins). Single shared ledger — no per-agent write slots. + """ self._path.parent.mkdir(parents=True, exist_ok=True) data = { "board": self._board.model_dump(), diff --git a/docker-compose.yml b/docker-compose.yml index e3c0a47..de2ed6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,10 @@ services: JARVIS_PORT: "8001" JARVIS_STORE_PATH: /data/jarvis-store.json JARVIS_CORS_ORIGINS: ${JARVIS_CORS_ORIGINS:-http://localhost:3000} + # Required by default. For local compose without a key, set + # JARVIS_ALLOW_UNAUTHENTICATED=1 (not recommended when publishing ports). JARVIS_API_KEY: ${JARVIS_API_KEY:-} + JARVIS_ALLOW_UNAUTHENTICATED: ${JARVIS_ALLOW_UNAUTHENTICATED:-} volumes: - ledger-data:/data restart: unless-stopped diff --git a/docs/CONTINUITY_LEDGER_SOC.md b/docs/CONTINUITY_LEDGER_SOC.md index ec71220..0eddc2f 100644 --- a/docs/CONTINUITY_LEDGER_SOC.md +++ b/docs/CONTINUITY_LEDGER_SOC.md @@ -4,9 +4,9 @@ ## This package owns -- Persist board context + ledger records to JSON store -- Retrieve with selection provenance (why / where / when / session) -- Surface conflicts without silent merge +- Persist board context + ledger records to **one shared** JSON store +- Retrieve with selection provenance (why / where / when / session) via **explicit filters** (id, type, status, session, subject, text query) +- Surface conflicts without silent merge — grouped by **`subject` across all `source_agent`s** - Content hash (`content_sha256`) for drift checks (**partial** vs multi-day protocol) ## This package does not own @@ -15,9 +15,18 @@ - Knowledge / Understanding engines - Constitutional Continuity Service (CCS) as multi-product authority - Chat transcript archival (out of policy — store decisions/evidence) +- Embedding / vector / similarity retrieval (deliberate non-goal — this is a ledger) + +## Shared continuity vs slots + +- **Built:** single shared ledger (pattern **C**). Board `slots` are UI context only. +- **Conflict guarantee:** cross-agent by `subject` (not within-agent-only). +- **Not built:** per-agent write partitions. If added later, must stay pattern **A** (shared conflict read) — never isolated per-agent logs (**B**). See `docs/PLATFORM_LIMITS.md`. ## Non-goals - Ranking confidence as truth - Auto-resolving `supersedes` into deletion - Multi-tenant SaaS isolation +- Recall-by-similarity / associative “memory” +- Claiming multi-writer safety from atomic file replace diff --git a/docs/DRIFT_PROTOCOL.md b/docs/DRIFT_PROTOCOL.md index 60a68b7..10f1b57 100644 --- a/docs/DRIFT_PROTOCOL.md +++ b/docs/DRIFT_PROTOCOL.md @@ -1,8 +1,21 @@ # Drift protocol (operator) -**Status:** **partial** — hash fidelity is enforced in tests; multi-day monitoring is operator-owned. +**Status:** **partial** — hash fidelity is enforced in tests; multi-day semantic consistency is operator-owned. + +## What the system verifies 1. Capture a baseline row (or use `tests/fixtures/drift_baseline.json` pattern). 2. On later retrieve, compare `content_sha256` to the baseline hash of normalized content. 3. On mismatch: treat as continuity incident — do not silently rewrite; append a new record with `supersedes` if replacing intentionally. -4. Automated multi-day schedulers are **not** shipped in this package. + +This catches **tampering / corruption / accidental rewrite** of stored bytes. It does **not** prove that day-30 decisions still agree with day-1 intent. + +## What operators must own + +- Multi-day / multi-week semantic agreement across agents and sessions +- Scheduled retrieve + human (or consumer) review of open conflicts on critical subjects +- Incident response when hash matches but meaning has drifted (new contradictory posts on same `subject`) + +Automated multi-day schedulers are **not** shipped in this package. + +**Does this gap matter for unattended long-horizon agents?** Yes — if an agent fleet must stay consistent without an operator, hash checks alone are insufficient. Plan an external protocol or accept that continuity is “byte fidelity + conflict surfacing,” not “semantic lock.” diff --git a/docs/OPERATOR_DEPLOY_CHECKLIST.md b/docs/OPERATOR_DEPLOY_CHECKLIST.md index 3123b14..70147b1 100644 --- a/docs/OPERATOR_DEPLOY_CHECKLIST.md +++ b/docs/OPERATOR_DEPLOY_CHECKLIST.md @@ -2,7 +2,7 @@ > **Status:** complete — Implementor-filled 2026-07-30 (crew2 trail) > Evidence required per Drive-G-1 before marking any item "complete". -> Evidence basis: `app/` source, `tests/` suite (51 passed), `Dockerfile`, `docker-compose.yml`, `scripts/smoke-test.ps1`, `SECURITY.md`, `.env.example`. +> Evidence basis: `app/` source, `tests/` suite, `Dockerfile`, `docker-compose.yml`, `scripts/smoke-test.ps1`, `SECURITY.md`, `.env.example`, `docs/PLATFORM_LIMITS.md`. --- @@ -35,7 +35,8 @@ | `JARVIS_STORE_PATH` | Recommended | E.g. `data/jarvis-store.json` or absolute path on durable volume | | `JARVIS_ENV` | **Yes** | Set to `production` — disables uvicorn `--reload` | | `JARVIS_CORS_ORIGINS` | **Yes** | Set to actual origin(s), comma-separated; **not `*`** in production | - | `JARVIS_API_KEY` | Recommended | Generate with `python -c "import secrets; print(secrets.token_hex(32))"` — store out-of-band, never commit | + | `JARVIS_API_KEY` | **Yes** (default) | Generate with `python -c "import secrets; print(secrets.token_hex(32))"` — store out-of-band, never commit | + | `JARVIS_ALLOW_UNAUTHENTICATED` | Local only | Set `1` only for trusted loopback when no key; never with published ports | ### 1c. Store path and permissions @@ -52,7 +53,7 @@ - [ ] Run tests once from the deploy directory to confirm environment: ```powershell - python -m pytest -q # must return 51 passed + python -m pytest -q # all tests must pass ``` --- @@ -232,10 +233,12 @@ Invoke-RestMethod http://127.0.0.1:8001/api/jarvis/memory/board | Non-claim | Detail | Where documented | |-----------|--------|-----------------| | **TLS** | Service binds HTTP only; TLS is operator-managed via reverse proxy | `SECURITY.md §5`; §2 above | -| **HA / replication** | Single atomic-write JSON file (`os.replace`); not replicated, not HA | Scorecard "Platform engineering" | +| **HA / replication** | Single atomic-write JSON file (`os.replace`); not replicated, not HA; **not multi-writer safe** | `docs/PLATFORM_LIMITS.md` | +| **Per-agent write slots** | Not built; board UI `slots` ≠ partitions. Conflicts are **cross-agent by subject** on one store | `docs/PLATFORM_LIMITS.md` §0 | | **CCS root authority** | Clause V / Constitutional Continuity Service declared only; not enforced by this service | `docs/CONTINUITY_LEDGER_SOC.md` | | **Mandala constitutional runtime** | No Cursor hook infra, no CCS engine; see `docs/RELATIONSHIP_TO_MANDALA.md` | `docs/RELATIONSHIP_TO_MANDALA.md` | | **Multi-tenant / commercial** | Not started; no tenant isolation, billing, or signup flow | Scorecard "Commercial operations" | +| **Vector / similarity memory** | Deliberate non-goal — ledger filters only | `docs/CONTINUITY_LEDGER_SOC.md` | | **JARVIS_ENV=production** | Disables uvicorn `--reload` only; does not enable HA, TLS, or Postgres | `app/__main__.py`, `Dockerfile` | **Operator checklist before exposing to a network:** diff --git a/docs/PLATFORM_LIMITS.md b/docs/PLATFORM_LIMITS.md new file mode 100644 index 0000000..fed01bc --- /dev/null +++ b/docs/PLATFORM_LIMITS.md @@ -0,0 +1,72 @@ +# Platform limits (accepted architectural constraints) + +**Status:** known gaps — Moderate platform maturity; **not** HA. +**Review:** operator acceptance 2026-08-01 (crew/prod-readiness follow-up). + +These are intentional honesty bounds, not temporary blind spots. + +## 0. Shared store + conflict scope (A / B / C) + +**What was built: C — single shared store, no per-agent write slots** — with **cross-agent** conflict detection by `subject`. + +| Pattern | Built? | Notes | +|---------|--------|-------| +| **A** Partitioned writes + shared conflict read | **No** | Declared only if/when write slots are added — must keep global-by-subject conflicts | +| **B** Fully isolated per-agent logs | **No** | Would break multi-tool conflict detection; not the design | +| **C** Single shared JSON ledger | **Yes** | One `_memories` map; all agents write the same store | + +### Board `slots` ≠ write partitions + +`MemoryBoard.slots` / `BoardSlot` are **UI/workspace context** fields only (`app/models.py`). They do **not** partition ledger writes or scope retrieval. + +### What `/conflicts` actually compares + +- Groups active (non-archived, non-superseded) rows by **`subject`** +- Does **not** filter by `source_agent` or `session_id` +- Codex vs Devin contradictory claims on the same `subject` **are** surfaced together +- Evidence: `app/continuity.py` `detect_conflicts`; `tests/test_acceptance.py::test_conflict_surfaces_both_no_merge` (agent-a vs agent-b) + +**Honest guarantee:** conflict detection is **cross-agent by subject** on one shared store. Write contention across concurrent processes remains a platform gap (see §1). + +If future write-slot partitioning is added to reduce contention, it must remain pattern **A** (shared conflict/retrieve across slots) — never **B**. + +## 1. Single JSON file / single-writer + +| Claim | Reality | +|-------|---------| +| Atomic writes (`os.replace`) | **Yes** — prevent torn/corrupt files on crash mid-write | +| Multi-writer safety | **No** — concurrent agent instances can lose updates (last writer wins) | +| HA / cluster | **No** — one process, one file store | + +**Fit:** one agent / one machine (or carefully serialized writers). Concurrent writers are the **first thing that breaks**. + +Do **not** claim multi-writer serialization, optimistic locking, or distributed consistency. + +Evidence: `app/store.py` (`_save`), scorecard Platform engineering. + +## 2. Drift = partial + +| Layer | Status | +|-------|--------| +| `content_sha256` match | **enforced** — tampering / bit-rot / accidental rewrite fidelity | +| Multi-day semantic agreement (day-1 vs day-30) | **operator-owned protocol** — not system-verified | + +Unattended long-horizon agents that need “still agree with yourself next month” must run an external schedule + incident process (`docs/DRIFT_PROTOCOL.md`). Hash equality is necessary but not sufficient for semantic continuity. + +## 3. API key required by default + +| Mode | How | +|------|-----| +| Default (secure) | Set `JARVIS_API_KEY`; clients send Bearer or `X-API-Key` | +| Local-dev opt-out | `JARVIS_ALLOW_UNAUTHENTICATED=1` (open routes; loopback only) | + +See `SECURITY.md`, `app/auth.py`. + +## 4. Ledger, not associative / vector memory + +This service is a **Continuity Ledger**: explicit, queryable rows (id, filters, retrieve, conflicts). + +It deliberately does **not** provide embedding/similarity search or “recall by meaning.” +“Memory” in product language means **durable evidence records**, not associative retrieval. + +Non-goal evidence: `docs/CONTINUITY_LEDGER_SOC.md`. diff --git a/docs/RELATIONSHIP_TO_MANDALA.md b/docs/RELATIONSHIP_TO_MANDALA.md index 20961f6..e35c9a7 100644 --- a/docs/RELATIONSHIP_TO_MANDALA.md +++ b/docs/RELATIONSHIP_TO_MANDALA.md @@ -23,7 +23,7 @@ Hash compare of `app/*` (PM = SoT for platform hardening): | `continuity.py` | IDENTICAL | Selections / conflict helpers | | `store.py` | DRIFT | PM uses atomic `os.replace` temp write | | `main.py` | DRIFT | PM: dotenv, `OptionalApiKeyMiddleware`, distribution metadata, import-at-top for `to_selection` | -| `auth.py` | MISSING in Mandala | Optional API key — PM only | +| `auth.py` | MISSING in Mandala | PM: API key **required by default** + `JARVIS_ALLOW_UNAUTHENTICATED` opt-out | | `__main__.py` | DRIFT | PM: `JARVIS_ENV=production` disables reload | **Sync policy:** Prefer fixes in **persistence-memory**. Mandala sync is an optional later operator action (port atomic store, auth, prod reload gate). Do not weaken PM to match Mandala. diff --git a/docs/scorecards/persistence-memory.md b/docs/scorecards/persistence-memory.md index ff70d43..643e68f 100644 --- a/docs/scorecards/persistence-memory.md +++ b/docs/scorecards/persistence-memory.md @@ -9,8 +9,8 @@ |-------|-------| | Project ID | `persistence-memory` | | Repository path | `G:\persistence-memory` | -| Review date | `2026-07-30` | -| Reviewer | MRS crew2 (`persistence-memory-crew2-modes-2026-07`) | +| Review date | `2026-08-01` | +| Reviewer | MRS crew + operator review (auth default + four constraints) | | Evidence anchor | branch `crew/prod-readiness-2026-07` + pytest + Docker image `persistence-memory:crew2` | ## Dimension ratings @@ -19,8 +19,8 @@ |-----------|--------|------------------------| | Constitutional model | Moderate | Continuity Ledger schema + SoC docs; CCS / Clause V **declared/partial** only | | Governance methodology | Moderate | Scorecard + Mandala CECP trails; no runtime CES gates | -| Reference implementation | Moderate | FastAPI + 51 acceptance/API/auth tests; live smoke exercised | -| Platform engineering | Moderate | CI, Docker build verified locally, optional API key, atomic JSON; no HA/TLS/DB | +| Reference implementation | Moderate | FastAPI + acceptance/API/auth tests; cross-agent conflict by subject | +| Platform engineering | Moderate | CI, Docker, **API key required by default**; single JSON file — **not HA / not multi-writer safe** | | Commercial operations | Not started | No signup, billing, tenant isolation, SLAs | ## Evidence by dimension @@ -32,18 +32,18 @@ ### Governance methodology - **Claims:** Honest maturity tags; Drive-G-1 wording in README -- **Evidence:** this scorecard; trails `persistence-memory-prod-2026-07`, `persistence-memory-crew2-modes-2026-07` +- **Evidence:** this scorecard; trails `persistence-memory-prod-2026-07`, `persistence-memory-crew2-modes-2026-07`; `docs/PLATFORM_LIMITS.md` - **Gaps:** No promotion receipt automation in this repo ### Reference implementation -- **Claims:** Continuity / Replay / Conflict acceptance **enforced** -- **Evidence:** `tests/test_acceptance.py`, `python -m pytest -q` → 51 passed; `scripts/smoke-test.ps1` -- **Gaps:** Drift multi-day protocol operator-owned (**partial**) +- **Claims:** Continuity / Replay / Conflict acceptance **enforced**; conflicts are **cross-agent by subject** on one shared store +- **Evidence:** `tests/test_acceptance.py` (agent-a vs agent-b); `app/continuity.py::detect_conflicts`; `python -m pytest -q` +- **Gaps:** Drift multi-day semantic protocol operator-owned (**partial**); no embedding/similarity retrieval (**deliberate**) ### Platform engineering -- **Claims:** CI workflow, Dockerfile/compose, optional API key, atomic saves; Docker image build on this host -- **Evidence:** `.github/workflows/ci.yml`, `Dockerfile`, `docker build -t persistence-memory:crew2 .` (exit 0), `app/auth.py`, `app/store.py` -- **Gaps:** No Postgres/HA, no built-in TLS, single-file store; compose default image name needs `docker compose up --build` (or explicit image tag) +- **Claims:** CI, Dockerfile/compose, auth required-by-default (`JARVIS_ALLOW_UNAUTHENTICATED` opt-out), atomic saves +- **Evidence:** `.github/workflows/ci.yml`, `Dockerfile`, `app/auth.py`, `app/store.py`, `SECURITY.md` +- **Gaps:** No Postgres/HA, no built-in TLS, **single-file single-writer** (atomic ≠ multi-writer); board `slots` are UI only — not write partitions ### Commercial operations - **Claims:** none @@ -57,6 +57,15 @@ | Operators (deploy & run) | Partial | Viable for careful local/Docker deploy with API key + checklist | | Users (signup & self-serve) | Not ready | No product UX / tenancy | +## Conflict detection honesty + +| Scope | Status | +|-------|--------| +| Within one `source_agent` on same `subject` | **enforced** | +| Across agents/sessions on same `subject` | **enforced** (shared store; no agent filter) | +| Per-agent write slots / partitioned logs | **not built** (board UI slots ≠ partitions) | +| Multi-writer concurrent safety | **not enforced** (platform gap) | + ## Overall framing > **This project is** a Moderate Continuity Ledger reference service **at the constitutional/reference layer**, and Moderate **at the platform layer**, with commercial operations **not started**. It is **not** a bare claim of “production ready” across all dimensions. @@ -68,3 +77,6 @@ - Not multi-tenant SaaS - Not Mandala constitutional runtime - Not HA durable database-backed ledger +- Not multi-writer safe / not HA +- Not associative/vector memory +- Not per-agent isolated ledgers (conflicts are global-by-subject) diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 51939ce..577cb93 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -8,6 +8,8 @@ $Base = if ($env:JARVIS_MEMORYBOARD_URL) { $env:JARVIS_MEMORYBOARD_URL.TrimEnd(" $Headers = @{} if ($env:JARVIS_API_KEY) { $Headers["Authorization"] = "Bearer $($env:JARVIS_API_KEY)" +} elseif ($env:JARVIS_ALLOW_UNAUTHENTICATED -notin @("1", "true", "yes", "on")) { + Write-Warning "Neither JARVIS_API_KEY nor JARVIS_ALLOW_UNAUTHENTICATED=1 is set; protected routes will 401." } Write-Host "=== Continuity Ledger smoke test ===" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4b1c38b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared pytest fixtures. + +API/acceptance tests use the local-dev auth opt-out so they exercise ledger +behavior without configuring a key. Auth-required behavior is covered in +``tests/test_auth.py``. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _allow_unauthenticated_for_tests(monkeypatch): + # Clear production key unless a test sets its own; enable explicit opt-out. + monkeypatch.delenv("JARVIS_API_KEY", raising=False) + monkeypatch.setenv("JARVIS_ALLOW_UNAUTHENTICATED", "1") diff --git a/tests/test_auth.py b/tests/test_auth.py index e5d7771..024140c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,4 @@ -"""Optional API-key middleware tests.""" +"""API-key middleware tests (required-by-default + local opt-out).""" from __future__ import annotations @@ -12,10 +12,21 @@ from app.store import JarvisStore +def _payload(): + return { + "content": "Auth gate check", + "source_agent": "test", + "session_id": "sess-auth", + "type": "fact", + "confidence": 0.5, + "status": "draft", + } + + @pytest.fixture() def client_with_key(monkeypatch): monkeypatch.setenv("JARVIS_API_KEY", "test-secret-key") - # Re-import app after env so middleware sees key on each request via getenv + monkeypatch.delenv("JARVIS_ALLOW_UNAUTHENTICATED", raising=False) tmp = Path(tempfile.mktemp(suffix=".json")) store = JarvisStore(str(tmp)) from app.main import app @@ -24,15 +35,29 @@ def client_with_key(monkeypatch): yield TestClient(app) -def _payload(): - return { - "content": "Auth gate check", - "source_agent": "test", - "session_id": "sess-auth", - "type": "fact", - "confidence": 0.5, - "status": "draft", - } +@pytest.fixture() +def client_locked(monkeypatch): + """Neither key nor opt-out — protected routes must 401.""" + monkeypatch.delenv("JARVIS_API_KEY", raising=False) + monkeypatch.delenv("JARVIS_ALLOW_UNAUTHENTICATED", raising=False) + tmp = Path(tempfile.mktemp(suffix=".json")) + store = JarvisStore(str(tmp)) + from app.main import app + + with patch("app.main.get_store", return_value=store): + yield TestClient(app) + + +@pytest.fixture() +def client_opt_out(monkeypatch): + monkeypatch.delenv("JARVIS_API_KEY", raising=False) + monkeypatch.setenv("JARVIS_ALLOW_UNAUTHENTICATED", "1") + tmp = Path(tempfile.mktemp(suffix=".json")) + store = JarvisStore(str(tmp)) + from app.main import app + + with patch("app.main.get_store", return_value=store): + yield TestClient(app) def test_health_public_with_key_configured(client_with_key): @@ -66,3 +91,19 @@ def test_get_accepted_with_x_api_key(client_with_key): headers={"X-API-Key": "test-secret-key"}, ) assert resp.status_code == 200 + + +def test_default_rejects_without_key_or_opt_out(client_locked): + resp = client_locked.post("/api/jarvis/memory", json=_payload()) + assert resp.status_code == 401 + detail = resp.json()["detail"] + assert "JARVIS_API_KEY" in detail + assert "JARVIS_ALLOW_UNAUTHENTICATED" in detail + # Health remains public + assert client_locked.get("/health").status_code == 200 + + +def test_opt_out_allows_unauthenticated_local_dev(client_opt_out): + resp = client_opt_out.post("/api/jarvis/memory", json=_payload()) + assert resp.status_code == 200 + assert resp.json()["memory"]["id"].startswith("mem-")