---
name: forge-sdk
description: Use this skill whenever you are writing, modifying, or reviewing code in the PayCargo Forge repository. Triggers include any work in cartridges/, shared/modules/, substrate/, console/, or .claude/. Covers the doctrine that governs cartridge authorship, the shared SDK, OAuth integration, model invocation, observability, security, and Terraform shapes. Loads the rules an agent needs to ship a cartridge that passes review without re-reading every Step doc.
---

# Forge SDK — agent skill

This skill encodes the PayCargo Forge doctrine into actionable patterns for an agent writing or modifying cartridge code. Companion to `Doctrine.md` at the repo root (the canonical human reference) and the Playground TLDR series (the plain-language framing).

Read the **Pre-flight** and **The 12 rules that get you to a merge** sections first. The rest is reference you load when the task touches that area.

---

## Pre-flight (before you write any code)

1. **Identify the layer.** Is this a cartridge change (`cartridges/<dept>/<name>/`)? A shared helper (`shared/modules/`)? Substrate (`substrate/modules/platform/`)? Console (`console/`)? Doctrine doc (`cartridges/administrative/obs/docs/`)?
2. **Identify the doctrine that applies.** Open `Doctrine.md` and skim section 1 through 8. Note the **Non-negotiable** entries that touch your layer.
3. **Identify which Anvil questions the task answers.** Purpose, audience, external systems, OAuth providers, data classification, capabilities used, cost estimate, risk callouts. If the task does not have answers, get them before writing code.
4. **Identify the smallest change.** If the task says "fix a bug," scope to the bug. Do not refactor. Do not add features. Do not add defensive error handling for impossible scenarios.

---

## The 13 rules that get you to a merge

These are the rules a PR is reviewed against. Internalize them.

1. **Zero npm in cartridge Lambdas.** AWS SDK from Node 20 runtime. JWT, HMAC, log, OAuth from `shared/modules/`. If you reach for a new npm dep, stop and lift the primitive into `shared/modules/` instead. The one exception in flight is `aws-jwt-verify` in obs (scheduled for removal).
2. **API Gateway HTTP API + X-Origin-Secret.** Never OAC + Lambda Function URL for POST/PUT/DELETE. CloudFront origin sends `X-Origin-Secret`; Lambda rejects without it.
3. **Cognito JWT + group check on every admin endpoint.** Read `X-Auth-Token`, verify, check `cognito:groups`. Three layers (CloudFront, JWT, group) must all pass.
4. **Permission boundary on every IAM role.** `permissions_boundary = var.permission_boundary_arn` on every `aws_iam_role`. No exceptions.
5. **No public Lambda Function URLs.** `AuthType = NONE` is SCP-denied. Do not try.
6. **Customer-managed CMKs only.** `aws_kms_key` with explicit key policy. Never `aws/...`-prefixed AWS-managed keys.
7. **Emit `*-complete` log lines on every Bedrock call.** Use `logComplete({msg, user, model, inputTokens, outputTokens, durationMs})` from `shared/modules/log.js`. The obs Cartridges tab joins on this schema.
8. **Bedrock guardrails on every InvokeModel.** Set `guardrailIdentifier` and `guardrailVersion`. KMS Decrypt on the caller role for the guardrail CMK.
9. **Bedrock inference profiles for Claude 4.5.** Use `us.anthropic.claude-haiku-4-5-20251001-v1:0` or `us.anthropic.claude-sonnet-4-5-20250929-v1:0`. The bare foundation-model ID will not invoke on-demand.
10. **OAuth via the Console, not per-cartridge.** Cartridges call `getOAuthClient(providerId, jwtPayload.username)`. They do not authorize, refresh, or store tokens.
11. **Per-user OAuth connections.** Connection ID is `<provider_id>:<username>`. Cartridge invocations resolve the current user's token. No platform-shared connections. No per-cartridge connections.
12. **Cartridge manifest declares metadata.** Owner, data classification, retention, OAuth providers, budget. Cartridges missing required fields fail review.
13. **No hard-coded user-facing strings in frontend.** All display text goes through `PCSDK.i18n.t()` against per-language catalogs (`frontend/locales/<lang>.js`: base + optional `.auto.js` machine-translated + `.js` manual overrides that win). Numbers/dates/currency use the `Intl`-backed `fmt*` helpers. Technical literals (CSS classes, IDs, catalog keys, brand names, icon tokens) are exempt. (Console 2.11)

---

## File layout reference

```
substrate/                         Foundation Terraform
  bootstrap/                       state backend, OIDC, permission boundary
  modules/platform/                identity, network, guardrails, observability,
                                    data, gateway, lambda-tools, schedules,
                                    domains, model-router (specced),
                                    oauth (next)
  environments/ai-sandbox/         the composition

console/                           Launcher app + shared SDK
  sdk/v1/                          SDK every cartridge loads from Console origin
    css/{tokens,components}.css
    js/{cartridge-auth,i18n,table,pagination,modal}.js
  lambda/                          Console Lambda (OAuth flow, Setup tab API)
  frontend/                        Console launcher SPA

shared/                            Hand-rolled zero-npm Lambda helpers
  modules/
    jwt-verify.js                  createJwtVerifier({region, userPoolId, ...})
    hmac.js                        checkOriginSecret, verifyGithubHmac
    log.js                         logInfo, logWarn, logError, logComplete
    oauth/                         OAuth flow + adapters
      pkce.js                      generatePkce, generateState
      store.js                     createStore (DDB + Secrets Manager)
      flow.js                      createOAuthFlow (start/complete)
      refresh.js                   createRefresher (refreshConnection, sweep)
      client.js                    createClient (getOAuthClient)
      adapters/
        _index.js                  adapter registry
        salesforce.js              Salesforce capabilities

cartridges/                        Apps, department-segmented
  _template/                       scaffold for new cartridges
  administrative/obs/              observability cartridge (operator surface)
  global/chatbot/                  chatbot cartridge (smallest live example)
  ai-test-kitchen/, engineering/,
  finance/, hr/, labs/,
  marketing/, sales/               department folders with .gitkeep
```

---

## Cartridge handler template (zero-npm)

```javascript
import {
  CognitoIdentityProviderClient,
  ListUserPoolClientsCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import { createJwtVerifier } from './_shared/jwt-verify.js';
import { checkOriginSecret } from './_shared/hmac.js';
import { logInfo, logWarn, logError, logComplete } from './_shared/log.js';

const REGION               = process.env.AWS_REGION || process.env.REGION || 'us-east-1';
const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID;
const ORIGIN_SECRET        = process.env.ORIGIN_SECRET;

const cognitoIdp = new CognitoIdentityProviderClient({ region: REGION });

let allowedClientIdsCache = null;
async function resolveAllowedClientIds() {
  if (allowedClientIdsCache) return allowedClientIdsCache;
  const res = await cognitoIdp.send(new ListUserPoolClientsCommand({
    UserPoolId: COGNITO_USER_POOL_ID, MaxResults: 20,
  }));
  allowedClientIdsCache = new Set((res.UserPoolClients || []).map(c => c.ClientId));
  return allowedClientIdsCache;
}

const jwtVerifier = createJwtVerifier({
  region: REGION,
  userPoolId: COGNITO_USER_POOL_ID,
  getAllowedClientIds: resolveAllowedClientIds,
});

export const handler = async (event) => {
  const path   = event.rawPath || event.path || '/';
  const method = event.requestContext?.http?.method || event.httpMethod || 'GET';
  logInfo('request', { path, method });

  if (!checkOriginSecret(event, ORIGIN_SECRET)) {
    logWarn('origin-secret-mismatch');
    return jsonResponse(403, { error: 'Forbidden' });
  }

  try {
    if (path === '/api/health' && method === 'GET') return jsonResponse(200, { ok: true });

    // JWT verify
    const authHeader = event.headers?.['x-auth-token'] || event.headers?.authorization || '';
    const token = authHeader.replace(/^Bearer\s+/i, '');
    if (!token) return jsonResponse(401, { error: 'Missing X-Auth-Token header' });

    let jwtPayload;
    try { jwtPayload = await jwtVerifier.verify(token); }
    catch (err) {
      logWarn('jwt-validation-failed', { error: err.message });
      return jsonResponse(401, { error: 'Invalid or expired token' });
    }

    // ... cartridge-specific routes here, using jwtPayload.username

    return jsonResponse(404, { error: 'Not found', path });
  } catch (err) {
    logError('handler-exception', { error: err.message, stack: err.stack });
    return jsonResponse(500, { error: 'Internal error' });
  }
};

function jsonResponse(statusCode, body) {
  return {
    statusCode,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
    body: JSON.stringify(body),
  };
}
```

---

## Cartridge backend.tf — required shape

Every cartridge's `backend.tf` includes the local_file staging block so `shared/modules/` lands in `lambda/_shared/` at apply time. Lifted from `cartridges/_template/backend.tf.snippet`:

```hcl
locals {
  shared_modules_dir = "${path.module}/../../../shared/modules"
}

resource "local_file" "shared_jwt_verify" {
  content  = file("${local.shared_modules_dir}/jwt-verify.js")
  filename = "${path.module}/lambda/_shared/jwt-verify.js"
}

resource "local_file" "shared_hmac" {
  content  = file("${local.shared_modules_dir}/hmac.js")
  filename = "${path.module}/lambda/_shared/hmac.js"
}

resource "local_file" "shared_log" {
  content  = file("${local.shared_modules_dir}/log.js")
  filename = "${path.module}/lambda/_shared/log.js"
}

data "archive_file" "backend_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/build/backend.zip"
  excludes    = ["build"]
  depends_on = [
    local_file.shared_jwt_verify,
    local_file.shared_hmac,
    local_file.shared_log,
  ]
}
```

Then:

```hcl
resource "aws_lambda_function" "backend" {
  function_name = "${var.project_name}-${var.cartridge_name}-backend"
  role          = aws_iam_role.backend.arn
  handler       = "handler.handler"
  runtime       = "nodejs20.x"
  filename         = data.archive_file.backend_zip.output_path
  source_code_hash = data.archive_file.backend_zip.output_base64sha256

  kms_key_arn = var.backend_kms_key_arn
  environment {
    variables = {
      REGION               = var.region
      COGNITO_USER_POOL_ID = var.cognito_user_pool_id
      ORIGIN_SECRET        = random_password.origin_secret.result
      # ... cartridge-specific env
    }
  }
}
```

IAM role must have:
- `permissions_boundary = var.permission_boundary_arn`
- KMS Decrypt grant for any CMK the Lambda touches (especially the Bedrock guardrail CMK if it invokes models)

CloudFront origin must send `X-Origin-Secret` via `custom_header` on the API Gateway origin. The API Gateway HTTP API uses an ANY `/{proxy+}` route to the Lambda.

---

## OAuth consumption pattern (when a cartridge needs Salesforce, GitHub, etc.)

```javascript
import { createClient } from './_shared/oauth/client.js';
import { createStore }  from './_shared/oauth/store.js';

// Wire once per Lambda init
const store = createStore({ ddb, secrets, tableName, secretPrefix, kmsKeyId });
const oauth = createClient({ ssm, store, providerParamPrefix });

// In the handler
const sf = await oauth.getOAuthClient('salesforce-sandbox', jwtPayload.username);
if (!sf) {
  return jsonResponse(409, {
    error:   'No Salesforce connection',
    detail:  'Open the Console Setup tab and click Connect on Salesforce Sandbox.',
    setupUrl: 'https://app.paycargo.ai/setup',
  });
}

// Adapter exposes typed capability verbs. No raw fetch, no URLs, no auth header.
const accounts = await sf.soql('SELECT Id, Name FROM Account LIMIT 10');
```

Cartridges do not:
- Call OAuth authorize/token/refresh endpoints
- Store any provider credentials
- Build provider URLs or set the Authorization header
- Decide which verbs are available (the scope-to-capability gate does)

If the cartridge needs a verb the adapter does not expose, the adapter's `raw(method, path, body?)` escape hatch is the temporary answer. When two cartridges use the same `raw()` call, lift it into the adapter as a named capability.

---

## Bedrock invocation pattern

```javascript
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime';

const bedrock = new BedrockRuntimeClient({ region: REGION });

function expandModelId(short) {
  // Claude 4.5 requires the us.* inference profile prefix.
  const map = {
    'anthropic.claude-haiku-4-5':  'us.anthropic.claude-haiku-4-5-20251001-v1:0',
    'anthropic.claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
  };
  return map[short] || short;
}

const startedAt = Date.now();
const cmd = new InvokeModelWithResponseStreamCommand({
  modelId: expandModelId('anthropic.claude-haiku-4-5'),
  contentType: 'application/json',
  accept: 'application/json',
  guardrailIdentifier: DEFAULT_GUARDRAIL_ID || undefined,
  guardrailVersion:    DEFAULT_GUARDRAIL_ID ? DEFAULT_GUARDRAIL_VERSION : undefined,
  body: JSON.stringify({
    anthropic_version: 'bedrock-2023-05-31',
    max_tokens: 2048,
    messages: messages.map(m => ({ role: m.role, content: m.content })),
  }),
});
const res = await bedrock.send(cmd);
// ... stream handling, accumulating inputTokens + outputTokens from stream events

logComplete({
  msg: '<cartridge-name>-complete',
  user: jwtPayload.username,
  model: expandedModelId,
  inputTokens, outputTokens,
  durationMs: Date.now() - startedAt,
});
```

---

## Scheduled jobs pattern (cron-like work)

```javascript
import { wrapScheduledHandler } from './scheduled-handler.js';

export const scheduledNarrative = wrapScheduledHandler('obs-app-narrative', async (event) => {
  // ... do the scheduled work
  return { ok: true };
});
```

The wrapper writes start + end run records to DDB so the obs Schedules tab and the watchdog can see them. EventBridge Scheduler invokes the Lambda; the schedules manifest in SSM declares the expected cadence; the watchdog records an alert in the mock-email sink when an expected run is missing (nothing is delivered).

Cron-like work does NOT use raw cron expressions in Lambda environment variables. Use EventBridge Scheduler with the wrapper.

---

## Logging patterns

```javascript
import { logInfo, logWarn, logError, logComplete } from './_shared/log.js';

logInfo('request', { path, method });                                          // routine
logWarn('jwt-validation-failed', { error: err.message });                      // expected-but-degraded
logError('handler-exception', { error: err.message, stack: err.stack });       // unexpected

logComplete({                                                                  // BEDROCK contract
  msg: 'chat-complete',                                                        //   ALWAYS '<cartridge>-complete'
  user: jwtPayload.username,
  model: modelId,
  inputTokens,
  outputTokens,
  durationMs: Date.now() - startedAt,
});
```

Never `console.log(JSON.stringify(...))` directly. Use the helpers; they emit the structured shape the obs Cartridges tab joins on.

---

## Anti-patterns reference

These get rejected at review. Recognize them so you do not write them.

| Anti-pattern | What's wrong | Replace with |
|---|---|---|
| OAC + Lambda Function URL for POST/PUT | Silent 403 from SigV4 body-hash mismatch | API Gateway HTTP API + X-Origin-Secret |
| Per-cartridge OAuth | Multiplies auth surface; IAM boundary already enforces scope | Shared OAuth in Console, per-user connections |
| npm packages in cartridge Lambda | Supply chain surface; audit story | Hand-roll in `shared/modules/` |
| AWS-managed KMS keys | Cannot answer "who decrypted what" | Customer-managed CMK |
| Single CMK for all data | Compromise of one role leaks all zones | Three CMKs per trust zone |
| Hardcoded model IDs in cartridge | Vendor lock-in | Model router substrate (when wired); for now use the `expandModelId` map |
| Silent caps in workflows | Operators assume coverage | `log()` what was dropped |
| Cartridge in wrong department folder | Ownership confusion | Move to owning department |
| Defensive error handling for impossible scenarios | Code that never runs | Trust framework guarantees; validate only at boundaries |
| Cartridge calling provider API directly with raw fetch | Bypasses adapter; reinvents URLs; bypasses scope gate | OAuth client adapter (`sf.soql`, not raw fetch) |
| Lazy OAuth token refresh in cartridge | Race condition on expiry; user-facing latency | Scheduler keeps tokens fresh; cartridge always reads the current one |
| Bare `console.log` for Bedrock completion | obs Cartridges tab loses cost attribution | `logComplete()` with the documented schema |
| Hard-coded user-facing strings in frontend | Can't localize; retrofitting i18n touches every view | `PCSDK.i18n.t()` + per-language `frontend/locales/<lang>.js` |

---

## Working in an external cartridge repo (SDK pulled, not in-tree)

If `./forge-sdk/` and `./forge-sdk.lock` exist at the cartridge repo root, you are NOT in the main `ai-sandbox` repo. You are in a cartridge repo that pulled the Forge SDK with `pull-sdk.sh`. Two adjustments:

**Terraform paths.** The `local_file` resources in `backend.tf` read from `./forge-sdk/shared/modules/` instead of `../../../shared/modules/`. Update the `shared_modules_dir` local accordingly:

```hcl
locals {
  shared_modules_dir = "${path.module}/forge-sdk/shared/modules"
}
```

**Doctrine + SDK source paths.** The pulled bundle is at `./forge-sdk/`. When you need to read a helper to understand its API, look at `./forge-sdk/shared/modules/<file>.js`. When you need to read the doctrine, open `./forge-sdk/Doctrine.md` (the cartridge repo's own pinned copy). The lock file `./forge-sdk.lock` tells you which version is pinned; do not change SDK files in-place (they get blown away on the next `pull-sdk.sh` call).

**Upgrade path.** If you need a newer SDK helper that the pinned version does not have, the upgrade ceremony is:

```bash
./pull-sdk.sh v<new>           # blows away ./forge-sdk/, writes new lock
git add forge-sdk.lock
git commit -m "chore(sdk): upgrade forge-sdk <old> -> <new>"
# then proceed with the cartridge change
```

Major bumps require reading the SDK changelog (or the diff between tags) before upgrading. Minor and patch are mechanical.

The browser SDK (`console/sdk/v1/`) at runtime always loads from the Console origin, never from `./forge-sdk/console/sdk/v1/` (that's just for IDE autocomplete and local testing).

---

## When in doubt

1. Read `Doctrine.md` section by section.
2. Read the Playground TLDR doc for plain-language framing of the doctrine that applies.
3. Read the Step.* doc for engineering depth.
4. Open `cartridges/global/chatbot/` for the smallest production reference, or `cartridges/administrative/obs/` for a full admin surface.
5. Open `cartridges/_template/` for the scaffold.

If the answer is not in those, draft a small Anvil sheet (purpose, audience, external systems, OAuth, data classification, capabilities, cost, risks) before writing code.

## Author-facing docs in the pulled SDK

When working in an external cartridge repo that pulled the SDK, the `./forge-sdk/docs/` directory has self-contained references:

| File | Use it for |
|---|---|
| `forge-sdk/docs/getting-started.md` | Orientation after pulling; "what's in this bundle and where to start" |
| `forge-sdk/docs/cartridge-walkthrough.md` | End-to-end build of a hello-world cartridge (echo) |
| `forge-sdk/docs/handler-reference.md` | Annotated `handler.js` reference |
| `forge-sdk/docs/backend-reference.md` | Annotated `backend.tf` reference (every resource explained) |
| `forge-sdk/docs/observability-contract.md` | The `*-complete` log schema spec the obs Cartridges tab joins on |
| `forge-sdk/docs/bedrock-pattern.md` | How to invoke Bedrock (streaming + non-streaming, inference profiles, guardrails) |
| `forge-sdk/docs/oauth-pattern.md` | How to use `getOAuthClient` and per-provider adapters (Salesforce, etc.) |

When you're asked "how do I X" in a cartridge repo, look at these markdown docs first. They're the same content as the online Step docs but lighter-weight and ship with the SDK.

---

## Checklist before opening a PR

Read every line. Each unchecked item is a probable review comment.

- [ ] No `npm install` in any cartridge Lambda folder (except obs `aws-jwt-verify`, scheduled for removal)
- [ ] Handler uses `checkOriginSecret`, `createJwtVerifier`, `logInfo/logWarn/logError`
- [ ] Every Bedrock invocation emits a `*-complete` log line via `logComplete`
- [ ] Every InvokeModel call has `guardrailIdentifier` set
- [ ] Claude 4.5 model IDs use the `us.*` inference profile prefix
- [ ] `aws_iam_role` has `permissions_boundary = var.permission_boundary_arn`
- [ ] No `aws_lambda_function_url` resources
- [ ] CMKs are customer-managed (`aws_kms_key`), not AWS-managed
- [ ] `local_file` staging block present in `backend.tf` for `shared/modules/`
- [ ] `lambda/_shared/` is gitignored at the repo root (no commits of staged files)
- [ ] Cartridge folder is `cartridges/<owning-department>/<name>/`
- [ ] Cartridge manifest declares: owner, data classification, retention, OAuth providers, budget
- [ ] If cartridge talks to Salesforce/GitHub/GitBook/Monday: uses `getOAuthClient` and adapter verbs, never raw fetch
- [ ] If cartridge handles PII: cartridge is gated until lockbox lands (do not ship)
- [ ] If cartridge ships scheduled work: uses `wrapScheduledHandler` + EventBridge Scheduler, not raw cron
- [ ] No hard-coded user-facing strings in any frontend (cartridge SPA, Console): all display text via `PCSDK.i18n.t()` + `frontend/locales/<lang>.js`; numbers/dates/currency via the `Intl` `fmt*` helpers
- [ ] PR description includes an Anvil sheet for new cartridges
- [ ] PR description names the doctrines violated (if any) with rationale (for **Default**-tier overrides) or removes the violation (for **Non-negotiable**-tier)
- [ ] `terraform fmt` clean; `terraform validate` passes

---

## Maintenance

When the doctrine changes (new entry in `Doctrine.md`):

1. Update the **12 rules** section above if the new doctrine is operational.
2. Update the anti-patterns table if it introduces a new failure mode.
3. Update the checklist if it adds a pre-PR check.
4. Update the Step.* and Playground.* docs cross-referenced in `Doctrine.md`.

The skill description (frontmatter `description`) controls when this skill auto-loads. Keep it specific to the Forge repo so it does not fire in unrelated work.
