Every cartridge interacts with five things beyond its own UI: data (what sensitivity tier, what KMS key, what PII handling), retention (how long it keeps what), AI spend (which models, how much), systems of record (which SoRs it can read/write, at what rate), and egress (which destinations its network calls can reach). Each one of these has a doctrinal layer that already exists at the platform level. Without metadata, every cartridge author redecides each question, and the platform can't audit or enforce centrally.
The metadata model says: the cartridge declares what it needs; the platform enforces what it declared. That's the iOS Info.plist + iOS entitlements pattern, applied to vibe apps.
Anything a cartridge author could otherwise quietly do (write to a more sensitive CMK than they need, call an SoR they shouldn't, burn through AI budget without limit) gets moved into the manifest. The cartridge can't deploy without declaring it. The platform can't lie about what it allowed because the manifest is the contract.
Nine sections. Sections marked (today) already exist in v0 and stay structurally where they are (just reorganized into the namespaced form below). Sections marked (extended) are the post-60d additions.
{
// ── Manifest envelope ────────────────────────────────────────
manifestVersion: 2,
id: 'chatbot',
version: '1.0.0',
// ── Display (today) ──────────────────────────────────────────
display: {
name: 'Chatbot',
description: 'Claude via Bedrock',
icon: 'fa-comments',
color: 'purple',
},
// ── Loading (today) ──────────────────────────────────────────
load: {
mode: 'iframe',
url: 'https://chatbot.ai-sandbox.paycargo.com/',
sandbox: ['allow-scripts','allow-same-origin','allow-forms'],
},
// ── Access control (today + extended) ────────────────────────
access: {
requiredGroups: ['chatbot-users'],
departmentOwner: 'engineering', // which dept's roster owns this
audience: 'platform', // platform / cxo / ciso / dept
},
// ── Data governance (extended) ───────────────────────────────
governance: {
sensitivityTier: 'internal', // public / internal / confidential / restricted
kmsTier: 'eng-low-risk', // which CMK from the data-governance grid
piiMode: 'none', // none / tokenized / lockbox-mediated
dataResidency: ['us-east-1'],
},
// ── Retention (extended) ─────────────────────────────────────
retention: {
sessionTtlDays: 90,
auditYears: 7,
archivePolicy: 'glacier-after-1y',
deletionMode: 'soft-then-purge',
},
// ── AI budget (extended) ─────────────────────────────────────
budget: {
monthlyCeilingUSD: 500,
softWarnPct: 0.75,
hardCapBehavior: 'degrade', // degrade / disable
allowedModels: [
'anthropic.claude-haiku-4-5-*',
'anthropic.claude-sonnet-4-5-*',
],
defaultGuardrailId: 'gr-xxxxx',
},
// ── Systems of Record (extended) ─────────────────────────────
sor: {
salesforce: { scope: 'read-only', rateLimit: '60/min' },
monday: { scope: 'read-write', rateLimit: '120/min' },
github: { scope: 'read-only', rateLimit: '5000/hr' },
gitbook: { scope: 'read-only', rateLimit: 'unlimited' },
},
// ── Egress allowlist (extended; derived from sor + extras) ───
egress: {
destinations: [
'api.salesforce.com',
'api.monday.com',
'api.github.com',
'api.gitbook.com',
],
},
// ── Lifecycle + ops (extended) ───────────────────────────────
lifecycle: {
status: 'beta', // alpha / beta / ga / deprecated / sunset
createdAt: '2026-06-01',
lastSecurityReview: '2026-06-01',
owningTeam: 'eng-platform',
onCallRotation: 'eng-platform',
},
}
access — who can see and use this cartridge| Field | Type | Enforced by | Notes |
|---|---|---|---|
requiredGroups | string[] | Console /api/cartridges filter + cartridge backend group check | Subset check against JWT cognito:groups. Today: empty array = visible-to-all. v2: every cartridge declares at least one group. |
departmentOwner | enum | Console launcher (badge color, audit attribution) | One of: global / sales / marketing / engineering / hr / finance / labs. Matches Step 6.d department segmentation. |
audience | enum | Console launcher (sidebar grouping) | platform / cxo / ciso / dept. Drives which sidebar section the tile appears under, if multiple. |
Today's requiredGroups is the only access mechanism. v2 keeps that AND adds
department ownership (for audit + accounting) and audience (for UI organization). Department
determines which CMK tier the cartridge's data is encrypted with; audience just affects which
Console section the tile renders in.
governance — data classification + encryption| Field | Values | Enforced by |
|---|---|---|
sensitivityTier | public · internal · confidential · restricted | Drives CMK assignment + audit log routing |
kmsTier | One of the CMK keys from the dept × tier grid (Step 4.5.a) | Cartridge's S3 bucket + Lambda env vars + DDB encrypted with this CMK |
piiMode | none · tokenized · lockbox-mediated | Cartridge backend's reveal-PII helper; pre-deploy lint |
dataResidency | string[] | Region-restricted resources at deploy time |
none — cartridge guarantees it never receives PII (the AI-sandbox pilot constraint). Lint fails if cartridge code includes any of the PII regex patterns.tokenized — cartridge receives opaque tokens; the lockbox resolves token→PII via authorized, audited calls. Default mode for any cartridge that touches customer data.lockbox-mediated — cartridge is itself the lockbox (rare). Has the unique audit-write CMK, all reveals routed through it.retention — how long data lives| Field | Default | Enforced by |
|---|---|---|
sessionTtlDays | 90 | DynamoDB TTL attribute on session-state items |
auditYears | 7 | S3 Object Lock COMPLIANCE on the audit bucket. Matches Step 4 default. |
archivePolicy | glacier-after-1y | S3 Lifecycle Rule on the cartridge's data bucket |
deletionMode | soft-then-purge | Cartridge backend delete helper; per-record tombstones before hard-delete |
Every cartridge MUST declare retention. A cartridge that doesn't know how long it
keeps data has not thought about regulatory compliance. The platform's lint fails
deploys where retention is missing.
budget — AI spend ceiling| Field | Type | Enforced by |
|---|---|---|
monthlyCeilingUSD | number | Cartridge backend Bedrock wrapper + CloudWatch billing alarm |
softWarnPct | 0..1 | Same wrapper — surface a banner in the cartridge UI when crossed |
hardCapBehavior | degrade · disable | Bedrock wrapper at 100% of ceiling |
allowedModels | string[] | Bedrock invoke wrapper rejects model IDs not in this list |
defaultGuardrailId | string | Auto-attached to every Bedrock InvokeModel call |
degrade means fall back to a cheaper model (Haiku) once the ceiling is
hit; disable means return a "monthly budget exhausted" error to the user.
Cartridge declares which behavior it wants based on its UX.
sor — Systems of Record access scopePer-SoR access is two dimensions: scope (read-only / read-write / none) and rate limit. The cartridge backend's SoR client library reads this manifest at startup and refuses calls that violate either.
sor: {
salesforce: { scope: 'read-only', rateLimit: '60/min' },
monday: { scope: 'read-write', rateLimit: '120/min' },
github: { scope: 'read-only', rateLimit: '5000/hr' },
gitbook: { scope: 'read-only', rateLimit: 'unlimited' },
}
Pilot allowlist is exactly four SoRs (Salesforce, Monday, GitHub, GitBook) per the egress doctrine. Adding a fifth requires a platform-level change, not a per-cartridge flag — that's intentional.
sor declarations require human review
(vs self-declared and lint-checked). For read-write scope on any SoR, almost
certainly review-required. For read-only, possibly self-declare.
egress — Network Firewall allowlist
Derived from sor for most cartridges (each SoR has known API hostnames).
But some cartridges may need additional egress destinations (e.g. a public RSS feed, a
static CDN). Listing them here lets the NFW rule generator produce the right allowlist
without manual touch.
Default egress posture: deny-all. Each entry in egress.destinations opens
one hostname. Sub-pattern matching (*.example.com) allowed but discouraged
— list exact hostnames where possible.
lifecycle — cartridge status + ownership| Field | Purpose |
|---|---|
status | Controls Console launcher behavior: alpha tiles get an "experimental" badge; beta tiles get a "beta" badge; ga tiles render plain; deprecated tiles get a warning + sunset date; sunset tiles are hidden from the launcher (still accessible to admins). |
createdAt | ISO date — used for "added X days ago" UX. |
lastSecurityReview | ISO date — Console flashes a warning if this is older than 365 days. |
owningTeam | The team responsible for the cartridge — surfaced in audit logs, billing reports, and "who do I page" UI. |
onCallRotation | PagerDuty rotation ID (or equivalent) for production incidents involving the cartridge. |
Enforcement is split between the Console (launcher-side) and the cartridge's own backend (runtime-side). Roughly: Console gates discoverability + UI; cartridge backend gates runtime behavior.
| Section | Console-side | Cartridge-side | Platform-side |
|---|---|---|---|
display | renders tile | — | — |
load | iframe / external / disabled dispatch | — | — |
access | requiredGroups filter on tile list | backend re-checks group claim on each request | — |
governance | — | code lint pre-deploy (PII regex on piiMode: none) | Terraform: CMK assignment, region restrictions |
retention | — | backend writes TTL attribute on DDB items, tombstone on delete | Terraform: S3 lifecycle rule, Object Lock policy |
budget | show usage % in tile (optional) | Bedrock invoke wrapper enforces ceiling + model allowlist + guardrail | CloudWatch billing alarm at the soft/hard caps |
sor | — | SoR client library enforces scope + rate limit | Platform reviews read-write scope on PR |
egress | — | — | NFW rule generator produces allowlist; CloudFormation drift detects out-of-band changes |
lifecycle | badges + deprecation warnings on tiles | — | Audit log attribution |
The top-level manifestVersion field exists so the Console can support v1
cartridges (today's 8-field shape) and v2 cartridges (this doc) side-by-side during the
migration. The handler reads manifestVersion first and dispatches to the
right parser.
Manifest version is per-cartridge, NOT global. A v1 cartridge and a v2 cartridge can
live in the same CARTRIDGES array; the launcher renders both correctly.
manifestVersion; default to 1 for entries that don't set it.governance.sensitivityTier: 'internal', retention.sessionTtlDays: 90). A v1 cartridge gets ALL defaults applied silently.
Today: hardcoded in handler.js's CARTRIDGES array. Fine for v0 with three entries; not fine when there are 20 cartridges and an Admin tab edits them.
Future iterations (the natural progression):
CARTRIDGES to cartridges.json in the same Lambda zip. Easier diff in PR, no logic change.s3://ai-sandbox-console-frontend/cartridges.json). Lambda reads at cold start. Admin tab can write without redeploy.piiMode: 'tokenized' assumes the lockbox exists (per
"Content lockbox / tokenization pattern" doctrine). Spec out the lockbox API surface
before any cartridge declares this mode. Currently scoped post-60d.
sor review gate threshold. Self-declare for
read-only, peer review for read-write? Or always peer review on first declaration of any
SoR scope?
budget.hardCapBehavior: 'degrade' needs a fallback
model declaration. Does every cartridge need to nominate a degrade-target model, or does
the platform pick one (always Haiku)?
egress.destinations be enforced by NFW
(network layer) OR by the cartridge's HTTP client (app layer) OR both? Layered defense
argues both. Cost argues just NFW.
version: '1.0.0' field is in
the manifest envelope, but how does the Console decide which version to launch when
multiple are registered? "Latest wins" by default, but maybe an admin pin?
lifecycle.status: 'sunset' hides tile from launcher
but admin still sees. Define the admin override mechanism (probably a URL query param
?show=sunset with admin-group check).
manifestVersion lets v1 and v2 cartridges coexist. v3+ adds sections, never moves existing ones.
When post-60d work begins, the platform team starts from this spec instead of a blank
page. Each TODO marker above is the entry point for a focused decision.
The migration path is already mapped. The enforcement split is already defined. The
first cartridge (chatbot) ships as v2 from day one because the manifest version is
future-aware. The substrate has shape before the build starts.