From df4d449242fb82f9be57a5308b8615fe7c573031 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:18:53 +0000 Subject: [PATCH 01/12] feat(tools): validate fixture metadata for every phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner skipped unimplemented phases before it read `diagnostics.json`, so a `semantic` fixture's declared codes and clause were checked by nothing at all. Issue #9 turns nearly every remaining `spec.md` TODO into a `semantic` rule, which would have landed a corpus this repository cannot execute and did not inspect either. Split the two concerns. Executing a case needs a phase implemented here; checking that a case is well-formed does not, and now runs for every case: - `metadata.phase` is one of the four, and `cases.json` agrees with it. The index entry's `phase` was previously cast and discarded. - `id` leads with its phase, per the README's `--`. - `clause` resolves to an `` that exists in the cited `spec.md`. Anchors are load-bearing precisely because fixtures cite them, and nothing noticed when one went stale. - every declared `code` appears in a diagnostics table reachable from the family's own — its own additions unioned with component §8, mirroring the "The codes in component §8 apply. This family adds:" inheritance the prose already declares — and at the phase that table assigns it. The last one is the reason for the rest. A typo'd or wrong-phase code used to pass CI silently, and for a skipped phase it would have stayed silent indefinitely. No fixture changes: all 25 existing cases satisfy the new checks unmodified. Refs: #9 Signed-off-by: Justin Merrell --- conformance/README.md | 8 ++ tools/src/conformance.ts | 173 +++++++++++++++++++++++++++++++++++---- tools/src/spec.ts | 3 + 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index d9c9750..ca8d195 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -119,3 +119,11 @@ report it as skipped. It MUST NOT report it as passed. A case that does not cite a `clause` will be questioned in review. Fixtures exist to pin down prose, not to freeze current implementation behaviour. + +`task check:conformance` checks two separate things, and only the first of them +needs an implemented phase. Whether or not a case can be *executed* here, its +metadata is validated: the `id` leads with its phase, `cases.json` and +`metadata.json` agree on that phase, the `clause` resolves to an anchor that +exists in the cited `spec.md`, and every declared `code` appears in a +diagnostics table reachable from the family's own — at the phase that table +assigns it. A `semantic` fixture is skipped for execution but not for this. diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts index e30b61d..fd0c1f6 100644 --- a/tools/src/conformance.ts +++ b/tools/src/conformance.ts @@ -5,6 +5,12 @@ * NOT the normative runner — there deliberately is none. Downstream * implementations write their own adapter over the same data, which is what * makes cross-language parity provable rather than asserted. + * + * Two kinds of check live here. Executing a case needs a phase this repository + * implements; validating that a case is *well-formed* — that it cites a clause + * that exists and declares codes the prose actually defines — does not. The + * second kind runs for every case, including the ones the first kind skips, + * because a `semantic` fixture would otherwise be checked by nothing at all. */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -14,6 +20,7 @@ import { type Family, isObject, type Json, + REPO_ROOT, readJson, relativeToRepo, } from './spec.ts' @@ -33,8 +40,68 @@ interface CaseMetadata { readonly summary?: string } +interface DeclaredDiagnostic { + readonly code: string + readonly path: string +} + +const PHASES: readonly Phase[] = ['parser', 'structural', 'semantic', 'capability'] + const IMPLEMENTED_PHASES = new Set(['parser', 'structural']) +/** + * The family whose diagnostics table the other families declare themselves + * deltas on: blueprint §7 and listing §7 both open "The codes in component §8 + * apply. This family adds:". The registry a fixture may draw on is therefore + * its own family's table unioned with this one's. + */ +const BASE_FAMILY = 'component' + +/** A row of a `| Code | Phase | Meaning |` table in a spec.md. */ +const DIAGNOSTIC_ROW = /^\|\s*`(ERR_[A-Z0-9_]+)`\s*\|\s*`([a-z]+)`\s*\|/ +/** A stable heading anchor, `## 2. Document envelope`. */ +const SPEC_ANCHOR = /<\/a>/g + +interface SpecIndex { + /** Diagnostic code to the phase the prose assigns it. */ + readonly codes: ReadonlyMap + readonly anchors: ReadonlySet +} + +const EMPTY_INDEX: SpecIndex = { codes: new Map(), anchors: new Set() } + +const specIndexCache = new Map() + +/** Index one spec.md, or `undefined` when there is no such file. */ +function specIndex(path: string): SpecIndex | undefined { + const cached = specIndexCache.get(path) + if (cached !== undefined || specIndexCache.has(path)) return cached + + let index: SpecIndex | undefined + if (existsSync(path)) { + const source = readFileSync(path, 'utf8') + const codes = new Map() + for (const line of source.split('\n')) { + const row = DIAGNOSTIC_ROW.exec(line) + if (row?.[1] !== undefined && row[2] !== undefined) codes.set(row[1], row[2] as Phase) + } + const anchors = new Set() + for (const match of source.matchAll(SPEC_ANCHOR)) { + if (match[1] !== undefined) anchors.add(match[1]) + } + index = { codes, anchors } + } + specIndexCache.set(path, index) + return index +} + +/** Every diagnostic code a fixture in this family may legitimately declare. */ +function registryFor(family: Family): ReadonlyMap { + const own = specIndex(family.specPath) ?? EMPTY_INDEX + const base = specIndex(family.specPath.replace(`/${family.name}/`, `/${BASE_FAMILY}/`)) + return new Map([...(base ?? EMPTY_INDEX).codes, ...own.codes]) +} + function loadIndex(family: Family, failures: Failures): CaseIndexEntry[] { const indexPath = join(family.conformanceDir, 'cases.json') if (!existsSync(indexPath)) return [] @@ -56,6 +123,92 @@ function loadIndex(family: Family, failures: Failures): CaseIndexEntry[] { return entries } +/** + * Check the parts of a case that hold whether or not the phase runs here: the + * declared outcome, the clause it traces to, and the codes it names. + * + * Returns the declared diagnostics so the executing half does not re-read them, + * or `null` when the case is malformed. + */ +function checkCaseShape( + family: Family, + entry: CaseIndexEntry, + caseDir: string, + label: string, + metadata: CaseMetadata, + failures: Failures, +): DeclaredDiagnostic[] | null { + let ok = true + + if (!PHASES.includes(metadata.phase)) { + failures.add(`${label}: phase "${metadata.phase}" is not one of ${PHASES.join(', ')}`) + return null + } + if (entry.phase !== metadata.phase) { + failures.add( + `${label}: cases.json says phase "${entry.phase}" but metadata.json says "${metadata.phase}"`, + ) + ok = false + } + if (!metadata.id.startsWith(`${metadata.phase}-`)) { + failures.add(`${label}: id must follow -- and lead with the phase`) + ok = false + } + + // Every case should trace to prose — a fixture that cites nothing is an + // assertion about an implementation, not about the specification. + if (metadata.clause !== undefined) { + const [clausePath, fragment] = metadata.clause.split('#') + const cited = clausePath === undefined ? undefined : specIndex(join(REPO_ROOT, clausePath)) + if (cited === undefined) { + failures.add(`${label}: clause cites ${clausePath}, which does not exist`) + ok = false + } else if (fragment === undefined || !cited.anchors.has(fragment)) { + failures.add(`${label}: clause anchor #${fragment ?? ''} is not declared in ${clausePath}`) + ok = false + } + } + + if (metadata.expected === 'pass') return ok ? [] : null + + const diagnosticsPath = join(caseDir, 'diagnostics.json') + if (!existsSync(diagnosticsPath)) { + failures.add(`${label}: a failing case must declare diagnostics.json`) + return null + } + const declared = readJson(diagnosticsPath) + if (!Array.isArray(declared) || declared.length === 0) { + failures.add(`${label}: diagnostics.json must be a non-empty array`) + return null + } + + const registry = registryFor(family) + const diagnostics: DeclaredDiagnostic[] = [] + for (const item of declared as Json[]) { + if (!isObject(item) || typeof item.code !== 'string' || typeof item.path !== 'string') { + failures.add(`${label}: every diagnostic needs string "code" and "path"`) + return null + } + const declaredPhase = registry.get(item.code) + if (declaredPhase === undefined) { + failures.add( + `${label}: ${item.code} is declared by no diagnostics table reachable from ` + + `${relativeToRepo(family.specPath)}`, + ) + ok = false + } else if (declaredPhase !== metadata.phase) { + failures.add( + `${label}: ${item.code} is a ${declaredPhase}-phase code but the case declares ` + + `${metadata.phase}`, + ) + ok = false + } + diagnostics.push({ code: item.code, path: item.path }) + } + + return ok ? diagnostics : null +} + function runCase( family: Family, entry: CaseIndexEntry, @@ -83,6 +236,9 @@ function runCase( return 'failed' } + const declared = checkCaseShape(family, entry, caseDir, label, metadata, failures) + if (declared === null) return 'failed' + if (!IMPLEMENTED_PHASES.has(metadata.phase)) { console.log(` · ${label}: ${metadata.phase} phase not implemented here — skipped`) return 'skipped' @@ -115,23 +271,8 @@ function runCase( } // Diagnostic codes and the failing phase are normative; message text is not. - const diagnosticsPath = join(caseDir, 'diagnostics.json') - if (!existsSync(diagnosticsPath)) { - failures.add(`${label}: a failing case must declare diagnostics.json`) - return 'failed' - } - const declared = readJson(diagnosticsPath) - if (!Array.isArray(declared) || declared.length === 0) { - failures.add(`${label}: diagnostics.json must be a non-empty array`) - return 'failed' - } - const produced = new Set(result.diagnostics.map((d) => `${d.code}@${d.path}`)) - for (const item of declared as Json[]) { - if (!isObject(item) || typeof item.code !== 'string' || typeof item.path !== 'string') { - failures.add(`${label}: every diagnostic needs string "code" and "path"`) - return 'failed' - } + for (const item of declared) { if (!produced.has(`${item.code}@${item.path}`)) { failures.add( `${label}: declared diagnostic ${item.code} at ${item.path || '/'} was not produced.\n` + diff --git a/tools/src/spec.ts b/tools/src/spec.ts index 5d29ae9..58e2379 100644 --- a/tools/src/spec.ts +++ b/tools/src/spec.ts @@ -29,6 +29,8 @@ export interface Family { readonly srcDir: string readonly distDir: string readonly examplesDir: string + /** Absolute path to the family's normative prose. */ + readonly specPath: string /** Absolute path to the generated bundle. */ readonly bundlePath: string /** Canonical publication URL of the bundle within its major-version alias. */ @@ -75,6 +77,7 @@ export function discoverFamilies(): Family[] { srcDir: join(dir, 'schemas', 'src'), distDir: join(dir, 'schemas', 'dist'), examplesDir: join(dir, 'examples'), + specPath: join(dir, 'spec.md'), bundlePath: join(dir, 'schemas', 'dist', `${name}.schema.json`), bundleUrl: `${SCHEMA_ORIGIN}/${name}/${major}/${name}.schema.json`, conformanceDir: join(CONFORMANCE_DIR, name, major), From c0b886efc7b5ceb1fef8b432abb676674a77fba6 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:28:04 +0000 Subject: [PATCH 02/12] docs(component): write the workload, source, health, volume and contract clauses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the ten TODO markers in this file, answered from behaviour already implemented and gated in the platform. Issue #9 supplies each answer with its enforcing call site; three of its rows change meaning on the way across, because the issue is written in the platform's vocabulary and this contract does not share it. §5 Workload — a kind × field table. `endpoints` are for a SERVICE, `command` is required on a JOB and a CRON, `schedule` is required on a CRON and rejected everywhere else. The §5 TODO asked whether a misplaced field here is an error or ignored, and §2 had already answered it for every other field: a misspelled field is an error, never a silently ignored one. A `schedule` on a SERVICE fails the same way and for the same reason. Two rows differ from the platform's table deliberately: - `command` stays permitted on a SERVICE and a WORKER, where it overrides the image's default command. That is meaningful, unlike a schedule, so the symmetry with `schedule` would be false symmetry. - a SERVICE is NOT required to expose an endpoint. The platform requires one; adopting that would reject `examples/minimal.yaml` and the `structural-001-minimal-valid` fixture, so it is recorded in the prose as a candidate for a later major rather than taken now. The platform's "input keys unique within a component" row is a no-op here: `contract.inputs` is a mapping, so a repeated key is already ERR_DUPLICATE_KEY in the parser phase. §5.1 Source — image references must be pinned, split across two layers. The grammar (a tag or an @sha256 digest is present) is structural. The floating-tag blocklist is semantic, and stays out of the schema on purpose: the list is curated and will grow, and growing a `pattern` makes a previously valid document invalid. Held as prose plus a fixture, it can be extended in a minor release. §5.4 Health probes — `readiness` is required for a SERVICE exposing at least one PUBLIC endpoint, and a private-only service is exempt. Probe defaults are written down. A probe naming an endpoint that does not exist is a new semantic code, ERR_UNKNOWN_ENDPOINT. §5.5 Volumes — `mountPath` must be absolute. Volume overlap and size bounds are stated as gaps rather than described aspirationally: neither project constrains them, and a reader needs to be able to tell which silences are decisions. §6 Configuration contract — the `ui`/`suppliedBy` correspondence and the `valueFrom`/`value` correspondence were both already asserted in the schema's own descriptions and enforced by nothing. A generated input must be USER- supplied and sensitive, both written explicitly, because `isSensitive` defaults to false and that is the wrong answer for a minted secret. Issue #9 frames the last §6 rule as a `params.*`/`self.*` template namespace. There is no template language in this contract — an output is `valueFrom: DERIVED|DECLARED` plus a static string — so it is restated in the contract's own terms: an output's value is a function of the producing node alone and must not depend on a value that node received over an inbound connection. That is what makes an output referenceable. It is explicitly not a licence for a cyclic graph; blueprint §4.2 requires acyclicity on its own grounds. Prose only. Nothing in this commit changes what validates. Refs: #9 Signed-off-by: Justin Merrell --- specifications/component/v1/spec.md | 199 +++++++++++++++++++++++++--- 1 file changed, 180 insertions(+), 19 deletions(-) diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index d4a9cff..14c9c52 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -81,17 +81,81 @@ validated against `v1.0.0` MUST validate against every later `v1.x.y`. ## 5. Workload -> **TODO** — Normative rules for each workload kind (`SERVICE`, `WORKER`, -> `JOB`, `CRON`), including which fields are meaningful for each. In particular: -> whether `endpoints` on a `WORKER`, or `schedule` on a `SERVICE`, is an error -> or ignored. Current schema permits both; that MUST be resolved before v1 is -> declared stable. +`spec.workload` says how the component runs. Its `kind` is the runtime shape, +and the shape decides which of the remaining fields carry meaning. + +| Field | `SERVICE` | `WORKER` | `JOB` | `CRON` | +|---|---|---|---|---| +| `endpoints` | permitted | forbidden | forbidden | forbidden | +| `command` | permitted | permitted | REQUIRED | REQUIRED | +| `schedule` | forbidden | forbidden | forbidden | REQUIRED | +| `health.readiness` | see [§5.4](#health) | permitted | permitted | permitted | + +`source`, `envVars`, and `volumes` are permitted on every kind. Every rule in +the table is decided in the `structural` phase. + +**Forbidden means rejected, not ignored.** A forbidden field MAY be absent, an +empty mapping, or an explicit null; anything else is an error. +[§2](#envelope) has already settled why — a misspelled field is an error rather +than a silently ignored one, "including when the misspelled field is optional, +where ignoring it would silently substitute the default". A `schedule` on a +`SERVICE` fails for the same reason: an author who writes one believes their +service is scheduled, and accepting it silently is how that belief survives to +production. + +**`command` is a different case from `schedule`.** A `JOB` and a `CRON` have +nothing to run without it, so it is REQUIRED on both. On a `SERVICE` or a +`WORKER` it overrides the image's default command, which is meaningful rather +than meaningless, so it stays permitted. + +**A `SERVICE` MAY declare no endpoint.** The smallest component that validates +is a service running a pinned image and nothing else. A service exposing no +endpoint and a worker are operationally the same thing, so requiring at least +one endpoint is a defensible rule — but it would reject documents this +specification currently accepts, which makes it a breaking change rather than +one v1 can absorb. + +Input and output keys are unique within a component because `contract.inputs` +and `contract.outputs` are mappings. A repeated key is `ERR_DUPLICATE_KEY` in +the `parser` phase, before any rule in this section is considered. ### 5.1 Source -> **TODO** — Image reference pinning rules. Floating tags (`latest`, `main`, -> `edge`) MUST be rejected; state the exact grammar and the diagnostic code. -> Define digest-pinned reference handling and Git ref resolution semantics. +`source` is discriminated on `type`. + +**`IMAGE`.** `ref` is REQUIRED and names a prebuilt OCI image. There is no +`build` on this branch — a prebuilt image is not built again, and the field is +absent rather than ignored so it cannot be misread as an override. + +**`GIT`.** `repositoryUrl` and `build` are both REQUIRED. `ref` is OPTIONAL and +pins a branch or a commit; omitting it takes the repository's default branch. A +`BRANCH` ref resolves at build time, so two builds of one unchanged document +can produce different images. A `COMMIT` ref is reproducible. Neither is +rejected, but a component that must build reproducibly SHOULD pin a commit. + +**Image references MUST be pinned.** An unpinned reference mutates under +whoever curates the registry, shifting a deployment with no change to any +document in the item. Two layers enforce it, and the split between them is +deliberate. + +A reference MUST carry a tag or a digest. A bare name — an implicit `:latest` — +is rejected in the `structural` phase with `ERR_INVALID_VALUE`. That is a +grammar, so the schema carries it. + +A reference MUST NOT carry a floating tag. The floating set is `latest`, +`main`, `main-stable`, `master`, `stable`, `edge`, `nightly`, `dev`, and +`rolling`, compared case-insensitively; a reference whose tag is one of them is +rejected in the `semantic` phase with `ERR_UNPINNED_IMAGE`. + +**Why the floating set is not a `pattern`.** It is a curated list and it will +grow. Growing a `pattern` makes a previously valid document invalid, which is a +major version. Held as a `semantic` rule instead, the list can be extended in a +minor release — the grammar above is fixed and belongs in the schema, the +blocklist is not and does not. + +A digest pin — `@sha256:` followed by 64 lowercase hexadecimal digits — +satisfies both rules whatever tag accompanies it, because the digest is what +resolves. The floating-tag rule applies only to a reference carrying no digest. ### 5.2 Endpoints @@ -105,20 +169,112 @@ validated against `v1.0.0` MUST validate against every later `v1.x.y`. ### 5.4 Health probes -> **TODO** — Semantics of `startup`, `readiness`, and `liveness`; default -> values; and the meaning of a probe naming an endpoint that does not exist. +Three staged probes. Each is OPTIONAL unless the rule below applies, and each +polls an HTTP path. + +| Probe | Gate | Consequence of failure | +|---|---|---| +| `startup` | Initialisation | The workload counts as not yet started. | +| `readiness` | Traffic | The replica leaves routing; it is not restarted. | +| `liveness` | Aliveness | The container is restarted. | + +`path` is the only REQUIRED property of a probe. The rest default: +`initialDelaySeconds: 10`, `periodSeconds: 10`, `timeoutSeconds: 5`, +`successThreshold: 1`, `failureThreshold: 3`. + +`endpoint` names the endpoint whose port the probe targets; null selects the +primary endpoint. A probe naming an endpoint the workload does not declare is +rejected in the `semantic` phase with `ERR_UNKNOWN_ENDPOINT`. The schema cannot +express it — the endpoint names are mapping keys elsewhere in the document, and +JSON Schema cannot constrain a value against a sibling's keys. + +**`readiness` is REQUIRED for a `SERVICE` exposing at least one `PUBLIC` +endpoint**, and OPTIONAL everywhere else, including on a `SERVICE` whose +endpoints are all `PRIVATE`. The rule is narrow on purpose. Without a readiness +gate a public URL routes to a replica that is running but not yet serving, and +the first request a user makes is the one that fails. A private consumer inside +the mesh retries; a browser does not. ### 5.5 Volumes -> **TODO** — Mount path constraints (absolute, no traversal, no overlap between -> two volumes), size bounds, and access-mode semantics. +`volumes` is a mapping from volume name to a declaration. `sizeGib` and +`mountPath` are REQUIRED. `accessMode` (default `READ_WRITE_ONCE`) and +`isReadOnly` (default `false`) are OPTIONAL. + +`mountPath` MUST be absolute. A relative path is rejected in the `structural` +phase with `ERR_INVALID_VALUE`. + +`accessMode` decides how the materialised volume is shared across replicas. +`READ_WRITE_ONCE` is an attached block volume and mounts on a single replica; +`READ_WRITE_MANY` is a shared network volume and mounts on every replica. + +**What v1 does not constrain.** Two volumes on one workload MAY declare +overlapping mount paths, and `sizeGib` has no lower or upper bound. Neither +silence is a considered permission — both are gaps, in this specification and +in the implementations reading it, and they are recorded here rather than +described aspirationally so that a reader can tell which silences are +decisions. Closing either one rejects documents that validate today and is +therefore a breaking change. ## 6. Configuration contract -> **TODO** — The `inputs`/`outputs` contract is the load-bearing part of -> composition and needs the most precise prose. Cover: `suppliedBy` semantics, -> generator determinism, what makes an output referenceable by a blueprint -> connection, and type compatibility between an output and the input it feeds. +`spec.contract` is what makes a component composable. `inputs` are values the +component needs; `outputs` are values it publishes for another component to +consume. A [Blueprint](../../blueprint/v1/spec.md) connection joins one node's +output to another node's input, and this section defines both ends of that +join. + +`contract` is OPTIONAL. A component that neither consumes nor publishes +configuration omits it. + +### 6.1 Inputs + +`schema` is the only REQUIRED property of an input. `suppliedBy` decides who +satisfies it and defaults to `USER`. + +| `suppliedBy` | Satisfied by | `ui` | +|---|---|---| +| `USER` | The deploying user, through the install form. | REQUIRED | +| `CONNECTION` | A blueprint connection, from an upstream output. | MUST be null | + +A `USER` input needs a label before a form can render it. A `CONNECTION` input +never reaches the install form, so presentation metadata on one is a statement +about a form it will never appear on. Both rules are `structural`. + +**A generated input is secret material.** An input carrying `generator` — one +whose value the platform mints at deploy time — MUST declare `suppliedBy: USER` +and `schema.isSensitive: true`. Both MUST be written explicitly rather than +left to a default: `isSensitive` defaults to `false`, and a generated value not +marked sensitive is echoed back into logs and interfaces. The value rides on a +`USER` input because that is the slot the install form already reserves for it +— the user simply does not have to type it. + +### 6.2 Outputs + +`schema` and `valueFrom` are REQUIRED. + +| `valueFrom` | `value` | Where the value comes from | +|---|---|---| +| `DECLARED` | REQUIRED, non-empty | This document. | +| `DERIVED` | MUST be null | The platform, from the running workload. | + +**An output depends on its own node and nothing else.** A `DECLARED` output's +value is written in this document. A `DERIVED` output's value comes from the +producing workload's own addressing — its private address, its public URL. An +output MUST NOT depend on a value the component received over an inbound +connection. + +That constraint is what makes an output referenceable at all: a consumer can +read a producer's output without the producer having first been told anything. +It is not, however, a licence for a cyclic graph. +[Blueprint §4.2](../../blueprint/v1/spec.md#connections) requires the +connection graph to be acyclic, for reasons of its own. + +> **TODO** — Type compatibility between an output and the input it feeds. +> `schema.semanticType` is the tag a composition layer matches on, but the +> matching rule is not yet stated. It belongs with +> [blueprint §4.2](../../blueprint/v1/spec.md#connections), which resolves the +> connection. ## 7. Validation layers @@ -151,9 +307,14 @@ different text and that is expected. | `ERR_MISSING_FIELD` | `structural` | A required property is absent. | | `ERR_INVALID_TYPE` | `structural` | A value has the wrong type. | | `ERR_INVALID_VALUE` | `structural` | A value violates a pattern, enum, or bound. | - -> **TODO** — Extend with the semantic-phase codes once section 6 is written. -> The registry above is the complete set an implementation must handle today. +| `ERR_UNPINNED_IMAGE` | `semantic` | An image reference carries a floating tag. | +| `ERR_UNKNOWN_ENDPOINT` | `semantic` | A probe names an endpoint the workload does not declare. | + +The rows above the `semantic` pair are the shared envelope registry: the +[blueprint](../../blueprint/v1/spec.md#diagnostics) and +[listing](../../listing/v1/spec.md#diagnostics) families declare themselves +additions to this table rather than restating it. The two `semantic` codes are +this family's own. ## 9. Conformance From 211cdd6f271bd5f36322f26518669c437bc38060 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:28:24 +0000 Subject: [PATCH 03/12] test(conformance): pin the component workload, source and contract rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen structural cases and two semantic ones covering every rule the preceding commit wrote into spec.md. Deliberately red at this commit — that redness is the fail-before evidence. Measured against the previous bundle, twelve of the seventeen structural cases fail: 006 expected to fail but validated cleanly endpoints on a WORKER 008 expected to fail but validated cleanly JOB without command 010 expected to fail but validated cleanly CRON without schedule 011 expected to fail but validated cleanly schedule on a SERVICE 012 expected to fail but validated cleanly bare image name 014 expected to fail but validated cleanly GIT source without build 015 expected to fail but validated cleanly public SERVICE, no readiness 017 expected to fail but validated cleanly relative mountPath 018 expected to fail but validated cleanly generated input not sensitive 019 expected to fail but validated cleanly ui on a CONNECTION input 020 expected to fail but validated cleanly USER input without ui 021 expected to fail but validated cleanly DERIVED output with a value The other five are green beforehand and are regression pins rather than proof of a fix. Each exists because it is the case a careless rule would break: 007 an empty `endpoints: {}` on a WORKER — forbidden bounds the contents, not whether the key may be written 009 a well-formed CRON, so the two CRON rules cannot be satisfied by rejecting every CRON 013 `localhost:5000/musher-dev/api@sha256:…` — the registry port is the case the pinning grammar has to get right, since a naive rule reads the port colon as a tag 016 a PRIVATE-only SERVICE with no readiness probe, pinning the exemption 022 the whole contract surface at once: a generated sensitive input, a DERIVED output, and a DECLARED one The two semantic cases — a floating `:nightly` tag and a probe naming an endpoint that does not exist — are skipped by this repository's adapter, which implements the parser and structural phases only. They are not unchecked: the preceding tools commit validates their metadata, clause anchor, and declared codes whether or not the phase runs here. Refs: #9 Signed-off-by: Justin Merrell --- conformance/component/v1/cases.json | 95 +++++++++++++++++++ .../case.yaml | 13 +++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../case.yaml | 22 +++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../006-non-service-with-endpoints/case.yaml | 20 ++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../case.yaml | 15 +++ .../metadata.json | 7 ++ .../008-job-without-command/case.yaml | 13 +++ .../008-job-without-command/diagnostics.json | 6 ++ .../008-job-without-command/metadata.json | 7 ++ .../v1/structural/009-cron-workload/case.yaml | 13 +++ .../009-cron-workload/metadata.json | 7 ++ .../010-cron-without-schedule/case.yaml | 13 +++ .../diagnostics.json | 6 ++ .../010-cron-without-schedule/metadata.json | 7 ++ .../011-schedule-on-service/case.yaml | 15 +++ .../011-schedule-on-service/diagnostics.json | 6 ++ .../011-schedule-on-service/metadata.json | 7 ++ .../012-unpinned-image-reference/case.yaml | 12 +++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../case.yaml | 13 +++ .../metadata.json | 7 ++ .../014-git-source-without-build/case.yaml | 13 +++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../case.yaml | 18 ++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../case.yaml | 18 ++++ .../metadata.json | 7 ++ .../017-relative-mount-path/case.yaml | 16 ++++ .../017-relative-mount-path/diagnostics.json | 6 ++ .../017-relative-mount-path/metadata.json | 7 ++ .../case.yaml | 23 +++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../019-connection-input-with-ui/case.yaml | 20 ++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../020-user-input-without-ui/case.yaml | 17 ++++ .../diagnostics.json | 6 ++ .../020-user-input-without-ui/metadata.json | 7 ++ .../021-derived-output-with-value/case.yaml | 19 ++++ .../diagnostics.json | 6 ++ .../metadata.json | 7 ++ .../022-contract-both-output-forms/case.yaml | 41 ++++++++ .../metadata.json | 7 ++ 53 files changed, 646 insertions(+) create mode 100644 conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml create mode 100644 conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json create mode 100644 conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json create mode 100644 conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml create mode 100644 conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json create mode 100644 conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json create mode 100644 conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml create mode 100644 conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json create mode 100644 conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json create mode 100644 conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml create mode 100644 conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json create mode 100644 conformance/component/v1/structural/008-job-without-command/case.yaml create mode 100644 conformance/component/v1/structural/008-job-without-command/diagnostics.json create mode 100644 conformance/component/v1/structural/008-job-without-command/metadata.json create mode 100644 conformance/component/v1/structural/009-cron-workload/case.yaml create mode 100644 conformance/component/v1/structural/009-cron-workload/metadata.json create mode 100644 conformance/component/v1/structural/010-cron-without-schedule/case.yaml create mode 100644 conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json create mode 100644 conformance/component/v1/structural/010-cron-without-schedule/metadata.json create mode 100644 conformance/component/v1/structural/011-schedule-on-service/case.yaml create mode 100644 conformance/component/v1/structural/011-schedule-on-service/diagnostics.json create mode 100644 conformance/component/v1/structural/011-schedule-on-service/metadata.json create mode 100644 conformance/component/v1/structural/012-unpinned-image-reference/case.yaml create mode 100644 conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json create mode 100644 conformance/component/v1/structural/012-unpinned-image-reference/metadata.json create mode 100644 conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml create mode 100644 conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json create mode 100644 conformance/component/v1/structural/014-git-source-without-build/case.yaml create mode 100644 conformance/component/v1/structural/014-git-source-without-build/diagnostics.json create mode 100644 conformance/component/v1/structural/014-git-source-without-build/metadata.json create mode 100644 conformance/component/v1/structural/015-public-service-without-readiness/case.yaml create mode 100644 conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json create mode 100644 conformance/component/v1/structural/015-public-service-without-readiness/metadata.json create mode 100644 conformance/component/v1/structural/016-private-service-without-readiness/case.yaml create mode 100644 conformance/component/v1/structural/016-private-service-without-readiness/metadata.json create mode 100644 conformance/component/v1/structural/017-relative-mount-path/case.yaml create mode 100644 conformance/component/v1/structural/017-relative-mount-path/diagnostics.json create mode 100644 conformance/component/v1/structural/017-relative-mount-path/metadata.json create mode 100644 conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml create mode 100644 conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json create mode 100644 conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json create mode 100644 conformance/component/v1/structural/019-connection-input-with-ui/case.yaml create mode 100644 conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json create mode 100644 conformance/component/v1/structural/019-connection-input-with-ui/metadata.json create mode 100644 conformance/component/v1/structural/020-user-input-without-ui/case.yaml create mode 100644 conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json create mode 100644 conformance/component/v1/structural/020-user-input-without-ui/metadata.json create mode 100644 conformance/component/v1/structural/021-derived-output-with-value/case.yaml create mode 100644 conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json create mode 100644 conformance/component/v1/structural/021-derived-output-with-value/metadata.json create mode 100644 conformance/component/v1/structural/022-contract-both-output-forms/case.yaml create mode 100644 conformance/component/v1/structural/022-contract-both-output-forms/metadata.json diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index f872981..95d57e1 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -31,6 +31,101 @@ "id": "structural-005-nested-unknown-field", "phase": "structural", "path": "structural/005-nested-unknown-field" + }, + { + "id": "structural-006-non-service-with-endpoints", + "phase": "structural", + "path": "structural/006-non-service-with-endpoints" + }, + { + "id": "structural-007-non-service-with-empty-endpoints", + "phase": "structural", + "path": "structural/007-non-service-with-empty-endpoints" + }, + { + "id": "structural-008-job-without-command", + "phase": "structural", + "path": "structural/008-job-without-command" + }, + { + "id": "structural-009-cron-workload", + "phase": "structural", + "path": "structural/009-cron-workload" + }, + { + "id": "structural-010-cron-without-schedule", + "phase": "structural", + "path": "structural/010-cron-without-schedule" + }, + { + "id": "structural-011-schedule-on-service", + "phase": "structural", + "path": "structural/011-schedule-on-service" + }, + { + "id": "structural-012-unpinned-image-reference", + "phase": "structural", + "path": "structural/012-unpinned-image-reference" + }, + { + "id": "structural-013-digest-pinned-image-reference", + "phase": "structural", + "path": "structural/013-digest-pinned-image-reference" + }, + { + "id": "structural-014-git-source-without-build", + "phase": "structural", + "path": "structural/014-git-source-without-build" + }, + { + "id": "structural-015-public-service-without-readiness", + "phase": "structural", + "path": "structural/015-public-service-without-readiness" + }, + { + "id": "structural-016-private-service-without-readiness", + "phase": "structural", + "path": "structural/016-private-service-without-readiness" + }, + { + "id": "structural-017-relative-mount-path", + "phase": "structural", + "path": "structural/017-relative-mount-path" + }, + { + "id": "structural-018-generated-input-not-sensitive", + "phase": "structural", + "path": "structural/018-generated-input-not-sensitive" + }, + { + "id": "structural-019-connection-input-with-ui", + "phase": "structural", + "path": "structural/019-connection-input-with-ui" + }, + { + "id": "structural-020-user-input-without-ui", + "phase": "structural", + "path": "structural/020-user-input-without-ui" + }, + { + "id": "structural-021-derived-output-with-value", + "phase": "structural", + "path": "structural/021-derived-output-with-value" + }, + { + "id": "structural-022-contract-both-output-forms", + "phase": "structural", + "path": "structural/022-contract-both-output-forms" + }, + { + "id": "semantic-001-floating-tag-image-reference", + "phase": "semantic", + "path": "semantic/001-floating-tag-image-reference" + }, + { + "id": "semantic-002-probe-names-unknown-endpoint", + "phase": "semantic", + "path": "semantic/002-probe-names-unknown-endpoint" } ] } diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml b/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml new file mode 100644 index 0000000..0a087da --- /dev/null +++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/case.yaml @@ -0,0 +1,13 @@ +# The tag satisfies the structural grammar — it is a tag, not a bare name — +# but it floats. The blocklist is held in the semantic phase rather than in a +# pattern so it can be extended without a major version. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: ghcr.io/musher-dev/api:nightly diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json b/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json new file mode 100644 index 0000000..e078224 --- /dev/null +++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNPINNED_IMAGE", + "path": "/spec/workload/source/ref" + } +] diff --git a/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json b/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json new file mode 100644 index 0000000..db8b571 --- /dev/null +++ b/conformance/component/v1/semantic/001-floating-tag-image-reference/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-001-floating-tag-image-reference", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#source", + "summary": "An image reference whose tag is in the floating set is rejected." +} diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml new file mode 100644 index 0000000..7d92983 --- /dev/null +++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/case.yaml @@ -0,0 +1,22 @@ +# The probe targets "api", but the only endpoint is "web". JSON Schema cannot +# catch this: the endpoint names are mapping keys elsewhere in the document, +# and no keyword constrains a value against a sibling's keys. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC + health: + readiness: + path: /healthz + endpoint: api diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json new file mode 100644 index 0000000..54b2be6 --- /dev/null +++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_ENDPOINT", + "path": "/spec/workload/health/readiness/endpoint" + } +] diff --git a/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json new file mode 100644 index 0000000..72eb9f5 --- /dev/null +++ b/conformance/component/v1/semantic/002-probe-names-unknown-endpoint/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-002-probe-names-unknown-endpoint", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#health", + "summary": "A probe naming an endpoint the workload does not declare is rejected." +} diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml b/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml new file mode 100644 index 0000000..b9a4637 --- /dev/null +++ b/conformance/component/v1/structural/006-non-service-with-endpoints/case.yaml @@ -0,0 +1,20 @@ +# Only a SERVICE may expose an endpoint. A WORKER that declares one has +# either the wrong kind or a misplaced endpoint, and both are worth saying out +# loud — accepting it silently leaves an author expecting traffic that will +# never arrive. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: WORKER + command: /bin/consume + source: + type: IMAGE + ref: ghcr.io/musher-dev/worker:1.4.0 + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json b/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json new file mode 100644 index 0000000..0682289 --- /dev/null +++ b/conformance/component/v1/structural/006-non-service-with-endpoints/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/endpoints" + } +] diff --git a/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json b/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json new file mode 100644 index 0000000..e529252 --- /dev/null +++ b/conformance/component/v1/structural/006-non-service-with-endpoints/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-006-non-service-with-endpoints", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A WORKER declaring an endpoint is rejected rather than having it ignored." +} diff --git a/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml new file mode 100644 index 0000000..1724f1b --- /dev/null +++ b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/case.yaml @@ -0,0 +1,15 @@ +# "Forbidden" bounds what the field may contain, not whether the key may be +# written. An empty mapping declares nothing, so it is accepted — the same +# spelling the blueprint examples use for an empty connections block. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: WORKER + command: /bin/consume + endpoints: {} + source: + type: IMAGE + ref: ghcr.io/musher-dev/worker:1.4.0 diff --git a/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json new file mode 100644 index 0000000..0e2bc8f --- /dev/null +++ b/conformance/component/v1/structural/007-non-service-with-empty-endpoints/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-007-non-service-with-empty-endpoints", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A forbidden mapping may still be written empty; only a declared entry is an error." +} diff --git a/conformance/component/v1/structural/008-job-without-command/case.yaml b/conformance/component/v1/structural/008-job-without-command/case.yaml new file mode 100644 index 0000000..b51b9ec --- /dev/null +++ b/conformance/component/v1/structural/008-job-without-command/case.yaml @@ -0,0 +1,13 @@ +# A JOB is a one-shot command. Without one there is no workload to run, and +# the image's default command is not a substitute — a job that silently ran a +# service's entrypoint would never terminate. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: JOB + source: + type: IMAGE + ref: ghcr.io/musher-dev/migrate:2.0.1 diff --git a/conformance/component/v1/structural/008-job-without-command/diagnostics.json b/conformance/component/v1/structural/008-job-without-command/diagnostics.json new file mode 100644 index 0000000..36f0b65 --- /dev/null +++ b/conformance/component/v1/structural/008-job-without-command/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/workload" + } +] diff --git a/conformance/component/v1/structural/008-job-without-command/metadata.json b/conformance/component/v1/structural/008-job-without-command/metadata.json new file mode 100644 index 0000000..b665f13 --- /dev/null +++ b/conformance/component/v1/structural/008-job-without-command/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-008-job-without-command", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A JOB omitting command is rejected; there is nothing for it to run." +} diff --git a/conformance/component/v1/structural/009-cron-workload/case.yaml b/conformance/component/v1/structural/009-cron-workload/case.yaml new file mode 100644 index 0000000..8000ef1 --- /dev/null +++ b/conformance/component/v1/structural/009-cron-workload/case.yaml @@ -0,0 +1,13 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: CRON + command: /bin/backup + schedule: + cron: "0 3 * * *" + source: + type: IMAGE + ref: ghcr.io/musher-dev/backup:1.0.2 diff --git a/conformance/component/v1/structural/009-cron-workload/metadata.json b/conformance/component/v1/structural/009-cron-workload/metadata.json new file mode 100644 index 0000000..90621fe --- /dev/null +++ b/conformance/component/v1/structural/009-cron-workload/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-009-cron-workload", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A CRON carrying both command and schedule validates." +} diff --git a/conformance/component/v1/structural/010-cron-without-schedule/case.yaml b/conformance/component/v1/structural/010-cron-without-schedule/case.yaml new file mode 100644 index 0000000..6816419 --- /dev/null +++ b/conformance/component/v1/structural/010-cron-without-schedule/case.yaml @@ -0,0 +1,13 @@ +# A CRON with no schedule names no time to run at. Defaulting one would be +# the platform choosing when someone else's job fires. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: CRON + command: /bin/backup + source: + type: IMAGE + ref: ghcr.io/musher-dev/backup:1.0.2 diff --git a/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json b/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json new file mode 100644 index 0000000..36f0b65 --- /dev/null +++ b/conformance/component/v1/structural/010-cron-without-schedule/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/workload" + } +] diff --git a/conformance/component/v1/structural/010-cron-without-schedule/metadata.json b/conformance/component/v1/structural/010-cron-without-schedule/metadata.json new file mode 100644 index 0000000..ae09788 --- /dev/null +++ b/conformance/component/v1/structural/010-cron-without-schedule/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-010-cron-without-schedule", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A CRON omitting schedule is rejected; nothing says when it runs." +} diff --git a/conformance/component/v1/structural/011-schedule-on-service/case.yaml b/conformance/component/v1/structural/011-schedule-on-service/case.yaml new file mode 100644 index 0000000..e2f269c --- /dev/null +++ b/conformance/component/v1/structural/011-schedule-on-service/case.yaml @@ -0,0 +1,15 @@ +# A SERVICE is not scheduled, so this schedule means nothing. Ignoring it +# would leave the author believing their service runs at 03:00, which is +# exactly the failure mode §2 refuses for any other misplaced field. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + schedule: + cron: "0 3 * * *" diff --git a/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json b/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json new file mode 100644 index 0000000..1175d08 --- /dev/null +++ b/conformance/component/v1/structural/011-schedule-on-service/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/workload/schedule" + } +] diff --git a/conformance/component/v1/structural/011-schedule-on-service/metadata.json b/conformance/component/v1/structural/011-schedule-on-service/metadata.json new file mode 100644 index 0000000..57fbae4 --- /dev/null +++ b/conformance/component/v1/structural/011-schedule-on-service/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-011-schedule-on-service", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#workload", + "summary": "A schedule on a SERVICE is rejected rather than ignored." +} diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml new file mode 100644 index 0000000..3361f39 --- /dev/null +++ b/conformance/component/v1/structural/012-unpinned-image-reference/case.yaml @@ -0,0 +1,12 @@ +# A bare name is an implicit :latest. The deployed image would then change +# whenever the registry moved the tag, with no change to any document here. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json b/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json new file mode 100644 index 0000000..8469060 --- /dev/null +++ b/conformance/component/v1/structural/012-unpinned-image-reference/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/source/ref" + } +] diff --git a/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json b/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json new file mode 100644 index 0000000..b04a66e --- /dev/null +++ b/conformance/component/v1/structural/012-unpinned-image-reference/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-012-unpinned-image-reference", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#source", + "summary": "An image reference carrying neither tag nor digest is rejected." +} diff --git a/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml b/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml new file mode 100644 index 0000000..54cc745 --- /dev/null +++ b/conformance/component/v1/structural/013-digest-pinned-image-reference/case.yaml @@ -0,0 +1,13 @@ +# Both accepted spellings of a pin, and the case the grammar has to get +# right: the colon in localhost:5000 is a registry port, not a tag, so the tag +# is the colon after the final slash. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: localhost:5000/musher-dev/api@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json b/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json new file mode 100644 index 0000000..627185d --- /dev/null +++ b/conformance/component/v1/structural/013-digest-pinned-image-reference/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-013-digest-pinned-image-reference", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#source", + "summary": "A digest pin validates, including behind a registry host carrying a port." +} diff --git a/conformance/component/v1/structural/014-git-source-without-build/case.yaml b/conformance/component/v1/structural/014-git-source-without-build/case.yaml new file mode 100644 index 0000000..ec9b6ed --- /dev/null +++ b/conformance/component/v1/structural/014-git-source-without-build/case.yaml @@ -0,0 +1,13 @@ +# A git repository is not an image. Leaving the build strategy implicit makes +# the resulting image a property of whichever builder happened to run, not of +# this document. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: GIT + repositoryUrl: https://github.com/musher-dev/examples diff --git a/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json b/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json new file mode 100644 index 0000000..38e8fef --- /dev/null +++ b/conformance/component/v1/structural/014-git-source-without-build/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/workload/source" + } +] diff --git a/conformance/component/v1/structural/014-git-source-without-build/metadata.json b/conformance/component/v1/structural/014-git-source-without-build/metadata.json new file mode 100644 index 0000000..c3fd4a5 --- /dev/null +++ b/conformance/component/v1/structural/014-git-source-without-build/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-014-git-source-without-build", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#source", + "summary": "A GIT source omitting build is rejected; nothing says how the image is made." +} diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml b/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml new file mode 100644 index 0000000..51c6485 --- /dev/null +++ b/conformance/component/v1/structural/015-public-service-without-readiness/case.yaml @@ -0,0 +1,18 @@ +# Without a readiness gate the public URL routes to a replica that is running +# but not yet serving, so the first request a user makes is the one that +# fails. A browser does not retry. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + endpoints: + web: + containerPort: 8080 + protocol: HTTP + visibility: PUBLIC diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json b/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json new file mode 100644 index 0000000..36f0b65 --- /dev/null +++ b/conformance/component/v1/structural/015-public-service-without-readiness/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/workload" + } +] diff --git a/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json b/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json new file mode 100644 index 0000000..cf47fb3 --- /dev/null +++ b/conformance/component/v1/structural/015-public-service-without-readiness/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-015-public-service-without-readiness", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#health", + "summary": "A SERVICE exposing a PUBLIC endpoint must declare a readiness probe." +} diff --git a/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml b/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml new file mode 100644 index 0000000..97d47a3 --- /dev/null +++ b/conformance/component/v1/structural/016-private-service-without-readiness/case.yaml @@ -0,0 +1,18 @@ +# The readiness rule is deliberately narrow. A private consumer inside the +# mesh retries; nothing user-facing depends on this replica being ready the +# first time, so the probe stays optional. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE diff --git a/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json b/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json new file mode 100644 index 0000000..271c87b --- /dev/null +++ b/conformance/component/v1/structural/016-private-service-without-readiness/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-016-private-service-without-readiness", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#health", + "summary": "A SERVICE whose endpoints are all PRIVATE is exempt from the readiness rule." +} diff --git a/conformance/component/v1/structural/017-relative-mount-path/case.yaml b/conformance/component/v1/structural/017-relative-mount-path/case.yaml new file mode 100644 index 0000000..f8b8062 --- /dev/null +++ b/conformance/component/v1/structural/017-relative-mount-path/case.yaml @@ -0,0 +1,16 @@ +# A mount path is resolved by the container runtime, not relative to anything +# in this document. A relative path has no meaning to resolve against. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + volumes: + data: + sizeGib: 10 + mountPath: var/lib/postgresql/data diff --git a/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json b/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json new file mode 100644 index 0000000..83e7639 --- /dev/null +++ b/conformance/component/v1/structural/017-relative-mount-path/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/workload/volumes/data/mountPath" + } +] diff --git a/conformance/component/v1/structural/017-relative-mount-path/metadata.json b/conformance/component/v1/structural/017-relative-mount-path/metadata.json new file mode 100644 index 0000000..f05ddfb --- /dev/null +++ b/conformance/component/v1/structural/017-relative-mount-path/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-017-relative-mount-path", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#volumes", + "summary": "A relative volume mountPath is rejected." +} diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml b/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml new file mode 100644 index 0000000..1885300 --- /dev/null +++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/case.yaml @@ -0,0 +1,23 @@ +# A platform-minted value is secret material. isSensitive defaults to false, +# so leaving it unwritten is not a neutral omission — it is the wrong answer, +# and the generated value would be echoed back into logs and interfaces. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + contract: + inputs: + apiKey: + schema: + type: STRING + suppliedBy: USER + generator: + encoding: HEX + ui: + label: API key diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json b/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json new file mode 100644 index 0000000..71849a1 --- /dev/null +++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/contract/inputs/apiKey/schema" + } +] diff --git a/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json b/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json new file mode 100644 index 0000000..817b10c --- /dev/null +++ b/conformance/component/v1/structural/018-generated-input-not-sensitive/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-018-generated-input-not-sensitive", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "An input carrying a generator must declare schema.isSensitive true." +} diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml b/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml new file mode 100644 index 0000000..5781a0c --- /dev/null +++ b/conformance/component/v1/structural/019-connection-input-with-ui/case.yaml @@ -0,0 +1,20 @@ +# A CONNECTION input is wired from an upstream output and never reaches the +# install form. A label on one describes a field that will never render. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + contract: + inputs: + databaseUrl: + schema: + type: STRING + suppliedBy: CONNECTION + ui: + label: Database URL diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json b/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json new file mode 100644 index 0000000..0d9cbe0 --- /dev/null +++ b/conformance/component/v1/structural/019-connection-input-with-ui/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/contract/inputs/databaseUrl/ui" + } +] diff --git a/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json b/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json new file mode 100644 index 0000000..96a27b0 --- /dev/null +++ b/conformance/component/v1/structural/019-connection-input-with-ui/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-019-connection-input-with-ui", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "Presentation metadata on a CONNECTION input is rejected." +} diff --git a/conformance/component/v1/structural/020-user-input-without-ui/case.yaml b/conformance/component/v1/structural/020-user-input-without-ui/case.yaml new file mode 100644 index 0000000..1c3bb57 --- /dev/null +++ b/conformance/component/v1/structural/020-user-input-without-ui/case.yaml @@ -0,0 +1,17 @@ +# suppliedBy defaults to USER, so omitting it does not opt out of the rule. +# A form cannot render a field it has no label for. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + contract: + inputs: + siteTitle: + schema: + type: STRING diff --git a/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json b/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json new file mode 100644 index 0000000..020cec2 --- /dev/null +++ b/conformance/component/v1/structural/020-user-input-without-ui/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/contract/inputs/siteTitle" + } +] diff --git a/conformance/component/v1/structural/020-user-input-without-ui/metadata.json b/conformance/component/v1/structural/020-user-input-without-ui/metadata.json new file mode 100644 index 0000000..dc64ad5 --- /dev/null +++ b/conformance/component/v1/structural/020-user-input-without-ui/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-020-user-input-without-ui", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#inputs", + "summary": "A USER input omitting ui is rejected, including when suppliedBy is defaulted." +} diff --git a/conformance/component/v1/structural/021-derived-output-with-value/case.yaml b/conformance/component/v1/structural/021-derived-output-with-value/case.yaml new file mode 100644 index 0000000..a40599b --- /dev/null +++ b/conformance/component/v1/structural/021-derived-output-with-value/case.yaml @@ -0,0 +1,19 @@ +# A DERIVED output is resolved from the running workload. A static value here +# contradicts that, and nothing says which of the two a consumer would read. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + contract: + outputs: + connectionString: + schema: + type: STRING + valueFrom: DERIVED + value: postgres://localhost:5432/app diff --git a/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json b/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json new file mode 100644 index 0000000..3c14b7a --- /dev/null +++ b/conformance/component/v1/structural/021-derived-output-with-value/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_TYPE", + "path": "/spec/contract/outputs/connectionString/value" + } +] diff --git a/conformance/component/v1/structural/021-derived-output-with-value/metadata.json b/conformance/component/v1/structural/021-derived-output-with-value/metadata.json new file mode 100644 index 0000000..6655110 --- /dev/null +++ b/conformance/component/v1/structural/021-derived-output-with-value/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-021-derived-output-with-value", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#outputs", + "summary": "A DERIVED output carrying a static value is rejected." +} diff --git a/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml b/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml new file mode 100644 index 0000000..04a6f35 --- /dev/null +++ b/conformance/component/v1/structural/022-contract-both-output-forms/case.yaml @@ -0,0 +1,41 @@ +# The whole contract surface in one document: a generated USER input marked +# sensitive, an output the platform resolves, and an output this document +# states outright. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + postgresPassword: + schema: + type: STRING + isSensitive: true + suppliedBy: USER + generator: + encoding: BASE64URL + byteLength: 32 + ui: + label: Database password + outputs: + connectionString: + schema: + type: STRING + format: CONNECTION_STRING + valueFrom: DERIVED + databaseName: + schema: + type: STRING + valueFrom: DECLARED + value: app diff --git a/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json b/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json new file mode 100644 index 0000000..1c45d1b --- /dev/null +++ b/conformance/component/v1/structural/022-contract-both-output-forms/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-022-contract-both-output-forms", + "phase": "structural", + "expected": "pass", + "clause": "specifications/component/v1/spec.md#outputs", + "summary": "A generated input, a DERIVED output, and a DECLARED output together validate." +} From 508d1c9907d5e918ec8caa057a834e3ae9a3e33f Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:28:47 +0000 Subject: [PATCH 04/12] =?UTF-8?q?feat(component):=20enforce=20the=20=C2=A7?= =?UTF-8?q?5=20and=20=C2=A76=20rules=20in=20the=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the preceding two commits green. Every rule here is one JSON Schema 2020-12 can carry, so it lands in the structural phase where CI actually runs it, rather than as a semantic rule this repository would skip. `ComponentWorkload` gains four conditionals under `allOf`: - endpoints are capped at `maxProperties: 0` on a WORKER, JOB and CRON. The cap rather than `endpoints: false` so an explicit empty mapping stays legal — the same spelling the blueprint examples use for an empty connections block. - `command` is required and non-empty on a JOB and a CRON. - `schedule` is required on a CRON and `type: null` everywhere else, so an absent or explicitly-null schedule is fine and a real one is not. - readiness is required for a SERVICE with at least one PUBLIC endpoint. Endpoints are a mapping and no keyword asserts "some member matches" over one, so the condition is the negation of "every member fails to match". A `$comment` says so; it is not a construction to re-derive at a glance. Each branch carries no `type`, so `lint.ts` `checkClosedObjects` skips it correctly — the object is closed once, by the schema declaring its properties. `ComponentImageSource.ref` gains the pinning grammar. Lookahead-free so it compiles under RE2 as well as ECMA-262, matching the precedent set by `BlueprintNode.component`. The load-bearing part is that the tag colon is the one after the final slash: without that, `localhost:5000/nginx` reads as an image named `localhost` tagged `5000/nginx` and an unpinned reference behind a ported registry validates. `ComponentGitSource` requires `build`, and it is no longer nullable. The "or null when defaults apply" the description promised was a default the document could not see and the platform does not implement. `ComponentInput` gains the `ui`/`suppliedBy` correspondence and the generated-input rule. `suppliedBy` defaults to USER, so the USER branch is the `else` — a default is invisible to a validator, and an input omitting `suppliedBy` must still be caught. `isSensitive` is required explicitly for the opposite reason: its default is false, which is the wrong answer for a minted secret, so absence cannot be allowed to mean it. `ComponentOutput` gains the `valueFrom`/`value` correspondence, and `ComponentVolume.mountPath` an anchored `^/`. Both only assert what the fields' own descriptions already claimed. All eight examples still validate; `examples/minimal.yaml` survives because the SERVICE-must-expose-an-endpoint rule was not adopted. Refs: #9 Signed-off-by: Justin Merrell --- .../v1/schemas/dist/component.schema.json | 247 ++++++++++++++++-- .../v1/schemas/src/component.schema.json | 247 ++++++++++++++++-- 2 files changed, 464 insertions(+), 30 deletions(-) diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index 61d399b..a42462d 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -221,16 +221,8 @@ "additionalProperties": false, "properties": { "build": { - "description": "How the repository is built into an image, or null when defaults apply.", - "anyOf": [ - { - "$ref": "#/$defs/ComponentBuild" - }, - { - "type": "null" - } - ], - "default": null + "description": "How the repository is built into an image. Required — a git source has no image until something builds one, and leaving the strategy implicit makes the resulting image depend on the builder rather than on this document.", + "$ref": "#/$defs/ComponentBuild" }, "ref": { "description": "Branch or commit to build from, or null when unpinned.", @@ -256,7 +248,8 @@ }, "required": [ "type", - "repositoryUrl" + "repositoryUrl", + "build" ], "type": "object" }, @@ -307,7 +300,10 @@ "additionalProperties": false, "properties": { "ref": { - "description": "OCI image reference the workload runs from.", + "description": "OCI image reference the workload runs from. Must carry a tag or an @sha256 digest — a bare name is an implicit :latest and shifts under the registry.", + "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. The tag colon is the one after the final slash, which is what keeps a registry port (localhost:5000/nginx) from reading as a tag. Only the grammar is here: the floating-tag blocklist stays a semantic rule so it can grow without a major version. See spec.md §5.1.", + "maxLength": 512, + "pattern": "^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]+)?/)?(?:[a-z0-9][a-z0-9._-]*/)*[a-z0-9][a-z0-9._-]*(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127}|(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?@sha256:[0-9a-f]{64})$", "type": "string" }, "type": { @@ -324,6 +320,70 @@ }, "ComponentInput": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §6.1. suppliedBy defaults to USER, and a default is invisible to a validator, so the USER branch is the `else` — an absent suppliedBy takes it.", + "else": { + "properties": { + "ui": { + "$ref": "#/$defs/ComponentInputUi" + } + }, + "required": [ + "ui" + ] + }, + "if": { + "properties": { + "suppliedBy": { + "const": "CONNECTION" + } + }, + "required": [ + "suppliedBy" + ] + }, + "then": { + "properties": { + "ui": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §6.1: a generated value is secret material. isSensitive is required explicitly because its default is false — the wrong answer here — while suppliedBy is only constrained when written, because its default is already USER.", + "if": { + "properties": { + "generator": { + "not": { + "type": "null" + } + } + }, + "required": [ + "generator" + ] + }, + "then": { + "properties": { + "schema": { + "properties": { + "isSensitive": { + "const": true + } + }, + "required": [ + "isSensitive" + ] + }, + "suppliedBy": { + "const": "USER" + } + } + } + } + ], "properties": { "description": { "description": "Human-readable description of the input, or null when none is set.", @@ -484,7 +544,29 @@ "type": "object" }, "ComponentOutput": { + "$comment": "spec.md §6.2. valueFrom is required and has two members, so the else branch is exactly DECLARED.", "additionalProperties": false, + "else": { + "properties": { + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "if": { + "properties": { + "valueFrom": { + "const": "DERIVED" + } + }, + "required": [ + "valueFrom" + ] + }, "properties": { "description": { "description": "Human-readable description of the output, or null when none is set.", @@ -527,6 +609,13 @@ "schema", "valueFrom" ], + "then": { + "properties": { + "value": { + "type": "null" + } + } + }, "type": "object", "x-additionalPropertiesName": "outputName" }, @@ -757,6 +846,8 @@ }, "mountPath": { "description": "Absolute path where the volume is mounted in the workload.", + "maxLength": 512, + "pattern": "^/", "type": "string" }, "sizeGib": { @@ -773,9 +864,135 @@ }, "ComponentWorkload": { "additionalProperties": false, + "allOf": [ + { + "$comment": "Kind × field validity, spec.md §5. Every branch below is a bare conditional carrying no `type`, so lint.ts checkClosedObjects correctly skips it — the object is closed once, by the schema that declares its properties.", + "if": { + "properties": { + "kind": { + "enum": [ + "WORKER", + "JOB", + "CRON" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "endpoints": { + "maxProperties": 0 + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "JOB", + "CRON" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "command": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "command" + ] + } + }, + { + "else": { + "properties": { + "schedule": { + "type": "null" + } + } + }, + "if": { + "properties": { + "kind": { + "const": "CRON" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "schedule": { + "$ref": "#/$defs/ComponentSchedule" + } + }, + "required": [ + "schedule" + ] + } + }, + { + "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.", + "if": { + "properties": { + "endpoints": { + "not": { + "additionalProperties": { + "properties": { + "visibility": { + "not": { + "const": "PUBLIC" + } + } + } + } + } + }, + "kind": { + "const": "SERVICE" + } + }, + "required": [ + "endpoints", + "kind" + ] + }, + "then": { + "properties": { + "health": { + "$ref": "#/$defs/ComponentHealthProbes", + "properties": { + "readiness": { + "$ref": "#/$defs/ComponentProbe" + } + }, + "required": [ + "readiness" + ] + } + }, + "required": [ + "health" + ] + } + } + ], "properties": { "command": { - "description": "Command run for JOB and CRON workloads, or null otherwise.", + "description": "Command the workload runs. Required for JOB and CRON, which have nothing to run without it. On a SERVICE or WORKER it overrides the image's default command.", "anyOf": [ { "type": "string" @@ -787,7 +1004,7 @@ "default": null }, "endpoints": { - "description": "Endpoints the workload exposes, keyed by endpoint name.", + "description": "Endpoints the workload exposes, keyed by endpoint name. Only a SERVICE may declare one; on any other kind the mapping must be absent or empty.", "additionalProperties": { "$ref": "#/$defs/ComponentEndpoint" }, @@ -823,7 +1040,7 @@ "type": "string" }, "schedule": { - "description": "Cron schedule for CRON workloads, or null otherwise.", + "description": "Cron schedule a CRON workload runs on. Required for CRON and rejected on every other kind, where a schedule would mean nothing.", "anyOf": [ { "$ref": "#/$defs/ComponentSchedule" diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index bd51742..e14b4cc 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -221,16 +221,8 @@ "additionalProperties": false, "properties": { "build": { - "anyOf": [ - { - "$ref": "#/$defs/ComponentBuild" - }, - { - "type": "null" - } - ], - "default": null, - "description": "How the repository is built into an image, or null when defaults apply." + "$ref": "#/$defs/ComponentBuild", + "description": "How the repository is built into an image. Required — a git source has no image until something builds one, and leaving the strategy implicit makes the resulting image depend on the builder rather than on this document." }, "ref": { "anyOf": [ @@ -256,7 +248,8 @@ }, "required": [ "type", - "repositoryUrl" + "repositoryUrl", + "build" ], "type": "object" }, @@ -307,7 +300,10 @@ "additionalProperties": false, "properties": { "ref": { - "description": "OCI image reference the workload runs from.", + "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. The tag colon is the one after the final slash, which is what keeps a registry port (localhost:5000/nginx) from reading as a tag. Only the grammar is here: the floating-tag blocklist stays a semantic rule so it can grow without a major version. See spec.md §5.1.", + "description": "OCI image reference the workload runs from. Must carry a tag or an @sha256 digest — a bare name is an implicit :latest and shifts under the registry.", + "maxLength": 512, + "pattern": "^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]+)?/)?(?:[a-z0-9][a-z0-9._-]*/)*[a-z0-9][a-z0-9._-]*(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127}|(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?@sha256:[0-9a-f]{64})$", "type": "string" }, "type": { @@ -324,6 +320,70 @@ }, "ComponentInput": { "additionalProperties": false, + "allOf": [ + { + "$comment": "spec.md §6.1. suppliedBy defaults to USER, and a default is invisible to a validator, so the USER branch is the `else` — an absent suppliedBy takes it.", + "else": { + "properties": { + "ui": { + "$ref": "#/$defs/ComponentInputUi" + } + }, + "required": [ + "ui" + ] + }, + "if": { + "properties": { + "suppliedBy": { + "const": "CONNECTION" + } + }, + "required": [ + "suppliedBy" + ] + }, + "then": { + "properties": { + "ui": { + "type": "null" + } + } + } + }, + { + "$comment": "spec.md §6.1: a generated value is secret material. isSensitive is required explicitly because its default is false — the wrong answer here — while suppliedBy is only constrained when written, because its default is already USER.", + "if": { + "properties": { + "generator": { + "not": { + "type": "null" + } + } + }, + "required": [ + "generator" + ] + }, + "then": { + "properties": { + "schema": { + "properties": { + "isSensitive": { + "const": true + } + }, + "required": [ + "isSensitive" + ] + }, + "suppliedBy": { + "const": "USER" + } + } + } + } + ], "properties": { "description": { "anyOf": [ @@ -485,6 +545,35 @@ }, "ComponentOutput": { "additionalProperties": false, + "$comment": "spec.md §6.2. valueFrom is required and has two members, so the else branch is exactly DECLARED.", + "else": { + "properties": { + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "if": { + "properties": { + "valueFrom": { + "const": "DERIVED" + } + }, + "required": [ + "valueFrom" + ] + }, + "then": { + "properties": { + "value": { + "type": "null" + } + } + }, "properties": { "description": { "anyOf": [ @@ -757,6 +846,8 @@ }, "mountPath": { "description": "Absolute path where the volume is mounted in the workload.", + "maxLength": 512, + "pattern": "^/", "type": "string" }, "sizeGib": { @@ -773,6 +864,132 @@ }, "ComponentWorkload": { "additionalProperties": false, + "allOf": [ + { + "$comment": "Kind × field validity, spec.md §5. Every branch below is a bare conditional carrying no `type`, so lint.ts checkClosedObjects correctly skips it — the object is closed once, by the schema that declares its properties.", + "if": { + "properties": { + "kind": { + "enum": [ + "WORKER", + "JOB", + "CRON" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "endpoints": { + "maxProperties": 0 + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "JOB", + "CRON" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "command": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "command" + ] + } + }, + { + "else": { + "properties": { + "schedule": { + "type": "null" + } + } + }, + "if": { + "properties": { + "kind": { + "const": "CRON" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "schedule": { + "$ref": "#/$defs/ComponentSchedule" + } + }, + "required": [ + "schedule" + ] + } + }, + { + "$comment": "spec.md §5.4: readiness is required for a SERVICE exposing at least one PUBLIC endpoint. Endpoints are a mapping, and no keyword asserts `some member matches` over one, so the condition is spelled as the negation of `every member fails to match`.", + "if": { + "properties": { + "endpoints": { + "not": { + "additionalProperties": { + "properties": { + "visibility": { + "not": { + "const": "PUBLIC" + } + } + } + } + } + }, + "kind": { + "const": "SERVICE" + } + }, + "required": [ + "endpoints", + "kind" + ] + }, + "then": { + "properties": { + "health": { + "$ref": "#/$defs/ComponentHealthProbes", + "properties": { + "readiness": { + "$ref": "#/$defs/ComponentProbe" + } + }, + "required": [ + "readiness" + ] + } + }, + "required": [ + "health" + ] + } + } + ], "properties": { "command": { "anyOf": [ @@ -784,13 +1001,13 @@ } ], "default": null, - "description": "Command run for JOB and CRON workloads, or null otherwise." + "description": "Command the workload runs. Required for JOB and CRON, which have nothing to run without it. On a SERVICE or WORKER it overrides the image's default command." }, "endpoints": { "additionalProperties": { "$ref": "#/$defs/ComponentEndpoint" }, - "description": "Endpoints the workload exposes, keyed by endpoint name.", + "description": "Endpoints the workload exposes, keyed by endpoint name. Only a SERVICE may declare one; on any other kind the mapping must be absent or empty.", "type": "object" }, "envVars": { @@ -832,7 +1049,7 @@ } ], "default": null, - "description": "Cron schedule for CRON workloads, or null otherwise." + "description": "Cron schedule a CRON workload runs on. Required for CRON and rejected on every other kind, where a schedule would mean nothing." }, "source": { "description": "Discriminated image or git source the workload is built or run from.", From 329dc4ea44ec4888a21fb693c076f58439f6150c Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:33:13 +0000 Subject: [PATCH 05/12] =?UTF-8?q?docs(blueprint):=20settle=20cycle=20detec?= =?UTF-8?q?tion=20and=20write=20=C2=A73,=20=C2=A74=20and=20=C2=A75?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #9 raised the one TODO in this repository that is a contradiction rather than a gap: §4.2 mandates an acyclic connection graph, and the platform deliberately permits cycles under ADR 0116, to support mutual service discovery. **The specification holds its position. The graph MUST be acyclic and ERR_DEPENDENCY_CYCLE stays.** ADR 0001 §1 is the grounds — "Where an implementation and the conformance suite disagree, the implementation is defective" — and the consequence is not hidden: the platform gains a cycle check that rejects a composition it accepts today. The platform's argument is recorded in the clause rather than dismissed, because it is correct as far as it goes. An output is a function of its own node alone, so a resolver that computes every output before binding any edge needs no topological order and does not fail on a cycle. §4.2 says so outright and then says why the rule is required anyway: permitting cycles obliges every implementation in every language to be that two-pass resolver in perpetuity, forecloses any later rule that needs an order — an ordered rollout, a health-gated start, a value that legitimately depends on an inbound edge — and leaves every reader of a six-node graph working out for themselves whether it terminates. One traversal is cheaper than the option it keeps open. Cycle reporting is pinned so it is comparable across implementations: the participating nodes as a closed walk from the lexicographically smallest node in the cycle, anchored at that node's `connections`. The rest of the file: §3 Identity gains its three rules — slug equals the item directory name, version agrees with the sibling listing, and every component document in the item is referenced by some node. The last is new, from issue #9, and is what stops an item silently accumulating last release's component file beside the one in use. Its diagnostic anchors at `/spec/components`, because a JSON Pointer addresses this document and the file it complains about is not in it. §3.1 defines the item directory. Nothing defined it, and §4.1 was already using "item root" as though something did — every identity rule, the reference containment rule, and listing §5's media paths are all measured against it. The section also draws the line §4.1 draws: a document handed over without a directory has no item root, and an implementation in that position MUST NOT report any of these rules rather than guess at one. §4 gains the node-name grammar, the slug grammar reused. Uniqueness needs no rule — `spec.components` is a mapping, so a repeat is ERR_DUPLICATE_KEY in the parser phase. The `size` TODO stays, narrowed: the Compute Profile vocabulary is not published in this repository and a reader outside the platform cannot resolve `general.standard.small`, which is part of what closing it means. §4.2 gains the `fromRole` and `fromOutput` resolution rules alongside the cycle clause, and a narrowed TODO for output/input type compatibility, which issue #9 does not answer. §5 Parameters gains derivation and the merge rule. Two corrections to what was there: - "An empty `parameters` mapping is not the same as an absent one" was not true and could not be made true — nothing distinguishes the two states. Both now mean derive. - issue #9's merge rule sorts by `(ordering, componentId)`. Neither field exists in this contract, so canonical order is redefined as lexicographic by node name, the only total order the document itself supplies. The platform's first-wins discards a conflicting second declaration silently. That is not blessed. An identical redeclaration is absorbed; a differing one is ERR_CONFLICTING_INPUT_SCHEMA. Silent first-wins hands the second component a value validated against the first component's rules, and the failure surfaces at deploy time inside the consuming workload with nothing pointing back at the two documents that disagreed. Prose only. Nothing in this commit changes what validates. Refs: #9 Signed-off-by: Justin Merrell --- specifications/blueprint/v1/spec.md | 198 +++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 20 deletions(-) diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 448161e..583a419 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -55,9 +55,53 @@ declarative apply — describe a row in a control plane, not a document. They MUST NOT appear on a blueprint document, and a validator MUST reject them with `ERR_UNKNOWN_FIELD` like any other unknown property. -> **TODO** — `metadata.slug` MUST equal the containing item directory name; -> `metadata.version` MUST agree with the sibling listing document. State the -> diagnostic codes for each violation and which phase detects them. +Three rules bind the item together. All three are `semantic`, and all three +are measured against the item root defined below. + +| Rule | Diagnostic | +|---|---| +| `metadata.slug` MUST equal the item directory name. | `ERR_SLUG_MISMATCH` | +| `metadata.version` MUST equal the sibling listing's `metadata.version`. | `ERR_VERSION_MISMATCH` | +| Every component document in the item MUST be referenced by some node. | `ERR_UNREFERENCED_COMPONENT` | + +**An unreferenced component document is an error, not dead weight.** A +component nothing references is not deployed, not checked against any node, +and not visible to a reader working out what the item contains. Permitted, it +accumulates: last release's `postgres.yaml` sitting beside the one actually in +use, with nothing in the directory saying which is live. The diagnostic +anchors at `/spec/components` — the mapping that should have named the file — +because a JSON Pointer addresses this document, and the file it is complaining +about is not in it. + +### 3.1 The item directory + +A blueprint does not travel alone. It and its sibling listing belong to a +**catalog item**: one directory holding one deployable thing. + +``` +/ + blueprint.yaml this document + listing.yaml the sibling storefront entry + components/ the component documents the graph references + media/ icon and screenshots +``` + +The directory containing `blueprint.yaml` is the **item root**. It is what +§3's three rules are measured against, what +[§4.1](#component-reference) means by containment, and what +[listing §5](../../listing/v1/spec.md#media) resolves a media path inside. + +Only two names in that tree are fixed: `blueprint.yaml` and `listing.yaml`. +Component documents MAY sit anywhere under the root — `components/` is a +convention, and [§4.1](#component-reference) accepts a flat sibling equally. +`media/` is fixed too, but by the listing family rather than by this one. + +**A document with no directory has no item root.** Every rule in this section +needs one, so an implementation handed a document rather than a directory MUST +NOT report any of them. It has not been given the means to check, and a +diagnostic it cannot substantiate is worse than a silence. +[§4.1](#component-reference) draws the same line for the component reference, +for the same reason. ## 4. Component graph @@ -65,10 +109,21 @@ MUST NOT appear on a blueprint document, and a validator MUST reject them with reference. The node name is the identifier used by connections; it is local to this blueprint and carries no meaning outside it. -> **TODO** — Node name grammar and uniqueness. +A node name MUST match `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`, the same grammar +`metadata.slug` uses. Uniqueness needs no rule of its own: `spec.components` +is a mapping, so a repeated node name is `ERR_DUPLICATE_KEY` in the `parser` +phase, before the graph is looked at. + +The name is graph-local, which is to say it means nothing outside this +document. Two blueprints MAY each declare a node called `db` and neither is +the other's. Its one job is to be what [§4.2](#connections) `fromRole` names, +and what [§5.2](#merge) orders the graph by. > **TODO** — `size` MUST name a Compute Profile in `family.tier.size` form. > State whether an unknown profile is a `semantic` or a `capability` failure. +> The profile vocabulary itself is not defined in this repository and a reader +> outside the platform cannot resolve a slug like `general.standard.small`; +> naming where it is published is part of closing this. ### 4.1 Component reference @@ -157,23 +212,126 @@ connections: fromOutput: connectionString ``` -> **TODO** — Normative rules: `fromRole` MUST name a node in the same -> blueprint; `fromOutput` MUST name a declared output of that node's component; -> the output's type MUST be compatible with the consuming input's schema. - -> **TODO** — **Cycle detection.** The connection graph MUST be acyclic. This is -> the canonical example of a rule JSON Schema cannot express; it belongs to the -> `semantic` phase with code `ERR_DEPENDENCY_CYCLE`. Specify how the cycle is -> reported (the participating node names, in a deterministic order). +The producer end MUST resolve. + +- `fromRole` MUST name a node in this blueprint. A connection cannot reach + outside the graph it is written in. `ERR_UNKNOWN_ROLE`. +- `fromOutput` MUST name an output declared by the component that node + deploys. `ERR_UNKNOWN_OUTPUT`. + +Both are `semantic`. The first needs only this document; the second needs the +referenced component document, which a repo-local reference makes readable +without a network. + +The consumer end needs no rule. The map key names the input being filled and +the enclosing node names the consumer, so one input cannot take two wires — +the mapping already makes that structural. + +**The connection graph MUST be acyclic.** A cycle is rejected in the +`semantic` phase with `ERR_DEPENDENCY_CYCLE`. + +This is the canonical rule JSON Schema cannot express, and it is worth being +straight about what it costs, because a resolver does not need it. An output +is a function of its own node and nothing else +([component §6.2](../../component/v1/spec.md#outputs)), so an implementation +that resolves every output before binding any edge needs no topological order +and does not fail on a cycle. Acyclicity is not a resolution hazard. + +It is required anyway. A specification that permits cycles obliges every +implementation, in every language, to be that two-pass resolver in perpetuity, +and forecloses any later rule that needs an order — an ordered rollout, a +health-gated start, a value that legitimately does depend on an inbound edge. +It also obliges every reader of a blueprint to work out for themselves whether +the composition in front of them terminates. A two-node cycle is legible; a +six-node one is not. The rule costs one traversal, which is less than the +option it keeps open. + +**Reporting a cycle.** The diagnostic MUST name the participating nodes as a +closed walk, beginning at the lexicographically smallest node name in the +cycle and following edges from there — `db → cache → queue → db`. Two +implementations that find the same cycle then report the same walk, which is +what makes the node names comparable across a conformance corpus instead of an +artifact of whichever node the traversal happened to start from. The +diagnostic anchors at `/spec/components//connections`. + +> **TODO** — Type compatibility between the output and the input it feeds. +> `schema.semanticType` is the tag a composition layer matches on, but the +> matching rule is not stated, and neither is what happens when a `STRING` +> output feeds an `INTEGER` input. See +> [component §6.2](../../component/v1/spec.md#outputs). ## 5. Parameters -An empty `parameters` mapping is not the same as an absent one. - -> **TODO** — Specify derivation: when `parameters` is empty, the effective -> parameter set is derived from the merged `USER`-supplied inputs of the -> referenced components. Define the merge rule for two components declaring the -> same input name with different schemas. +`spec.parameters` is the install form: what a deploying user is asked for once, +for the whole composition, rather than once per node. + +**Absent and empty mean the same thing.** Both say "derive the form from the +graph". An earlier draft of this section asserted a difference between them; +nothing in the document distinguishes a missing key from an empty mapping, and +a distinction no author can express is one that survives in a specification +and in no implementation. + +A non-empty mapping is an authored override, used in place of derivation +rather than merged with it. + +### 5.1 Derivation + +When `parameters` is empty, the effective parameter set is derived from the +`USER`-supplied inputs of the components the graph references, merged by +[§5.2](#merge). + +A `CONNECTION` input is never derived — it is satisfied by a wire, not by a +person. 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. + +A derived parameter takes the declaring input's `schema`, `ui` and +`isRequired` unchanged. [Component §6.1](../../component/v1/spec.md#inputs) +requires a `USER` input to carry `ui`, so every derived parameter arrives with +the label a form needs; there is no such thing as a derived parameter that +cannot be rendered. + +### 5.2 Merge + +Two components MAY declare an input under the same key. The merge is +**first-wins in lexicographic node-name order**: + +1. Sort the entries of `spec.components` by node name. +2. Walk them in that order, taking each `USER` input key not already taken. + +Node name because it is the only total order the document itself supplies. A +mapping has no sequence, and a rule that depended on file order, on parse +order, or on an identifier internal to a control plane would not be +reproducible by someone reading the document. + +**A conflicting redeclaration is an error.** Where a later node declares a key +already taken and its declaration differs, the blueprint is rejected in the +`semantic` phase with `ERR_CONFLICTING_INPUT_SCHEMA`. An identical +redeclaration is absorbed in silence — two components that agree on what +`adminPassword` is are not in conflict, and making them say so twice in +different words would be the only way to trip this. + +Two declarations are identical when their `schema` blocks are equal once +defaults are applied. `ui` and `isRequired` are not compared: they describe how +a value is asked for, not what it is, and the first node's presentation winning +is a presentation decision rather than a contract one. + +**Why this is not silent first-wins.** Taking the first schema and discarding a +different second one settles the ambiguity without telling anyone there was +one. The second component then receives a value validated against the first +component's rules — a bare `STRING` where it required an enum member, a +64-byte secret where its pattern allowed 32. Nothing fails at validation time. +It fails at deploy time, inside the consuming workload, a long way from the two +documents that disagreed and with nothing pointing back at them. + +An author who wants one shared value across two components says so by writing +`spec.parameters` outright, which is what an authored override is for. + +> **TODO** — How an authored parameter binds to the component inputs it +> satisfies. Derivation makes that correspondence by key; an override is used +> verbatim, which does not say what happens to a parameter key matching no +> input, or to a `USER` input no parameter covers. ## 6. Validation layers @@ -200,8 +358,8 @@ family adds: | `ERR_DEPENDENCY_CYCLE` | `semantic` | The connection graph contains a cycle. | | `ERR_SLUG_MISMATCH` | `semantic` | `metadata.slug` disagrees with the item directory name. | | `ERR_VERSION_MISMATCH` | `semantic` | `metadata.version` disagrees with the sibling listing document. | - -> **TODO** — Confirm this list is complete once §4 and §5 are written. +| `ERR_UNREFERENCED_COMPONENT` | `semantic` | A component document in the item is referenced by no node. | +| `ERR_CONFLICTING_INPUT_SCHEMA` | `semantic` | Two nodes declare the same input key with different schemas. | ## 8. Conformance From ac5a5c1bd889b29189c9343b50207a759ec3c35f Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:33:33 +0000 Subject: [PATCH 06/12] test(conformance): pin the node-name grammar and two semantic graph rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 012 expected to fail but validated cleanly — `Web_Server` is a legal map key today, so the node-name grammar is the fail-before evidence. The two semantic cases are the only rules from the preceding commit that a single document can express. Everything else §3 added — slug against the directory name, version against the sibling listing, the unreferenced component document — needs a directory on disk, and the fixture contract is one `case.yaml`. Those are deferred rather than approximated; see the follow-up on extending the contract. semantic-001 a connection whose `fromRole` names no node. The whole graph is in this one document, so nothing external is needed. semantic-002 a two-node cycle, and the case the reporting rule exists for: `api` and `web` discover each other, and the walk must be reported from `api` because it is lexicographically smaller — otherwise which node a traversal happened to start from would leak into a normative diagnostic. Both are skipped by this repository's adapter, which implements the parser and structural phases only. Their metadata, clause anchors and declared codes are checked regardless. Refs: #9 Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 15 +++++++++ .../case.yaml | 23 ++++++++++++++ .../diagnostics.json | 6 ++++ .../metadata.json | 7 +++++ .../semantic/002-connection-cycle/case.yaml | 31 +++++++++++++++++++ .../002-connection-cycle/diagnostics.json | 6 ++++ .../002-connection-cycle/metadata.json | 7 +++++ .../012-invalid-node-name/case.yaml | 15 +++++++++ .../012-invalid-node-name/diagnostics.json | 6 ++++ .../012-invalid-node-name/metadata.json | 7 +++++ 10 files changed, 123 insertions(+) create mode 100644 conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml create mode 100644 conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json create mode 100644 conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml create mode 100644 conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json create mode 100644 conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml create mode 100644 conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 30fb155..74eb5da 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -61,6 +61,21 @@ "id": "structural-011-traversal-inside-local-reference", "phase": "structural", "path": "structural/011-traversal-inside-local-reference" + }, + { + "id": "structural-012-invalid-node-name", + "phase": "structural", + "path": "structural/012-invalid-node-name" + }, + { + "id": "semantic-001-connection-names-unknown-role", + "phase": "semantic", + "path": "semantic/001-connection-names-unknown-role" + }, + { + "id": "semantic-002-connection-cycle", + "phase": "semantic", + "path": "semantic/002-connection-cycle" } ] } diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml new file mode 100644 index 0000000..710fc50 --- /dev/null +++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/case.yaml @@ -0,0 +1,23 @@ +# `fromRole: database` names no node — the only other node is `db`. A +# connection cannot reach outside the graph it is written in, so there is +# nowhere else this could have meant. The whole graph is in this one document, +# which is why the check needs no sibling file. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: web-and-database + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + web: + component: ./components/web.yaml + size: general.standard.small + connections: + DATABASE_URL: + fromRole: database + fromOutput: connectionString + parameters: {} diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json new file mode 100644 index 0000000..040b968 --- /dev/null +++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_ROLE", + "path": "/spec/components/web/connections/DATABASE_URL/fromRole" + } +] diff --git a/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json new file mode 100644 index 0000000..fcc8792 --- /dev/null +++ b/conformance/blueprint/v1/semantic/001-connection-names-unknown-role/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-001-connection-names-unknown-role", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A connection whose fromRole names no node in this blueprint is rejected." +} diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml new file mode 100644 index 0000000..36c9fae --- /dev/null +++ b/conformance/blueprint/v1/semantic/002-connection-cycle/case.yaml @@ -0,0 +1,31 @@ +# Mutual discovery: `api` reads `web`'s address and `web` reads `api`'s. Every +# output here is a function of its own node, so a two-pass resolver would +# settle this without complaint — the graph is rejected because a +# specification that permits cycles obliges every implementation to be that +# resolver, and forecloses any later rule needing an order. +# +# The walk is reported from `api`, the lexicographically smallest node in the +# cycle, so that two implementations finding this cycle name it identically: +# api -> web -> api. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: mutual-discovery + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: + WEB_URL: + fromRole: web + fromOutput: publicUrl + web: + component: ./components/web.yaml + size: general.standard.small + connections: + API_URL: + fromRole: api + fromOutput: privateAddress + parameters: {} diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json b/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json new file mode 100644 index 0000000..ab154e3 --- /dev/null +++ b/conformance/blueprint/v1/semantic/002-connection-cycle/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_DEPENDENCY_CYCLE", + "path": "/spec/components/api/connections" + } +] diff --git a/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json b/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json new file mode 100644 index 0000000..cd9eb5d --- /dev/null +++ b/conformance/blueprint/v1/semantic/002-connection-cycle/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-002-connection-cycle", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#connections", + "summary": "A cyclic connection graph is rejected, and the walk is reported from its smallest node." +} diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml b/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml new file mode 100644 index 0000000..9a79130 --- /dev/null +++ b/conformance/blueprint/v1/structural/012-invalid-node-name/case.yaml @@ -0,0 +1,15 @@ +# `Web_Server` is not a node name. The grammar is the one `metadata.slug` +# uses, so a name that reads as an identifier in one language and not in +# another cannot be what a connection's `fromRole` refers to. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: web-and-database + version: 1 +spec: + components: + Web_Server: + component: ./components/web.yaml + size: general.standard.small + connections: {} + parameters: {} diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json b/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json new file mode 100644 index 0000000..e28b1a4 --- /dev/null +++ b/conformance/blueprint/v1/structural/012-invalid-node-name/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/components" + } +] diff --git a/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json b/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json new file mode 100644 index 0000000..0b4eedc --- /dev/null +++ b/conformance/blueprint/v1/structural/012-invalid-node-name/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-012-invalid-node-name", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#components", + "summary": "A node name outside the slug grammar is rejected." +} From 200be07d13dfd3371f8269a261f77cc403a14488 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:33:36 +0000 Subject: [PATCH 07/12] feat(blueprint): constrain node names to the slug grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spec.components` accepted any string as a node name. `propertyNames` now holds it to `^[a-z][a-z0-9-]{0,61}[a-z0-9]$`, the grammar `metadata.slug` already uses — one grammar for the names this family coins rather than two. Turns structural-012 green. The diagnostic anchors at `/spec/components` rather than at the offending key, which is what `propertyNames` gives; the key itself is not addressable by a JSON Pointer when it is the thing being rejected. The other §3 and §4.2 rules from the prose commit are not expressible here. Slug-versus-directory, version-versus-sibling, reference resolution and cycle detection all need something outside the document, which is what makes them semantic. Refs: #9 Signed-off-by: Justin Merrell --- .../blueprint/v1/schemas/dist/blueprint.schema.json | 5 ++++- .../blueprint/v1/schemas/src/blueprint.schema.json | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index 8d6d4f2..24cae1a 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -431,10 +431,13 @@ "additionalProperties": false, "properties": { "components": { - "description": "Component references composing the graph, keyed by graph-local node name.", + "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.", "additionalProperties": { "$ref": "#/$defs/BlueprintNode" }, + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + }, "type": "object" }, "parameters": { diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index 6890181..7d62391 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -434,7 +434,10 @@ "additionalProperties": { "$ref": "#/$defs/BlueprintNode" }, - "description": "Component references composing the graph, keyed by graph-local node name.", + "description": "Component references composing the graph, keyed by graph-local node name. The name is what a connection's fromRole refers to and what the parameter merge orders the graph by; it means nothing outside this document.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9-]{0,61}[a-z0-9]$" + }, "type": "object" }, "parameters": { From a690483927c4ba210e8d67ce0c661a40c437c85e Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:36:56 +0000 Subject: [PATCH 08/12] =?UTF-8?q?docs(listing):=20write=20=C2=A73=20identi?= =?UTF-8?q?ty=20and=20the=20=C2=A75=20media=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3 mirrors blueprint §3 and measures against the item root that blueprint §3.1 now defines. One nuance the issue does not cover: the version rule needs a sibling blueprint to compare against, and a `listingKind: COMPONENT` item need not contain one. The rule is conditioned on there being a blueprint, and a narrow TODO records what an item without one agrees with — not the same rule with a different sibling, since an item may hold several component documents each carrying its own version. §5 splits the media rules by what they need. Structural, because it is a grammar: relative, first segment exactly `media`, every later segment beginning with a letter or a digit, extension in `.png`, `.jpg`, `.jpeg`, `.webp`. The `media/` requirement is stronger than the "relative to the listing document" this section used to claim, and it is the rule that actually gets enforced — one fixed directory is what lets a publisher copy an item's assets without walking its listing to discover them. Semantic, because each needs the filesystem: existence, containment of the resolved target, and basename uniqueness across the whole item. `ERR_PATH_ESCAPE` survives the grammar rather than being made redundant by it. `..` is now unspellable, so traversal is gone — but a symlink under `media/` is a legal spelling resolving to an illegal target, and containment is a property of the resolved location rather than of the string. Blueprint §4.1 draws the same line for `ERR_REFERENCE_ESCAPE`. The basename rule is adopted from the platform with its provenance stated: published assets are addressed by basename, so `media/desktop/overview.png` and `media/mobile/overview.png` are one file. It is a real constraint on an author and is better written down than discovered when the second screenshot replaces the first. §4 keeps both its TODOs, as issue #9 asks, but says what is already settled: `summary` is plain text and must not be rendered as Markdown, `description` is Markdown bounded at 20 000 characters. The Markdown-subset TODO is reframed as what it is — an open security question about rendering untrusted input, naming the three cases that need deciding — rather than a behaviour someone forgot to document. Dimension and file-size bounds are recorded as gaps, as in component §5.5. Prose only. Nothing in this commit changes what validates. Refs: #9 Signed-off-by: Justin Merrell --- specifications/listing/v1/spec.md | 107 +++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/specifications/listing/v1/spec.md b/specifications/listing/v1/spec.md index 3c39c9a..1480a7f 100644 --- a/specifications/listing/v1/spec.md +++ b/specifications/listing/v1/spec.md @@ -39,20 +39,53 @@ format; `listingKind` identifies what the listing points at (`BLUEPRINT` or ## 3. Identity -> **TODO** — `metadata.slug` MUST equal the item directory name and MUST agree -> with the sibling blueprint document. `metadata.version` MUST agree likewise. -> Both are `semantic`-phase rules. +`metadata` carries `slug` and `version` — the same shape the sibling blueprint +carries, and the two MUST agree. + +| Rule | Diagnostic | +|---|---| +| `metadata.slug` MUST equal the item directory name. | `ERR_SLUG_MISMATCH` | +| `metadata.version` MUST equal the sibling blueprint's `metadata.version`. | `ERR_VERSION_MISMATCH` | + +Both are `semantic`, and both are measured against the item root defined in +[blueprint §3.1](../../blueprint/v1/spec.md#item-directory). A listing handed +over with no directory has no item root, and an implementation in that +position MUST NOT report either rule. + +**The two documents are one item's two halves.** A listing whose version has +moved ahead of its blueprint describes something other than what would be +installed — this release's storefront copy over last release's graph. The rule +is what keeps "read about this" and "install this" the same thing. + +The version rule needs a blueprint to compare against. An item whose listing +is `listingKind: COMPONENT` need not contain one; where there is no sibling +blueprint the rule has nothing to compare and does not apply. + +> **TODO** — What `metadata.version` agrees with in a `COMPONENT` item, which +> has no blueprint. The component documents each carry their own +> `metadata.version` and an item may hold more than one, so this is not the +> same rule with a different sibling. ## 4. Presentation -> **TODO** — Define the rendering contract for `summary` and `description`: -> `summary` is plain text of bounded length; `description` is Markdown, and the -> permitted subset MUST be stated (a storefront rendering untrusted Markdown is -> an injection surface). +`summary` is plain text, at most 280 characters, and MUST NOT be rendered as +Markdown. It is a one-line tagline; rendering it as Markdown turns an +underscore in a product name into emphasis and an asterisk into a bullet. + +`description` is Markdown, at most 20 000 characters. + +> **TODO** — The permitted Markdown subset for `description`. This one is a +> security question rather than an undocumented behaviour: neither this +> specification nor any implementation constrains the subset today, and a +> storefront rendering untrusted Markdown from a third-party listing is an +> injection surface. Raw embedded HTML, `javascript:` URLs, and remote image +> references are the three that need deciding. > **TODO** — `category` and `lifecycleStage` are controlled vocabularies. State > the governance rule for adding a term — this is the field most likely to need -> extension, and unmanaged growth makes the storefront incoherent. +> extension, and unmanaged growth makes the storefront incoherent. Note that +> adding a term is a minor release and removing one is major, since narrowing +> an enum rejects a document that validated before. Featured-row placement is **not** part of this contract. A listing document MUST NOT declare `spec.featured`; promotion is a storefront-operator action, @@ -63,15 +96,52 @@ listing. ## 5. Media -`icon` and `screenshots[].file` are paths relative to the listing document. - -> **TODO** — Normative constraints: paths MUST be relative, MUST NOT escape the -> item directory (`..` rejected), MUST resolve to a file that exists, and MUST -> be one of a stated set of image formats. Dimension and size bounds SHOULD be -> specified so the storefront can render without layout shift. - -Path containment is a `semantic`-phase rule; JSON Schema can constrain the -string shape but cannot confirm the target exists inside the item directory. +`icon` and `screenshots[].file` are media paths, resolved inside the item root +defined in [blueprint §3.1](../../blueprint/v1/spec.md#item-directory). + +A media path MUST satisfy all of the following, and is rejected in the +`structural` phase with `ERR_INVALID_VALUE` when it does not: + +- it MUST be relative — a leading `/` is not accepted; +- its first segment MUST be exactly `media`; +- every segment after that MUST begin with a letter or a digit, which is how + `.` and `..` are excluded as segments without a negative lookahead; +- it MUST end in `.png`, `.jpg`, `.jpeg`, or `.webp`, in any case. + +**`media/` is a stronger rule than "relative to the listing document".** That +was this section's earlier wording, and it is not what anything enforces. One +fixed directory means a reader can find every asset an item ships without +first reading its listing, and a publisher can copy that directory without +walking the document to work out what to take. + +Three rules need the filesystem and are therefore `semantic`: + +| Rule | Diagnostic | +|---|---| +| The path MUST resolve to a file that exists. | `ERR_MEDIA_NOT_FOUND` | +| The resolved target MUST lie inside the item root. | `ERR_PATH_ESCAPE` | +| Two screenshots MUST NOT share a basename. | `ERR_DUPLICATE_MEDIA_BASENAME` | + +**`ERR_PATH_ESCAPE` outlives the grammar.** The pattern above makes `..` +unspellable, so no path can escape by traversal any more. One can still escape +by symlink — `media/icon.png` pointing outside the item is a legal spelling +resolving to an illegal target. Containment is a property of the resolved +location rather than of the string, the same distinction +[blueprint §4.1](../../blueprint/v1/spec.md#component-reference) draws for a +component reference. + +**Basenames must differ across the whole item**, not merely within a +directory: `media/desktop/overview.png` and `media/mobile/overview.png` +collide. Published assets are addressed by basename, so two files called +`overview.png` are one file. The constraint is a real one on an author and is +stated here rather than left to be found out when the second screenshot +silently replaces the first. + +**What v1 does not constrain.** Neither dimensions nor file size are bounded. +A storefront cannot reserve space for an image whose aspect ratio it does not +know, so this is a gap rather than a permission — but bounding either one +rejects listings that validate today, which makes closing it a breaking +change. ## 6. Validation layers @@ -87,7 +157,8 @@ family adds: | `ERR_SLUG_MISMATCH` | `semantic` | `metadata.slug` disagrees with the item directory name. | | `ERR_VERSION_MISMATCH` | `semantic` | `metadata.version` disagrees with the sibling blueprint document. | | `ERR_MEDIA_NOT_FOUND` | `semantic` | A referenced media file does not exist. | -| `ERR_PATH_ESCAPE` | `semantic` | A media path escapes the item directory. | +| `ERR_PATH_ESCAPE` | `semantic` | A media path resolves outside the item directory. | +| `ERR_DUPLICATE_MEDIA_BASENAME` | `semantic` | Two screenshots share a basename. | ## 8. Conformance From 5016fb1a75374215259cb1ca5548e9e703c3b540 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:37:00 +0000 Subject: [PATCH 09/12] test(conformance): pin the listing media grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 007 expected to fail but validated cleanly ../../etc/passwd.png 008 expected to fail but validated cleanly assets/overview.png 009 expected to fail but validated cleanly media/overview.gif 010 is green beforehand and is the regression pin: a nested path under `media/` and an extension in upper case. A JSON Schema pattern carries no case-insensitive flag, so a careless alternation accepts `.png` and rejects `.PNG`, and nothing else in the corpus would notice. semantic-001 is the basename rule, which needs no directory on disk — two paths in one document are enough to collide. The existence and containment rules do need one and are deferred with the rest of the tree-shaped cases. Refs: #9 Signed-off-by: Justin Merrell --- conformance/listing/v1/cases.json | 25 +++++++++++++++++++ .../case.yaml | 17 +++++++++++++ .../diagnostics.json | 6 +++++ .../metadata.json | 7 ++++++ .../007-icon-escaping-the-item/case.yaml | 15 +++++++++++ .../diagnostics.json | 6 +++++ .../007-icon-escaping-the-item/metadata.json | 7 ++++++ .../case.yaml | 16 ++++++++++++ .../diagnostics.json | 6 +++++ .../metadata.json | 7 ++++++ .../009-unsupported-media-format/case.yaml | 15 +++++++++++ .../diagnostics.json | 6 +++++ .../metadata.json | 7 ++++++ .../v1/structural/010-media-paths/case.yaml | 19 ++++++++++++++ .../structural/010-media-paths/metadata.json | 7 ++++++ 15 files changed, 166 insertions(+) create mode 100644 conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml create mode 100644 conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json create mode 100644 conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json create mode 100644 conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml create mode 100644 conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json create mode 100644 conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json create mode 100644 conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml create mode 100644 conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json create mode 100644 conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json create mode 100644 conformance/listing/v1/structural/009-unsupported-media-format/case.yaml create mode 100644 conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json create mode 100644 conformance/listing/v1/structural/009-unsupported-media-format/metadata.json create mode 100644 conformance/listing/v1/structural/010-media-paths/case.yaml create mode 100644 conformance/listing/v1/structural/010-media-paths/metadata.json diff --git a/conformance/listing/v1/cases.json b/conformance/listing/v1/cases.json index 000c8db..b61df42 100644 --- a/conformance/listing/v1/cases.json +++ b/conformance/listing/v1/cases.json @@ -36,6 +36,31 @@ "id": "structural-006-featured-not-in-contract", "phase": "structural", "path": "structural/006-featured-not-in-contract" + }, + { + "id": "structural-007-icon-escaping-the-item", + "phase": "structural", + "path": "structural/007-icon-escaping-the-item" + }, + { + "id": "structural-008-media-outside-the-media-directory", + "phase": "structural", + "path": "structural/008-media-outside-the-media-directory" + }, + { + "id": "structural-009-unsupported-media-format", + "phase": "structural", + "path": "structural/009-unsupported-media-format" + }, + { + "id": "structural-010-media-paths", + "phase": "structural", + "path": "structural/010-media-paths" + }, + { + "id": "semantic-001-duplicate-screenshot-basename", + "phase": "semantic", + "path": "semantic/001-duplicate-screenshot-basename" } ] } diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml new file mode 100644 index 0000000..e60cd6c --- /dev/null +++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/case.yaml @@ -0,0 +1,17 @@ +# Two distinct files, two distinct paths, one basename. Published assets are +# addressed by basename, so these are one file — and without the rule the +# second screenshot silently replaces the first. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + screenshots: + - file: media/desktop/overview.png + - file: media/mobile/overview.png diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json new file mode 100644 index 0000000..d850818 --- /dev/null +++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_DUPLICATE_MEDIA_BASENAME", + "path": "/spec/screenshots/1/file" + } +] diff --git a/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json new file mode 100644 index 0000000..e4b40fc --- /dev/null +++ b/conformance/listing/v1/semantic/001-duplicate-screenshot-basename/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "semantic-001-duplicate-screenshot-basename", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#media", + "summary": "Two screenshots sharing a basename under different directories are rejected." +} diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml b/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml new file mode 100644 index 0000000..720376c --- /dev/null +++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/case.yaml @@ -0,0 +1,15 @@ +# Traversal is now unspellable rather than merely resolved-and-rejected. A +# segment must begin with a letter or digit, so `..` cannot appear at all, and +# the escape is caught by the grammar instead of by the filesystem. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + icon: ../../etc/passwd.png diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json b/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json new file mode 100644 index 0000000..4c554d2 --- /dev/null +++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/icon" + } +] diff --git a/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json b/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json new file mode 100644 index 0000000..23ce0e3 --- /dev/null +++ b/conformance/listing/v1/structural/007-icon-escaping-the-item/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-007-icon-escaping-the-item", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#media", + "summary": "A media path spelling a traversal is rejected before containment is considered." +} diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml b/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml new file mode 100644 index 0000000..77fa96a --- /dev/null +++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/case.yaml @@ -0,0 +1,16 @@ +# `assets/` is a perfectly ordinary directory name and an entirely legal +# relative path. It is rejected because one fixed directory is what lets a +# publisher copy an item's media without reading its listing first. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + screenshots: + - file: assets/overview.png diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json b/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json new file mode 100644 index 0000000..8b8c8cc --- /dev/null +++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/screenshots/0/file" + } +] diff --git a/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json b/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json new file mode 100644 index 0000000..fff308f --- /dev/null +++ b/conformance/listing/v1/structural/008-media-outside-the-media-directory/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-008-media-outside-the-media-directory", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#media", + "summary": "A media path whose first segment is not media/ is rejected." +} diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml b/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml new file mode 100644 index 0000000..a281d33 --- /dev/null +++ b/conformance/listing/v1/structural/009-unsupported-media-format/case.yaml @@ -0,0 +1,15 @@ +# The permitted set is png, jpg, jpeg and webp. A gif is not in it, and the +# storefront would have no way to render a format it was never told about. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + screenshots: + - file: media/overview.gif diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json b/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json new file mode 100644 index 0000000..8b8c8cc --- /dev/null +++ b/conformance/listing/v1/structural/009-unsupported-media-format/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/screenshots/0/file" + } +] diff --git a/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json b/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json new file mode 100644 index 0000000..18aa564 --- /dev/null +++ b/conformance/listing/v1/structural/009-unsupported-media-format/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-009-unsupported-media-format", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#media", + "summary": "A media path outside the permitted image formats is rejected." +} diff --git a/conformance/listing/v1/structural/010-media-paths/case.yaml b/conformance/listing/v1/structural/010-media-paths/case.yaml new file mode 100644 index 0000000..cb91725 --- /dev/null +++ b/conformance/listing/v1/structural/010-media-paths/case.yaml @@ -0,0 +1,19 @@ +# Both things the grammar has to keep accepting: a nested directory under +# media/, and an extension in upper case. A JSON Schema pattern carries no +# case-insensitive flag, so the alternation spells the cases out. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + icon: media/icon.png + screenshots: + - file: media/shots/desktop.JPEG + caption: The connection panel + - file: media/shots/mobile.webp diff --git a/conformance/listing/v1/structural/010-media-paths/metadata.json b/conformance/listing/v1/structural/010-media-paths/metadata.json new file mode 100644 index 0000000..5c24d50 --- /dev/null +++ b/conformance/listing/v1/structural/010-media-paths/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-010-media-paths", + "phase": "structural", + "expected": "pass", + "clause": "specifications/listing/v1/spec.md#media", + "summary": "Media paths nest below media/ and match their extension in any case." +} From e895e483de61424349108ce7660fe4a3bcc51c24 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:37:03 +0000 Subject: [PATCH 10/12] feat(listing): constrain media paths to the media/ grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `icon` and `screenshots[].file` were unconstrained strings, so `../../etc/passwd.png` was a structurally valid icon and the whole media contract rested on a semantic phase nothing in this repository runs. Both now carry the §5 grammar. Lookahead-free so it compiles under RE2 as well as ECMA-262, and a segment must begin with a letter or a digit — the technique `BlueprintNode.component` already uses to exclude `.` and `..` without a negative lookahead. The extension alternation is spelled in character classes because a JSON Schema pattern has nowhere to put a case-insensitive flag. `icon` also gains the `maxLength: 512` its sibling `file` already had. It was the only unbounded string path in the family. Refs: #9 Signed-off-by: Justin Merrell --- .../listing/v1/schemas/dist/listing.schema.json | 9 +++++++-- .../listing/v1/schemas/src/listing.schema.json | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/specifications/listing/v1/schemas/dist/listing.schema.json b/specifications/listing/v1/schemas/dist/listing.schema.json index 89e1e37..cceefae 100644 --- a/specifications/listing/v1/schemas/dist/listing.schema.json +++ b/specifications/listing/v1/schemas/dist/listing.schema.json @@ -72,9 +72,11 @@ "default": null }, "file": { - "description": "Relative path of the screenshot asset under the item's media/ directory.", + "description": "Path of the screenshot asset under the item's media/ directory, relative to the item root. Must be a .png, .jpg, .jpeg or .webp below media/.", + "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. A segment must begin with a letter or digit, which is how `.` and `..` are excluded without a negative lookahead; the extension alternation is spelled in character classes because a JSON Schema pattern carries no case-insensitive flag. See spec.md §5.", "maxLength": 512, "minLength": 1, + "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$", "type": "string" } }, @@ -137,9 +139,12 @@ "default": null }, "icon": { - "description": "Relative path of the listing's icon under the item's media/ directory (e.g. media/icon.png), or null for no icon.", + "description": "Path of the listing's icon under the item's media/ directory (e.g. media/icon.png), relative to the item root, or null for no icon.", "anyOf": [ { + "$comment": "The same grammar ListingScreenshot.file carries; see the note there.", + "maxLength": 512, + "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$", "type": "string" }, { diff --git a/specifications/listing/v1/schemas/src/listing.schema.json b/specifications/listing/v1/schemas/src/listing.schema.json index 217ead6..7a0861f 100644 --- a/specifications/listing/v1/schemas/src/listing.schema.json +++ b/specifications/listing/v1/schemas/src/listing.schema.json @@ -72,9 +72,11 @@ "description": "Accessible caption shown beneath the screenshot, or null when none." }, "file": { - "description": "Relative path of the screenshot asset under the item's media/ directory.", + "$comment": "Lookahead-free so it compiles under RE2 as well as ECMA-262. A segment must begin with a letter or digit, which is how `.` and `..` are excluded without a negative lookahead; the extension alternation is spelled in character classes because a JSON Schema pattern carries no case-insensitive flag. See spec.md §5.", + "description": "Path of the screenshot asset under the item's media/ directory, relative to the item root. Must be a .png, .jpg, .jpeg or .webp below media/.", "maxLength": 512, "minLength": 1, + "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$", "type": "string" } }, @@ -139,6 +141,9 @@ "icon": { "anyOf": [ { + "$comment": "The same grammar ListingScreenshot.file carries; see the note there.", + "maxLength": 512, + "pattern": "^media/(?:[A-Za-z0-9][A-Za-z0-9._-]*/)*[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:[Pp][Nn][Gg]|[Jj][Pp][Gg]|[Jj][Pp][Ee][Gg]|[Ww][Ee][Bb][Pp])$", "type": "string" }, { @@ -146,7 +151,7 @@ } ], "default": null, - "description": "Relative path of the listing's icon under the item's media/ directory (e.g. media/icon.png), or null for no icon." + "description": "Path of the listing's icon under the item's media/ directory (e.g. media/icon.png), relative to the item root, or null for no icon." }, "license": { "anyOf": [ From 02e6833f2b1173495cd1cb2e2fa868f52f49373d Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:38:01 +0000 Subject: [PATCH 11/12] docs(conformance): record what the fixture contract cannot yet express MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage note said semantic cases land "once the corresponding rules are written into spec.md". They are written now, and five semantic cases landed with them — but eight rules did not, and not because the prose is missing. Every one of them is about a document's surroundings rather than its contents: a directory with a name, a sibling document, a file on disk. A case is one `case.yaml`, so none of them is expressible, and the shortfall would otherwise read as prose still owed. Extending the contract to a case tree is ADR-gated — GOVERNANCE.md lists "Changing the conformance fixture contract" — so the note says what is blocked and on what, rather than leaving the corpus looking arbitrarily incomplete. Refs: #9 Signed-off-by: Justin Merrell --- conformance/README.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index ca8d195..6d064dd 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -101,10 +101,29 @@ later-phase diagnostic before the earlier phases pass. ## Coverage status -Cases exist today for the `parser` and `structural` phases only. `semantic` and -`capability` cases land once the corresponding rules are written into `spec.md` -— a fixture without a normative clause to cite is an assertion about an -implementation, not about the specification. +`parser` and `structural` are covered. `semantic` is covered for every rule a +single document can express — a floating image tag, a probe naming an endpoint +that is not there, a connection naming a node that is not there, a cyclic +graph, two screenshots sharing a basename. `capability` has no cases. + +The gap is not which rules are written down; it is what a case can say. Every +remaining `semantic` rule is about a document's surroundings rather than its +contents: + +| Rule | Needs | +|---|---| +| `ERR_SLUG_MISMATCH` | a directory with a name | +| `ERR_VERSION_MISMATCH` | a sibling document | +| `ERR_UNREFERENCED_COMPONENT` | the item's other files | +| `ERR_COMPONENT_NOT_FOUND`, `ERR_REFERENCE_ESCAPE` | a resolvable target | +| `ERR_UNKNOWN_OUTPUT` | the referenced component document | +| `ERR_MEDIA_NOT_FOUND`, `ERR_PATH_ESCAPE` | a file on disk | + +A case is one `case.yaml`, so none of those is expressible. Extending the +fixture contract to a case tree is an ADR-gated change — GOVERNANCE.md lists +"Changing the conformance fixture contract" among the changes needing one — +and those cases land against that ADR rather than being approximated in the +meantime. An adapter encountering a phase it does not implement SHOULD skip the case and report it as skipped. It MUST NOT report it as passed. From 270830617f9cd1833560535a7224bf1d50f10f94 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Sun, 9 Aug 2026 04:40:06 +0000 Subject: [PATCH 12/12] docs: correct the empty-form rule and pin node-name collation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the clauses in this branch, found by checking the prose against what the schema actually accepts rather than against what it was meant to. component §5 said a forbidden field "MAY be absent, an empty mapping, or an explicit null". Only one of the three is true per field, and the schema is right: `endpoints` is a mapping and rejects null, `schedule` is a nullable block and rejects an empty mapping. Each field has one empty form and they are not interchangeable. The clause now says which is which. The §5 note on a SERVICE without endpoints claimed the rule "would reject documents this specification currently accepts, which makes it a breaking change rather than one v1 can absorb". The second half overstates: nothing is released yet, so v1 could absorb it. It was not adopted, which is a different claim, and the clause now makes that one. blueprint §5.2 leaves the merge order comparable across implementations: §4 confines a node name to lowercase ASCII, so byte order and lexicographic order coincide and no collation can change which node wins a key. Without that the merge rule is reproducible only among implementations that happen to agree on string comparison. Refs: #9 Signed-off-by: Justin Merrell --- specifications/blueprint/v1/spec.md | 5 +++++ specifications/component/v1/spec.md | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 583a419..61b82eb 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -305,6 +305,11 @@ mapping has no sequence, and a rule that depended on file order, on parse order, or on an identifier internal to a control plane would not be reproducible by someone reading the document. +The comparison is unambiguous across implementations: [§4](#components) +confines a node name to lowercase ASCII letters, digits and hyphens, so byte +order and lexicographic order coincide and no collation or locale can change +the result. + **A conflicting redeclaration is an error.** Where a later node declares a key already taken and its declaration differs, the blueprint is rejected in the `semantic` phase with `ERR_CONFLICTING_INPUT_SCHEMA`. An identical diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 14c9c52..5fb02b5 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -94,8 +94,11 @@ and the shape decides which of the remaining fields carry meaning. `source`, `envVars`, and `volumes` are permitted on every kind. Every rule in the table is decided in the `structural` phase. -**Forbidden means rejected, not ignored.** A forbidden field MAY be absent, an -empty mapping, or an explicit null; anything else is an error. +**Forbidden means rejected, not ignored.** A forbidden field MAY be omitted, or +written in its own empty form — `endpoints: {}` for a mapping, `schedule: null` +for a nullable block. Anything else is an error, and the two empty forms are +not interchangeable: `endpoints` is a mapping and takes no null, +`schedule` is a nullable block and takes no empty mapping. [§2](#envelope) has already settled why — a misspelled field is an error rather than a silently ignored one, "including when the misspelled field is optional, where ignoring it would silently substitute the default". A `schedule` on a @@ -110,10 +113,9 @@ than meaningless, so it stays permitted. **A `SERVICE` MAY declare no endpoint.** The smallest component that validates is a service running a pinned image and nothing else. A service exposing no -endpoint and a worker are operationally the same thing, so requiring at least -one endpoint is a defensible rule — but it would reject documents this -specification currently accepts, which makes it a breaking change rather than -one v1 can absorb. +endpoint and a worker are operationally much the same thing, so requiring at +least one endpoint is a defensible rule. It is simply not this version's rule, +and adopting it later rejects documents v1 accepts. Input and output keys are unique within a component because `contract.inputs` and `contract.outputs` are mappings. A repeated key is `ERR_DUPLICATE_KEY` in