diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index d68e914..7fa1699 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -66,10 +66,11 @@
3. GraphQL response normalization transforms Unraid API shape to workflow contract
4. GraphQL error handling standardized (checks response.errors[], handles HTTP 304)
5. Timeout configuration accounts for myunraid.net cloud relay latency (200-500ms)
-**Plans**: TBD
+**Plans**: 2 plans
Plans:
-- [ ] 15-01: TBD
+- [ ] 15-01-PLAN.md — Container ID Registry and Callback Token Encoding/Decoding
+- [ ] 15-02-PLAN.md — GraphQL Response Normalizer, Error Handler, and HTTP Template
#### Phase 16: API Migration
**Goal**: All container operations work via Unraid GraphQL API
@@ -132,7 +133,7 @@ Phases execute in numeric order: 1-14 (complete) → 15 → 16 → 17 → 18
| 12 | Polish & Audit | v1.2 | 2/2 | Complete | 2026-02-08 |
| 13 | Documentation Overhaul | v1.2 | 1/1 | Complete | 2026-02-08 |
| 14 | Unraid API Access | v1.3 | 2/2 | Complete | 2026-02-08 |
-| 15 | Infrastructure Foundation | v1.4 | 0/? | Not started | - |
+| 15 | Infrastructure Foundation | v1.4 | 0/2 | Not started | - |
| 16 | API Migration | v1.4 | 0/? | Not started | - |
| 17 | Cleanup | v1.4 | 0/? | Not started | - |
| 18 | Documentation | v1.4 | 0/? | Not started | - |
@@ -140,4 +141,4 @@ Phases execute in numeric order: 1-14 (complete) → 15 → 16 → 17 → 18
**Total: 4 milestones shipped (14 phases, 50 plans), v1.4 in progress (4 phases)**
---
-*Updated: 2026-02-09 — v1.4 Unraid API Native roadmap created*
+*Updated: 2026-02-09 — Phase 15 planned (2 plans)*
diff --git a/.planning/phases/15-infrastructure-foundation/15-01-PLAN.md b/.planning/phases/15-infrastructure-foundation/15-01-PLAN.md
new file mode 100644
index 0000000..ade7c12
--- /dev/null
+++ b/.planning/phases/15-infrastructure-foundation/15-01-PLAN.md
@@ -0,0 +1,243 @@
+---
+phase: 15-infrastructure-foundation
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - n8n-workflow.json
+autonomous: true
+
+must_haves:
+ truths:
+ - "Container ID Registry maps container names to Unraid PrefixedID format"
+ - "Callback token encoder produces 8-char tokens from PrefixedIDs"
+ - "Callback token decoder resolves 8-char tokens back to PrefixedIDs"
+ - "Token collisions are detected and handled"
+ - "Registry and token store persist across workflow executions via static data JSON serialization"
+ artifacts:
+ - path: "n8n-workflow.json"
+ provides: "Container ID Registry, Callback Token Encoder, Callback Token Decoder utility nodes"
+ contains: "_containerIdMap"
+ key_links:
+ - from: "Container ID Registry node"
+ to: "static data _containerIdMap"
+ via: "JSON.parse/JSON.stringify pattern"
+ pattern: "JSON\\.parse\\(.*_containerIdMap"
+ - from: "Callback Token Encoder node"
+ to: "static data _callbackTokens"
+ via: "SHA-256 hash + JSON serialization"
+ pattern: "crypto\\.subtle\\.digest"
+ - from: "Callback Token Decoder node"
+ to: "static data _callbackTokens"
+ via: "JSON.parse lookup"
+ pattern: "_callbackTokens"
+---
+
+
+Build the Container ID Registry and Callback Token Encoding system as utility Code nodes in the main workflow.
+
+Purpose: Phase 16 (API Migration) needs to translate between container names and Unraid PrefixedIDs (129-char format like `server_hash:container_hash`). The registry provides centralized ID translation, and the token system compresses PrefixedIDs to 8-char tokens for Telegram callback_data (64-byte limit). These nodes are not wired into the active flow yet -- they are standalone utilities that Phase 16 will connect.
+
+Output: Three new Code nodes in n8n-workflow.json: Container ID Registry, Callback Token Encoder, Callback Token Decoder.
+
+
+
+@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
+@/home/luc/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@ARCHITECTURE.md
+@.planning/phases/15-infrastructure-foundation/15-RESEARCH.md
+@.planning/phases/14-unraid-api-access/14-02-SUMMARY.md
+@.planning/phases/11-update-all-callback-limits/11-01-SUMMARY.md
+
+
+
+
+
+ Task 1: Add Container ID Registry utility node
+ n8n-workflow.json
+
+Add a new Code node named "Container ID Registry" to n8n-workflow.json. This is a **standalone utility node** -- do NOT connect it to any existing nodes. Place it at position [200, 2400] (below the active workflow area, in an "infrastructure utilities" zone).
+
+The node must implement:
+
+1. **Registry initialization** using static data with JSON serialization pattern (CRITICAL: must use top-level assignment per CLAUDE.md):
+```javascript
+const registry = $getWorkflowStaticData('global');
+if (!registry._containerIdMap) {
+ registry._containerIdMap = JSON.stringify({});
+}
+const containerMap = JSON.parse(registry._containerIdMap);
+```
+
+2. **updateRegistry(containers)** function that:
+ - Takes an array of Unraid GraphQL container objects (format: `{ id, names[], state }`)
+ - Maps each container: name (from `names[0]`, strip leading `/`, lowercase) -> `{ name, unraidId: id }`
+ - The Unraid `id` field IS the PrefixedID (129-char `server_hash:container_hash` format per ARCHITECTURE.md)
+ - Stores timestamp: `registry._lastRefresh = Date.now()`
+ - Serializes: `registry._containerIdMap = JSON.stringify(newMap)`
+ - Returns the new map
+
+3. **getUnraidId(containerName)** function that:
+ - Strips leading `/`, lowercases input
+ - Looks up in containerMap
+ - If not found AND registry older than 60 seconds: throws `Container "${name}" not found. Registry may be stale - try "status" to refresh.`
+ - If not found AND registry fresh: throws `Container "${name}" not found. Check spelling or run "status" to see all containers.`
+ - Returns `entry.unraidId`
+
+4. **getContainerByName(containerName)** function that:
+ - Same lookup as getUnraidId but returns the full entry object
+ - Returns `{ name, unraidId }`
+
+5. **Node input/output contract:**
+ - Input: `$input.item.json.containers` (array) for registry updates, OR `$input.item.json.containerName` (string) for lookups
+ - Detect mode: if `containers` array exists, update registry. If `containerName` exists, do lookup.
+ - Output: Updated map (for updates) or `{ containerName, unraidId }` (for lookups)
+
+Node properties:
+- id: "code-container-id-registry"
+- name: "Container ID Registry"
+- type: "n8n-nodes-base.code"
+- typeVersion: 2
+- Do NOT add any connections for this node.
+- Add `notesDisplayMode: "show"` and `notes: "UTILITY: Container name <-> Unraid PrefixedID translation. Not connected - Phase 16 will wire this in."` to parameters.
+
+
+Run: `python3 -c "import json; wf=json.load(open('n8n-workflow.json')); nodes={n['name']:n for n in wf['nodes']}; r=nodes.get('Container ID Registry'); print('EXISTS' if r else 'MISSING'); print('ID:', r['id'] if r else 'N/A'); print('Has _containerIdMap:', '_containerIdMap' in r['parameters'].get('jsCode','') if r else False); print('Has JSON.parse:', 'JSON.parse' in r['parameters'].get('jsCode','') if r else False); print('Has JSON.stringify:', 'JSON.stringify' in r['parameters'].get('jsCode','') if r else False)"`
+
+ Container ID Registry Code node exists in n8n-workflow.json with updateRegistry() and getUnraidId() functions, uses JSON serialization for static data persistence, is not connected to any other nodes.
+
+
+
+ Task 2: Add Callback Token Encoder and Decoder utility nodes
+ n8n-workflow.json
+
+Add two new Code nodes to n8n-workflow.json for callback token encoding/decoding. These are **standalone utility nodes** -- do NOT connect them to any existing nodes. Place them at positions [600, 2400] and [1000, 2400].
+
+**Node 1: Callback Token Encoder**
+
+id: "code-callback-token-encoder"
+name: "Callback Token Encoder"
+Position: [600, 2400]
+
+Implement:
+
+1. **Token store initialization** using static data JSON serialization:
+```javascript
+const staticData = $getWorkflowStaticData('global');
+if (!staticData._callbackTokens) {
+ staticData._callbackTokens = JSON.stringify({});
+}
+const tokenStore = JSON.parse(staticData._callbackTokens);
+```
+
+2. **encodeToken(unraidId)** async function that:
+ - Generates SHA-256 hash of the unraidId string using `crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId))`
+ - Takes first 8 hex characters as the token
+ - **Collision detection**: if `tokenStore[token]` exists AND `tokenStore[token] !== unraidId`, try next 8 chars from the hash (offset by 8). Repeat up to 7 times (56 chars of SHA-256 = 7 non-overlapping 8-char windows). Throw if all windows collide.
+ - Stores mapping: `tokenStore[token] = unraidId`
+ - Persists: `staticData._callbackTokens = JSON.stringify(tokenStore)`
+ - Returns the 8-char token
+
+3. **Input/output contract:**
+ - Input: `$input.item.json.unraidId` (string, the 129-char PrefixedID)
+ - Also accepts optional `$input.item.json.action` (string) for convenience
+ - Output: `{ token, unraidId, callbackData: "action:{action}:{token}" (if action provided), byteSize }`
+ - Include byte size validation: if callbackData > 64 bytes, include `warning: "Callback data exceeds 64-byte limit"`
+
+4. Add notes: "UTILITY: Encode Unraid PrefixedID to 8-char callback token. Not connected - Phase 16 will wire this in."
+
+**Node 2: Callback Token Decoder**
+
+id: "code-callback-token-decoder"
+name: "Callback Token Decoder"
+Position: [1000, 2400]
+
+Implement:
+
+1. **Token store read** (same JSON parse pattern):
+```javascript
+const staticData = $getWorkflowStaticData('global');
+const tokenStore = JSON.parse(staticData._callbackTokens || '{}');
+```
+
+2. **decodeToken(token)** function that:
+ - Looks up token in tokenStore
+ - If not found: throws `Token not found: ${token}. Token store may have been cleared.`
+ - Returns the full unraidId (129-char PrefixedID)
+
+3. **Input/output contract:**
+ - Input: `$input.item.json.token` (string, 8-char token) OR `$input.item.json.callbackData` (string like "action:start:a1b2c3d4")
+ - If callbackData provided, parse it: split by `:`, extract token from last segment
+ - Output: `{ token, unraidId, action (if parsed from callbackData) }`
+
+4. Add notes: "UTILITY: Decode 8-char callback token to Unraid PrefixedID. Not connected - Phase 16 will wire this in."
+
+**For both nodes:** Do NOT add any connections. These are standalone utilities.
+
+After adding all nodes, push the updated workflow to n8n using the push recipe from CLAUDE.md.
+
+
+Run: `python3 -c "
+import json
+wf = json.load(open('n8n-workflow.json'))
+nodes = {n['name']: n for n in wf['nodes']}
+enc = nodes.get('Callback Token Encoder')
+dec = nodes.get('Callback Token Decoder')
+print('Encoder:', 'EXISTS' if enc else 'MISSING')
+print('Decoder:', 'EXISTS' if dec else 'MISSING')
+if enc:
+ code = enc['parameters'].get('jsCode', '')
+ print('Has crypto.subtle:', 'crypto.subtle' in code)
+ print('Has collision detection:', 'tokenStore[token]' in code)
+ print('Has _callbackTokens:', '_callbackTokens' in code)
+if dec:
+ code = dec['parameters'].get('jsCode', '')
+ print('Has decodeToken:', 'decodeToken' in code)
+ print('Has _callbackTokens:', '_callbackTokens' in code)
+
+# Verify no connections to/from utility nodes
+conn = wf.get('connections', {})
+for name in ['Container ID Registry', 'Callback Token Encoder', 'Callback Token Decoder']:
+ has_outgoing = name in conn
+ has_incoming = any(
+ any(any(c.get('node') == name for c in group) for group in outputs if group)
+ for outputs in conn.values()
+ for outputs in [list(outputs.values()) if isinstance(outputs, dict) else []]
+ )
+ print(f'{name} connections: outgoing={has_outgoing}')
+print('Total nodes:', len(wf['nodes']))
+"`
+
+ Callback Token Encoder and Decoder Code nodes exist in n8n-workflow.json. Encoder uses SHA-256 hashing with collision detection, produces 8-char tokens. Decoder resolves tokens back to PrefixedIDs. Both use JSON serialization for static data persistence. Neither node is connected to any other nodes. Workflow pushed to n8n.
+
+
+
+
+
+1. Three new utility Code nodes exist in n8n-workflow.json: Container ID Registry, Callback Token Encoder, Callback Token Decoder
+2. All three nodes use `$getWorkflowStaticData('global')` with JSON.parse/JSON.stringify pattern (not deep mutation)
+3. None of the three nodes have connections to/from other nodes (standalone utilities)
+4. Container ID Registry handles: updateRegistry(containers), getUnraidId(name), getContainerByName(name)
+5. Token Encoder handles: encodeToken(unraidId) with SHA-256 + collision detection
+6. Token Decoder handles: decodeToken(token) with error handling for missing tokens
+7. Workflow JSON is valid and pushed to n8n
+
+
+
+- Container ID Registry node maps container names to Unraid PrefixedID format (129-char)
+- Callback token encoding produces 8-char hex tokens that fit within Telegram's 64-byte callback_data limit
+- Token collision detection prevents wrong-container scenarios
+- All static data uses JSON serialization (top-level assignment) per CLAUDE.md convention
+- Three standalone utility nodes ready for Phase 16 to wire in
+
+
+
diff --git a/.planning/phases/15-infrastructure-foundation/15-02-PLAN.md b/.planning/phases/15-infrastructure-foundation/15-02-PLAN.md
new file mode 100644
index 0000000..0eab627
--- /dev/null
+++ b/.planning/phases/15-infrastructure-foundation/15-02-PLAN.md
@@ -0,0 +1,291 @@
+---
+phase: 15-infrastructure-foundation
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - n8n-workflow.json
+autonomous: true
+
+must_haves:
+ truths:
+ - "GraphQL response normalizer transforms Unraid API shape to Docker API contract"
+ - "Normalized containers have Id, Names (with leading slash), State (lowercase) fields"
+ - "GraphQL error handler checks response.errors[] array and maps error codes"
+ - "ALREADY_IN_STATE error code maps to HTTP 304 equivalent"
+ - "HTTP Request template node has 15-second timeout configured"
+ artifacts:
+ - path: "n8n-workflow.json"
+ provides: "GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template utility nodes"
+ contains: "normalizeContainers"
+ key_links:
+ - from: "GraphQL Response Normalizer node"
+ to: "Docker API contract"
+ via: "Field mapping (id->Id, names->Names with slash, state->State lowercase)"
+ pattern: "normalizeContainers"
+ - from: "GraphQL Error Handler node"
+ to: "HTTP 304 pattern"
+ via: "ALREADY_IN_STATE error code mapping"
+ pattern: "ALREADY_IN_STATE"
+ - from: "Unraid API HTTP Template node"
+ to: "myunraid.net cloud relay"
+ via: "15-second timeout configuration"
+ pattern: "timeout.*15000"
+---
+
+
+Build the GraphQL Response Normalizer, Error Handler, and HTTP Request template as utility nodes in the main workflow.
+
+Purpose: Phase 16 (API Migration) needs to transform Unraid GraphQL responses into the Docker API contract that 60+ existing Code nodes expect. The normalizer ensures zero changes to downstream nodes. The error handler standardizes GraphQL error checking (response.errors[] array) with equivalent HTTP status codes. The HTTP template provides correctly-configured timeout and retry settings for the myunraid.net cloud relay.
+
+Output: Three new utility nodes in n8n-workflow.json: GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template.
+
+
+
+@/home/luc/.claude/get-shit-done/workflows/execute-plan.md
+@/home/luc/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@ARCHITECTURE.md
+@.planning/phases/15-infrastructure-foundation/15-RESEARCH.md
+@.planning/phases/14-unraid-api-access/14-02-SUMMARY.md
+
+
+
+
+
+ Task 1: Add GraphQL Response Normalizer utility node
+ n8n-workflow.json
+
+Add a new Code node named "GraphQL Response Normalizer" to n8n-workflow.json. This is a **standalone utility node** -- do NOT connect it to any existing nodes. Place it at position [200, 2600] (below the registry nodes from Plan 01).
+
+The node must implement a `normalizeContainers(graphqlResponse)` function that transforms Unraid GraphQL API responses to match the Docker API contract used by existing workflow nodes.
+
+**CRITICAL field mappings based on ARCHITECTURE.md verified schema:**
+
+Unraid GraphQL format (from Phase 14 verification):
+```json
+{
+ "data": {
+ "docker": {
+ "containers": [
+ { "id": "server_hash:container_hash", "names": ["/n8n"], "state": "RUNNING" }
+ ]
+ }
+ }
+}
+```
+
+Docker API format (expected by existing nodes):
+```json
+{ "Id": "abc123...", "Names": ["/n8n"], "State": "running", "Status": "running" }
+```
+
+Normalization rules:
+1. `Id`: Copy from Unraid `id` (keep the full PrefixedID -- downstream will use Container ID Registry for translation)
+2. `Names`: Copy from Unraid `names` array as-is (Phase 14 verified they already have `/` prefix)
+3. `State`: Convert Unraid UPPERCASE to lowercase (`"RUNNING"` -> `"running"`, `"STOPPED"` -> `"exited"` to match Docker convention)
+4. `Status`: Set to same as State (Docker has separate Status field, set to normalized state value)
+5. `Image`: Set to `""` (not available in basic Unraid query, add when schema is extended)
+6. `_unraidId`: Preserve original Unraid PrefixedID for debugging
+
+**State mapping** (Unraid -> Docker equivalents):
+- `RUNNING` -> `running`
+- `STOPPED` -> `exited`
+- `PAUSED` -> `paused`
+- Default: lowercase the value
+
+**Validation:**
+- If `graphqlResponse.errors` exists, throw with error messages joined
+- If `graphqlResponse.data?.docker?.containers` is missing, throw "Invalid GraphQL response structure"
+
+**Input/output contract:**
+- Input: `$input.item.json` is the raw GraphQL response object
+- Output: Array of items, each `{ json: normalizedContainer }` (n8n multi-item output format)
+
+Add notes: "UTILITY: Transform Unraid GraphQL response to Docker API contract. Not connected - Phase 16 will wire this in."
+
+Node properties:
+- id: "code-graphql-normalizer"
+- name: "GraphQL Response Normalizer"
+- type: "n8n-nodes-base.code"
+- typeVersion: 2
+
+
+Run: `python3 -c "
+import json
+wf = json.load(open('n8n-workflow.json'))
+nodes = {n['name']: n for n in wf['nodes']}
+norm = nodes.get('GraphQL Response Normalizer')
+print('EXISTS' if norm else 'MISSING')
+if norm:
+ code = norm['parameters'].get('jsCode', '')
+ print('Has normalizeContainers:', 'normalizeContainers' in code)
+ print('Has State mapping:', 'RUNNING' in code and 'running' in code)
+ print('Has STOPPED->exited:', 'STOPPED' in code and 'exited' in code)
+ print('Has Names pass-through:', 'Names' in code)
+ print('Has error check:', 'errors' in code)
+ print('Has _unraidId:', '_unraidId' in code)
+"`
+
+ GraphQL Response Normalizer Code node exists with normalizeContainers() function that maps Unraid fields (id, names, state) to Docker fields (Id, Names, State), handles UPPERCASE->lowercase state conversion, validates response structure, and is not connected to any other nodes.
+
+
+
+ Task 2: Add GraphQL Error Handler and Unraid API HTTP Template nodes
+ n8n-workflow.json
+
+Add two new utility nodes to n8n-workflow.json. These are **standalone utility nodes** -- do NOT connect them to any existing nodes.
+
+**Node 1: GraphQL Error Handler (Code node)**
+
+id: "code-graphql-error-handler"
+name: "GraphQL Error Handler"
+Position: [600, 2600]
+
+Implement a `checkGraphQLErrors(response)` function that:
+
+1. **Check GraphQL errors array:**
+ - If `response.errors` exists and has entries, examine first error
+ - Extract `error.extensions?.code` and `error.message`
+ - Map error codes to HTTP equivalents:
+ - `ALREADY_IN_STATE` -> `{ statusCode: 304, alreadyInState: true, message }` (matches Docker API `statusCode === 304` pattern used in n8n-actions.json)
+ - `NOT_FOUND` -> throw `Container not found: ${message}`
+ - `FORBIDDEN` / `UNAUTHORIZED` -> throw `Permission denied: ${message}. Check API key permissions.`
+ - Any other code -> throw `Unraid API error: ${message}`
+
+2. **Check HTTP-level errors:**
+ - If `response.statusCode >= 400`: throw `HTTP ${statusCode}: ${statusMessage}`
+
+3. **Check missing data:**
+ - If no `response.data`: throw `GraphQL response missing data field`
+
+4. **Success return:**
+ - `{ statusCode: 200, alreadyInState: false, data: response.data }`
+
+5. **Input/output contract:**
+ - Input: `$input.item.json` is the raw response from HTTP Request node
+ - Output: `{ json: { success, statusCode, alreadyInState, message, data } }`
+ - On `alreadyInState: true`: `success: true, message: "Container already in desired state"`
+ - On error: throw (n8n error handling catches it)
+ - On success: `success: true, data: response.data`
+
+Add notes: "UTILITY: Standardized GraphQL error checking. Maps ALREADY_IN_STATE to HTTP 304. Not connected - Phase 16 will wire this in."
+
+**Node 2: Unraid API HTTP Template (HTTP Request node)**
+
+id: "http-unraid-api-template"
+name: "Unraid API HTTP Template"
+Position: [1000, 2600]
+
+This is an n8n HTTP Request node pre-configured for Unraid GraphQL API calls. It serves as a **copy-paste template** for Phase 16 -- when migrating each API call, duplicate this node and update the query body.
+
+Configuration:
+```json
+{
+ "method": "POST",
+ "url": "={{ $env.UNRAID_HOST }}/graphql",
+ "authentication": "genericCredentialType",
+ "genericAuthType": "none",
+ "sendHeaders": true,
+ "headerParameters": {
+ "parameters": [
+ { "name": "Content-Type", "value": "application/json" },
+ { "name": "x-api-key", "value": "={{ $env.UNRAID_API_KEY }}" }
+ ]
+ },
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={\"query\": \"query { docker { containers { id names state } } }\"}",
+ "options": {
+ "timeout": 15000,
+ "allowUnauthorizedCerts": true,
+ "response": {
+ "response": {
+ "fullResponse": true
+ }
+ }
+ },
+ "onError": "continueRegularOutput"
+}
+```
+
+Key settings rationale:
+- **15000ms timeout**: myunraid.net cloud relay adds 200-500ms latency (Phase 14 research). 15s provides safety margin for slow operations (container start/stop can take 3-5s + relay overhead).
+- **allowUnauthorizedCerts: true**: Matches Phase 14 verified configuration.
+- **genericAuthType: none + manual x-api-key header**: Phase 14 decision -- environment variables more reliable than n8n Header Auth credentials.
+- **onError: continueRegularOutput**: Allows downstream Code node (GraphQL Error Handler) to check errors instead of n8n catching them.
+- **fullResponse: true**: Returns statusCode, statusMessage alongside body for HTTP-level error checking.
+
+Add notes: "TEMPLATE: Pre-configured HTTP Request for Unraid GraphQL API. 15s timeout for cloud relay. Duplicate and modify query for Phase 16 migration."
+
+After adding all nodes, push the updated workflow to n8n using the push recipe from CLAUDE.md. Verify total node count is previous + 3 (from Plan 01) + 3 (from this plan) = original + 6.
+
+
+Run: `python3 -c "
+import json
+wf = json.load(open('n8n-workflow.json'))
+nodes = {n['name']: n for n in wf['nodes']}
+
+# Check Error Handler
+eh = nodes.get('GraphQL Error Handler')
+print('Error Handler:', 'EXISTS' if eh else 'MISSING')
+if eh:
+ code = eh['parameters'].get('jsCode', '')
+ print(' Has checkGraphQLErrors:', 'checkGraphQLErrors' in code)
+ print(' Has ALREADY_IN_STATE:', 'ALREADY_IN_STATE' in code)
+ print(' Has 304:', '304' in code)
+ print(' Has NOT_FOUND:', 'NOT_FOUND' in code)
+ print(' Has FORBIDDEN:', 'FORBIDDEN' in code)
+
+# Check HTTP Template
+ht = nodes.get('Unraid API HTTP Template')
+print('HTTP Template:', 'EXISTS' if ht else 'MISSING')
+if ht:
+ params = ht.get('parameters', {})
+ print(' Has timeout 15000:', '15000' in json.dumps(params))
+ print(' Has UNRAID_HOST:', 'UNRAID_HOST' in json.dumps(params))
+ print(' Has UNRAID_API_KEY:', 'UNRAID_API_KEY' in json.dumps(params))
+ print(' Has continueRegularOutput:', ht.get('onError') == 'continueRegularOutput' or 'continueRegularOutput' in json.dumps(params))
+
+print('Total nodes:', len(wf['nodes']))
+
+# Verify no connections for new utility nodes
+conn = wf.get('connections', {})
+for name in ['GraphQL Response Normalizer', 'GraphQL Error Handler', 'Unraid API HTTP Template']:
+ has_outgoing = name in conn
+ print(f'{name}: outgoing connections = {has_outgoing}')
+"`
+
+ GraphQL Error Handler Code node exists with checkGraphQLErrors() function that maps ALREADY_IN_STATE to HTTP 304 equivalent, handles NOT_FOUND/FORBIDDEN/UNAUTHORIZED errors. Unraid API HTTP Template node exists with 15-second timeout, correct auth headers, and continueRegularOutput error handling. Neither node is connected. Workflow pushed to n8n.
+
+
+
+
+
+1. Three new utility nodes exist: GraphQL Response Normalizer, GraphQL Error Handler, Unraid API HTTP Template
+2. Normalizer correctly maps Unraid fields to Docker API contract (id->Id, UPPERCASE->lowercase state, names preserved)
+3. Error Handler maps ALREADY_IN_STATE to 304 (matches Docker API pattern in n8n-actions.json)
+4. HTTP Template has 15-second timeout, x-api-key header via env var, continueRegularOutput
+5. None of the three nodes have connections to/from other nodes (standalone utilities)
+6. Workflow JSON is valid and pushed to n8n
+7. Combined with Plan 01, total of 6 new utility nodes added to workflow
+
+
+
+- GraphQL response normalization transforms Unraid API shape to match Docker API contract exactly
+- State values properly converted (RUNNING->running, STOPPED->exited)
+- GraphQL error handling checks response.errors[] and maps to HTTP status codes
+- ALREADY_IN_STATE maps to 304 (same pattern as Docker API's HTTP 304 for "already started/stopped")
+- HTTP template has 15-second timeout for myunraid.net cloud relay latency
+- Six standalone utility nodes total (3 from Plan 01 + 3 from Plan 02) ready for Phase 16
+
+
+