gridmason docs / Writing Widgets

Writing Widgets

This is the end-to-end author path for Gridmason: how you go from nothing to a published, verified widget. A widget is a custom element that a Gridmason host (the dashboard, or any app that mounts Gridmason widgets) loads and hands a capability-scoped data handle. You describe that element in a manifest, develop it against a local harness, run the exact checks a registry will run, and publish it with a keyless signature.

Everything below is a single tool — the gridmason binary — plus two contracts your code targets: the widget ABI (how the host talks to your element) and the manifest schema (how you describe your element to the platform). You never write a build step you don't want, you never hand-write a signature, and you never guess at what review will say — the CLI runs review's checks locally first.

Anatomy of a widget

A widget is three things that travel together: a custom element (the code), a manifest (the description), and a settings schema (the user-facing options). Understanding the contract between them is the whole job.

The custom element contract

Your widget is a standard customElements-registered element. The host mounts one instance of your element per placed item on a page. It configures the element before inserting it into the DOM, so your connectedCallback always observes a fully-configured element.

The host sets four attributes and one property:

Set as Name Carries Read as
attribute context The page-context value the page is scoped to (e.g. "the customer this page is about") JSON
attribute settings This instance's saved user settings, validated against your settings schema ({} when none) JSON
attribute instance-id An opaque, stable id for this mount string
attribute edit-mode Whether the dashboard is in edit mode — a boolean attribute: presence is the signal, no value is significant boolean
property sdk The capability-scoped host SDK handle — all privileged I/O flows through it object (opaque)

Two rules make this ergonomic:

  • context, settings, and edit-mode update in place. When they change, the host updates the attribute on the same element — it does not tear your widget down and re-mount it. List them in your element's observedAttributes and react in attributeChangedCallback; a context change or an edit-mode toggle never loses your widget's state.
  • The sdk handle is delivered before connectedCallback. The property name is pinned ABI (this.sdk), so you may read this.sdk synchronously on the first line of connectedCallback. If your widget needs to interoperate with a host that mints a distinct handle per instance after mount, read this.sdk at first use rather than latching it once — the host may re-assign it in place (no re-mount, no notification). When a host never sets a handle, this.sdk reads undefined, so a widget that needs one must tolerate its absence.

What your widget receives through .sdk — the SDK handle (@gridmason/sdk) is the only sanctioned path from your widget to data. It is capability-scoped: every call is checked against min(user permissions, your declared capabilities) before any transport happens, so a call your manifest didn't declare is denied with a typed PermissionDenied before it leaves the widget. Through it you get records (read/ query domain records), net (scoped network fetch), events (a typed, namespaced cross-widget event bus), context, settings, nav, telemetry, and identity. You never call global fetch — you call sdk.

What your widget emits (events out). Your element dispatches bubbling, composed DOM CustomEvents that the host shell catches. Two are part of the mount lifecycle:

  • gm:ready — dispatch this once your widget is interactive. If your widget finishes initializing synchronously, you don't need it — you're considered ready when connectedCallback returns. If your widget loads asynchronously, signal pending during connectedCallback (dispatch a bubbling gm:loading event, or set the boolean attribute gm-loading); the host shows a skeleton until you dispatch gm:ready.
  • gm:error (detail?: { message?, error? }) — dispatch at any time to fall back to the host's error card.

You may also dispatch your own author-defined outbound events (the vanilla scaffold wires a sample gm:action button). Note these DOM CustomEvents are the mount-level channel and are not the SDK event bus — the SDK event bus (sdk.events) is capability-gated and needs an events:<ns> capability; the DOM events do not.

Cleanup is your responsibility. The host guarantees your disconnectedCallback fires before your instance is removed or re-mounted. You must release anything you allocated outside the SDK there: timers (setInterval/setTimeout), observers (IntersectionObserver/ResizeObserver/MutationObserver), and listeners you added to targets you don't own (window, document, media queries). SDK event-bus subscriptions are auto-released on unmount, so those are not your burden — but a timer or observer you leak keeps running after your widget leaves the page.

The manifest

manifest.json describes your artifact to the platform. It is validated against the authoritative @gridmason/protocol manifest JSON Schema — the same schema gridmason lint and the registry both enforce. additionalProperties is false, so an unknown field is an error.

Required fields (the artifact cannot load without them):

Field Meaning
formatVersion Wire-format version of the manifest, as major.minor (e.g. "1.0").
tag Your widget's custom-element tag. Must be lowercase, contain a hyphen, use only [a-z0-9-] starting with a letter, and be prefixed with <publisher>-.
kind One of widget, plugin, page-type, layout.
name Human-readable name.
publisher Your publisher namespace prefix (unique within one registry).
version SemVer of this artifact (e.g. "0.1.0").
entry Path to the ES-module that registers the custom element; content-hashed at publish.

Optional fields that describe placement, capabilities, and dependencies:

Field Meaning
requiresContext Context slots you need the page to supply, keyed by slot name. Each slot may name a recordType (e.g. { "record": { "recordType": "customer" } }).
supportsPages Page-type globs your widget may be placed on (e.g. ["dashboards.*"]).
size Grid footprint: a required default [columns, rows], plus optional min / max.
capabilities The privileged APIs you declare (see below).
props Path to the JSON Schema for your widget's user-facing settings.
requires Dependency-DAG edges: [{ tag, range }]. The registry rejects cycles at publish.
sharedScope Import-map ranges you expect the host to satisfy (e.g. react, vue). Omit it for a fully self-contained module graph.
thumbnail Path to a thumbnail asset.
pageType Present only when kind is page-type.

Capabilities are the load-bearing part. Each is { api, scope? }, where api is one of records.read, records.write, net, or events, and scope is a colon-delimited path (or omitted for an unscoped capability). The string form is <api>[:<scope>]. Examples:

  • records.read:recordType:customer — read customer records. (The records scope grammar is records.read:recordType:<type>, not a bare records.read:<type>.)
  • net:api.example.com — make scoped network calls to that host.
  • events:sales — emit/subscribe on the sales event namespace.

The SDK enforces min(user permissions, declared capabilities) on every call, so declare only what your widget actually uses — a smaller capability set clears review faster, and (as covered under Versioning) adding a capability in a later version re-triggers a review lane. A capability you declare but never use is an over-declaration to trim before you publish.

Page-type targeting. requiresContext says what the page must give you; supportsPages says which page types you may be placed on. A widget that reads a customer record declares requiresContext: { record: { recordType: "customer" } } and a matching records.read:recordType:customer capability — the manifest and the capability are paired, and a scaffold pairs them for you.

The settings schema

If your widget has user-facing options, ship a draft-07 JSON Schema at the path your manifest's props points to (the scaffold generates props.schema.json, empty to start). A widget that ships only a schema and no custom settings element is a schema-only widget: the host renders your schema as a form in its own design system and round-trips the values through the handle. You write the schema; the host writes the field UI. See Settings forms below.

Scaffold to running

Install the CLI

npm i -g @gridmason/cli   # provides the `gridmason` binary
# or, without installing:
npx @gridmason/cli --help

Requires Node.js >= 22. One binary spans the whole author loop — scaffold → develop → lint → publish — and runs the identical automated checks a registry review runs, locally, so "green locally" predicts "passes review."

Scaffold a project

gridmason widget init "Sales Chart" --publisher acme
cd sales-chart

init prompts for anything you don't pass as a flag (and, in a non-interactive context like CI, errors instead of prompting for a missing required answer):

Answer Flag Default Notes
Name [name] argument (required) Human name; slugified for the tag and directory.
Publisher prefix --publisher <prefix> (required) The tag becomes <publisher>-<slug>; lowercase [a-z0-9-], starting with a letter.
Kind --kind <kind> widget widget, plugin, page-type, or layout.
Framework --framework <name> vanilla vanilla, react, or vue. Sets sharedScope defaults.

init refuses to write into a non-empty directory — it never clobbers an existing project. The scaffold is lint-clean out of the box: the publisher-prefix rule is enforced at creation, so a scaffold can never emit a manifest that would fail review.

Templates. Three starters are available via --framework, and each emits a plain ES-module entry that speaks the same widget ABI:

  • vanilla — a hand-written ES module, no build step. This is the reference the other two are measured against. It imports @gridmason/sdk/vanilla for its helpers and declares no sharedScope.
  • react — a baseline that uses plain createElement (no JSX), so it also needs no build step. sharedScope is react ^18, react-dom ^18. Adopt JSX and any ESM-emitting bundler when you want — the CLI is not a bundler.
  • vue — a baseline render function (no SFC), no build step. sharedScope is vue ^3. Add .vue files and a bundler when you want.

For React and Vue, the entry imports its framework by bare specifier (react, react-dom/client, vue) — exactly the sharedScope entries — so the host supplies the framework through its import map instead of your widget bundling its own copy. @gridmason/sdk is not in sharedScope; it is the platform SDK the host provides ambiently.

Project structure tour

gridmason widget init "Sales Chart" --publisher acme produces sales-chart/:

File Purpose
manifest.json The manifest stub: publisher-prefixed tag, entry, props, thumbnail, a size default, a sample requiresContext slot, and a matching records.read capability. sharedScope is set for React/Vue.
src/entry.js The ES-module entry that registers the custom element (from the chosen template).
props.schema.json A draft-07 JSON Schema for your user-facing settings (empty to start).
thumbnail.svg A neutral thumbnail placeholder, so the manifest's thumbnail path is valid from the first lint.
src/<slug>.stories.js A framework-agnostic Storybook story stub that renders the element by tag.
.github/workflows/ci.yml CI that runs gridmason lint on every push/PR — fails the PR before a registry review sees it.
fixtures/ Seeded sample data for gridmason dev (default.json plus a contexts/ preset), derived from the manifest.
package.json dev/lint scripts and the @gridmason/sdk + @gridmason/cli deps.
README.md, .gitignore Authoring readme and standard ignores.

The scaffolded entry registers your custom element idempotently (if (!customElements.get(tag))), reads the four ABI attributes, emits gm:ready on mount, and — before a real host wires a handle — falls back to a no-op SDK (createNoopSDK, seeded from the attributes) so it renders on first run with no host at all. You trim that fallback for production.

The framework templates consume the real SDK helpers over the handle:

  • React (@gridmason/sdk/react): useRecord(sdk, ref) reads the primary context record, useSettings(sdk) gives reactive settings.
  • Vue (@gridmason/sdk/vue): useRecord and useSettings composables returning reactive refs, called in setup().
  • vanilla (@gridmason/sdk/vanilla): watchRecord subscribes to the primary record's read state and bindSettings binds settings imperatively; each returns an Unsubscribe you call on teardown.

Every helper mirrors an sdk method 1:1 and adds no privileged logic, so your widget stays auditable by reading its SDK calls.

Develop against the local harness

gridmason dev            # fixture harness at http://127.0.0.1:3000

gridmason dev is the develop step. It starts a localhost server that does four jobs and no more — it is a conduit, never a data backend:

  1. Serves your entry module so the dashboard's dev mode can hot-load it.
  2. Runs a standalone fixture harness at / that mounts your widget on the SDK fixture implementation — so the loop works with no dashboard and no backend.
  3. Live-reloads on every edit to src/, manifest.json, or fixtures/, and re-validates the manifest on the fly.
  4. Mounts an SDK inspector at /@dev/inspector (also linked from the harness dev bar).

Every datum your widget sees comes from a fixture file or a --proxy target, never from the dev server itself. Useful invocations:

gridmason dev --context example-2              # mount the fixtures/contexts/example-2.json preset
gridmason dev --proxy http://localhost:5173    # forward SDK calls to a real running host
gridmason dev --port 4000 --json

There are two ways to run against a real host:

  • The standalone fixture harness (/) is best for the plain-ESM (vanilla) entry, which imports only its own module graph. A React or Vue entry imports its framework from the host's shared scope, so exercise it through the dashboard's dev sideload (which supplies that scope), not the standalone harness.
  • --proxy <host-url> trades fixtures for integration realism: your SDK calls are forwarded to a real running host, with the capability check still enforced — a capability your manifest doesn't declare stays denied through the proxy, and a denied call never reaches the target. (No shipped host implements the proxy receive side yet; this is the shape a host must match to be a --proxy target.)

The SDK inspector answers one review-anticipating question: do the capabilities your manifest declared match the SDK calls your widget actually makes? It has two tables — declared capabilities (each marked used or not yet used this session) and observed gated SDK calls (each with the capability it required, whether that capability is declared or undeclared, and how it resolved). An undeclared row is a red-flagged violation — the manifest never declared that capability, so a host and review would deny it. A not yet used declared capability is an over-declaration to trim. Call outcomes you'll see: fixture-hit (a fixture answered), default-empty (allowed but no fixture matched — an uncovered path, add a fixture), allowed (a gated events call), denied (the undeclared-capability violation). Only gated records/net/events calls appear; ungated settings/nav/telemetry calls carry no capability and are out of scope.

Fixtures for realistic dev data

gridmason widget init seeds a fixtures/ directory so the first gridmason dev renders with data before you write a line of widget code. The layout:

fixtures/
  default.json               # the FixtureFile gridmason dev mounts by default
  contexts/
    <record>-2.json          # a named page-context preset (gridmason dev --context <name>)

default.json is a complete fixture file: records (reads + queries), net (scoped-request stubs), events (scripted emissions), and context (the default page-context the widget mounts against). A contexts/<name>.json file is a standalone named page-context preset; gridmason dev --context <name> loads it and overrides the default context while records/net/events still come from default.json. A named preset that doesn't exist is a hard error — a typo shouldn't silently fall back.

The seed is derived mechanically from your manifest — change the manifest's context or capabilities and re-run init, and the seeded files change to match:

Manifest declaration Seeds
a requiresContext slot with a recordType a records.read template (serves any id of that type), a one-row records.query list, and a record-ref for that slot in every context preset
a requiresContext slot without a recordType a string placeholder (sample-<slot>) in every context preset
a net:<host> capability one empty net stub keyed by host + path /, responding 200 with {} — fill it in
an events:<ns> capability one scripted emission on that namespace (topic <ns>/sample, fired at delay: 0)

Everything under fixtures/ is plain JSON and hot-reloads like source. Capability enforcement is real even here: createFixtureSDK enforces your declared capabilities exactly as a host would, so a call for a capability you didn't declare is denied and no fixture satisfies it — fixture-green predicts review-green. If you add a requiresContext type without its records.read:recordType:<type> capability, the dev inspector flags that call denied, the same verdict review would give.

Quality gates

gridmason lint

gridmason lint [path] [--json] [--registry <url>]

gridmason lint runs the same automated checks the registry runs, from one shared module the registry imports verbatim — one implementation, no divergence — so local-green predicts review-pass. It reads <path>/manifest.json (default: the current directory), runs every check, prints human diagnostics to stderr (one /!/ line per finding, with a fix hint under each failure), and exits 0 if and only if no check failed (a warning does not fail the run). --json prints a single machine- readable report to stdout for CI gating.

The checks fall into these categories:

  • Manifest checks (fail on violation, resolved synchronously at publish): your manifest conforms to the protocol JSON Schema; your tag is well-formed and publisher-prefixed; each capability's scope grammar is well-formed. (These are three distinct check ids because they come from three protocol primitives, and a reviewer wants to know which failed.)
  • Dependency checks (fail): your requires graph is acyclic. Offline, lint sees only your one manifest, so it can prove only a self-dependency; a --registry run adds a check against the registry's live transitive graph to catch a cross-manifest cycle.
  • SDK-adherence checks (the frontend-remote human-review tier): your source does no raw network I/O outside the SDK (no global fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon — all egress goes through the handle); it doesn't reach ambient credential/storage surfaces the sandbox withholds (document.cookie, Web Storage — a fail; indexedDB/window.name — a warn); and it contains no obfuscation that hides network/DOM access (eval/new Function fail; decode chains and computed global access warn). sdk.raw-network is the check most likely to fail a naive widget — surfacing it locally is the point.
  • DOM checks (advisory warnings): a frontend remote keeps DOM access inside its own subtree — document-wide queries, document.body/head, top-level navigation, or cross-frame reach are flagged for the reviewer but do not fail your local gate.

Two honesty caveats worth internalizing. First, the sdk.* and dom.* checks are heuristics run against a masked view of your source (comments and string contents are blanked first, so a keyword in a comment is never mistaken for a call). They catch the honest mistake and the lazy evasion, not a determined one — aliasing a primitive into a variable, runtime name assembly, and code hidden in template-literal interpolations can slip past. A clean run is evidence, not a guarantee; the registry's review is the authority. Second, lint reads only your src/ tree plus the manifest entry locally — the registry closes that gap by analyzing the built artifact.

Registry-aware checks (--registry <url>) add two more that consult the target registry: a capability-diff against the last published version (warns on an increase — see Versioning) and a server-side acyclic check against the live dependency graph. Both are fail-safe, not fail-closed: an unreachable or malformed registry yields a warning (the answer is unknown), never a false pass or a phantom failure.

The --json report tags every check result with the registry review tier its findings feed, so CI learns which review SLA the artifact will hit before it publishes.

Settings forms

If your widget is schema-only, verify its settings render correctly. The settings-form helper (useSettingsForm in the React adapter) compiles your JSON Schema into an ordered field list, seeds each field from the current settings (or the schema default), and persists every edit through the handle — you write one hook call, the host writes one field adapter against its design system. The SDK ships no field UI.

The v0 compiler handles a top-level object schema of scalar and single-choice properties:

Schema property Rendered control
boolean checkbox
number / integer number input
any type with an enum select
string with format: "textarea" textarea
string (otherwise) text

title becomes the label (falling back to the property name), description becomes help text, default is the value shown when the store has none, and the schema required array sets each field's required flag. A property whose type v0 doesn't handle — a nested object, an array, null, or a type-union — is skipped, and a schema that is not a type: "object" with properties compiles to an empty form. Design your settings schema within these shapes for v0.

Conformance

"Conformance" in Gridmason refers to the host-conformance kit — the machine-checkable definition of "a valid Gridmason host." As a widget author you don't run it; it is what a host builder runs to prove their HostSDK implementation honors the six contract rules (capability intersection before transport, net-host scoping, per-instance identity, typed namespaced events, per-instance isolation, unmount revocation). Your author-side equivalent of conformance is the pairing above: gridmason lint (review's checks) plus a green gridmason dev SDK inspector (declared capabilities match observed calls).

Publishing

Publishing is the trust path. You establish an OIDC identity, then publish lint-gates, signs keyless, uploads, and polls the review outcome.

Establish an identity (gridmason login)

Gridmason signs keyless, Sigstore-style — there is no long-lived signing key on your machine. login establishes the OIDC identity that is the real trust anchor; at publish time a short-lived certificate is bound to that identity.

gridmason login    # interactive browser sign-in
gridmason whoami   # print the established identity, or report logged-out

In an interactive terminal, login with no token source opens your browser via the standard OAuth native-app flow — an authorization code with PKCE and a 127.0.0.1 loopback redirect. login records only public OIDC claims (issuer, subject, asserted claims, expiry) to an owner-only session.json under the gridmason config directory. No private key and no token are ever written — signing mints an ephemeral in-memory keypair at publish time and discards it, and publish re-acquires a fresh short-lived token when it signs. The session is a record of who you are, not a credential.

Which OIDC issuer to trust is a per-registry decision — the issuer you authenticate against becomes the trust anchor the registry allowlists. login defaults to the Sigstore public-good issuer; override it with --issuer (and the OAuth client with --client-id) for a different trust domain:

gridmason login --issuer https://oauth2.sigstage.dev/auth   # e.g. Sigstore staging

The registry enforces its own issuer allowlist and rejects a token from an un-allowlisted issuer with 403 issuer_not_allowed, so pick an issuer the target registry trusts. In CI, skip the browser: --ambient (auto-detected on GitHub Actions with id-token: write) or --token <jwt> / GRIDMASON_OIDC_TOKEN. A non-interactive context never opens a browser — it fails fast with an actionable interactive-unsupported message pointing you to --ambient or --token.

Publish (gridmason publish)

gridmason publish [path] --registry <url> [--signer <sigstore|ephemeral>] [--sigstore <instance>] [--token <jwt>] [--ambient] [--json]

--registry is required — there is no baked-in default registry yet, so point it at a running Gridmason Registry (you can self-host one from github.com/gridmason/registry). path is the project directory (default: current directory). The flow fails closed at every gate:

  1. Assemble the immutable, content-hashed artifact — each part addressed by the SHA-256 of its exact bytes (sha2-256:<hex>), computed with @gridmason/protocol so the digests match the registry's own content addressing. The uploaded set is a fixed convention: manifest.json, the entry module, every *.schema.json (schema), every .js/.mjs/.cjs under src/ other than the entry (chunk), and README.md plus docs/*.md (doc). node_modules, dist/build, .git, .github, fixtures/, package.json, and *.stories.*/*.test.*/*.spec.* are never uploaded.
  2. Lint-gate with the shared checks module — the identical code the registry's automated review runs. If any check fails (a manifest-lint failure, a cyclic requires, or an undeclared capability reach), publish prints the failing findings and refuses to upload — never upload known-bad.
  3. Sign keyless. A short-lived signature is bound to your login OIDC identity; no long-lived key is written.
  4. Upload to the registry's Publish API with your OIDC token as the bearer. The registry content-addresses the parts, structurally validates the signature, enforces (tag, version) immutability, and runs its automated review.
  5. Poll the review status. On approval the registry countersigns, logs, and CDN-publishes. On rejection publish prints the reviewer's findings mapped to the same lint check ids you already know (sdk.raw-network, manifest.schema, …), and exits non-zero.

publish exits 0 when published or accepted-and-under-review, 1 on any refusal.

Envelope signing, in plain language. Your signature is the publisher half of a two-party envelope. publish computes a canonical release document — the map of { served path → content hash } of everything you upload — and signs its hash (releaseHash). That commits your signature to the exact served bytes: the registry reproduces the same document byte-for-byte and refuses to countersign a signature whose releaseHash doesn't bind the uploaded content. So the reviewed hash is the runnable artifact.

Sigstore vs. ephemeral signer — when each is appropriate:

  • --signer sigstore (the default) is the real identity path. It uses the Sigstore public-good instance: an ephemeral keypair is minted in memory and a Fulcio short-lived certificate is obtained, bound to your OIDC identity — nothing touches disk. Transparency-log anchoring happens at the registry's countersign. This needs network and an allowlisted-issuer token. Use --sigstore staging to select the staging CA. This is what you use to publish for real.
  • --signer ephemeral is an offline keyless signer that reaches no network: it mints a per-invocation in-memory ECDSA P-256 keypair, signs, and self-issues a leaf certificate in the same profile the protocol verifies, then discards the key. It is a dev / e2e affordance, not a Fulcio identity — its cert chains to nothing a production host pins, so a conforming host that pins real Fulcio roots refuses it. Use it to drive the real publish binary deterministically in local/CI testing without Sigstore network; do not use it to publish something a production host must trust.

The review pipeline, from your side

After upload, publish polls the registry's publisher-facing review surface. What you experience:

  • Status polling. publish polls until the review reaches a terminal state and reports it. It also falls back gracefully to the upload response's state if the status surface is absent.
  • Findings speak the lint vocabulary. Because the registry runs the same checks module you ran locally, a rejection's findings reference the same check ids — so you fix a review failure with the identical id you already know. A reviewer's hand-made judgement is shown as a manual-review finding.
  • Review tiers set the SLA. Your artifact hits a review lane based on what it contains: manifest and dependency checks are the automated stage every publish runs synchronously; SDK-adherence and DOM checks feed the frontend-remote human review (a ~5-day lane); a capability increase feeds a re-review lane (~3 days). The --json lint report tells you which lane you're headed for before you publish.

Appeal. If a submission is rejected and you disagree:

gridmason appeal <artifact-id> --registry <url>

<artifact-id> is the id publish printed. appeal routes the submission to a second reviewer — never the original, because the reviewer-is-not-the-author rule is the registry's.

What countersigning means for you. On approval the registry applies the second half of the signature envelope — its countersignature — and issues a transparency-log inclusion proof. From then on, anyone can independently verify that the reviewed hash is the runnable artifact: both signatures plus the content hash plus the log inclusion form one chain that a host (or you, with gridmason verify) checks against pinned trust roots before loading a release. You don't produce the countersignature or the log proof — the registry does — but your publisher signature is what they bind to.

Revocation. Trust is revocable after the fact. Because every release is content-hashed, anchored to a signed release document, and recorded in a transparency log, a registry can publish a signed revocation feed that a host checks at load — so a widget later found to be malicious can be pulled from under hosts that already resolved it, without depending on the widget to behave. As an author, the practical consequence is that your published identity and the exact bytes you signed are permanently attributable; publish only what you're willing to stand behind.

Offline distribution (optional)

For air-gapped consumers, gridmason bundle export repackages an already-signed release into a single self-verifying .gmb file that carries the signed chain and every servable byte, and gridmason verify runs the full dual-signature + content-hash + log-inclusion chain against pinned trust roots (online or offline against a .gmb). bundle export signs nothing — it repackages what publish and the registry already produced — so a fully green offline verify requires genuine registry signature material.

Versioning and capability changes

Bump your manifest version (SemVer) for each release; (tag, version) is immutable at the registry, so you can never overwrite a published version — you publish a new one.

The one thing to plan for is capability history. The registry compares each new version's declared capabilities against the last published version of the same tag:

  • Adding a capability (one your last version didn't declare) is an increase, which re-enters review at the capability re-review lane (a ~3-day SLA). This is by design — a widget quietly gaining net: or records.write access between versions is exactly what review should re-examine.
  • Removing a capability is not a re-review trigger — a pure decrease passes.
  • A first publish (the tag has never been published) has no baseline to diff, so it passes the capability-diff.

You can see this coming before you publish. gridmason lint --registry <url> runs the registry-aware capability.diff check: it fetches your tag's last-published capabilities and warns on each added api[:scope] ("will re-trigger review"). It's advisory — it does not fail your local gate — but it lets you decide deliberately: if the increase is intended, expect the slower SLA; if it's an accident (an over-declaration), trim it. Because the check consults the registry it's fail-safe: an unreachable registry warns rather than falsely passing. The registry runs the authoritative diff at publish; lint --registry just makes the likely verdict visible early.

The practical discipline, then, is: declare the minimum capabilities your widget actually uses (the dev inspector's not yet used markers and lint's findings help you find over-declarations), and treat any capability addition in a later version as a deliberate choice that costs a review cycle.