Dicey Table

What Gets Rejected

DiceyTable enforces mod safety with 68 distinct rejection rules, spread across five enforcement points that run at different times. This page lists every one of them — the exact machine-readable code, the exact condition that trips it, and the exact user-facing message — grouped the way the underlying source groups them. If you have an error string in hand, use your browser's find-in-page on the code and you will land on the right rule.

For the 5 script-content patterns specifically — the ones people hit by accident — see Script Safety. If you already have a code and just want the fix, see Fixing a Rejection.

Where a rule runs#

Every rule below fires at exactly one (occasionally two) of these five points. This matters because the same code can mean "your draft won't save" in one context and "your published mod won't load for a room" in another.

Enforcement point What it is Runs
Local draft validation validateLocalModProjectDraft (packages/shared/src/modManifest.ts) Continuously in the in-app editor, on every draft save (ProjectStore.prepareDraft, apps/server/src/store.ts), and in the browser before an unpublished Preview loads the draft. Never fetches GitHub.
Asset upload gate validateUploadedAssetExtension (apps/server/src/githubScanner.ts) When you upload a file into a draft (ProjectStore.uploadProjectAsset, store.ts), before the payload is even decoded.
GitHub scan (registration) scanGitHubMod (apps/server/src/githubScanner.ts) Once, when a mod's GitHub repo is registered or re-scanned. Fetches the manifest, then — only if the manifest itself passed — every declared asset and the entry script/setup, straight from raw.githubusercontent.com.
GitHub re-scan (serve time) fetchGitHubModArtifact (apps/server/src/githubScanner.ts) Every time a compatible mod is actually loaded into a room. See the serve-time risk below — this is not the same moment as registration.
Registry resolution (publish) ModService.upsertScannedMod (apps/server/src/services/modService.ts) and InMemoryStore.upsertScannedMod (apps/server/src/store.ts) Once per POST /api/mods/register, after the whole GitHub scan has finished. The only point that consults registry state — what else has been published, and at which commit. Nothing in the security boundary is skipped, reordered, or made conditional on what the registry answers.

Rules that stop the scan early#

A manifest-level error — a bad path, an unsupported asset type, an incompatible engine range, or unpinned-scan above — ends the scan before a single asset is fetched. Two consequences worth knowing when you are reading a rejection:

  • You will see the manifest problem only. Asset errors that would also have fired are not reported in the same pass, so fix the manifest and re-scan to see what is underneath.
  • assetCatalog comes back empty for such a mod. That is expected, not a second failure.

The reason is not tidiness: a manifest may declare up to 2000 assets of up to 50 MB each, so scanning a mod that has already been disqualified turned one inbound registration into hundreds of upstream requests.

Why a message sometimes ends in ...#

Refusal text is capped before it is stored. capCompatibilityIssueMessage (packages/shared/src/modManifest.ts) caps the message — the shared message builders call it themselves, so an editor-surface issue is capped identically — and the GitHub scan applies a matching cap to the path on its way out (apps/server/src/githubScanner.ts):

Field Cap What you see past it
message 400 characters The first 397 characters followed by ...
path 180 characters The first 177 characters followed by ...

Both caps come from compatibilityIssueSchema itself, so they are not a display truncation you can scroll past — the stored text really is shorter. The one that bites in practice is a long script name: a rule such as scene-script-undeclared-capability interpolates the script's name into a message that is already close to 400 characters, and component-pack script names come straight off repo JSON with no length bound of their own. So a mod with a verbose script name can see the closing rationale of an otherwise-ordinary refusal cut off, while an identical mod with a short name sees it in full.

Read the front of the message, not the back. Every message in the scanner is deliberately written with its actionable clause first — "Add read-hidden-information to capabilities.allowed in diceytable.mod.json and republish, or remove the call" — precisely so that the instruction survives the cut and only the explanatory tail is lost. A truncated path still names the right file; the missing characters are at the end of the locator, not the start.

This replaced a worse failure. An over-long message used to be written out whole and then dropped on the way back in: the server re-validates each stored issue when it reads it, an over-long one failed that validation, and the issue silently disappeared. The mod stayed incompatible with no reason shown at all — a refusal whose explanation is the mitigation, reported as a clean scan. Truncation is strictly better than disappearance, which is why the cap exists rather than a looser schema.

Script-content patterns (5)#

These are checked by the identical rule set in two places — SANDBOX_SCANNER_RULES (apps/server/src/githubScanner.ts) and bannedScriptPatterns (packages/shared/src/modManifest.ts) — and fire during GitHub scan/re-scan (scanScriptText) and local draft validation / scene-script validation (scanModScriptSafety). They are documented in full, including the false-positive trade-off and workarounds, on their own page: Script Safety. See also the Surface B api reference: /docs/scripting-api/api.

Commit pinning (1)#

unpinned-scan#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: the scan cannot be pinned to a commit it has proven. Either the ref could not be resolved to a commit sha (registration prefers the sha the publisher supplies, falls back to an api.github.com lookup, and both came up empty), or a commitSha was supplied and GitHub's commits API did not confirm it. A supplied sha must be the full 40 characters and GET /repos/{owner}/{repo}/commits/{sha} must answer with that exact sha. An abbreviated sha, a sha that is not a commit, a branch or tag literally named like a sha (the API resolves it to a different commit), and a lookup GitHub does not answer are all refused, and the sha is not recorded.
  • Message (unresolvable ref): Could not resolve ${ref} to a commit, so this scan cannot be pinned. What was scanned is not guaranteed to be what is served. Set GITHUB_TOKEN on the server or re-submit with an explicit commit sha.
  • Message (abbreviated supplied sha): A commit sha supplied with a mod registration must be the full 40 characters; an abbreviated sha can also name a branch or tag. Omit it to pin whatever the ref resolves to.
  • Message (not a commit): The supplied sha is not a commit in ${owner}/${repo}. Register the exact commit sha. — with (it resolves as a branch or tag name) before the full stop when GitHub answered with a different sha.
  • Not a rejection — GitHub did not answer. If the commits lookup is rate-limited (401, 403, 408, 429), returns a 5xx, fails on the network or times out, registration is not recorded as unpinned-scan. A definite answer (404, 409 empty repository, 410, 422, 451) is still a refusal. POST /api/mods/register answers 503 with { "error": "github-unavailable", "retryable": true } and a Retry-After header, and the existing record for the mod is left exactly as it was, so a GitHub hiccup during publish cannot take a working pack offline. Retry the registration.
  • A just-merged commit. If the commits endpoint briefly answers 404 for a sha you merged a moment ago, registration also looks up the ref you registered at. If that ref's head is exactly your sha, the sha is accepted. A 404 is refused only when the ref points somewhere else or does not exist.

Why this is an error and not a warning. Every other rule on this page asks "is this mod safe?" This one asks "will the mod we just checked be the mod we later serve?" — and without a sha the honest answer is no. The scan would read one commit off a branch and the artifact would be served from wherever that branch points later, so a repo owner could pass review and then change what runs. A warning would have left the mod installable, which is the same as not having the rule.

Why a supplied sha is looked up rather than trusted. GitHub resolves the ref in a raw.githubusercontent.com URL against branch and tag names as well as commits. A value that merely looks like a sha — cafe1234, or 40 hex characters — can be a branch, and a branch moves. Accepting it on shape alone would scan whatever the branch holds today and record it as a permanent pin. This is the same rule plugins follow.

If you are self-hosting and every scan trips this, the server is missing GITHUB_TOKEN: the commit lookup is unauthenticated without it and GitHub allows only 60 such requests per hour for the whole server. Publishing through DiceyTable's own flow supplies the sha directly and never needs the lookup.

Reserved ids (1)#

reserved-pack-id#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only, decided from the manifest before any asset is fetched.
  • Condition: the manifest's id is the id of a pack built into DiceyTable — today diceytable.dining-table, the built-in Dining Table. Compared case-insensitively. The list is the shared BUILT_IN_TABLE_PACKS export, so a new built-in pack is reserved the moment it ships.
  • Message: "${id}" is reserved for a pack built into DiceyTable (diceytable.dining-table). Every client loads that id from the app itself, so a mod published under it could never load. Choose a different id.

Why it is refused rather than allowed to shadow. A game assigns a built-in table with an ordinary pin, and every client resolves that pin from its own build before it asks the registry. A mod registered under the same id would therefore never be the table that loads — but its registry card, credits and picker entry would say it was. Refusing the id keeps "the pack named diceytable.dining-table" meaning exactly one thing.

Path & asset rules (8)#

unsafe-path#

  • Severity: error
  • Where: validateManifestPathspackages/shared/src/modManifest.ts. Runs during GitHub scan (scanGitHubMod, githubScanner.ts) and local draft validation.
  • Condition: any of manifest.assets[], entry.setup, or entry.script starts with /, contains .., contains a backslash, or matches ^https?:// (modManifest.ts).
  • Message: Mod paths must be relative repository paths without traversal or external URLs.

unsupported-asset-type (manifest)#

  • Severity: error
  • Where: validateManifestPathspackages/shared/src/modManifest.ts. GitHub scan + local draft validation.
  • Condition: an asset path in manifest.assets[] has an extension outside MOD_ASSET_ALLOWED_EXTENSIONS (19 extensions, verified by count from source — modManifest.ts: .json .png .jpg .jpeg .webp .gif .bmp .avif .ktx2 .basis .glb .gltf .bin .mp3 .ogg .wav .txt .csv .hdr).
  • Message: Unsupported asset type: ${extension || "none"}. (extension included verbatim, e.g. Unsupported asset type: .psd.)
  • Note: the scanner's content-type policy (ASSET_CONTENT_TYPE_ALLOWLIST) covers the same 19 extensions and is typed as Record<ModAssetExtension, …>, so those two cannot drift without a compile error. The upload gate below is one narrower. UPLOADABLE_ASSET_EXTENSIONS is derived from the same tuple with .bin removed, so it holds 18.bin is legal in a hand-authored manifest beside a .gltf, and is refused as a direct upload because no editor flow produces one. githubScanner.test.ts pins the difference at exactly [".bin"].

unsupported-script-type#

  • Severity: error
  • Where: validateManifestPathspackages/shared/src/modManifest.ts. GitHub scan + local draft validation.
  • Condition: manifest.entry.script is set and does not end in .js.
  • Message: Sandboxed mod scripts must be JavaScript files.

missing-asset#

  • Severity: error
  • Where: validateAssetapps/server/src/githubScanner.ts. GitHub scan only — this does a live HEAD request to raw.githubusercontent.com, so it cannot run against a local draft.
  • Condition: the HEAD request for a declared asset (or an undeclared coverImage/screenshots[].path) gets a definite non-2xx answer, such as 404. A rate limit (401, 403, 408, 429), a 5xx, a network failure or a timeout is not recorded as missing-asset: registration answers a retryable 503 github-unavailable and leaves the existing record untouched. The same applies to plugin resource files.
  • Message: Asset could not be fetched: ${response.status} ${response.statusText}. (dynamic — reflects the real GitHub HTTP status).

asset-too-large#

  • Severity: error
  • Where: validateAssetapps/server/src/githubScanner.ts (checked twice: once from the HEAD response's content-length, once from the actual downloaded byte length). GitHub scan only.
  • Condition: the asset is larger than MAX_ASSET_BYTES = 50 MB (githubScanner.ts) — or, for a .hdr sky, larger than MAX_HDR_ASSET_BYTES = 10 MB (packages/shared/src/assetContentTypes.ts). Both are checked from the header first, so an oversize file is never downloaded.
  • Message: Assets must be 50 MB or smaller in v1. — for a .hdr: HDR skies must be 10 MB or smaller — re-import it in the editor, which downscales it to fit.
  • Fix (.hdr): import the sky through the editor's Upload. It re-encodes an oversize .hdr (and converts an .exr) and halves its resolution until it fits.

asset-content-mismatch#

  • Severity: error
  • Where: fetchCheckedGitHubAssetapps/server/src/githubScanner.ts. GitHub scan (mods and plugins) only.
  • Condition: a .hdr asset's bytes do not begin with a Radiance header line (#?RADIANCE or #?RGBE). .hdr accepts generic content types (application/octet-stream, text/plain) because GitHub has none registered for it, so the bytes are what prove the file is a sky.
  • Message: A .hdr asset must be a Radiance RGBE image (starting with #?RADIANCE or #?RGBE).

asset-content-type-missing#

  • Severity: error
  • Where: validateAssetContentTypeapps/server/src/githubScanner.ts. GitHub scan (via validateAsset) and the asset upload gate (via store.ts, which re-runs it against the browser-supplied contentType).
  • Condition: the extension has a known content-type policy in ASSET_CONTENT_TYPE_ALLOWLIST but no content-type header (or an empty one) was returned/supplied.
  • Message: Asset is missing a content-type header.

asset-content-type-mismatch#

  • Severity: error
  • Where: validateAssetContentType — same function and call sites as above.
  • Condition: the extension's policy set does not contain the normalized content-type, and the content-type is not one of the two generic binary fallbacks (application/octet-stream, binary/octet-stream, githubScanner.ts).
  • Message: Asset content-type ${normalized} does not match expected policy for ${extension}.

asset-read-failed#

  • Severity: error
  • Where: validateAssetapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: the HEAD request succeeded (so missing-asset did not fire) but the follow-up GET used to hash the asset body got a definite non-2xx answer. A transient failure answers 503 github-unavailable instead, as for missing-asset.
  • Message: Asset content could not be read for hashing: ${bodyResponse.status} ${bodyResponse.statusText}.

Media rules (2)#

Files under the reserved media/ folder (cover image, screenshots) get their own, additive checks on top of everything above. GitHub scan only — see Required & Conventional Files for what lives in media/.

media-unsupported-type#

  • Severity: error
  • Where: validateMediaImageAssetapps/server/src/githubScanner.ts.
  • Condition: a path under media/ (per isMediaPath) has an extension outside .webp, .png, .jpg, .jpeg (MEDIA_IMAGE_EXTENSIONS, githubScanner.ts).
  • Message: Files under media/ must be images (.jpeg, .jpg, .png, .webp); got ${extension || "no extension"}. (the list is [...MEDIA_IMAGE_EXTENSIONS].sort() — alphabetical, not declaration order).

media-path-invalid#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts.
  • Condition: manifest.coverImage or a manifest.screenshots[].path is not a safe relative path (isSafeRelativeModPath) or is not under media/ (isMediaPath).
  • Message: coverImage and screenshots must be relative paths under the media/ folder.

Capability rules (3)#

undeclared-capability#

  • Severity: error
  • Where: validateManifestCapabilitiespackages/shared/src/modManifest.ts. Runs during GitHub scan/re-scan and local draft validation, against the entry script's compiled/source text.
  • Condition: the script's source matches one of the capability detector regexes (scriptCapabilityDetectors, modManifest.ts — 13 regexes for the 12 capabilities, because read-world has a second detector that fires on an onZone*/onTrigger* hook subscription) whose capability is not present in manifest.capabilities.allowed.
  • Message: Script uses capability "${capability}" but it is not declared in manifest.capabilities.allowed.
  • The asymmetry: only using an undeclared one rejects the mod. Declaring a capability you never use no longer passes in silence, though — it is a warning (scene-script-capability-unused, or component-pack-capability-unused for an Asset pack), because an unused entry over-warns every player who reads your declaration. Full detector-by-detector detail: Capabilities Reference.
  • The one worth knowing by name: /\bapi\.getUnredactedSnapshot\s*\(/ demands read-hidden-information. The elevated read was given its own method name rather than a flag on api.getSnapshot() precisely so this detector could exist — the scanner reads script text, so "this mod reads hidden information" has to be visible in the script, greppable by a reviewer, not only in the manifest.
  • The plugin half: a manifest.plugins entry naming at least one function, without plugin-call in capabilities.allowed, is reported under this same code, with the message manifest.plugins declares plugin functions, so capabilities.allowed must include "plugin-call". A resource-only entry ("functions": []) grants no call and does not trigger it.

dynamic-plugin-call#

  • Severity: error
  • Where: validateManifestPluginUsespackages/shared/src/modManifest.ts, folded into validateManifestCapabilities, so it runs on every path that one does: GitHub scan, serve-time re-scan, and local draft validation.
  • Condition: the script contains an api.callPlugin( whose first two arguments are not both plain string literals. A variable, a computed name, or a template literal carrying an interpolation all count — the scanner counts every call site and separately reads the ones it can resolve, and a site it could not read is a rejection rather than a shrug.
  • Message: api.callPlugin() must name its plugin id and function as plain string literals so the scanner can see which plugin functions this mod calls.
  • Why there is no exemption: the point of the declaration is that a reviewer can see a mod's entire external reach from its manifest without running anything. A computed target defeats that in both directions — the manifest would describe a reach the script does not have, or the script would have one the manifest does not describe. Branching between two literal call sites is one extra line.

undeclared-plugin-call#

  • Severity: error
  • Where: the same function, the same three paths.
  • Condition: a statically-readable api.callPlugin("<id>", "<fn>" pair that manifest.plugins does not declare.
  • Message: Script calls plugin function "<id>.<fn>" but manifest.plugins does not declare it.
  • Also enforced at run time: an undeclared pair resolves to { ok: false, reason: "not-found" }, indistinguishable from a plugin that is not installed. This rule is the half that makes it visible before anybody installs the mod. Full rules: Calling a plugin from a mod.

JSON & schema rules (13)#

invalid-json#

  • Severity: error
  • Where: two call sites, same code, different fallback text.
    • validateJsonFile (apps/server/src/githubScanner.ts) — GitHub scan, for entry.setup when it parses as JSON but is neither a valid edit-scene nor a valid mod-setup document (or is not valid JSON at all, or is really missing). If GitHub does not answer while fetching it (rate limit, 5xx, network failure, timeout), this is not recorded: registration answers a retryable 503 github-unavailable and the existing record is untouched. The same is true for the entry script and the manifest.
    • parseJsonDocument (packages/shared/src/modManifest.ts) — local draft validation, for the manifest text and the setup text.
  • Condition: JSON.parse throws, or (scan path only) the parsed document fails both editSceneSnapshotSchema and modSetupSchema.
  • Message: the real parse/Zod error message, or a fallback — "Setup JSON could not be parsed." (scan path) vs. "JSON could not be parsed." (local-draft path). The fallback text itself differs between the two call sites; this is a genuine, if harmless, small inconsistency — see escalations.

invalid-model-meta#

  • Severity: error
  • Where: validateSidecarJsonAssetapps/server/src/githubScanner.ts. GitHub scan only (runs inside validateAsset, so it needs the fetched bytes).
  • Condition: the asset path matches isModelMetaPath (a <model>.meta.json sidecar) and its content is not valid JSON, or does not match modelAssetMetaSchema.
  • Message: two variants under the same code — Model meta sidecar is not valid JSON: ${error.message}. or Model meta sidecar does not match the expected schema (${field}: ${zodMessage}).

model-meta-authoring-ignored#

  • Severity: warning — the model still loads.
  • Where: validateSidecarJsonAsset — the same function, the isModelMetaPath branch. GitHub scan only.
  • Condition: a <model>.meta.json sidecar is valid JSON and fails modelAssetMetaSchema, and every Zod issue is confined to the two model-editor authoring keys, collider and triggers (MODEL_META_AUTHORING_KEYS). A root-level failure — the file is not an object at all — is not confined, and stays the error above.
  • Message: Model meta sidecar has an invalid `collider`/`triggers` block (${field}: ${zodMessage}). It will be IGNORED at runtime — the model still loads with an automatic collider and no trigger volumes. Re-save it in the model editor to fix.
  • Why this is a warning and not an error. Extending modelAssetMetaSchema with collider/triggers changed this validator with no diff of its own: keys that used to be unknown and silently stripped became schema failures, and every other schema failure here makes the mod incompatible. Refusing a mod outright for a block the runtime simply ignores would have taken published mods down for a cosmetic authoring defect.

invalid-deck-definition#

  • Severity: error
  • Where: same function as above, isDeckDefinitionPath branch (assets/decks/<slug>.deck.json).
  • Condition: not valid JSON, or does not match customDeckDefinitionSchema.
  • Message: Custom deck definition is not valid JSON: ${error.message}. or Custom deck definition does not match the expected schema (${field}: ${zodMessage}).

engine-incompatible#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: isCompatibleEngineRange(manifest.compatibility.engine, "0.1.0") returns false.
  • Message: Mod targets ${manifest.compatibility.engine}; current engine is 0.1.0.
  • Read this with a grain of salt: compatibility.engine is not semver — see Manifest — Compatibility for the actual 2-token grammar, and a malformed range like "^0.1" matches everything rather than rejecting (isCompatibleEngineRange, packages/shared/src/modManifest.ts, falls through to return true when neither the >= nor the < regex matches).

missing-manifest#

  • Severity: error
  • Where: validateLocalModProjectDraftpackages/shared/src/modManifest.ts. Local draft validation only — a GitHub scan can't reach this state because scanGitHubMod already threw fetching the manifest.
  • Condition: the draft's manifest text is empty.
  • Message: Draft is missing diceytable.mod.json contents.

missing-local-asset#

  • Severity: error
  • Where: validateLocalModProjectDraftmodManifest.ts. Local draft validation only.
  • Condition: an asset the manifest declares is not present among the draft's known files/asset paths.
  • Message: Manifest references an asset that is not present in the local draft.

missing-local-setup#

  • Severity: error
  • Where: validateLocalModProjectDraftmodManifest.ts. Local draft validation only.
  • Condition: manifest.entry.setup is set but not present in the draft's file list.
  • Message: Manifest entry.setup does not exist in the local draft file list.

missing-local-script#

  • Severity: error
  • Where: validateLocalModProjectDraftmodManifest.ts. Local draft validation only.
  • Condition: manifest.entry.script is set but not present in the draft's file list.
  • Message: Manifest entry.script does not exist in the local draft file list.

template-instantiation-failed#

  • Severity: error
  • Where: validateLocalModProjectDraftmodManifest.ts (two call sites, same code/message shape). Local draft validation. (The same code is also produced by the separate, legacy POST /api/mods/validate/setup route — apps/server/src/modValidation.ts — which validates the old mod-setup format only and is out of scope for this reference; see Setup JSON.)
  • Condition: the mod-setup document parses against its schema but instantiateSetupObjects throws while expanding templates.
  • Message: the thrown error's message, or "Unable to instantiate setup templates." as a fallback.

unsafe-project-path#

  • Severity: error
  • Where: validateProjectFilePathsmodManifest.ts, called from validateLocalModProjectDraft. Local draft validation only — this checks the draft's own file-metadata list, which a GitHub scan never sees.
  • Condition: a draft file's path fails isSafeRelativeModPath.
  • Message: Project files must use forward-slash relative paths without traversal or external URLs.

duplicate-project-path#

  • Severity: error
  • Where: validateProjectFilePaths — same function. Local draft validation only.
  • Condition: the same path appears more than once in the draft's file-metadata list.
  • Message: Project contains duplicate file metadata for ${file.path}.

schema-* (dynamic family)#

  • Severity: error
  • Where: toCompatibilityIssuepackages/shared/src/modManifest.ts. Local draft validation only, for a manifest or edit-scene setup document that fails Zod validation.
  • Condition: any Zod issue produced while parsing the manifest or the setup document with safeParse.
  • Code: schema-${zodIssue.code} — e.g. schema-invalid_type, schema-too_small, schema-custom. This is a family, not one fixed string; counted here as one rule.
  • Message: the raw Zod issue message, verbatim.

Per-type and setup-option rules (3)#

manifest.setupOptions declares a small typed form the host answers in the lobby, before the table exists. Everything about one option in isolation — the key format, the length caps, the 12-option and 32-choice ceilings — and every cross-field rule — duplicate keys, a default outside its own bounds or absent from its own choice list — is decided by modManifestSchema while the manifest is being parsed, which is a hard 400 at registration and a schema-* issue in the editor. See manifest schema failure.

The two rules below are the ones a single option cannot decide, because they need the rest of the manifest. Both run in scanGitHubMod (apps/server/src/githubScanner.ts) before the early-return gate, so a mod refused for either fetches no assets at all.

setup-options-unreadable#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: manifest.setupOptions is non-empty, and either the pack is not a game-pack or manifest.entry.script is absent.
  • Message (a game pack with no script): Declare "entry.script", or remove "setupOptions". Setup options reach a mod only as the third argument to setup(api, manifest, options), and this mod has no script. It declares ${count} (first: "${key}"), which nothing can ever read.
  • Message (any other pack type): Remove "setupOptions", or publish this as a game pack. Setup options are answered in the lobby for the room's GAME and reach it as the third argument to setup(api, manifest, options). Only a game pack is ever that mod, so these would be a form nothing can read. It declares ${count} (first: "${key}"), which nothing can ever read.

Why a refusal rather than silence. Setup options arrive as the third argument to setup and by no other route — there is no capability for them, no API method and no hook. A mod whose entry is a setup.json alone has no code at all: instantiating a setup file places the entities it names and consults nothing else. So an option declared there is asked of a host, answered by them, and then read by nobody — the host configures something that does nothing, and nothing anywhere says so. That is the failure this rule exists to replace, and it is the same argument CAP-1 makes for refusing a scriptless pack's entry script by name instead of stripping it.

Two shapes trip it, and the difference is which advice is honest. A game pack with no script is told to add one. Anything else is told to remove the options: the type refusal outranks the script one, because the lobby resolves setup options from the mod it calls the room's game (resolveRoomGameModId — the provider, or the single selected mod). A room, table or asset pack is a dependency of a game rather than the game, so "add a script" would be advice that changes nothing — and for a room or table pack the schema refuses the script outright (CAP-1) on top of that.

Only the option key is quoted back to you — bounded to 64 characters and to lowercase snake_case by the schema. Your label and help are never interpolated into a refusal: two of them at their maximum lengths would exhaust the 400-character message cap on their own, and the message would be dropped rather than shown.

lobby-requirements-unread#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: manifest.lobbyRequirements is non-empty and manifest.type is not game-pack.
  • Message: Remove "lobbyRequirements", or publish this as a game pack. Lobby requirements gate Start on what each seated player has chosen for the room's GAME, resolved from the room's provider mod. Only a game pack is ever that mod, so these would gate nothing. It declares ${count}, which nothing will ever ask.
  • path: diceytable.mod.json:lobbyRequirements

The counterpart to the rule above, one axis over. Scriptlessness is not what decides this: a game pack with no entry.script may declare requirements perfectly well, because the platform asks, checks and applies them without any code of yours. What decides it is being the room's game. Both setupOptions and lobbyRequirements are read from the single mod resolveRoomGameModId resolves, and a room, table or asset pack is never it. A requirement declared on one is asked of nobody — the pack publishes, the lobby never shows the picker, and nothing anywhere says why.

It is refused at publish and not at parse, deliberately. modManifestSchema still accepts the field on any type, so a pack already published carrying it stays readable rather than disappearing from the tables that depend on it. Publish is the moment an author is present to be told. The whole per-type table, and the rule for which tier a field belongs in, is in packages/shared/src/modTypeFields.ts.

The editor does not offer it. The manifest editor renders the Lobby requirements section for a game pack only, so the ordinary way to reach this refusal is a hand-written manifest — and if one arrives that way, the editor lists the key with its reason and a Remove button rather than hiding it along with the section.

rules-unread#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: manifest.rules is non-empty and manifest.type is not game-pack.
  • Message: Remove "rules", or publish this as a game pack. Rules are opened from the room's GAME (the Rules button resolves the room's game mod) and shown on a game's public page. Only a game pack is ever that mod, so these would be a rulebook nothing can open. Publish them on the game pack that uses this one. It declares ${count}, which nothing will ever open.
  • path: diceytable.mod.json:rules

The same argument as the two rules above, with a cost they do not have. Rules are read from the single mod resolveRoomGameModId resolves — the room's provider, or the one selected mod — and a room, table or asset pack is a dependency of a game rather than the game. A rulebook declared on one is opened by nobody. Unlike an unread setup option, though, it is not merely inert: an author who uploads a 40-page rulebook to a table pack has pushed megabytes to their repository that no surface will ever show them. That is why this is a publish refusal and not an advisory.

Refused at publish rather than at parse, for the reason every publish-tier row records: a manifest already published carrying the field stays readable rather than disappearing from the tables that depend on it. The editor does not render the Rules section for the other three types.

dice-unread#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: manifest.type is not game-pack, and either manifest.dice is non-empty or manifest.diceEnabled is false. Each of the two is reported separately.
  • Message (list): Remove "dice", or publish this as a game pack. The dice picker offers the dice declared by the room's GAME, resolved from the room's provider mod. Only a game pack is ever that mod, so this list would be offered to nobody. Declare it on the game pack that uses this one. It declares ${count}, which nothing will ever offer.
  • Message (switch): Remove "diceEnabled", or publish this as a game pack. followed by the same explanation, without the count.
  • path: diceytable.mod.json:dice or diceytable.mod.json:diceEnabled

The same argument as rules-unread, one field along. The dice picker offers the dice declared by the single mod resolveRoomGameModId resolves — the room's provider, or the one selected mod — and a room, table or asset pack is a dependency of a game rather than the game. A dice list on one is offered to nobody.

A dice list is cheap, so this is not the bandwidth argument rules-unread makes: it is the invisibility one. The picker does not fall back to nothing, it falls back to the standard d4–d20 set, so an author who curated dice on a table pack sees a working picker showing the wrong dice, with nothing anywhere to say why the ones they chose never appeared. Refused at publish, where they are still around to move the list to the game pack.

Refused at publish rather than at parse, for the reason every publish-tier row records: a manifest already published carrying the field stays readable rather than disappearing from the tables that depend on it.

rules-path-invalid#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: a file named by a rules document is not a safe relative path under rules/.
  • Message: Move this file under the rules/ folder. Every rules file must be a relative repo path there, which is the folder both the scanner and the CDN scope rules file types to.
  • path: diceytable.mod.json:rules[${index}]

rules/ is not a naming convention — it is the scope of a permission. .md and .pdf are deliberately not mod asset types: you cannot declare one in assets, and the editor refuses one as an upload anywhere else in the tree. They are readable only under rules/, and that scoping is what lets a rulebook be published without widening what a mod may ship generally. A rules file named outside the folder would be a file the CDN then refuses to serve, so it is refused here instead, where the message can say what to do.

Like the two rules above this one runs before the early-return gate, so a malformed rules block fetches nothing at all — the same amplification argument setup-options-unreadable makes.

rules-unsupported-type#

  • Severity: error
  • Where: scanGitHubMod and validateRulesFileAssetapps/server/src/githubScanner.ts.
  • Condition: a rules file's extension is not one of .md, .webp, .pdf.
  • Message: Rules files must be one of .md, .webp, .pdf. A PDF is published as rendered .webp pages plus the original; the editor does that conversion for you.

A PDF is published twice, and only one of them is read. The editor renders each page to a web-sized .webp and it is those images the Rules panel shows — no PDF reader is involved on the reading side, on any surface, which is also why a rulebook works in the installed app. The original .pdf rides along only as a Download the original PDF link. So a rules folder holds images and markdown, plus at most one PDF per document, and anything else is a file nothing would ever open.

rules-too-large#

  • Severity: error
  • Where: scanGitHubModapps/server/src/githubScanner.ts. GitHub scan only.
  • Condition: one rules document's files — every page plus the preserved original — exceed 10 MB, either by the sizes the manifest declares or by the bytes actually in the repository.
  • Message: Shrink this rulebook to 10.0 MB or less, or Upload it to a file host (Google Drive, Dropbox, your own site) and add it as a Link instead. It declares ${size}. (the measured variant ends Its files measure ${size}.)
  • path: diceytable.mod.json:rules[${index}]

Checked twice, on purpose. The declared sizes are checked before the network so an obviously oversize block costs no outbound fetches; the measured sizes are checked after, because a hand-edited manifest can claim whatever it likes and only the bytes settle it. The editor enforces a 20 MB / 120-page ceiling on the upload itself, which is a courtesy — this is the rule.

The way out is named in the message. A rulebook too big to publish is not a rulebook you cannot ship: upload it to a file host and declare it as a link document. The Rules panel shows the destination host and lets the reader follow it, and nothing is downloaded from us at all.

manifest-secret#

  • Severity: error
  • Where: scanPluginManifestForSecrets (packages/shared/src/pluginManifest.ts), called from scanGitHubMod over manifest.setupOptions. GitHub scan only. The same function is the plugin registry's own credential tripwire — one implementation, two callers.
  • Condition: any string inside setupOptions — a default, a select choice value or label, a help line — matches a credential shape: a GitHub token (ghp_…, github_pat_…), a Stripe-style sk-live…/sk-test… key, a Slack xox… token, an AWS key id (AKIA…), a JWT, or a Bearer … header value.
  • Message: ${trail} looks like an API credential. Plugin source is public and sha-pinned — configure credentials server-side instead. — where trail locates the offending string, e.g. setupOptions[0].options[1].value.
  • path: diceytable.mod.json:setupOptions

Why this is checked at all. A manifest is public and, once registered, pinned to an immutable commit — a key committed there is a published key, and re-pointing the pin does not unpublish it. A select whose choices name deck sources is exactly the place an author reaches for an API key without thinking about where the file lives.

The message says "Plugin source" because the wording belongs to the shared tripwire that the plugin registry also calls. Read it as manifest source: the sentence is true of a mod manifest for precisely the same reasons.

Pack content refusals — CAP-1 (4)#

A room pack describes a room: a lit box with decor. A table pack describes a table: a play surface with metrics. Neither is a game, and neither may carry executable code or describe the other's subject. Both halves are refused by name rather than stripped, because a silently dropped key is indistinguishable from one that was honoured — you would lay out seats in a room pack, see nothing, and have no way to tell "ignored on purpose" from "broken".

Why scripts specifically, and why this fails closed. Capability disclosure used to be derived from the room's own mod list alone (room.selectedModIds, roomCapabilities.ts), which excludes transitive dependencies — so a script sitting inside a pack you depend on ran with none of the capability disclosures firing: no consent prompt, no pre-join gate entry, no in-room indicator, no registry badge. The disclosure now unions the capabilities of every pack in a mod's resolved dependency graph, read from each pinned pack's own published manifest, and a dependency it cannot resolve is disclosed as elevated rather than as harmless.

That does not retire this rule, and the reason is worth stating: the union discloses what a pack declares, and a room-pack or table-pack may declare nothing beyond log. A script smuggled into one would therefore run while the room described it as log-only — under-disclosed rather than undisclosed, which is no better to the player whose hand it read. "A pack cannot carry a script at all" remains the protection, which is why this is an error (the mod lands incompatible) and not a warning.

Presence is the test — not shape, not validity, not content. The check reads the raw JSON, before any schema touches it, and fires on the presence of a top-level roomPack / tablePack key. It does not require the document to be a well-formed room or table pack, and it does not require the document to be scene-shaped. That is the whole point of the rule: a hand-written {"roomPack": …, "scripts": […]} carrying neither schemaVersion nor environment used to fall through to modSetupSchema, whose fields all have defaults and which therefore stripped both unknown keys and reported nothing — and the document published compatible. An empty "scripts": [] beside roomPack is still a refusal, for the same reason: Zod can only ever make a document look cleaner than it is.

One issue is emitted per offending key, so a document carrying both scripts and sceneScriptIds produces two.

room-pack-scripts-forbidden#

  • Severity: error
  • Where: roomPackForbiddenKeyIssuesapps/server/src/githubScanner.ts, called from validateJsonFile on both of its branches. GitHub scan only — it is not part of the serve-time re-scan and not part of local draft validation.
  • Condition: the raw entry.setup JSON has a top-level roomPack key and a scripts or sceneScriptIds key (either one, defined, of any value).
  • Message: A room pack may not carry scripts. Capability disclosure is derived from the room's own mod list, which excludes transitive dependencies, so a script in a depended-on pack would run with no disclosure at all (CAP-1). Put scripts in the game pack.
  • Also refused one level up: a manifest whose type is room-pack or table-pack may not declare entry.script at all, and may not declare any capability except log — both are refusals inside modManifestSchema itself (packages/shared/src/modManifest.ts, SCRIPTLESS_MOD_TYPES), which means they arrive as a manifest schema failure rather than under a code of their own. That closes the other door: this rule stops a script arriving as scene data, that one stops it arriving as an entry point.

room-pack-forbidden-key#

  • Severity: error
  • Where: the same function, the same call sites.
  • Condition: a document with a top-level roomPack key carries any of the other keys in ROOM_PACK_FORBIDDEN_KEYS (packages/shared/src/packPayload.ts) — prefabs, zones, seatZones, seatTemplate, snapPoints, tablePack, tableMetrics, packRefs.
  • Message: one per key, naming why. Layout keys read like A room pack describes a room. Player zones belong to the game pack that uses it. The three single-writer keys read longer: the table owns the play-surface height and footprint, a room may only read them, and packRefs is refused because only a game assigns a room and a table — a pack that assigned a table would be a second writer for surfaceY, and one that assigned a room would leave composition with two rooms and no rule for choosing.
  • Why two codes and not one: a CAP-1 violation is a security finding and has to stay greppable apart from "you put seats in a room pack".

table-pack-scripts-forbidden#

  • Severity: error
  • Where: tablePackForbiddenKeyIssuesapps/server/src/githubScanner.ts, called from validateJsonFile on both of its branches. GitHub scan only — it is not part of the serve-time re-scan and not part of local draft validation.
  • Condition: the raw entry.setup JSON has a top-level tablePack key and a scripts or sceneScriptIds key (either one, defined, of any value).
  • Message: A table pack may not carry scripts. Capability disclosure is derived from the room's own mod list, which excludes transitive dependencies, so a script in a depended-on pack would run with no disclosure at all (CAP-1). Put scripts in the game pack.
  • The asymmetry this page used to describe is closed. Until the transitive capability union landed, this check ran only on documents that were not scene-shaped, justified by the fact that a valid table pack is scene-shaped and its scripts are scanned there by validateSceneScriptSafety. Scanning is not refusing: that pass judges a script's content, so a scene-shaped document carrying tablePack plus a script that passed the five content patterns published clean and was refused only at load. It now runs on both branches, exactly like the room's, so publish and load give the same verdict.
  • Also refused one level up: a manifest whose type is table-pack may not declare entry.script and may not declare any capability except log — refusals inside modManifestSchema itself, which arrive as a manifest schema failure.

table-pack-forbidden-key#

  • Severity: error
  • Where: the same function, the same two call sites.
  • Condition: a document with a top-level tablePack key carries any of the other keys in TABLE_PACK_FORBIDDEN_KEYS (packages/shared/src/packPayload.ts) — room, prefabs, zones, snapPoints, packRefs. Note the deliberate difference from the room's list: seatZones and seatTemplate are kept, because the seat ring is a property of the table's own footprint.
  • Message: one per key, e.g. A table pack describes a table. The room is owned by the game's assigned room pack; a table may not bring its own (O-19/O-30 — one writer per subject).

Component pack rules — CAP-1 (5)#

A component pack is the inverse of the two above. A room pack and a table pack are refused outright for carrying a script at all; a component pack is the pack kind whose whole point is prefabs with scripts, so nothing here can be refused for existing. What is refused is a pack whose declaration does not describe its code.

That declaration is not paperwork. Every room that depends on your pack discloses the union of its dependencies' declared capabilities to its players, read from each pinned pack's own published manifest. Your capabilities.allowed is therefore the only thing standing between a prefab script and a player who was never told about it — and it is read from the immutable version record, so narrowing it after publish changes nothing for anyone already pinned to that version.

All five rules run in componentPackScanIssues / componentPackSetupIssues (apps/server/src/githubScanner.ts), after every rule above it on this page. They fire only when manifest.type is component-pack.

component-pack-capabilities-undeclared#

  • Severity: error
  • Where: componentPackScanIssuesGitHub scan only. It cannot run at serve time: the stored manifest has already been through the schema, so "the author omitted the block" is no longer distinguishable from "the author wrote log".
  • Condition: the pack ships executable code — an entry.script, or at least one script in its setup document — and the raw diceytable.mod.json has no capabilities.allowed array, or has an empty one.
  • Message: A component pack that ships executable code must declare capabilities.allowed explicitly in diceytable.mod.json. Omitting it is not the same as declaring ["log"]: the schema's default is published to every dependent room as though the author had asserted it (CAP-1), and this is the pack type whose prefab scripts actually run.
  • Writing "allowed": ["log"] satisfies it. The rule is about writing the list, not about how short it is. A pack that ships no code at all is untouched.

component-pack-undeclared-capability#

  • Severity: error
  • Where: componentPackSetupIssuesGitHub scan and the serve-time re-scan, because a rule enforced on one path and not its sibling is not enforced.
  • Condition: a script carried in the setup document uses a capability that is not in manifest.capabilities.allowed, detected by the same detectScriptCapabilities patterns the entry-script rule uses.
  • Message: Prefab script "<name>" uses capability "<capability>" but it is not declared in manifest.capabilities.allowed. A component pack's declaration is what every dependent room discloses to its players (CAP-1), so an undeclared capability would run with no disclosure at all.
  • This is the setup-document sibling of undeclared-capability, which has only ever covered entry.script. A script carried inside the setup document had no capability check at all before this rule. Its own code, so the two stay greppable apart. One issue per undeclared capability per script, pathed at setup.json#scripts/<name>.

component-pack-capability-unused#

  • Severity: warning — it never blocks a publish.
  • Where: componentPackScanIssues — GitHub scan only.
  • Condition: capabilities.allowed names a capability that no script the pack ships appears to use. log is exempt: it is the schema default and the floor every pack has.
  • Message: This pack declares capability "<capability>" but no script it ships appears to use it. Every room that depends on this pack discloses the declaration to its players (CAP-1), so an unused entry over-warns them. Trim it, or ignore this if the call is one the static detector cannot see.
  • Why a warning and not a refusal. Over-declaring fails closed for the player — the room discloses more than your pack can do, so nobody is under-warned. And capability detection is a whole-word pattern match, not a type checker, so refusing a pack because the detector could not see a call you genuinely make would refuse real work. The cost it is naming is real in the other direction: an unused read-hidden-information puts a consent prompt in front of every player of every game that depends on you.

component-pack-script-not-compiled#

  • Severity: warning
  • Where: componentPackSetupIssues — GitHub scan and the serve-time re-scan.
  • Condition: a script in the setup document has no compiled body.
  • Message: Prefab script "<name>" has no compiled body. It will not run, and its capability use cannot be verified against this pack's declaration until it is saved from the code editor.
  • The component-pack sibling of scene-script-not-compiled, and it says the extra thing that matters here: an uncompiled script is not capability-checked either, so your declaration has not been verified against it.

component-pack-script-compiled-scan#

  • Severity: warning — it accompanies a refusal, it is never the refusal.
  • Where: componentPackSetupIssues — GitHub scan and the serve-time re-scan.
  • Condition: one of the 5 script-content patterns refused a script in the setup document.
  • Message: The safety scan reads the COMPILED body of a prefab script, and comments survive the TypeScript emit. It is a whole-word text match with no lexer, so a bare window, top, parent, fetch or eval in a comment or a string is refused exactly as a real call would be. If you did not write the call, reword the comment.
  • 🔴 This is the one that wastes an afternoon. The scan reads the compiled body — what actually executes — and TypeScript keeps your comments in it. It has no lexer, on purpose, because that is what catches self["fetch"]-style lookups. So a comment reading "we never call fetch here" is rejected for calling fetch. That behaviour is inherited unchanged from validateSceneScriptSafety: a component pack's scripts are neither more nor less strictly scanned than any other scene script. This warning exists only so the refusal explains itself.

Two shapes, one verdict. A component pack document may or may not carry an environment key — the schema defaults it — and validateJsonFile routes on schemaVersion + environment. So a pack that omits it misses the scene branch entirely, and its script bodies would otherwise publish unscanned. componentPackSetupIssues therefore content-scans them itself, suppressing by (code, path) anything the scene branch already reported, so a document that is scene-shaped reports each finding exactly once.

Scene-script capability rules (2)#

Every mod type except component-pack ships its scripts inside the scene (entry.setup), and until these two rules landed nothing compared what those scripts use against what the manifest declares. undeclared-capability has only ever read entry.script; component-pack-undeclared-capability reads only an Asset pack's prefab scripts. A game-pack — the type that actually ships a scene full of scripts — could call api.getUnredactedSnapshot() under a manifest declaring ["log"], and the disclosure a player read before joining would be a lie about code running on their table.

This is why an author should care rather than work around it. capabilities.allowed is the text every player is shown before they load your mod: the pre-join gate, the consent prompt, the in-room indicator, the registry badge. A capability your declaration omits does not get a quieter prompt — it runs with no disclosure at all. Widening the rule to every mod type can newly refuse an already-published game pack, and that migration cost was accepted deliberately rather than softened: an undeclared capability is a hard error, and the mod stays incompatible until its author republishes with a corrected list.

Scope, worth stating plainly: only scripts that can actually execute are checked. The scripts read are the ones already parsed off the scene branch (schemaVersion + environment). A setup document that is not scene-shaped goes to modSetupSchema instead — a z.object whose every field has a default, which therefore strips a scripts key — so those bodies never reach the sandbox, and refusing a mod for them would be refusing dead data. (component-pack is the one type whose document may legitimately miss the scene gate and still run its scripts, which is exactly why its own rules read the raw JSON and content-scan it themselves.)

No new content scan. validateSceneScriptSafety already scanned these exact compiled bodies, so the compiled-comment trap is inherited unchanged — neither stricter nor looser. Nothing here re-runs or relaxes it.

scene-script-undeclared-capability#

  • Severity: error
  • Where: sceneScriptCapabilityIssuespackages/shared/src/modManifest.ts, re-exported by apps/server/src/githubScanner.ts. GitHub scan and the serve-time re-scan, because a rule enforced on one path and not its sibling is not enforced. At registration it makes the mod incompatible; at serve time the same function becomes the hard throw Scene scripts no longer declared.
  • Applies to: every manifest.type except component-pack, which has its own, differently-coded rule — so one script never collects both codes. The two are kept distinct on purpose: an Asset pack's declaration is consumed by a third party's room through CAP-1's union, where nobody can read the pack's code; a game pack's declaration describes only itself.
  • Condition: a scene script's compiled body matches a capability detector (detectScriptCapabilities — the same patterns undeclared-capability uses) whose capability is not in manifest.capabilities.allowed. One issue per undeclared capability per script, pathed at <entry.setup>#scripts/<script name> — e.g. setup.json#scripts/spy.ts.
  • Message: Scene script "<name>" uses capability "<capability>" but it is not declared in manifest.capabilities.allowed. Add "<capability>" to capabilities.allowed in diceytable.mod.json and republish, or remove the call. capabilities.allowed is what every player of this mod is shown before they load it, so a capability the declaration omits would run on their table with no disclosure at all.
  • A script with no compiled body is skipped silently. It cannot run and it cannot be capability-checked; scene-script-not-compiled already warns about it on the same path, and a second warning here would double-report one fact.

scene-script-capability-unused#

  • Severity: warning — it never blocks a publish.
  • Where: declaredCapabilityUsageIssuespackages/shared/src/modManifest.ts, re-exported by apps/server/src/githubScanner.ts. GitHub scan only, pathed at diceytable.mod.json (the declaration is the thing at fault, not any one script).
  • Applies to: every type except component-pack, and only to a mod that carries code at all — an entry.script, or at least one compiled scene script. Usage is the union of the entry script and every scene script, so a capability used by the entry script alone counts as used.
  • Condition: capabilities.allowed names a capability that nothing the mod ships appears to use. log is exempt: it is the schema default and the floor every mod has.
  • plugin-call is also used by a lobby deck database. A lobbyRequirements[].deckDatabase makes the platform call a declared plugin function for the member, with no script involved, so it counts as a use and no warning is raised.
  • Message: This mod declares capability "<capability>" but no script it ships appears to use it. Every player is shown the declaration before loading, so an unused entry over-warns them. Trim it, or ignore this if the call is one the static detector cannot see.
  • Message when plugin-call is unused but plugins still lists functions: This mod declares "plugin-call" and lists plugin functions, but no script calls a plugin and no lobby deckDatabase uses one, so every player is warned for nothing. To trim it, remove "plugin-call" AND empty each plugin's "functions" list — removing only the capability is refused. Ignore this if the detector cannot see your call. Removing only the capability would trip the undeclared-capability plugin rule, which refuses listed functions without plugin-call. A plugin kept only for its resources is { "id": "…", "functions": [] }.

Why undeclared is an error but declared-but-unused is only a warning. The pair looks inconsistent until you notice which way each one fails. Under-declaring fails open for the player: code runs that nothing disclosed. Over-declaring fails closed: the mod claims more than it can do, so nobody is under-warned — the real cost is only that an unused read-hidden-information puts a consent prompt in front of every player for nothing. And detectScriptCapabilities is a whole-word pattern match with no type checker, so making the unused case an error would refuse legitimate work whose call the detector simply cannot see. That is the same rationale already documented for component-pack-capability-unused, one mod type over.

Room performance budget (3)#

A room's cost is checked at publish so a weak GPU is not where a heavy room is first discovered. These are cost rules, not safety rules, and they are deliberately kept apart from the sandbox checks: they run after them, they only add issues, and a change to a cost threshold can never reach a security verdict. The same numbers are shown live in the Room Editor while you build, from the same classifier (roomPackBudgetFindings, packages/shared/src/packPayload.ts), so a refusal here is never the first time you see them.

The thresholds are measured, not guessed, and packages/shared/src/packPayload.ts records the hardware and method behind each one. The most useful thing to know: light count is free. Going from 7 to 24 room lights measured as no frame-cost change at all, so a 24-light room publishes and lights has no refusal threshold. What costs is shadow views — a shadow-casting point light renders six views of every caster (it is a cube map), a spot or directional renders one.

All three rules fire from roomPackBudgetIssues (apps/server/src/githubScanner.ts), called by validateJsonFile during the GitHub scan. Scoping is by the presence of a top-level roomPack key in the raw JSON — exactly like the CAP-1 pack refusals above, and for the same reason. It is not limited to a scene-shaped or otherwise valid document: a setup.json that claims roomPack and then fails to parse as one reports room-budget-unmeasurable rather than being skipped. A plain game scene, which carries no roomPack key at all, is not budget-checked. The forbidden-key checks above run first and stay a separate function, so a cost threshold can never reach a security verdict.

room-budget-exceeded#

  • Severity: error
  • Condition: a measured cost is above its refusal threshold: more than 24 shadow views, more than 256 draw calls, more than 1,500,000 triangles, or more than 256 MB of encoded texture bytes (summed from the image assets the scan already fetched and sized).
  • Message: the measured number, the limit, and the remedy — e.g. 144 shadow views exceeds the limit of 24. Shadow views, not light count, are what a room costs…

room-budget-warning#

  • Severity: warning
  • Condition: a measured cost is above the recommended value but below the refusal threshold — more than 8 shadow views, 96 draw calls, 250,000 triangles or 96 MB of texture bytes.
  • Message: the measured number and the recommended value, naming the refusal threshold too.

room-budget-unmeasurable#

  • Severity: warning
  • Condition: the document carries roomPack data but does not validate as a room pack, so its cost could not be measured.
  • Message: This document declares roomPack data but does not validate as a room pack, so its performance budget could not be measured.

Asset pack performance budget (3)#

The sibling of the room budget above, for a component pack (author-facing name: Asset pack), and built on the same principle: cost rules, kept apart from the safety rules, running after them, adding only issues, and never able to reach a security verdict. The same numbers are shown live in the editor from the same classifier (componentPackBudgetFindings, packages/shared/src/packPayload.ts).

The thresholds are measured, and one measurement is worth knowing before you optimise anything: a sprite sheet is not faster than one image per card. 300 cards cost 2,784 draw calls either way, and the per-card condition actually measured slightly ahead. A card's UV window lives on its material, so a sheet-based card already needs its own material and never batched. What does cost is the number of distinct card faces, because distinct faces are distinct materials — going from 1 to 300 of them across the same 300 cards cost about 1.2 ms per render on the reference GPU.

Two more results shape the rules. A deck is nearly free until it is dealt: a 300-card deck object measured 6 draw calls, and 2,448 once the same cards were on the table. A pack cannot know how much of a deck a game will deal, so deckCards warns and never refuses. And a sprite sheet larger than the GPU's MAX_TEXTURE_SIZE never uploads at all — 32,768px raised a hard GL INVALID_VALUE on the reference GPU, and every card in that deck would render with no art. That is the one real cliff, and the one rule most likely to refuse you. The in-app deck builder caps itself at 4,096px, so a deck it generated cannot trip it; a hand-written or imported .deck.json can.

All three rules fire from componentPackBudgetIssues (apps/server/src/githubScanner.ts), called by validateJsonFile on both of its branches. Scoping is by the presence of a top-level componentPack key in the raw JSON, exactly as for the room.

component-pack-budget-exceeded#

  • Severity: error
  • Condition: a measured cost is above its refusal threshold: a sprite sheet more than 8,192px on a side, more than 4,096 draw calls, more than 1,500,000 triangles, or more than 256 MB of texture bytes.
  • Message: the measured number, the limit, and the remedy — e.g. 16384px largest sheet side exceeds the limit of 8192. A sprite sheet larger than the GPU's MAX_TEXTURE_SIZE never uploads…
  • Note on texture bytes: deck sheet bytes are decoded VRAM, computed from the sheet grid the document itself declares, so the editor and the scanner compute the same number. Encoded bytes for model textures the document does not describe are added on top; encoded is the smaller number for the same art, so publish errs lenient.

component-pack-budget-warning#

  • Severity: warning
  • Condition: a measured cost is above the recommended value but below any refusal threshold — more than 200 prefabs, 300 deck cards, 256 distinct card faces, a 4,096px sheet, 1,200 draw calls, 250,000 triangles or 96 MB of texture bytes.
  • Message: the measured number and the recommended value.
  • Three of those fields can only ever warn. prefabs, deckCards and distinctCardFaces have no refusal threshold at all, because the measurement did not find a cost worth refusing them for: a 300-card deck is 6 draw calls, and a 300-card TCG set is legitimate work.

component-pack-budget-unmeasurable#

  • Severity: warning
  • Condition: the document carries componentPack data but does not validate as an Asset pack, so its cost could not be measured.
  • Message: This document declares componentPack data but does not validate as an Asset pack, so its performance budget could not be measured.

Dependency graph rules (7)#

A mod's manifest.dependencies pins other published packs, each to one exact version. Seven rules check the resulting graph, and all seven need something no single document can see: the registry. They therefore run at the fifth enforcement point — modDependencyIssues (apps/server/src/githubScanner.ts), called by ModService.upsertScannedMod (apps/server/src/services/modService.ts) with InMemoryStore.findPublishedPack as the lookup — after the whole GitHub scan has already fetched and statically scanned the manifest, the entry script and the setup document. Nothing in the security boundary is skipped or reordered to make room for them; they only ever append errors.

The rules themselves live in resolveModDependencies (packages/shared/src/modManifest.ts), which reports and never throws. The server function adds exactly one rule of its own (dependency-commit-mismatch) and otherwise just translates. Every code is the shared ModDependencyIssueCode prefixed with dependency-, so a dependency refusal is one search and can never collide with a scanner code, and the issue's path field carries the pack id, not a file path.

What an error here costs you, which is the point. An incompatible mod is not served (fetchGitHubModArtifact serves compatible only) and gets no published version row, so it cannot itself be depended on by anybody. The graph rules are enforced transitively rather than once at the root: a pack can only appear in your graph if its own graph was clean when it published.

Two design decisions explain most of this list. Dependencies are capped at depth 2 and cycles are refused (O-24), because deep graphs are where dependency systems rot. And versions are named, immutable, and pinned to a commit (O-20) — semver ranges are deliberately rejected, not unimplemented. A range hands you back exactly the automatic breakage the model exists to prevent, and it makes a table non-reproducible: two peers who resolved the range at different times would compose different tables, and the resolved room/table is not replicated, so nothing would ever detect the disagreement.

What is refused earlier, and so never appears here. A range instead of an exact label, more than 24 declared dependencies, a self-reference or a repeated pin in your own manifest, and a room-pack/table-pack that declares an entry script or a capability are all refused by modManifestSchema while the manifest is being parsed — before any of this runs. At registration that is a manifest schema failure, a hard 400 carrying the Zod message; in the editor the same rules produce a schema-* issue.

dependency-cycle#

  • Severity: error
  • Condition: a pack id reappears on its own resolution path — a → b → a, or a depth-2 return to the root. The depth cap alone would already stop the walk; the cycle rule exists to make the graph illegal and say so, because a cycle silently truncated at depth 2 is a graph whose meaning changes the day the cap changes.
  • Message: Dependency cycle: a -> b -> a. Cycles are refused (O-24) — a graph that reaches itself has no order in which its packs can be composed.

dependency-depth-exceeded#

  • Severity: error
  • Condition: a pack at depth 2 declares dependencies of its own. The cap bites there and only there, and the too-deep entries are refused by name rather than truncated — you are told the graph is too deep instead of watching a pack quietly fail to arrive.
  • Message: a -> b -> c is 2 levels deep. Transitive dependencies are capped at 2 (O-24). Depend on "c" directly, or vendor it.

dependency-unresolved#

  • Severity: error
  • Condition: the registry has nothing to hand back for this exact (packId, version) pair. Three different facts collapse into this one code, because to a dependent they are the same fact — this pin does not name something that can be loaded:
    1. No published version row. Only a published, pinned version is depend-on-able. A mod that was registered without a resolvable commit sha has no version row, and there is deliberately no fallback to the live record or to its branch.
    2. No live mod record. The mod was deleted; the pin points at nothing.
    3. The mod is blocked. An administrator's takedown reaches dependents too, and a taken-down pack must not keep entering new dependency graphs.
  • Matching is exact on both id and label — no case folding, no normalisation, no "did you mean".
  • Message: "some-pack" version 1.2.0 could not be resolved. A dependency that cannot be fetched is a table that will not load.

dependency-version-conflict#

  • Severity: error
  • Condition: the same pack is pinned to two different versions anywhere in the graph — most often you pin [email protected] directly while something you depend on pins [email protected].
  • Message: "a" is pinned to 1.0.0 by root -> a and to 2.0.0 by root -> b -> a. One pack, one version: two peers that resolved different ones would compose different tables and nothing would detect it.
  • The fix is a real decision, not a flag. There is no resolver, no "nearest wins" and no latest: move your own pin to the version the other one wants, or ask the other pack to move.

dependency-self-dependency#

  • Severity: error
  • Condition: a pack declares itself as a dependency. A pack is composed after its dependencies, so a self-reference has no order in which it can be resolved.
  • Message: "a" cannot depend on itself. A pack is composed after its dependencies, so a self-reference has no order in which it can be resolved (O-24).
  • You are unlikely to see this code, because the same rule is applied to your own manifest at parse time and fails there first. It exists here as defence in depth, for a declaration found on a pack deeper in the graph.

dependency-duplicate-dependency#

  • Severity: error
  • Condition: one manifest lists the same pack id twice, in any casing. A pack may be pinned to exactly one version, or the pin does not determine what loads.
  • Message: "a" declares "b" more than once. A pack may be pinned to exactly one version, or the pin does not determine what loads (O-20).
  • Same caveat as above: your own manifest is refused at parse; this code covers a deeper one.

dependency-commit-mismatch#

  • Severity: error
  • Where: the one rule modDependencyIssues owns outright, rather than delegating to resolveModDependencies.
  • Condition: a pin supplies an optional commitSha and that sha is not the one the named version was actually published at. Checked at depth 1 and 2 — a transitive pin is a pin. Pins that failed to resolve are skipped, since they already carry dependency-unresolved.
  • Message: "a" version 1.0.0 was published at commit 0123456789ab, but this pin names abcdef012345. A published label is never repointed, so the two cannot both be right. Drop the commitSha and let the label resolve, or pin the version that really carries that commit.
  • Why it cannot be resolved in your favour: the label is the pin and the commit is the record of what it resolved to. When the two disagree, exactly one can be honoured, and choosing silently is precisely the failure O-20 exists to prevent — two peers could each honour a different half.
  • Message length: dependency messages embed a -> b -> c id chains, and ids run to 96 characters, so any message over 400 characters is truncated with a trailing ... before it is stored. A truncated message is not a second failure.

Warnings (3)#

scene-script-not-compiled#

  • Severity: warning — the original warning-severity rule in the scanner, since joined by the two room-budget warnings, the two Asset-pack budget warnings, the two Asset-pack script warnings, model-meta-authoring-ignored and scene-script-capability-unused. A warning never flips a mod's status to incompatible by itself.
  • Where: validateSceneScriptSafetypackages/shared/src/modManifest.ts. Runs from all three script-relevant contexts: GitHub scan (validateJsonFile), GitHub re-scan (fetchGitHubModArtifact), and local draft validation (assignEditSceneSnapshot).
  • Condition: an edit-scene setup document has a scripts[] entry whose compiled body is missing (i.e. the script was saved but never re-transpiled from the code editor).
  • Message: Scene script "${script.name}" has no compiled body and will not run until saved from the code editor.

unstated-model-collider#

  • Severity: warning — the mod loads, and the runtime protects itself either way.
  • Where: sceneCheckFindingspackages/shared/src/sceneCheck.ts, called from assignEditSceneSnapshot in packages/shared/src/modManifest.ts, so it runs on the GitHub scan, the GitHub re-scan and local draft validation alike.
  • Condition: an entity carries metadata.customModelAssetId but no physics.collisionSize.
  • Message: "${name}" has no saved collider, so until its model finishes downloading it is a ${x} x ${z} ft box — its scale, not its shape. Measure it to save the real collider.
  • What it actually means. A custom model's real collider cannot be known until its GLB has downloaded and been measured, so until then the entity wears a placeholder box the size of its scale. For a chess set authored at scale: 3 that is a 3 ft cube per pawn, on a board whose squares are 0.174 ft apart. Saving a collider removes that window entirely.
  • Why this is a warning and not an error. The placeholder is sometimes right (a crate-shaped model at scale: 1), and the runtime does not trust it regardless — an entity whose collider is still a guess is kept out of the simulation rather than allowed to shove its neighbours. So this is the scanner telling you your scene is relying on that defence, not refusing it.
  • Fix: open the scene in the editor, select Entities, and use Scene Check — one button measures every model and saves its collider.

colliders-overlap#

  • Severity: warning.
  • Where: the same sceneCheckFindings call as above.
  • Condition: two unlocked entities whose effective colliders interpenetrate by more than 0.01 ft at their authored positions. Locked entities are excluded, because pieces standing on a locked board are meant to be inside its collider.
  • Message: "${a}" and "${b}" overlap by ${depth} ft. Physics will push them apart the moment the table runs, even though they look correct here — Edit Mode does not simulate.
  • Only the first 5 pairs are reported, followed by a line counting the rest. One shared mistake produces a finding per pair — 528 of them for a 33-piece set — and a CompatibilityIssue list is capped at 500 rows.
  • Why you did not see it in the editor. Edit Mode does not run physics, deliberately: an author placing 32 pieces does not want them settling under gravity mid-edit. The cost is that overlap is invisible until a real table runs, which is why this check exists at all.

Hard throws (8)#

Unlike everything above, these are not CompatibilityIssue objects collected into a list — they are JavaScript Errors thrown out of the scan/serve functions, which the caller surfaces as a hard failure (a failed registration request, or a room that can't load the mod) rather than a structured, path-bound issue.

Manifest failed schema validation#

  • Where: scanGitHubModapps/server/src/githubScanner.ts, the modManifestSchema.parse(...) that runs before any other check. GitHub scan only.
  • Condition: diceytable.mod.json does not satisfy modManifestSchema (packages/shared/src/modManifest.ts) — a missing or malformed field, or one of the refusals the schema makes on its own:
    • a dependencies pin written as a range (^1.2.0, ~1.2, 1.x, >=1.0.0) instead of an exact label, or more than 24 pins;
    • a self-reference or the same pack id declared twice in your own dependencies;
    • a room-pack/table-pack that declares entry.script, or that declares any capability other than log (SCRIPTLESS_MOD_TYPES, CAP-1).
  • Message: the raw Zod error, returned by POST /api/mods/register as {"error": "mod-scan-failed", "message": "…"} with HTTP 400.
  • Why you meet it as a throw and not as an issue: everything else on this page describes a mod the scanner could read. A manifest that will not parse is not a mod yet, so there is nothing to attach a path-bound issue to. The same rules produce structured schema-* issues in the editor, where you are editing a draft rather than submitting one — that is the difference between the two spellings, not a difference in the rules.

Invalid repository URL#

  • Where: parseGitHubRepoUrlapps/server/src/githubScanner.ts (two throw sites, identical message).
  • Condition: the given repoUrl does not match ^https:\/\/github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[/?#].*)?$.
  • Message: Use a public GitHub repository URL such as https://github.com/owner/repo.

Upstream fetch failure#

  • Where: fetchTextapps/server/src/githubScanner.ts. Used for the manifest, the entry script, and every JSON setup fetch.
  • Condition: the fetch to raw.githubusercontent.com returns a non-2xx response.
  • Message: Unable to fetch ${url}: ${response.status} ${response.statusText}

Mod not compatible#

  • Where: fetchGitHubModArtifactapps/server/src/githubScanner.ts.
  • Condition: the caller asks to load a RegisteredMod whose stored status is not "compatible".
  • Message: Only compatible mods can be loaded.

status is usually the scanner's own verdict, but it has one other writer: an administrator can set it to blocked to take a published mod down. A block is stored separately from the scan result, so re-registering the repo does not clear it, and it takes effect on the next load rather than whenever a cache happens to expire. If a mod of yours is blocked, re-scanning will not help — get in touch.

Scene scripts no longer safe (serve time)#

  • Where: fetchGitHubModArtifactapps/server/src/githubScanner.ts.
  • Condition: the mod's edit-scene setup is re-parsed at serve time and at least one of its scripts fails validateSceneScriptSafety with an error-severity issue.
  • Message: Mod scene scripts no longer pass sandbox compatibility checks.
  • This is the serve-time re-scan risk — see below.

Entry script no longer safe (serve time)#

  • Where: fetchGitHubModArtifactapps/server/src/githubScanner.ts.
  • Condition: the mod's entry.script is re-fetched and re-scanned at serve time and fails either the 5 script patterns or validateManifestCapabilities with an error-severity issue.
  • Message: Mod script no longer passes sandbox compatibility checks.
  • This is the serve-time re-scan risk — see below.

Prefab scripts no longer declared (serve time)#

  • Where: fetchGitHubModArtifactapps/server/src/githubScanner.ts.
  • Condition: the mod is a component-pack, its setup document is re-read at serve time, and a script it carries produces an error-severity issue from componentPackSetupIssues — an undeclared capability, or a banned content pattern in a document the scene branch does not cover.
  • Message: Mod prefab scripts no longer pass component-pack capability checks.
  • Registered before this rule existed does not mean served past it, which is the same posture the two throws above take.

Scene scripts no longer declared (serve time)#

  • Where: fetchGitHubModArtifactapps/server/src/githubScanner.ts, in the edit-scene branch only (the one branch whose scripts actually execute). Runs after the content check above, so the content verdict is reached first and unchanged.
  • Condition: the mod is not a component-pack, its edit-scene setup is re-parsed at serve time, and a scene script uses a capability that manifest.capabilities.allowed does not declare — i.e. scene-script-undeclared-capability, at serve time.
  • Message: Mod scene scripts no longer pass manifest capability checks.
  • 🔴 The wording is one word away from the sandbox one. "…no longer pass manifest capability checks" means your script content is fine and your declaration is not. "…no longer pass sandbox compatibility checks" means a banned content pattern. The two throws are a few lines apart in the same branch and read almost identically, so check which one you got before you start editing code.
  • A mod registered before this rule existed does not get to keep being served past it — the same posture the three throws above take.

Publish refusals — HTTP 409 (2)#

These two are not scanner codes and never appear in a mod's compatibilityIssues. They refuse the POST /api/mods/register request itself, are thrown before anything in the registry is written (so a refused publish leaves the previous record byte-identical rather than half-applied), and come back as an HTTP 409 Conflict with a named error field that the editor keys on. They are on this page because to an author they are the same experience as everything above: a publish that does not go through, with a code to look up.

Both are refused before the version-row write, and mod-type-immutable is checked first on purpose: a type change also changes the artifact and therefore the commit sha, so both refusals can be true at once, and the less specific one must not shadow the more specific one.

mod-type-immutable (409)#

  • Where: InMemoryStore.upsertScannedMod (apps/server/src/store.ts) throws ModTypeImmutableError; POST /api/mods/register (apps/server/src/router/mods.ts) maps it to a 409 carrying publishedType and attemptedType.
  • Condition: this mod id has already been published under a different manifest.type. "Already published" reads the earliest version row's manifest first (append-only, so it still tells the truth), and falls back to the live registry record only when that record has actually been served — an incompatible record is excluded, so correcting a mis-typed first attempt is not blocked.
  • Message: This mod was first published as "room-pack" and cannot be re-published as "game-pack". A pack's type is immutable (O-26): dependents pin a version, not a type, so changing it would change what they load — and what capabilities they inherit — with no version bump and no disclosure. Publish the new kind under a new id.
  • Why it is a hard refusal. A dependency pin names (packId, version) and deliberately does not record the depended-on pack's type — the registry's answer is the only answer. So a room-pack that could become a game-pack would change what every dependent loads, with no version bump and no disclosure firing.
  • The fix: publish the new kind under a new mod id. There is no override.

mod-version-immutable (409)#

  • Where: InMemoryStore.upsertScannedMod throws ModVersionImmutableError; the same route maps it to a 409 carrying version and publishedCommitSha.
  • Condition: a published version row already exists for this (id, manifest.version) at a different commit sha, and the incoming scan is compatible and has a resolved sha of its own. Re-scanning the same label at the same commit is a no-op, not an error — the registry re-scans routinely.
  • Message: Mod "my-game" version 1.0.0 is already published at commit 0123456789ab and cannot be repointed at abcdef012345. Bump the version in the manifest and publish again.
  • Why (O-20): a published label is a name for a specific commit, forever. Repointing it would change what every dependent loads without any of them touching their pin — the same silent breakage that makes semver ranges refused everywhere else in this feature.
  • The fix: bump version in the manifest and publish again. If you meant to publish the same bytes, you already have: nothing needed doing.

The editor upload gate (1)#

unsupported-asset-type (upload gate)#

  • Severity: error
  • Where: validateUploadedAssetExtensionapps/server/src/githubScanner.ts. Fires from ProjectStore.uploadProjectAsset (apps/server/src/store.ts) — a direct file upload into a draft, not a GitHub scan.
  • Condition: the uploaded file's extension is not one of the 17 entries in UPLOADABLE_ASSET_EXTENSIONS (githubScanner.ts) — the 18 manifest-legal extensions with .bin removed. A .bin declared in a GitHub manifest is fine; a .bin uploaded from the editor is refused here.
  • Message: Unsupported asset type: <ext>. Allowed: .avif, .basis, .bmp, .csv, .gif, .glb, .gltf, .jpeg, .jpg, .json, .ktx2, .mp3, .ogg, .png, .txt, .wav, .webp. (alphabetically sorted, computed as [...UPLOADABLE_ASSET_EXTENSIONS].sort()) — note this message is not identical to the manifest-context unsupported-asset-type above: it additionally lists every allowed extension.
  • How you actually see it: uploadProjectAsset wraps this (and the content-type checks that follow it) into a thrown Error rather than a structured issue: Asset upload failed for "${assetPath}" (${code}): ${message}. (store.ts, uploadProjectAsset).
  • Also enforced at upload time, but not one of the 68 rules on this page (ad hoc store.ts checks, not from githubScanner.ts/modManifest.ts): an empty path, a path containing .., a model asset (.glb/.gltf) over 25 MB (MAX_UPLOADED_MODEL_BYTES, store.ts), or any asset over 50 MB (MAX_UPLOADED_ASSET_BYTES, store.ts).

The serve-time re-scan risk#

This is worth reading even if nothing is currently rejecting your mod. A mod is not scanned once and then trusted forever. fetchGitHubModArtifact (githubScanner.ts) re-fetches the entry script and the edit-scene setup every time a compatible mod is loaded into a room, and re-runs the same script-safety and capability checks against whatever is on the ref right now — and throws if they no longer pass (see the two "no longer safe" hard throws above).

Concretely: your mod can pass registration today, sit untouched in your repo, and then fail to load next week — not because you changed anything, but because:

  • a maintainer edits SANDBOX_SCANNER_RULES / bannedScriptPatterns (tightens a pattern, adds a new one), or
  • your ref is a mutable branch name (not a resolved commit sha — see Publishing & GitHub on resolveCommitSha being best-effort) and someone pushes a change to that branch that a human reviewer never ran through registration.

There is no notification for this today: the first sign is a room failing to load the mod. If you want a published mod to be immune to future rule changes, pin ref to an immutable commit sha rather than a branch.

A note on duplication (not a user-facing risk today)#

The 5 script-content patterns exist in two source locations — SANDBOX_SCANNER_RULES (apps/server/src/githubScanner.ts) and bannedScriptPatterns (packages/shared/src/modManifest.ts) — with no shared constant between them. As verified for this page (2026-07-27), they are byte-identical in effect: same 5 codes, same order, character-identical regex patterns and messages. The only structural difference is a rationale field on the scanner's copy that nothing reads. This page documents them as the one set they are today. The risk is future drift if one copy is edited without the other — nothing currently enforces that they stay in sync beyond a test asserting SANDBOX_SCANNER_RULES.length >= 5 (githubScanner.test.ts). See Script Safety for the rules themselves.

See also#