--- phase: 14.1-cloud-local-file-parity-hardening plan: 02 type: execute wave: 2 depends_on: [14.1-01] files_modified: - backend/api/cloud/schemas.py - backend/services/cloud_items.py - backend/api/cloud/operations.py - backend/services/cloud_analysis.py - backend/api/cloud/analysis.py autonomous: true requirements: [CLOUD-02, ANALYZE-01, ANALYZE-05, ANALYZE-06, ANALYZE-07, CACHE-03, CACHE-05] must_haves: truths: - "An owner can fetch a cloud item detail with extracted text, analysis status, topics, and source metadata through an authorized endpoint" - "The cloud detail response never includes credentials_enc, object_key, version_key, or raw provider URLs" - "A foreign user gets 404 and an admin is blocked from cloud detail" - "Default analysis enqueue still skips already-current items; force=true re-queues them without mutating the provider" - "Failed-item retry with no surviving active job creates a single-item retry job through the authorized analysis path" - "Cloud detail resolution downloads zero provider bytes" artifacts: - path: "backend/api/cloud/schemas.py" provides: "CloudItemDetailOut schema (allowlist, analysis fields, no object_key/credentials_enc) and force field on AnalysisEnqueueRequest" contains: "class CloudItemDetailOut" - path: "backend/services/cloud_items.py" provides: "resolve_owned_cloud_item_detail — owner-scoped detail + topic resolution, raises ValueError/domain exception" contains: "resolve_owned_cloud_item_detail" - path: "backend/api/cloud/operations.py" provides: "GET cloud item detail route returning CloudItemDetailOut, metadata-only (no byte hydration)" contains: "/detail" - path: "backend/services/cloud_analysis.py" provides: "force re-analyze bypass of already_current and a single-item retry-job creation helper" contains: "force" - path: "backend/api/cloud/analysis.py" provides: "enqueue route passes force through; retry route falls back to single-item job creation" contains: "force" key_links: - from: "backend/api/cloud/operations.py" to: "backend/services/cloud_items.py" via: "detail route calls resolve_owned_cloud_item_detail" pattern: "resolve_owned_cloud_item_detail" - from: "backend/api/cloud/analysis.py" to: "backend/services/cloud_analysis.py" via: "enqueue route forwards force= to enqueue_analysis_job" pattern: "force=" --- Add the backend surface that makes cloud files behave like local documents: an owner-scoped cloud item detail endpoint that returns extracted text, analysis status, topics, and subtle source metadata through a strict credential-free schema; a force re-analyze flag that lets a user re-queue an already-current cloud item; and a single-item retry-job creation path so a failed item can be retried even when no active job survives. Purpose: Implements D-01..D-08, D-11, D-12, D-15, D-16 backend contracts so the frontend (Plan 03/04) can render parity without forking. Satisfies CLOUD-02 (authorized open/preview/view), ANALYZE-05 (retry), ANALYZE-06 (idempotency with explicit force override), ANALYZE-07 (no provider mutation), CACHE-03/CACHE-05 (bytes only via cache, owner-scoped). Output: CloudItemDetailOut schema, resolve_owned_cloud_item_detail service helper, GET detail route, force-aware enqueue, single-item retry-job path — all covered by Plan 01 tests turning GREEN. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/14.1-cloud-local-file-parity-hardening/14.1-CONTEXT.md @.planning/phases/14.1-cloud-local-file-parity-hardening/14.1-RESEARCH.md @CLAUDE.md @backend/api/cloud/schemas.py @backend/services/cloud_items.py @backend/api/cloud/operations.py @backend/services/cloud_analysis.py @backend/api/cloud/analysis.py @backend/db/models.py @backend/tests/test_cloud_detail_parity.py @backend/tests/test_cloud_reanalyze_force.py Task 1: CloudItemDetailOut schema + resolve_owned_cloud_item_detail service + GET detail route - backend/api/cloud/schemas.py (CloudItemOut, CloudCapabilityOut, CacheStatusOut allowlist style — model the new schema on these) - backend/services/cloud_items.py (resolve_owned_connection, list_cloud_children, ListingResult — reuse owner-scoping pattern; service raises ValueError/domain exception, never HTTPException) - backend/api/cloud/operations.py (open/preview/download routes, _resolve_and_get_adapter, get_regular_user dependency, parse_uuid usage, JSONResponse vs HTTPException convention) - backend/db/models.py (CloudItem fields: extracted_text, analysis_status, semantic_index_status, provider_size, content_type, modified_at, parent_ref, path_snapshot; CloudItemTopic ↔ Topic join; CloudConnection.provider/display_name) - backend/tests/test_cloud_detail_parity.py (the exact field names and forbidden tokens the route must satisfy) - .planning/phases/14.1-cloud-local-file-parity-hardening/14.1-CONTEXT.md (D-04, D-05, D-07, D-08, D-15, D-16, D-18) In backend/api/cloud/schemas.py add class CloudItemDetailOut(BaseModel) as an explicit allowlist modeled on CloudItemOut + CacheStatusOut. Fields (and ONLY these — no credentials_enc, object_key, version_key, provider URL, token, fingerprint): id (DocuVault UUID str), provider_item_id, name, kind, parent_ref, content_type, size, modified_at, etag, provider (connection provider string), display_name (connection display name), location (human path_snapshot or parent_ref, no provider URL), analysis_status, semantic_index_status, extracted_text (Optional[str]), topics (list[str] of topic names), capabilities (dict[str, CloudCapabilityOut]), unsupported_analysis_reason (Optional[str], populated from capabilities when analyze is unsupported per D-16), is_stale (bool derived from analysis_status == "stale" per D-07). Document at class top that object_key/credentials_enc are absent by design (mirror the T-14-02 docstring pattern). In backend/services/cloud_items.py add async def resolve_owned_cloud_item_detail(session, *, user_id, connection_id, provider_item_id) that: resolves the connection via resolve_owned_connection (owner check), selects the CloudItem by (connection_id, provider_item_id) scoped to user_id, raises a domain exception / ValueError when not found (router maps to 404), loads topic names via a CloudItemTopic→Topic join, and returns a plain dataclass/dict the router maps into CloudItemDetailOut. This helper performs metadata-only DB reads — it MUST NOT call adapter.get_object or hydrate_and_cache_bytes (T-14-04 / CACHE-03). In backend/api/cloud/operations.py register GET /connections/{connection_id}/items/{item_id:path}/detail with response_model=CloudItemDetailOut and dependency get_regular_user (admin blocked); parse connection_id with parse_uuid; call resolve_owned_cloud_item_detail; on the domain not-found exception raise HTTPException(404) (router layer translates, per CLAUDE.md service-vs-router rule); never decrypt or expose credentials. Do not add a parallel detail router file — keep it on the existing operations router so it shares the /api/cloud prefix. cd backend && python -m pytest tests/test_cloud_detail_parity.py -x 2>&1 | tail -20 - `grep -c 'class CloudItemDetailOut' backend/api/cloud/schemas.py` returns 1. - CloudItemDetailOut has no forbidden fields: `grep -E 'object_key|credentials_enc|version_key' backend/api/cloud/schemas.py | grep -A0 -i detail` is empty within the class body (manual confirm the class block excludes them). - `grep -c 'resolve_owned_cloud_item_detail' backend/services/cloud_items.py` returns >= 1 (definition present). - The detail route exists: `grep -F '/detail' backend/api/cloud/operations.py` returns a route line with `response_model=CloudItemDetailOut`. - The detail service does not hydrate bytes: `grep -n 'get_object\|hydrate_and_cache_bytes' backend/services/cloud_items.py` shows no new call inside resolve_owned_cloud_item_detail. - test_cloud_detail_parity.py passes: owner gets fields, foreign user 404, admin 403, response has no forbidden tokens, stale preserves extracted_text/topics. Cloud detail endpoint returns analysis fields + source metadata for the owner through a credential-free schema, blocks foreign user/admin, and downloads no bytes; Plan 01 detail tests pass. Task 2: Force re-analyze flag + single-item retry-job creation - backend/services/cloud_analysis.py (enqueue_analysis_job signature, already_current branch ~line 546-660, retry_job_item ~line 1049, _check_already_current, EnqueueResult) - backend/api/cloud/schemas.py (AnalysisEnqueueRequest — add force; AnalysisEnqueueOut) - backend/api/cloud/analysis.py (enqueue_job route ~line 137, retry route, _build_job_out, AnalysisControlOut) - backend/tests/test_cloud_reanalyze_force.py (exact force=true and single-item retry assertions) - .planning/phases/14.1-cloud-local-file-parity-hardening/14.1-CONTEXT.md (D-11, D-12) - .planning/phases/14.1-cloud-local-file-parity-hardening/14.1-RESEARCH.md (Forced Re-Analyze Pattern section) Add force: bool = Field(default=False) to AnalysisEnqueueRequest in backend/api/cloud/schemas.py (default preserves existing idempotency). Extend enqueue_analysis_job in backend/services/cloud_analysis.py to accept force: bool = False; when force is True, skip the already_current branch so supported items are created as queued job items even if their version_key matches an indexed item — the already_current check becomes (already_current and not force). Forced enqueue must still: validate owner/connection/item and unsupported type (unsupported items stay unsupported), compute version_key/fingerprint normally, and create queued items only — it MUST NOT call any provider mutation (no rename/move/delete) and MUST NOT bypass the cache lifecycle; processing remains process_cloud_analysis_item + hydrate_and_cache_bytes (ANALYZE-07, CACHE-03). Wire force through the enqueue_job route in backend/api/cloud/analysis.py: pass body.force into enqueue_analysis_job(...). For D-12, add an owner-scoped single-item retry path: when a failed item cannot be retried within an existing job (no surviving active job, or retry_job_item raises AnalysisJobNotFound for that cloud_item_id), create a one-item analysis job for that cloud_item_id via the existing enqueue_analysis_job(scope="file", provider_item_ids=[provider_item_id], force=True) so a fresh queued item is produced through the authorized path. Implement this fallback either in the retry route handler (catch the not-found/invalid-state domain exception and create the single-item job) or as a thin service helper (e.g. retry_or_create_single_item_job) in cloud_analysis.py — choose the service helper if both the route and tests need it. Keep all aggregate counters consistent and return the existing typed result schemas (AnalysisControlOut / AnalysisEnqueueOut). Do not raise HTTPException from the service layer — raise domain exceptions and translate in the route. cd backend && python -m pytest tests/test_cloud_reanalyze_force.py tests/test_cloud_analysis_contract.py -x 2>&1 | tail -20 - `grep -c 'force' backend/api/cloud/schemas.py` shows force added to AnalysisEnqueueRequest (>= 1 occurrence near the class). - enqueue_analysis_job accepts force: `grep -n 'def enqueue_analysis_job' backend/services/cloud_analysis.py` and the signature/body reference force. - The already_current branch respects force: `grep -n 'already_current and not force\|not force' backend/services/cloud_analysis.py` returns >= 1. - The enqueue route forwards force: `grep -F 'force=' backend/api/cloud/analysis.py` returns >= 1 (or body.force passed positionally is visible). - Single-item retry fallback exists: `grep -nE 'retry_or_create_single_item_job|scope="file"|single' backend/services/cloud_analysis.py backend/api/cloud/analysis.py` shows the fallback path. - test_cloud_reanalyze_force.py passes: default skips already-current, force re-queues, no provider mutation, single-item retry yields one queued item. test_cloud_analysis_contract.py still passes (no regression in default idempotency). force=true re-queues already-current items without provider mutation, default enqueue is unchanged, and failed items can be retried via a single-item job when no active job exists; Plan 01 force/retry tests pass with no idempotency regression. ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser → GET detail | connection_id/provider_item_id untrusted; must resolve under current_user.id only | | browser → POST enqueue (force) | force is a user-driven flag; must not enable cross-user enqueue or provider mutation | | service → DB | metadata-only reads; no byte hydration during detail | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-14.1-03 | Information Disclosure | CloudItemDetailOut | mitigate | Allowlist schema excludes credentials_enc/object_key/version_key/provider URL; Plan 01 no-leak test enforces | | T-14.1-04 | Elevation of Privilege | detail route + force enqueue | mitigate | get_regular_user (admin 403) + resolve under current_user.id (foreign 404); tests enforce | | T-14.1-05 | Tampering | forced re-analyze | mitigate | Force only re-queues analysis; processing path unchanged (no rename/move/delete adapter call); ANALYZE-07 test enforces | | T-14.1-06 | Information Disclosure | detail byte access | mitigate | Detail resolution is metadata-only; never calls get_object/hydrate_and_cache_bytes (CACHE-03) | | T-14.1-SC | Tampering | npm/pip installs | accept | No package installs (RESEARCH Package Legitimacy Audit: none) | - `cd backend && python -m pytest tests/test_cloud_detail_parity.py tests/test_cloud_reanalyze_force.py -x` passes. - `cd backend && python -m pytest tests/test_cloud_analysis_contract.py tests/test_cloud_security.py -x` passes (no regression). - `cd backend && python -m pytest -q 2>&1 | tail -5` — full backend suite passes. - CloudItemDetailOut exists as a credential-free allowlist with analysis fields + source metadata. - resolve_owned_cloud_item_detail resolves owner-scoped detail + topics with zero byte hydration. - GET detail route is owner-scoped (foreign 404, admin 403). - force=true re-queues already-current items; default enqueue unchanged; no provider mutation. - Single-item retry job creation works when no active job survives. ## Artifacts this phase produces (Plan 02) - Class: `CloudItemDetailOut` (backend/api/cloud/schemas.py) — credential-free cloud detail response with extracted_text, analysis_status, semantic_index_status, topics, provider, display_name, location, capabilities, unsupported_analysis_reason, is_stale. - Field: `force: bool` on `AnalysisEnqueueRequest` (backend/api/cloud/schemas.py). - Function: `resolve_owned_cloud_item_detail(session, *, user_id, connection_id, provider_item_id)` (backend/services/cloud_items.py). - Route: `GET /api/cloud/connections/{connection_id}/items/{item_id:path}/detail` → CloudItemDetailOut (backend/api/cloud/operations.py). - Parameter: `force: bool = False` on `enqueue_analysis_job` (backend/services/cloud_analysis.py). - Function/path: single-item retry-job creation fallback (e.g. `retry_or_create_single_item_job`) in backend/services/cloud_analysis.py and/or the retry route in backend/api/cloud/analysis.py. Create `.planning/phases/14.1-cloud-local-file-parity-hardening/14.1-02-SUMMARY.md` when done