Skip to content

Proposal: let the install-form contract express the control it already promises #78

Description

@justinmerrell

Proposing that the install-form contract be able to say what it already promises a client will render — a picker, a labelled choice, a grouped form, and a value the platform fills in — and that the derivation rule this rests on move from a schema description into normative prose.

This is framed as reconciliation rather than new surface area, for the reason ADR 0013 gave about type: the rule is already the contract's stated behaviour, it is just written somewhere the repository declares non-normative.

Context: musher-dev/platform's console renders the deploy form entirely from schema today — it has no control field and wants none. musher-dev/catalog has 13 curated items waiting on the vocabulary.


1. The rule the whole design rests on is not a rule

ComponentInputUi carries one property, and its description says why:

The install-form control is derived from the input's schema (enum → select, BOOLEAN → toggle, EMAIL format → email field, sensitive → masked field) — only the label needs declaring.

That sentence is the entire contract between an author and a renderer. CONTRIBUTING.md ground rule 1 declares schema description fields informative. spec.md never states the mapping — component §6.1 says only that a USER input "needs a label before a form can render it", and grep -n 'select\|toggle\|widget\|render' specifications/*/v1/spec.md finds no clause.

So two conforming implementations may render the same document completely differently and neither is defective. That is the defect, independent of everything below, and it is worth closing on its own.

2. generator is renderable and platformDefault is not — and §5.1 already says why that is wrong

Blueprint §5.1 explains why a generated input is still derived into a parameter:

An input carrying a generator is derived, even though the user never types a value for it: a client rendering the install form still has to know it exists, and "three secrets will be generated for you" is a thing worth being able to say.

That reasoning is exactly right, and it applies word for word to platformDefault. But §5.1 derives only schema, ui and isRequired; BlueprintParameter has a generator property and no platformDefault counterpart at all. A platform-derived input is therefore structurally unable to reach the install form as anything other than an ordinary optional field.

The consequence, in musher-dev/catalog's n8n item — three inputs carrying platformDefault: PUBLIC_HOSTNAME / PUBLIC_URL:

Public hostname       [                    ]
Webhook base URL      [                    ]
Editor base URL       [                    ]

Three empty boxes, indistinguishable from an unset optional, in a form whose own help text has to say "leave blank to use this deployment's own generated hostname" in prose because the contract has no way to say it. "This is filled in for you, override it only for a custom domain" is at least as worth being able to say as "three secrets will be generated for you", and the specification currently grants one and withholds the other.

3. The measured corpus

Every USER input across the 13 items in musher-dev/catalog, at dc00d3f:

count
USER inputs 28
…carrying generator (never typed) 11
…carrying platformDefault (never typed) 6
…a person actually types 11
of those 11: carrying enum 0
carrying format 1 (label-studio.adminEmail, EMAIL)
carrying pattern 0
type: BOOLEAN 1
type: NUMBER / JSON 0 / 0

All 13 blueprints declare parameters: {}, so every one of these forms is derived rather than authored.

Ten of the eleven typed inputs render as an undifferentiated text box. Of the four inference rules the ui description promises, enum → select is exercised zero times in the entire corpus, BOOLEAN → toggle once, EMAIL → email field once.

Two of the ten are the same field, independently hand-rolled by two authors:

# items/n8n/components/n8n.yaml
timezone:
  schema: { type: STRING, default: UTC }
  ui: { label: Timezone }
  description: IANA timezone for scheduled workflows

# items/openclaw/components/openclaw.yaml — same shape, same default

An IANA timezone is a closed set of ~400 terms that changes a few times a year. It cannot be an enum (nobody inlines 400 strings into every component document, and the list is not stable), it cannot be a pattern, and there is no format for it — so a specification that has a vocabulary for "this is an email address" has none for the single most common picker in configuration software.

Two more of the ten are URLs with no format: URI: mlflow.backendStoreUri and open-webui.ollamaBaseUrl. That is an authoring miss rather than a gap, and it is the kind of miss a generated field reference makes easy to keep making when the vocabulary is thin.

Eleven of the thirteen items already require zero typed input. The vocabulary is the remaining distance between the curated catalog and a one-click deploy.

4. The proposal

Three groups, deliberately separated by who may contradict whom. The load-bearing constraint is that nothing added to ui may change what a value means — presentation stays derived from schema, which is what makes drift between the data contract and the rendered control unrepresentable.

A. Semantic vocabulary, in schema — the control derives from these

Addition Applies to Rationale
format: TIMEZONE STRING Two items hand-roll it today. A renderer needs no list shipped with it: Intl.supportedValuesOf('timeZone') in a browser, zoneinfo.available_timezones() in Python, time/tzdata in Go. The spec would name the convention (an IANA Area/Location identifier) and nothing more.
format: HOSTNAME STRING n8n.n8nHost is a bare host, not a URI. The distinction is already load-bearing in platformDefault, which separates PUBLIC_HOSTNAME from PUBLIC_URL.
format: MULTILINE STRING A PEM block, an allow-list, a system prompt. Renders as a text area rather than a one-line box. Arguably the weakest of the three — see §7.
enumTitles any type carrying enum enum is array of string, so a select can only show the raw stored term. S3 / GCS / AZURE reaches a buyer as S3 / GCS / AZURE. Positional, same length as enum, structural.
minimum / maximum NUMBER ADR 0013 chose one numeric type on the reasoning that authors "write the constraint rather than reaching for a second type" — but the only constraint available is pattern, a regular expression over a number's string form. pattern: '^-?[0-9]+$' is the documented way to say "whole number"; there is no way at all to say "between 1 and 65535".

format stays null for non-STRING types — COMP-VAL-003 is unchanged, and numeric refinement is deliberately not a format member for that reason.

B. Presentation-only, in ui — these cannot contradict schema

Addition Rationale
group An eleven-field form is a wall. Grouping is the one thing a renderer cannot infer, because relatedness is not in the data.
order Derivation order today is lexicographic node name, then mapping order within a node — an artefact of the merge, never an authoring decision.
isAdvanced Distinct from isRequired. n8n's three URL overrides are optional and advanced; postgres.postgresDb is optional and not. Collapsing an optional field is currently a client-side guess.
placeholder An example value, distinct from default — which is a value that is actually submitted.

Each is inert to validation. A renderer that ignores all four still produces a correct form.

C. platformDefault → a discriminated valueFrom

Generalise the existing field into a shape that names its kind, and give BlueprintParameter the counterpart it lacks:

inputs:
  webhookUrl:
    schema: { type: STRING, format: URI }
    ui: { label: Webhook base URL, group: Networking, isAdvanced: true }
    valueFrom:
      selfAddress:
        source: PUBLIC_URL
        endpoint: primary

Today's four sources move under selfAddress unchanged, with §6.1's endpoint-pairing rules and their three diagnostics carried over verbatim. The change buys two things:

  1. A renderer can switch on it without parsing anything, which is what makes "auto-configured, override for a custom domain" expressible.
  2. Room for the next kind — a value drawn from saved organization configuration, or from another node's published output — without a second field beside the first.

This is the shape the comparable contracts converged on: Kubernetes env[].valueFrom (fieldRef / secretKeyRef / configMapKeyRef), Render's fromService / fromDatabase, Heroku app.json's generator. A named-kind reference rather than a template string, precisely because a downstream renderer has to be able to read it.

generator stays where it is. It is not a reference to anything.

D. Mirror in blueprint/v1

§5.1 derives schema, ui and isRequired unchanged, so BlueprintParameterValueSchema and BlueprintParameterUi take A and B verbatim, and BlueprintParameter gains the valueFrom counterpart from C. The ERR_UNCOVERED_REQUIRED_INPUT table in §5.3 already reads platformDefault | absent as one of the five conditions, so that row moves with the field.

5. Why no ui.widget

Worth stating up front, because it is the obvious alternative and a reviewer will ask.

A widget enum is what react-jsonschema-form and Backstage's scaffolder use (ui:widget), and it is more expressive than anything above. It is rejected here because it makes presentation a second declaration of the same fact. schema: {type: STRING} beside ui: {widget: TIMEZONE} is a document that says a value is free text and says it is a timezone, and nothing can decide which is wrong. Under this proposal format: TIMEZONE says it once, and validation and rendering are the same statement seen from two sides.

The platform reached this independently: its ADR 0046 deleted a control field it had already shipped, on the grounds that "the console's form control could drift from the data contract because both were declared."

The cost is real and worth naming: a genuinely presentational choice with no semantic counterpart — "render this enum as radio buttons, not a select" — is inexpressible, and stays inexpressible. That looks like the right trade at this size.

6. Compatibility

Every addition in A, B and D is an optional field or a new enum member — additive, minor, no previously valid document becomes invalid.

C is breaking, and is the reason to raise it now rather than later. git tag -l is empty, published.json reads {"releases": {}}, and the manifest is 0.0.0 for all three families, so ADR 0005 §1's pre-publication window is open: no v2 directory, no migration note, maintainer approval and a breaking-change trailer. ADR 0013 put it exactly — "Adding members stays free forever; withdrawing one is free now and never again." If C is going to happen at all it costs a major version the day after the first tag.

Splitting is easy if that is preferred: A + B + D land as a minor on their own and are useful without C; C is separable and is the one that needs the window.

7. What a reviewer should push on

Three weak points, stated rather than left to be found.

format: MULTILINE is not like the others. EMAIL, URI, TIMEZONE and HOSTNAME each name a lexical convention a validator could check. MULTILINE names a shape a text box should have — it is a presentational fact wearing a semantic field's clothes, and by §5's own argument it belongs in ui or nowhere. It is listed under A because "the string may contain newlines" is arguably a statement about the value; I do not think that argument is strong, and dropping it or moving it to ui would not weaken the rest.

enumTitles as a positional parallel array is the weaker of two shapes. A list of {value, title} objects cannot go out of sync by construction; a parallel array needs a structural length rule to hold it together. The parallel array is proposed because enum is already published as array of string and changing its shape is breaking, while adding a sibling is not. If C is being taken anyway, the window makes the object form free too, and it is the better contract.

ui.group invites a group vocabulary. Free-text group names across a merged multi-node form will collide, near-miss ("Network" vs "Networking"), and be ordered by nothing. A closed vocabulary would be a lifecycleStage-shaped decision needing its own ADR; free text is proposed as the smaller commitment, and the failure mode is a slightly untidy form rather than a wrong one. If a maintainer would rather not open the door, dropping group and keeping order + isAdvanced still gets most of the benefit.

8. Process, as we read it

Recording our reading so it can be corrected rather than assumed:

  • An ADR is required. Both C (a reshape of a published field) and any closed vocabulary of this shape fall under GOVERNANCE's structural-change and lifecycleStage-shaped rules. Happy to draft it against ADR 0001's format.
  • Normative prose in both spec.md files — component §6.1 and §6.3, blueprint §5.1 and §5.3 — carrying the derivation rule from §1 with a COMP-UI-00N requirement ID, not only schema descriptions.
  • schemas/src/ only, then task bundle; dist/ never hand-edited.
  • At least one positive and one negative conformance fixture per new rule, each failing before and passing after, plus cases.json and a docs/traceability.md regeneration.
  • .config/spelling/musher.txt entries for the new terms.
  • DCO sign-off; feat(component): and feat(blueprint): scoped commits so both families release.

Happy to open this as one PR or as A+B+D first and C separately — whichever a maintainer would rather review. And happy to take a "no" on any individual item; the one that would be a shame to lose is §1, which costs nothing and is true regardless of the rest.


Related

  • musher-dev/catalog — the 13-item corpus measured in §3; a companion issue will track backfilling each item once this lands.
  • musher-dev/catalog#9 — the sibling of spec#72, still open.
  • ADR 0013 (value-shape vocabulary) — the reconciliation precedent §1 leans on.
  • ADR 0005 §1 (pre-publication window) — the reason §6's timing matters.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions