PayCargo · AI-Sandbox · Step 6.o

6.oForge SDK: versioning, manifest, and distribution

The SDK is the contract external cartridge authors program against. Define what counts as the SDK, give it semver, ship it as a pinnable git tag, and let consumers pull only what they need with a single script. Future-proofs the contract before the first external cartridge lands.
Block: 6.o (Console doctrine; companion to 6.d repo structure, 6.i cartridge metadata) · State: Shipped at v1.0.0 · Surface: VERSION, forge-sdk-manifest.json, console/sdk/pull-sdk.sh, console/sdk/release-sdk.sh
Doctrine
Until v1.0.0 of this doctrine, "the SDK" was an implicit thing: whatever happened to be in shared/modules/ and console/sdk/v1/ at HEAD. That worked while every cartridge lived in the same repo. It does not work the moment a cartridge author wants to build outside this repo. v1.0.0 names the bundle, versions it, and gives operators a release ceremony.

Sections

  1. Why version the SDK
  2. What's in the bundle
  3. Semver rules
  4. The manifest
  5. Consumer ceremony: pull-sdk.sh
  6. Operator ceremony: release-sdk.sh
  7. The lock file
  8. Wiring the pulled SDK into a cartridge
  9. Upgrade path
  10. Future iterations
  11. v2 destination: MCP-served, CMS-managed

1. Why version the SDK

Three failure modes that an unversioned SDK invites:

The fix is small. Name the bundle. Version it. Ship it as a git tag. Provide a pull script. That's the whole doctrine.

2. What's in the bundle

Four parts. The manifest at forge-sdk-manifest.json is the source of truth; this table is its prose form.

PartPathWhy it's in the bundle
Server-side helpers shared/modules/ The zero-npm Lambda primitives (JWT verify, HMAC, log, OAuth). Staged into cartridge Lambdas via local_file at Terraform apply time. Without these every cartridge re-implements primitives that should be shared.
Browser SDK console/sdk/v1/ CSS tokens and JS components. The bundled copy is for IDE autocomplete and local reference. At runtime the cartridge loads these from the Console CloudFront URL (https://console.paycargo.org/sdk/v1/), not from disk.
Cartridge scaffold cartridges/_template/ Minimal handler, package.json, backend.tf snippet, README. Starting point for a new cartridge. External authors copy from here; internal authors copy directly inside the main repo.
Doctrine + agent skill Doctrine.md, .claude/skills/forge-sdk/ The rules (read before opening a PR) and the Claude agent skill that loads them automatically when an agent works on the cartridge. Versioned with the rest of the SDK because doctrine changes are SDK changes.

What's NOT in the bundle:

3. Semver rules

SDK changes break down into three tiers. Operator picks the tier when running release-sdk.sh; the script enforces semver arithmetic.

TierTriggerExamples
Major (X+1.0.0) Breaking change to a public API or required pattern. A cartridge built against the previous version may fail to build or run against this one. Removing a helper. Renaming an exported function. Breaking the *-complete log schema. Changing the manifest format incompatibly.
Minor (X.Y+1.0) New helper, new adapter, new template field. Backward-compatible: any cartridge that built against the previous version still builds. Adding the salesforce adapter. Adding a new SDK component (e.g. PCSDK.ui.DatePicker). New optional cartridge metadata field.
Patch (X.Y.Z+1) Bug fix. No API change. Behavior closer to the documented contract. JWT verify edge-case fix. Log helper typo. Terraform template syntax error.

When in doubt, ship as minor

A premature major bump is harmless: consumers still pull successfully. A missed major bump breaks consumers silently and they learn about it from broken builds. The asymmetry says: bias toward bumping more aggressively.

4. The manifest

forge-sdk-manifest.json at the repo root is the canonical declaration of what counts as the SDK. Schema:

{
  "name": "forge-sdk",
  "version": "1.0.0",
  "description": "...",
  "repo": "PayCargoDevOps/ai-sandbox",
  "homepage": "https://d2kh3b2wl3msbg.cloudfront.net/",
  "doctrine": "https://d2kh3b2wl3msbg.cloudfront.net/Doctrine.html",

  "paths": [
    "shared/modules",
    "console/sdk/v1",
    "cartridges/_template",
    "Doctrine.md",
    ".claude/skills/forge-sdk"
  ],

  "endpoints": {
    "browser_sdk_base":     "https://console.paycargo.org/sdk/v1/",
    "browser_sdk_fallback": "https://da7cclwu7mps0.cloudfront.net/sdk/v1/",
    "console_origin":       "https://console.paycargo.org"
  },

  "compatibility": {
    "node_runtime":  ">= 20",
    "terraform":     ">= 1.6",
    "aws_provider":  "~> 5.70"
  },

  "bundle": {
    /* prose summary of each path: summary + key files */
  }
}

The paths array is the load-bearing part: pull-sdk.sh reads this list and copies exactly those paths into ./forge-sdk/. Adding a new path to the bundle is a one-line manifest change plus a minor version bump.

The endpoints block surfaces the runtime URLs cartridges connect to. browser_sdk_fallback is the CloudFront URL that works before the custom domain is wired; consumers should reference browser_sdk_base by preference and fall back to the fallback URL during the cutover.

The compatibility block names the runtime baselines we test against. Diverging from these is a major bump.

5. Consumer ceremony: pull-sdk.sh

From a cartridge repo:

curl -O https://raw.githubusercontent.com/PayCargoDevOps/ai-sandbox/main/console/sdk/pull-sdk.sh
chmod +x pull-sdk.sh

./pull-sdk.sh                  # latest stable (highest sdk-vX.Y.Z tag on origin)
./pull-sdk.sh v1.0.0           # pin to a specific tag
./pull-sdk.sh main             # track main for development against unreleased SDK

What the script does, step by step:

  1. Resolves the version arg to a git ref. latest queries git ls-remote --tags and picks the highest sdk-vX.Y.Z. v1.0.0 becomes sdk-v1.0.0. main stays main.
  2. Shallow-clones the repo at that ref into a temp directory.
  3. Reads paths from forge-sdk-manifest.json (using jq if present, falling back to a grep+sed parser otherwise).
  4. Copies each path into ./forge-sdk/<same path> in the caller's repo.
  5. Writes ./forge-sdk.lock with the resolved version, ref, commit SHA, timestamp, and source repo URL.
  6. Cleans up the temp clone.

Idempotent: re-running blows away ./forge-sdk/ and re-fetches. No merge conflicts on the SDK side.

Auth follows whatever git already uses. SSH keys for SSH URL, HTTPS token for HTTPS URL. For private repos the caller must have access; for public repos no auth is needed.

6. Operator ceremony: release-sdk.sh

From the main repo, after the SDK changes you want to release are on main:

./console/sdk/release-sdk.sh patch    # 1.0.0 -> 1.0.1
./console/sdk/release-sdk.sh minor    # 1.0.0 -> 1.1.0
./console/sdk/release-sdk.sh major    # 1.0.0 -> 2.0.0
./console/sdk/release-sdk.sh 1.2.3    # explicit

What the script does:

  1. Validates the working tree is clean (no uncommitted changes the release would silently capture).
  2. Reads the current version from VERSION, computes the new version.
  3. Refuses to proceed if sdk-vX.Y.Z already exists locally or on origin.
  4. Updates VERSION and the version field in forge-sdk-manifest.json.
  5. Commits the bump (chore(sdk): bump to v1.0.1).
  6. Tags sdk-v1.0.1.

Does NOT push. The operator pushes manually so the release goes through review:

git push origin main
git push origin sdk-v1.0.1

The push is the public release event. Consumers' pull-sdk.sh calls see the new tag immediately.

Why no auto-push

Pushing the tag is the release. We want a human to look at git log and the manifest diff before that release goes live. Auto-push from a script that runs locally is a foot-gun.

7. The lock file

forge-sdk.lock is the pin a consumer carries in their repo. It looks like this:

{
  "version":   "1.0.0",
  "ref":       "sdk-v1.0.0",
  "sha":       "abc123def456...",
  "pulled_at": "2026-06-14T19:32:00Z",
  "repo":      "git@github.com:PayCargoDevOps/ai-sandbox.git"
}

The lock file does three things:

Cartridge repos check in forge-sdk.lock (commit it). ./forge-sdk/ itself is gitignored in the consumer repo because it's derivable from the lock plus the manifest.

8. Wiring the pulled SDK into a cartridge

Two surfaces to think about: server (Lambda) and browser (frontend).

Server (Lambda)

The cartridge's backend.tf reads from ./forge-sdk/shared/modules/ instead of ../../../shared/modules/. One-character path change in the shared_modules_dir local:

locals {
  # In-repo (when the cartridge lives inside ai-sandbox):
  # shared_modules_dir = "${path.module}/../../../shared/modules"
  # External (when the cartridge lives in a separate repo with forge-sdk/ pulled):
  shared_modules_dir = "${path.module}/forge-sdk/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"
}
# ... same pattern for hmac.js, log.js, oauth/*

The handler itself doesn't care:

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

The _shared/ path is what Terraform stages at apply. Whether it came from ./forge-sdk/shared/modules/ (external) or ../../../shared/modules/ (in-repo) is invisible to the handler.

Browser (frontend)

Cartridge HTML loads the browser SDK live from the Console origin. The pulled copy is for IDE autocomplete and local testing:

<link rel="stylesheet" href="https://console.paycargo.org/sdk/v1/css/tokens.css">
<link rel="stylesheet" href="https://console.paycargo.org/sdk/v1/css/components.css">
<script src="https://console.paycargo.org/sdk/v1/js/cartridge-auth.js"></script>
<script src="https://console.paycargo.org/sdk/v1/js/table.js"></script>

No CORS concern: <link> and <script> tags don't trigger CORS. The browser fetches the file and treats the content as same-origin for the parent document. Same pattern works inside the Console (same-origin) and from a cartridge iframe (cross-origin).

Doctrine + agent skill

The cartridge's .claude/skills/ directory should include the pulled forge-sdk skill so any Claude agent working on the cartridge auto-loads it:

mkdir -p .claude/skills
cp -R forge-sdk/.claude/skills/forge-sdk .claude/skills/
# (or symlink, if the consumer is comfortable with symlinks in git)

The skill's description matches "writing or modifying code in the PayCargo Forge"; an external cartridge author working in their cartridge repo still gets the doctrine loaded.

9. Upgrade path

Consumer upgrades by re-running pull-sdk.sh with the new version:

./pull-sdk.sh v1.1.0           # rewrites forge-sdk/ + forge-sdk.lock
git diff forge-sdk.lock        # see what changed
git add forge-sdk.lock
git commit -m "chore(sdk): upgrade forge-sdk 1.0.0 -> 1.1.0"

Because ./forge-sdk/ is gitignored, the only thing the consumer commits is the lock file change. The actual files on disk are regenerated by the next pull-sdk.sh call.

For major bumps, the consumer reads the SDK changelog (when one exists; the next iteration of this doctrine) or the diff between tags. Breaking changes get called out so consumers know what to update in their cartridge code.

Per-cartridge upgrade testing

Recommended workflow for upgrading:

  1. Branch in the cartridge repo.
  2. Run ./pull-sdk.sh v<new>.
  3. Run the cartridge's local tests and a terraform plan.
  4. Deploy to staging.
  5. Smoke-test the cartridge end-to-end.
  6. Merge.

Major bumps deserve a longer staging soak. Minor and patch are mechanical.

10. Future iterations

v1.0.0 of the SDK ships the minimum that supports external cartridge authorship. Things we deliberately deferred:

Each of these is one PR when the need is real. Doctrine for them lands as Step 6.o successors.

11. v2 destination: MCP-served, CMS-managed

v1.x git-pull is the bootstrap layer. The destination is a real-time, protected, versioned MCP call backed by a CMS-managed content store. The migration is intentional and sequenced.

Propertyv1 (today)v2 (target)
Distributiongit clone via pull-sdk.shMCP server cartridge over HTTPS
AuthenticationGitHub credentialsCognito JWT on every fetch
AuthorizationGitHub repo accessCognito group (Request Roles assignment)
AuditGitHub log (limited)Per-resource fetch log with requester, version, prompt context
Content sourceFiles in this git repoCMS cartridge (DDB-backed)
AuthoringEditor + git PR + Terraform applyConsole UI: draft, submit, approve, publish
VersioningBundle-level (git tag)Per-document semver + curated SDK release manifest
DeprecationNone (consumers don't notice)Server-returned deprecation flag, sunset dates, hard revoke
Fallbackn/aGit mirror, byte-equivalent to CMS published content
Agent integrationRead files from diskClaude Code speaks MCP natively; per-resource fetch with auditable prompt context

Sequencing:

  1. v1.x continues to ship via git-pull (this doctrine).
  2. Request Roles + SSO land (Step 6.p).
  3. CMS cartridge lands (Step 6.s). Existing v1.x markdown is imported as DDB rows; git mirror sync begins.
  4. MCP server cartridge lands (Step 6.r). Reads CMS, serves MCP, audits every fetch.
  5. SDK v2.0 ships. pull-sdk.sh becomes a thin MCP wrapper; agents fetch directly. v1 git-pull continues to work as the documented fallback.

Doctrine 2.9 (MCP-served distribution) and Doctrine 2.10 (CMS-managed content) are the non-negotiable target. This doc (Step 6.o) is the interim spec; Step 6.r and Step 6.s are the destination specs.

References