Status: Draft · v1.0 (2026-08-05). RFC 0137 — Form-content packs. Specifies a declarative pack kind that distributes form templates — a named, versioned set of typed input fields a host instantiates into an ordinary, editable form. Depends on
chat-card-packs.md§"Input fields" (RFC 0071 Phase 2) for the portable field-type subset, which this kind reuses rather than redefines. Keywords MUST, SHOULD, MAY follow RFC 2119. Status legend perauth.md.
Why this exists
Hosts that let a user build something — a survey, an intake form, an RSVP, a request — almost always ship a set of starter forms. Today those starters live in host-private code: a FormTemplateDefinition table, a hard-coded array, a seeder. A starter form authored for one host cannot be installed on another, cannot be versioned independently of the host's release train, and cannot be contributed by anyone without commit access to the host.
openwop already carries every piece needed to distribute one. registry-operations.md defines publication, signing, and integrity; RFC 0107 made the published version manifest kind-aware so declarative kinds can be served at all; and chat-card-packs.md §"Input fields" already settled what a portable typed input field is. What is missing is the distribution unit: a pack that carries form templates and nothing else.
A form-content pack is that unit. A template is, precisely: (a label + title + an ordered list of typed fields) → the host's normal form-create path. It composes existing primitives — the registry contract and the RFC 0071 portable field subset — rather than introducing new machinery.
This doc stops at the wire contract. It does not specify form layout, widgets, theming, conditional field visibility, or the submission surface — those are host-product concerns per positioning.md. It specifies the template identity, the field contract, and the trust boundary.
A form-content pack is also distinct from x-openwop-form (node-packs.md §"x-openwop-form UX hints", RFC 0066). That is a consumer-side rendering hint — a vendor-extension annotation a node-pack author places on configSchema properties so a host can render a nicer config form for an existing node. It annotates a schema the pack already ships; it is not a distribution unit, carries no template identity or version, and is never published as a pack of its own. A form-content pack distributes standalone form content. The two compose without overlapping: a host MAY use x-openwop-form hints when rendering node config and form-content templates when seeding user-authored forms.
A form-content pack is distinct from a chat-card pack (chat-card-packs.md): a card binds typed inputs to a prompt template and produces a typed artifact via ctx.aiEnvelope.generate. A form-content template binds typed inputs to nothing — it is inert, and the host does with the resulting form whatever it does with a form its own user typed. Cards are AI steps; form-content templates are form starters. They deliberately share one field vocabulary.
Pack kind
Form-content packs are the sixth declarative pack kind, peer to node, workflow-chain, prompt, artifact-type, and card. A manifest with kind: "form-content" validates against form-content-pack-manifest.schema.json, MUST declare a non-empty templates[], and MUST NOT declare nodes[] / chains[] / prompts[] / artifactTypes[] / cards[]; mixing is rejected at registry PUT with pack_kind_invalid.
A form-content pack is declarative: it MUST NOT carry a runtime block, and a published version manifest that does is rejected (registry-version-manifest.schema.json §allOf; registry-operations.md §"Validation flow" #3). The registry's runtime-support check (#7) is skipped for this kind.
Manifest format
<!-- normative-example: form-content-pack-manifest.schema.json -->
{
"name": "core.openwop.forms.starters",
"version": "1.0.0",
"kind": "form-content",
"engines": { "openwop": ">=1.1.0 <2.0.0" },
"templates": [
{
"templateId": "core.openwop.form.rsvp",
"version": "1.0.0",
"label": "RSVP",
"title": "Will you be joining us?",
"description": "Collect attendance and dietary needs.",
"category": "events",
"fields": [
{ "id": "guestName", "type": "text", "label": "Your name", "required": true },
{ "id": "email", "type": "text", "label": "Email", "format": "email", "required": true },
{ "id": "partySize", "type": "number", "label": "How many in your party?" },
{ "id": "meal", "type": "select", "label": "Meal", "options": ["standard", "vegetarian", "vegan"] },
{ "id": "notes", "type": "longtext", "label": "Anything else we should know?" }
]
}
]
}
templates[].version is SemVer 2.0.0, the same axis as the pack version — deliberately not the non-negative-integer schema-version axis used by chat-card-pack-manifest.schema.json schemaVersion. A template's shape is author-meaningful (removing a field breaks anything storing that template's submissions), so it carries the same contract as the pack that ships it.
Field types — the RFC 0071 portable subset, reused
fields[].type is the closed portable subset already defined in chat-card-packs.md §"Input fields — a closed portable subset": text, longtext, number, boolean, select, multiselect, file, artifact-ref. A host MAY extend it with a vendor.<org>.<kind>- or x-<kind>-prefixed value that other hosts MUST ignore (degrading to a plain text input).
This kind MUST NOT define its own field-type vocabulary. Two declarative pack kinds that both collect typed user input, rendered by the same host machinery, MUST agree on what a field type means; a second enum would fork the wire contract inside one protocol. The subset was resolved as RFC 0071 gap G9 (2026-05-27) against a real host's authoritative field-type catalog, and that resolution binds here unchanged.
The subset is named by data kind, not widget — a host renders boolean as whatever toggle or checkbox it likes, and longtext as whatever multi-line control it likes. Host catalogs map onto it:
| A typical host catalog | openwop fields[].type |
|---|---|
text | text |
textarea | longtext |
checkbox / toggle | boolean |
select | select |
email | text + format: "email" (see below) |
Validation formats are not types
A field MAY carry an optional format constraining its value: spec-reserved email, uri, date, date-time, time, plus x-<format> host extensions. It follows the JSON Schema format idiom used throughout this corpus.
formatis only meaningful fortype: "text"/"longtext"; a host MUST ignore it on any other type.- A host that recognizes the format SHOULD apply it as an input-validation constraint.
- A host that does NOT recognize the format MUST ignore it and accept the value as plain text — the same degradation contract as an unrecognized
type.
email is deliberately not a member of the type enum. It is a validation constraint on a text field, not a data kind. Admitting it as a type invites url, tel, date, and zip next, and the portable subset degenerates into the widget catalog that keeping it data-kind-named was meant to prevent.
Unique identifiers (normative)
Each templates[].templateId MUST be unique within the pack, and each fields[].id MUST be unique within its template. A registry MUST refuse a pack violating either at PUT (pack_kind_invalid), and a host MUST refuse to install one.
This is a data-integrity requirement, not a style rule. Two fields sharing an id silently overwrite one another in the submission value bag: the earlier value is lost, no error is raised, and the loss is invisible until someone reads a submission that is missing an answer the user gave.
JSON Schema cannot express uniqueness-by-property for an array of objects (uniqueItems compares whole objects, so two fields with the same id but different labels are "unique" to it). The requirement is therefore normative prose backed by a registry/host check — the same treatment every peer pack kind gives its own id arrays (nodes[].typeId, artifactTypes[].artifactTypeId, cards[].cardTypeId, chains[].chainId).
No submission routing (normative — F2)
A form template describes a form's shape. It MUST NOT describe where the resulting submissions go.
- A
FormTemplateMUST NOT carry submission-routing configuration of any kind — no destination, binding, webhook, list id, mailbox, CRM object, or equivalent. The schema declares no such property andadditionalProperties: falserejects one. - A host MUST refuse to install a pack that attempts to carry one, and MUST NOT infer a routing destination from any other template field (
categoryis a grouping hint, not a destination — see its schema description). - Should a future RFC introduce a routing surface, it MUST NOT let a pack bind a destination unilaterally: routing MUST be a host-side decision, made by the operator, gated by explicit operator consent at install or instantiation time.
This constrains the pack, not the host. A host is free to route submissions anywhere its operator configures — an intake binding, a CRM list, a queue, a workflow trigger. The requirement is that the destination is chosen by the operator, never declared by a pack author and never derived from pack-authored content. A form instantiated from a template MUST therefore begin with whatever routing the host's own create path gives a hand-authored form (typically none), and the operator configures it afterward exactly as they would for any other form.
Where a tenant's submissions are delivered is a decision about the operator's data — frequently personal data with legal obligations attached. A third-party pack author does not get to make that decision by shipping a signed manifest, and a signature does not confer that authority (see §"Trust boundary"). Enforced as the form-content-template-no-submission-routing SECURITY invariant; asserted server-free in form-content-packs.test.ts.
Host storage is out of scope (non-normative)
This document constrains the wire. It does not constrain how a host stores forms internally.
A host MAY keep its own internal field representation and translate at the pack boundary — an anti-corruption layer — rather than migrating stored rows to the wire vocabulary. That is explicitly not the forked-vocabulary failure this kind exists to prevent: the prohibition in §"Field types" is against two divergent vocabularies on the wire. The test is whether anything outside the host observes the internal names; if the only wire-facing surface speaks the portable subset, there is one wire vocabulary and the host is conformant.
Translation is often the safer choice. Coercing stored rows into the portable subset can silently change semantics (a stored multi-line field collapsing to single-line) or hard-fail host logic keyed on an internal type name — a real migration hazard on tenant data, with no upside on the wire. Per positioning.md, storage is a host-product concern.
Instantiation (normative)
When a host advertises host.forms.contentPacks: supported and a registered template is instantiated:
1. The host MUST create the form through its normal create path — the same path that serves a form the host's own user authored by hand. A form-content pack MUST NOT cause the host to accept a form, field, or submission surface it would not otherwise accept. 2. The host MUST map each fields[].type to a control of the corresponding data kind, and MUST degrade an unrecognized (vendor.* / x--prefixed) type to a plain text input rather than failing the instantiation.
**Degrade applies to extensions, not to malformed values.** The two cases are distinct and a host MUST treat them differently:
fields[].type value | Schema | Host behavior |
|---|---|---|
A portable-subset member (text, longtext, …) | valid | Map to a control of that data kind. |
A vendor.<org>.<kind> / x-<kind> extension the host does not recognize | valid | MUST degrade to a plain text input. Failing here would break forward compatibility — the extension is well-formed and simply belongs to another host. |
Anything else (e.g. emial, textarea, email) | invalid — the type pattern admits only the subset and the two extension prefixes | MUST refuse. The pack is malformed, not extended. A bare unknown is a typo or a widget name, and silently coercing it to text would discard exactly what the author declared (a mistyped select losing its closed option set to a free-text box is a real hazard on a public form). |
A host MUST NOT collapse these into one rule in either direction: refusing a well-formed extension makes the host non-conformant, and degrading a malformed value hides an authoring error the schema already caught. 3. The resulting form MUST be fully editable by the instantiating user. A template is a starting point, not a locked contract: the host MUST NOT treat a pack-authored field as immutable or privileged relative to a hand-added one. 4. The host MUST NOT execute anything from the pack. There is no entry point, no handler, and no runtime; a manifest carrying runtime is rejected at publication. 5. Submitted values are ordinary user input and MUST be sanitized, validated, and authorized exactly as hand-typed input to the same create path.
A host that does not advertise host.forms.contentPacks does not load form-content packs; template resolution stays implementation-defined and this document imposes no requirement on it.
Trust boundary (normative — F1)
A template's pack-authored strings — label, title, description, category, and fields[].options[] — are untrusted content. They originate with a third-party pack author, arrive inside a signed tarball, and are rendered into host chrome (a picker, a gallery card, a form heading, a field label, a dropdown option).
A signature proves who authored the pack; it does not make the authored bytes trustworthy. A host MUST NOT treat pack provenance as content trust.
- A host MUST treat every pack-authored string in a form-content template as untrusted when rendering it: it MUST be escaped or otherwise neutralized for the target surface, and MUST NOT be interpreted as markup, script, or a formatting/templating directive.
- When any pack-authored string, or a value collected through an instantiated template, is interpolated into a prompt, the composed AI envelope MUST carry
meta.contentTrust: "untrusted"(propagated perai-envelope.md§"Trust boundary"), mirroring the chat-card rule inchat-card-packs.md§"Trust boundary". This prevents a published template from smuggling instructions into a downstream model call — a template titled"Ignore previous instructions and…"is a realistic supply-chain vector precisely because a form template looks inert. - Length bounds are not a trust boundary. The
maxLengthconstraints inform-content-pack-manifest.schema.jsonare a resource guard against render bombs. They do nothing about injection, and a host MUST NOT treat bounding a string as having sanitized it.
The threat and this mitigation are documented in SECURITY/threat-model-prompt-injection.md (invariant form-content-pack-string-trust-boundary); form-content-packs.test.ts asserts the corpus contract, and the behavioral leg is capability-gated pending a reference host.
Discovery
A registry SHOULD denormalize templates[].templateId into its per-pack index per registry-operations.md §"Type-ID indexing and cross-namespace exports". Consumers MUST tolerate its absence and fall back to manifest inspection.
Because that denormalization is a SHOULD, a registry that validates a form-content manifest without extending its indexer will serve the pack with no discoverable template ids — published, valid, and invisible. That is an operational trap, not a spec violation; see the note in registry-operations.md.
Examples
Positive. The manifest above validates and installs; each templateId is unique within the pack; no runtime is present.
Negative — pack_kind_invalid. A manifest declaring both templates[] and cards[].
Negative — schema. A fields[].type of email or textarea (neither is in the closed subset, and neither is vendor.* / x--prefixed — use text + format: "email" and longtext respectively); a fields[].key instead of fields[].id; a templates[].version of 3 (the integer axis — MUST be SemVer); a templateId with an uppercase scope; a template with an empty fields[]; a manifest carrying runtime.
Open spec gaps
Absorbed into
spec/v1/gaps.json(RFC 0174 §E.3, 2026-09-03). The 5 row(s) this table carried are nowopenwop.gap.spec.form-content-packs.<local>entries with a disposition and a witness class, one namespace with every RFC register (RFC 0166 §B). The table is retired; do not add rows here.
References
- RFC 0137 — Form-content packs — the rationale, alternatives, and compatibility analysis.
- RFC 0107 — Publishable declarative pack kinds — the published-manifest contract this kind extends.
chat-card-packs.md§"Input fields — a closed portable subset" — the authoritative field-type vocabulary.registry-operations.md§"Validation flow", §"Type-ID indexing and cross-namespace exports".host-capabilities.md§host.forms — the capability advertisement.ai-envelope.md§"Trust boundary" —meta.contentTrustpropagation.positioning.md— why layout, widgets, and theming are out of scope.form-content-pack-manifest.schema.json,registry-version-manifest.schema.json.