diff --git a/README.md b/README.md
index 756d577..dbc30cc 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ npm run verify # lint, format, typecheck, ADR contract, tests + coverage
`npm run lint:adrs` alone runs the decision-record contract, and `npm test`
runs the suite without enforcing coverage. `npm run test:coverage` (part of
`npm run verify`) enforces the ratchet in `vitest.config.ts`: statements
-98.6%, branches 93.8%, functions 100%, lines 98.51%.
+98.67%, branches 94.3%, functions 100%, lines 98.58%.
## Conventions
diff --git a/docs/requirements.md b/docs/requirements.md
index 890b168..73c90fc 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -19,7 +19,7 @@ test file and holds at least one test; ids are unique; the count this
document states matches the number of rows it holds; and every id cited
anywhere in the tracked tree resolves to a row here.
-This ledger holds **23** rows. The compiler's behaviours join it as they land.
+This ledger holds **24** rows. The compiler's behaviours join it as they land.
| id | a contributor or a consumer can rely on | proved by |
|---|---|---|
@@ -46,3 +46,4 @@ This ledger holds **23** rows. The compiler's behaviours join it as they land.
| REQ-021 | An authored Project Intent file parses to its committed intent oracle byte for byte, and YAML outside the one-document, anchor-free subset or a field outside the language is refused with a diagnostic rather than guessed at | [test/model/project-intent.test.ts](../test/model/project-intent.test.ts) |
| REQ-022 | Every module under `src/` is mutation-tested, and a surviving mutant that takes the score below the measured threshold fails the build | [test/mutation-contract.test.ts](../test/mutation-contract.test.ts) |
| REQ-023 | The Project Intent metamodel's structure is committed as a descriptor both implementations are held to, and the JSON Schema an editor completes a project file against regenerates from the metamodel without a diff | [test/model/descriptor.test.ts](../test/model/descriptor.test.ts) |
+| REQ-024 | A Project Intent document that breaks a model constraint is refused with the code and the JSON Pointer its committed diagnostics oracle names, and every refusal fixture carries one | [test/model/refusals.test.ts](../test/model/refusals.test.ts) |
diff --git a/emf/cli/META-INF/MANIFEST.MF b/emf/cli/META-INF/MANIFEST.MF
index ee459e6..c98bdf8 100644
--- a/emf/cli/META-INF/MANIFEST.MF
+++ b/emf/cli/META-INF/MANIFEST.MF
@@ -9,4 +9,6 @@ Export-Package: dev.jorisjonkers.deploykit.emf.cli
Require-Bundle: dev.jorisjonkers.deploykit.emf.metamodel,
dev.jorisjonkers.deploykit.emf.syntax,
org.eclipse.emf.ecore,
- org.eclipse.xtext
+ org.eclipse.xtext,
+ org.eclipse.ocl.pivot,
+ org.eclipse.ocl.xtext.completeocl
diff --git a/emf/cli/pom.xml b/emf/cli/pom.xml
index 3697356..34b3cdc 100644
--- a/emf/cli/pom.xml
+++ b/emf/cli/pom.xml
@@ -13,4 +13,23 @@
dev.jorisjonkers.deploykit.emf.clieclipse-plugindeploy-kit model-driven cli
+
+
+
+
+ org.pitest
+ pitest-maven
+
+
+
+ org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup
+
+
+
+
+
diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java
new file mode 100644
index 0000000..bdcedf0
--- /dev/null
+++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Constraints.java
@@ -0,0 +1,61 @@
+package dev.jorisjonkers.deploykit.emf.cli;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.emf.common.util.Diagnostic;
+import org.eclipse.emf.common.util.URI;
+import org.eclipse.emf.ecore.EObject;
+import org.eclipse.emf.ecore.EPackage;
+import org.eclipse.emf.ecore.EValidator;
+import org.eclipse.emf.ecore.util.Diagnostician;
+import org.eclipse.ocl.xtext.completeocl.CompleteOCLStandaloneSetup;
+import org.eclipse.ocl.xtext.completeocl.validation.CompleteOCLEObjectValidator;
+
+/**
+ * The Complete OCL constraints of a metamodel, evaluated over one parsed document. An invariant is
+ * named by the diagnostic code it emits and its context is the object the diagnostic points at, so a
+ * violation becomes a {@link dev.jorisjonkers.deploykit.emf.cli.Diagnostic} without a lookup table
+ * (emf/docs/architecture.md#constraints). Every failed invariant is reported, never only the first.
+ */
+public final class Constraints {
+
+ /** The invariant name inside the message Eclipse OCL builds for a violation. */
+ private static final Pattern VIOLATED = Pattern.compile("'[^']*::([A-Za-z0-9_]+)' constraint is violated");
+
+ /** The constraints the metamodel carries, as the build puts them beside its classes. */
+ public static URI beside(Class> metamodel, String file) {
+ return URI.createURI(metamodel.getResource("/" + file).toString());
+ }
+
+ private Constraints() {}
+
+ /** Evaluates {@code document} against the constraints at {@code constraints}, in document order. */
+ public static List check(EObject document, URI constraints) {
+ CompleteOCLStandaloneSetup.doSetup();
+ EPackage metamodel = document.eClass().getEPackage();
+ EValidator previous = EValidator.Registry.INSTANCE.getEValidator(metamodel);
+ EValidator.Registry.INSTANCE.put(metamodel, new CompleteOCLEObjectValidator(metamodel, constraints));
+ try {
+ return refusals(Diagnostician.INSTANCE.validate(document));
+ } finally {
+ EValidator.Registry.INSTANCE.put(metamodel, previous);
+ }
+ }
+
+ private static List refusals(Diagnostic diagnostic) {
+ List refusals = new ArrayList<>();
+ Matcher violated = VIOLATED.matcher(diagnostic.getMessage());
+ if (violated.find()) {
+ // A violation carries the object it refused as its first datum.
+ EObject refused = (EObject) diagnostic.getData().get(0);
+ refusals.add(new dev.jorisjonkers.deploykit.emf.cli.Diagnostic(
+ violated.group(1), Pointer.of(refused), diagnostic.getMessage()));
+ }
+ for (Diagnostic child : diagnostic.getChildren()) {
+ refusals.addAll(refusals(child));
+ }
+ return refusals;
+ }
+}
diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java
index adc854c..fb6a584 100644
--- a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java
+++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pipeline.java
@@ -6,6 +6,7 @@
import java.util.ArrayList;
import java.util.List;
import org.eclipse.emf.common.util.URI;
+import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EPackage;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.xtext.resource.XtextResourceSet;
@@ -16,6 +17,9 @@
*/
public final class Pipeline {
+ /** The Complete OCL file the metamodel carries, beside its classes. */
+ private static final String CONSTRAINTS = "project-intent.ocl";
+
private Pipeline() {}
/** The parsed intent of the project file at {@code path}, or the diagnostics refusing it. */
@@ -34,8 +38,12 @@ public static Parsed intent(Path path) {
if (resource.getContents().isEmpty()) {
refusals.add(new Diagnostic(Diagnostic.SCHEMA, "", path.getFileName() + " holds no document"));
}
- return refusals.isEmpty()
- ? Parsed.of(IntentJson.of(resource.getContents().get(0)))
- : Parsed.refused(refusals);
+ if (!refusals.isEmpty()) {
+ return Parsed.refused(refusals);
+ }
+ EObject document = resource.getContents().get(0);
+ List broken =
+ Constraints.check(document, Constraints.beside(ProjectIntentPackage.class, CONSTRAINTS));
+ return broken.isEmpty() ? Parsed.of(IntentJson.of(document)) : Parsed.refused(broken);
}
}
diff --git a/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java
new file mode 100644
index 0000000..4b62d80
--- /dev/null
+++ b/emf/cli/src/main/java/dev/jorisjonkers/deploykit/emf/cli/Pointer.java
@@ -0,0 +1,34 @@
+package dev.jorisjonkers.deploykit.emf.cli;
+
+import java.util.List;
+import org.eclipse.emf.ecore.EObject;
+import org.eclipse.emf.ecore.EStructuralFeature;
+
+/**
+ * The RFC 6901 JSON Pointer of an object inside its document, read off its containment chain: the
+ * name of the feature that holds it, and its index where the feature holds many
+ * (docs/architecture.md#the-parity-contract). The root is the empty pointer.
+ */
+public final class Pointer {
+
+ private Pointer() {}
+
+ /** The pointer of {@code object} in the document it belongs to. */
+ public static String of(EObject object) {
+ EObject owner = object.eContainer();
+ if (owner == null) {
+ return "";
+ }
+ EStructuralFeature feature = object.eContainingFeature();
+ String step = escape(feature.getName());
+ if (feature.isMany()) {
+ step = step + "/" + ((List>) owner.eGet(feature)).indexOf(object);
+ }
+ return of(owner) + "/" + step;
+ }
+
+ /** A feature name as a pointer segment: the two characters a pointer spells differently. */
+ private static String escape(String name) {
+ return name.replace("~", "~0").replace("/", "~1");
+ }
+}
diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java
new file mode 100644
index 0000000..9edb036
--- /dev/null
+++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/ConstraintsTest.java
@@ -0,0 +1,96 @@
+package dev.jorisjonkers.deploykit.emf.cli;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentPackage;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** The constraints a parsed document answers, and the diagnostics a violation becomes. */
+class ConstraintsTest {
+
+ private static final String REFUSED = """
+ apiVersion: intent.jorisjonkers.dev/v1
+ kind: Project
+ schemaVersion: 1.0.0
+ project: refusals
+ owner: joris
+ applications:
+ - id: unwired
+ observability:
+ alertClass: page
+ processes:
+ - name: unwired-worker
+ lifecycle: application
+ image: unwired-worker
+ runtime: node
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ cutover: rolling
+ """;
+
+ private static Path file(Path directory, String text) throws IOException {
+ Path file = directory.resolve("unwired.project.yml");
+ Files.writeString(file, text);
+ return file;
+ }
+
+ @Test
+ void aViolationCarriesTheInvariantsNameAndThePointerOfWhatItRefused(@TempDir Path directory) throws IOException {
+ Parsed parsed = Pipeline.intent(file(directory, REFUSED));
+
+ assertThat(parsed.ok()).isFalse();
+ assertThat(parsed.intent()).isEmpty();
+ assertThat(parsed.diagnostics()).singleElement().satisfies(diagnostic -> {
+ assertThat(diagnostic.code()).isEqualTo("E_ALERT_CLASS_WITHOUT_SIGNAL");
+ assertThat(diagnostic.path()).isEqualTo("/applications/0/observability");
+ assertThat(diagnostic.message()).contains("E_ALERT_CLASS_WITHOUT_SIGNAL");
+ });
+ }
+
+ private static final String ACCEPTED = """
+ apiVersion: intent.jorisjonkers.dev/v1
+ kind: Project
+ schemaVersion: 1.0.0
+ project: refusals
+ owner: joris
+ applications:
+ - id: wired
+ observability:
+ alertClass: page
+ scrape:
+ process: wired-worker
+ surface: http
+ path: /metrics
+ processes:
+ - name: wired-worker
+ lifecycle: application
+ image: wired-worker
+ runtime: node
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ cutover: rolling
+ """;
+
+ @Test
+ void aDocumentThatBreaksNoConstraintCarriesNoDiagnostic(@TempDir Path directory) throws IOException {
+ Parsed parsed = Pipeline.intent(file(directory, ACCEPTED));
+
+ assertThat(parsed.diagnostics()).isEmpty();
+ assertThat(parsed.intent()).containsKey("applications");
+ }
+
+ @Test
+ void theConstraintsAreTheFileTheMetamodelCarries() {
+ assertThat(Constraints.beside(ProjectIntentPackage.class, "project-intent.ocl")
+ .toString())
+ .endsWith("project-intent.ocl");
+ }
+}
diff --git a/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java
new file mode 100644
index 0000000..a4f969c
--- /dev/null
+++ b/emf/cli/src/test/java/dev/jorisjonkers/deploykit/emf/cli/PointerTest.java
@@ -0,0 +1,47 @@
+package dev.jorisjonkers.deploykit.emf.cli;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Application;
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Observability;
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Process;
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.Project;
+import dev.jorisjonkers.deploykit.emf.metamodel.projectintent.ProjectIntentFactory;
+import org.junit.jupiter.api.Test;
+
+/** Where an object sits in its document, as the pointer a diagnostic carries. */
+class PointerTest {
+
+ private static final ProjectIntentFactory MODEL = ProjectIntentFactory.eINSTANCE;
+
+ @Test
+ void theRootIsTheEmptyPointer() {
+ assertThat(Pointer.of(MODEL.createProject())).isEmpty();
+ }
+
+ @Test
+ void aFeatureHoldingManyValuesCarriesTheIndex() {
+ Project project = MODEL.createProject();
+ Application first = MODEL.createApplication();
+ Application second = MODEL.createApplication();
+ project.getApplications().add(first);
+ project.getApplications().add(second);
+ Process process = MODEL.createProcess();
+ second.getProcesses().add(process);
+
+ assertThat(Pointer.of(first)).isEqualTo("/applications/0");
+ assertThat(Pointer.of(second)).isEqualTo("/applications/1");
+ assertThat(Pointer.of(process)).isEqualTo("/applications/1/processes/0");
+ }
+
+ @Test
+ void aFeatureHoldingOneValueCarriesItsNameAlone() {
+ Project project = MODEL.createProject();
+ Application application = MODEL.createApplication();
+ Observability observability = MODEL.createObservability();
+ project.getApplications().add(application);
+ application.setObservability(observability);
+
+ assertThat(Pointer.of(observability)).isEqualTo("/applications/0/observability");
+ }
+}
diff --git a/emf/docs/architecture.md b/emf/docs/architecture.md
index 82b6bb2..7f2db5e 100644
--- a/emf/docs/architecture.md
+++ b/emf/docs/architecture.md
@@ -136,6 +136,15 @@ offending object, computed from its containment chain: each containing feature's
name, and the index for a many-valued feature. Validation reports every failed
invariant, never only the first.
+Two consequences of evaluating OCL over Ecore, both recorded here because they
+shaped the metamodel. The constraints import the metamodel by its `nsURI`, not
+by file, so they bind to the classes the parser instantiates rather than to a
+second copy. And EMF reads an unset enumeration as its first literal, so a
+vocabulary an invariant tests for absence carries a literal with no spelling:
+`Engine::absent` is what an unset `engine` reads as, a document cannot write it,
+and the descriptor leaves it out because a literal the language cannot write is
+not part of the vocabulary.
+
The constraint ledger's OCL column lives in `emf/`: a table mapping each
`CONS-NNN` id to the OCL invariant that enforces it. `parity/` fails when a
ledger constraint has no invariant, or an invariant names a code no ledger row
diff --git a/emf/docs/witnesses.md b/emf/docs/witnesses.md
index e3104b3..eb2b68d 100644
--- a/emf/docs/witnesses.md
+++ b/emf/docs/witnesses.md
@@ -10,9 +10,10 @@ names the JUnit test that proves the same behaviour here
has no witness here, when a witness names an id that is not a model row, or
when it names a test method that does not exist.
-This list holds **2** witnesses.
+This list holds **3** witnesses.
| id | JUnit test |
|---|---|
| REQ-021 | `ParityTest#theParsedIntentEqualsTheCommittedOracle` |
| REQ-023 | `ParityTest#theMetamodelsStructureEqualsTheCommittedDescriptor` |
+| REQ-024 | `ParityTest#aRefusedDocumentEqualsItsCommittedDiagnostics` |
diff --git a/emf/metamodel/build.properties b/emf/metamodel/build.properties
index 765502e..ccdc6bd 100644
--- a/emf/metamodel/build.properties
+++ b/emf/metamodel/build.properties
@@ -1,4 +1,5 @@
source.. = src/main/java/,\
+ model/,\
target/generated-sources/emf/
bin.includes = META-INF/,\
.,\
diff --git a/emf/metamodel/model/project-intent.ecore b/emf/metamodel/model/project-intent.ecore
index 698450f..5eca3d0 100644
--- a/emf/metamodel/model/project-intent.ecore
+++ b/emf/metamodel/model/project-intent.ecore
@@ -26,7 +26,7 @@
-
+
@@ -166,10 +166,14 @@
-
-
-
-
+
+
+
+
+
+
+
+
@@ -231,7 +235,7 @@
-
+
diff --git a/emf/metamodel/model/project-intent.ocl b/emf/metamodel/model/project-intent.ocl
new file mode 100644
index 0000000..c148ddf
--- /dev/null
+++ b/emf/metamodel/model/project-intent.ocl
@@ -0,0 +1,66 @@
+-- The document-level constraints of the Project Intent model, in the graded
+-- constraint language (docs/adr/emf/0110). An invariant is named by the
+-- diagnostic code it emits, and its context is the object the diagnostic points
+-- at, so a violation maps to a (code, path) pair without a lookup table.
+--
+-- Constraints that need more than one document belong to composition.
+
+-- The registered package, not the file: the constraints must bind to the same
+-- classes the parser instantiates.
+import 'https://jorisjonkers.dev/deploy-kit/project-intent/1'
+
+package projectintent
+
+context Observability
+ -- The block is whole or absent: a class states how loudly to wake someone,
+ -- and means nothing without a signal to wake them about.
+ inv E_ALERT_CLASS_WITHOUT_SIGNAL: scrape <> null
+
+context Process
+ -- A volume cannot attach to the surge a rolling cutover needs, and a silent
+ -- downgrade is what makes an owner believe in continuity they do not have.
+ inv E_CUTOVER_UNHONOURABLE: cutover = Cutover::rolling implies volumes->isEmpty()
+
+ -- The engine keys the backup method, so it names one nothing asks for when no
+ -- volume derives a backup. `Engine::absent` is the literal a document cannot
+ -- write: EMF reads an unset enumeration as its first literal, so the model
+ -- gives absence one of its own.
+ inv E_ENGINE_WITHOUT_DURABILITY:
+ engine <> Engine::absent implies
+ volumes->exists(durability = DurabilityClass::recoverable or durability = DurabilityClass::irreplaceable)
+
+context Volume
+ -- The backup method comes from the Process's engine, and a guessed backup is
+ -- the kind nobody finds out about until a restore.
+ inv E_DURABILITY_WITHOUT_ENGINE:
+ (durability = DurabilityClass::recoverable or durability = DurabilityClass::irreplaceable) implies
+ oclContainer().oclAsType(Process).engine <> Engine::absent
+
+context Rotation
+ -- An environment variable is read once, when the process starts.
+ inv E_ENV_CANNOT_RELOAD:
+ tolerates = Tolerance::reload implies oclContainer().oclAsType(Grant).delivery <> Delivery::env
+
+context KvGrant
+ -- Custody creates and deletes paths at runtime, and a renewal tier holds no
+ -- capability on its path: neither has anything to project.
+ inv E_ILLEGAL_DELIVERY_FOR_ACCESS:
+ not ((access = AccessTier::custody and (delivery = Delivery::env or delivery = Delivery::file))
+ or (access = AccessTier::selfRenew and delivery = Delivery::env))
+
+context TransitGrant
+ -- A transit key is used, never read: there is no value to write anywhere.
+ inv E_NON_KV_DELIVERY: delivery = Delivery::selfDelivery
+
+context DatabaseGrant
+ -- A database credential is minted per lease and re-read at runtime.
+ inv E_NON_KV_DELIVERY: delivery = Delivery::selfDelivery
+
+context Route
+ -- Precedence is derived, and two routes sharing a path and a match cannot be
+ -- ordered by it.
+ inv E_DUPLICATE_ROUTE_MATCH:
+ oclContainer().oclAsType(Exposure).routes
+ ->select(r | r.path = self.path and r.match = self.match)->first() = self
+
+endpackage
diff --git a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java
index 344820c..9381449 100644
--- a/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java
+++ b/emf/metamodel/src/main/java/dev/jorisjonkers/deploykit/emf/metamodel/descriptor/Descriptor.java
@@ -69,9 +69,14 @@ private static boolean isMapEntry(EClassifier classifier) {
private static Map vocabulary(EEnum vocabulary) {
Map json = new LinkedHashMap<>();
json.put("name", vocabulary.getName());
+ // A literal with no spelling is the model's way of saying "unset", which is
+ // not a value the language can write and not part of the vocabulary.
json.put(
"literals",
- vocabulary.getELiterals().stream().map(EEnumLiteral::getLiteral).toList());
+ vocabulary.getELiterals().stream()
+ .map(EEnumLiteral::getLiteral)
+ .filter(literal -> !literal.isEmpty())
+ .toList());
return json;
}
diff --git a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java
index f68c354..43e4966 100644
--- a/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java
+++ b/emf/parity/src/test/java/dev/jorisjonkers/deploykit/emf/parity/ParityTest.java
@@ -11,7 +11,10 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Comparator;
import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -44,6 +47,31 @@ void theParsedIntentEqualsTheCommittedOracle(Path directory) throws IOException
assertThat(CanonicalJson.write(parsed.intent())).isEqualTo(read(directory.resolve("expected/intent.json")));
}
+ private static List refusalsWithADiagnosticsOracle() {
+ Path refusals = repository().resolve("spec/v1/examples/refusals");
+ try (Stream files = Files.list(refusals)) {
+ return files.filter(path -> path.getFileName().toString().endsWith(".diagnostics.json"))
+ .sorted()
+ .toList();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("refusalsWithADiagnosticsOracle")
+ void aRefusedDocumentEqualsItsCommittedDiagnostics(Path oracle) throws IOException {
+ String stem = oracle.getFileName().toString().replace(".diagnostics.json", "");
+ Parsed parsed = Pipeline.intent(oracle.resolveSibling(stem + ".project.yml"));
+
+ assertThat(CanonicalJson.write(parsed.diagnostics().stream()
+ .map(diagnostic ->
+ (Object) new TreeMap<>(Map.of("code", diagnostic.code(), "path", diagnostic.path())))
+ .sorted(Comparator.comparing(Object::toString))
+ .toList()))
+ .isEqualTo(read(oracle));
+ }
+
@Test
void theMetamodelsStructureEqualsTheCommittedDescriptor() throws IOException {
assertThat(CanonicalJson.write(Descriptor.of(ProjectIntentPackage.eINSTANCE)))
diff --git a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext
index 51dbc38..1531e09 100644
--- a/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext
+++ b/emf/syntax/src/main/java/dev/jorisjonkers/deploykit/emf/syntax/ProjectIntent.xtext
@@ -26,7 +26,7 @@ Application returns Application:
Observability returns Observability:
(('alertClass' ':' alertClass=AlertClass)
- & ('scrape' ':' BEGIN scrape=Scrape END));
+ & ('scrape' ':' BEGIN scrape=Scrape END)?);
Scrape returns Scrape:
(('process' ':' process=Text)
@@ -241,7 +241,7 @@ enum TransitOp returns TransitOp:
sign='sign' | verify='verify' | encrypt='encrypt' | decrypt='decrypt' | rotate='rotate';
enum Delivery returns Delivery:
- env='env' | file='file' | self='self';
+ env='env' | file='file' | selfDelivery='self';
enum Tolerance returns Tolerance:
restart='restart' | reload='reload';
diff --git a/scripts/lint-codes.ts b/scripts/lint-codes.ts
index 1cb9748..6ea40dc 100644
--- a/scripts/lint-codes.ts
+++ b/scripts/lint-codes.ts
@@ -45,19 +45,6 @@ export const RETIRED: Readonly> = {
};
export const PENDING: readonly Pending[] = [
- {
- ticket: "#38",
- reason:
- "a rule over one Project Intent document, registered with the metamodel",
- codes: [
- "E_DURABILITY_WITHOUT_ENGINE",
- "E_ENGINE_WITHOUT_DURABILITY",
- "E_ENV_CANNOT_RELOAD",
- "E_ILLEGAL_DELIVERY_FOR_ACCESS",
- "E_NON_KV_DELIVERY",
- "E_DUPLICATE_ROUTE_MATCH",
- ],
- },
{
ticket: "#39",
reason: "a reference resolved by name, which needs the linking step",
diff --git a/spec/v1/10-project-intent.md b/spec/v1/10-project-intent.md
index f572cef..b53207d 100644
--- a/spec/v1/10-project-intent.md
+++ b/spec/v1/10-project-intent.md
@@ -322,8 +322,8 @@ Vault role are called (chapter 16).
never a digest here.
`lifecycle` is `application` or `job`. Not `deployment` / `statefulset` / `job`,
-because those are mechanisms; the object kind derives from `lifecycle`, `stateful`
-and `volumes`.
+because those are mechanisms; the object kind derives from `lifecycle` and
+`volumes`.
`runtime` selects the Runtime Profile: `jvm`, `python`, `node`, `static`, `none`.
`none` is correct for a third-party image and injects no profile values at all.
@@ -1741,7 +1741,7 @@ startupBudget: 600s # knowledge-api: JVM cold start measured at ~250-300s
cutover: rolling # required: continuity during the cutover, or an accepted stop-then-start
```
-Derived from these plus `stateful`, `placement` and `volumes`: rollout strategy,
+Derived from these plus `placement` and `volumes`: rollout strategy,
surge and unavailability, startup probe period and threshold, the progress
deadline, and the health-gate deadline the Application's switchover waits on.
@@ -2008,7 +2008,6 @@ classDiagram
+Engine engine
+Duration startupBudget
+Cutover cutover
- +bool stateful
+Path[] writablePaths
}
class Capacity {
diff --git a/spec/v1/20-resolved-deployment.md b/spec/v1/20-resolved-deployment.md
index 7ba9ce4..9021f8e 100644
--- a/spec/v1/20-resolved-deployment.md
+++ b/spec/v1/20-resolved-deployment.md
@@ -138,7 +138,7 @@ field's placement link to this anchor rather than copying rows.
| process `name` | Application | unique, checked | unique within the **project**; `E_DUPLICATE_PROCESS_NAME`, and it names the derived identity |
| `provides` surface names and ports | Application | no contention | declared on the Process, because a port is a property of a process; written once, there |
| `dependsOn` edges | Application | no contention | provider, surface, necessity ([chapter 16](16-dependencies.md#dependency-edges)) |
-| `image`, `runtime`, `lifecycle`, `stateful` | Application | no contention | what the Process is |
+| `image`, `runtime`, `lifecycle` | Application | no contention | what the Process is |
| env files, `assets` | Application | no contention | per Process; derived values appear only as placeholders |
| `secrets` grants: `path`, `keys`, `access`, `delivery`, `rotation` | Application | no contention to declare | per Application and never raised; the *path* is arbitrated (below), what an Application asks of a path is its own |
| `exposure[].name` | Application | unique, checked | required; unique **within the Application**, `E_DUPLICATE_EXPOSURE_NAME` at composition. It is the half `${exposure:.#url}` addresses |
@@ -184,7 +184,7 @@ field's placement link to this anchor rather than copying rows.
| container probe timings | derived | - | the startup probe's target from the **liveness** declaration and its period from `startupBudget`; readiness and liveness cadence from the Platform Intent's probe policy ([0088](../../docs/adr/model/0088-startup-probe-targets-liveness.md)) |
| `progressDeadlineSeconds` | derived | - | from `startupBudget` |
| rollout strategy, surge, unavailability | derived | - | from `cutover` and `volumes`; `cutover: rolling` over an RWO volume is `E_CUTOVER_UNHONOURABLE`, not a silent downgrade |
-| object kind | derived | - | from `lifecycle`, `stateful` and `volumes` |
+| object kind | derived | - | from `lifecycle` and `volumes` |
| the Application's release-gate deadline | derived | - | `max` over the Application's Processes of `progressDeadlineSeconds` ([The release gate](#the-release-gate)) |
| the object label set | derived | - | fixed, from Process name, Application Id and the images lock ([chapter 10](10-project-intent.md#the-label-set)) |
| Secret and VSO sync objects | derived | - | from grants with `delivery: env` or `file`, plus `rolloutRestartTargets` from `rotation`; a grant with `delivery: self` and `tolerates: reload` derives **no** restart target, which is what makes its rotation zero-downtime ([chapter 10](10-project-intent.md#zero-downtime-rotation)) |
diff --git a/spec/v1/diagrams/10-project-intent-model.drawio.svg b/spec/v1/diagrams/10-project-intent-model.drawio.svg
index ff93018..4008dff 100644
--- a/spec/v1/diagrams/10-project-intent-model.drawio.svg
+++ b/spec/v1/diagrams/10-project-intent-model.drawio.svg
@@ -1,4 +1,4 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/spec/v1/examples/expected/descriptor.json b/spec/v1/examples/expected/descriptor.json
index 2f44a8e..43af0c1 100644
--- a/spec/v1/examples/expected/descriptor.json
+++ b/spec/v1/examples/expected/descriptor.json
@@ -1 +1 @@
-{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"role","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","required":true,"types":["string"]},{"many":false,"map":false,"name":"required","required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"port","required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","required":true,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"site","required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","required":false,"types":["NoProbes","Probes"]},{"many":false,"map":true,"name":"provides","required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","required":true,"types":["string"]},{"many":false,"map":false,"name":"project","required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"redirectTo","required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"key","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]},{"many":false,"map":false,"name":"size","required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]}
\ No newline at end of file
+{"classes":[{"features":[{"many":true,"map":false,"name":"exposure","required":false,"types":["Exposure"]},{"many":false,"map":false,"name":"id","required":true,"types":["string"]},{"many":false,"map":false,"name":"observability","required":false,"types":["Observability"]},{"many":true,"map":false,"name":"processes","required":true,"types":["Process"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]}],"name":"Application"},{"features":[{"many":false,"map":false,"name":"from","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]}],"name":"Asset"},{"features":[{"many":false,"map":false,"name":"count","required":true,"types":["int"]},{"many":false,"map":false,"name":"reason","required":true,"types":["string"]}],"name":"Capacity"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["DatabaseEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"role","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"DatabaseGrant"},{"features":[{"many":false,"map":false,"name":"application","required":true,"types":["string"]},{"many":false,"map":false,"name":"required","required":false,"types":["boolean"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"DependencyEdge"},{"features":[{"many":true,"map":false,"name":"media","required":true,"types":["Media"]}],"name":"DiskRequest"},{"features":[{"many":false,"map":false,"name":"audience","required":true,"types":["Audience"]},{"many":false,"map":false,"name":"contentPolicy","required":false,"types":["ContentPolicy"]},{"many":false,"map":false,"name":"host","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":true,"map":false,"name":"routes","required":true,"types":["Route"]}],"name":"Exposure"},{"features":[{"many":false,"map":false,"name":"class","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]}],"name":"GpuRequest"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"port","required":true,"types":["int"]}],"name":"HttpProbe"},{"features":[{"many":false,"map":false,"name":"access","required":true,"types":["AccessTier"]},{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":true,"map":false,"name":"keys","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"KvGrant"},{"features":[],"name":"NoProbes","scalar":"none"},{"features":[{"many":false,"map":false,"name":"alertClass","required":true,"types":["AlertClass"]},{"many":false,"map":false,"name":"scrape","required":false,"types":["Scrape"]}],"name":"Observability"},{"features":[{"many":true,"map":false,"name":"arch","required":false,"types":["Arch"]},{"many":true,"map":false,"name":"capabilities","required":false,"types":["string"]},{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"disk","required":false,"types":["DiskRequest"]},{"many":false,"map":false,"name":"gpu","required":false,"types":["GpuRequest"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"site","required":false,"types":["string"]}],"name":"Placement"},{"features":[{"many":false,"map":false,"name":"liveness","required":false,"types":["HttpProbe","TcpProbe"]},{"many":false,"map":false,"name":"readiness","required":false,"types":["HttpProbe","TcpProbe"]}],"name":"Probes"},{"features":[{"many":true,"map":false,"name":"assets","required":false,"types":["Asset"]},{"many":false,"map":false,"name":"cutover","required":true,"types":["Cutover"]},{"many":true,"map":false,"name":"dependsOn","required":false,"types":["DependencyEdge"]},{"many":false,"map":false,"name":"engine","required":false,"types":["Engine"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"lifecycle","required":true,"types":["Lifecycle"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]},{"many":false,"map":false,"name":"placement","required":true,"types":["Placement"]},{"many":false,"map":false,"name":"probes","required":false,"types":["NoProbes","Probes"]},{"many":false,"map":true,"name":"provides","required":false,"types":["int"]},{"many":false,"map":false,"name":"replicas","required":false,"types":["Capacity"]},{"many":false,"map":false,"name":"runtime","required":true,"types":["Runtime"]},{"many":true,"map":false,"name":"secrets","required":false,"types":["DatabaseGrant","KvGrant","TransitGrant"]},{"many":true,"map":false,"name":"sidecars","required":false,"types":["Sidecar"]},{"many":false,"map":false,"name":"startupBudget","required":false,"types":["string"]},{"many":true,"map":false,"name":"volumes","required":false,"types":["Volume"]},{"many":true,"map":false,"name":"writablePaths","required":false,"types":["string"]}],"name":"Process"},{"features":[{"many":false,"map":false,"name":"apiVersion","required":true,"types":["string"]},{"many":true,"map":false,"name":"applications","required":true,"types":["Application"]},{"many":false,"map":false,"name":"kind","required":true,"types":["string"]},{"many":false,"map":false,"name":"owner","required":true,"types":["string"]},{"many":false,"map":false,"name":"project","required":true,"types":["string"]},{"many":false,"map":false,"name":"schemaVersion","required":true,"types":["string"]}],"name":"Project"},{"features":[{"many":false,"map":false,"name":"maxAge","required":false,"types":["string"]},{"many":false,"map":false,"name":"tolerates","required":true,"types":["Tolerance"]}],"name":"Rotation"},{"features":[{"many":false,"map":false,"name":"audience","required":false,"types":["Audience"]},{"many":false,"map":false,"name":"match","required":true,"types":["Match"]},{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"redirectTo","required":false,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Route"},{"features":[{"many":false,"map":false,"name":"path","required":true,"types":["string"]},{"many":false,"map":false,"name":"process","required":true,"types":["string"]},{"many":false,"map":false,"name":"surface","required":true,"types":["string"]}],"name":"Scrape"},{"features":[{"many":false,"map":false,"name":"cpu","required":true,"types":["string"]},{"many":false,"map":false,"name":"image","required":true,"types":["string"]},{"many":false,"map":false,"name":"memory","required":true,"types":["string"]},{"many":false,"map":false,"name":"name","required":true,"types":["string"]}],"name":"Sidecar"},{"features":[{"many":false,"map":false,"name":"tcp","required":true,"types":["int"]}],"name":"TcpProbe"},{"features":[{"many":false,"map":false,"name":"delivery","required":true,"types":["Delivery"]},{"many":false,"map":false,"name":"engine","required":true,"types":["TransitEngine"]},{"many":false,"map":false,"name":"fileMode","required":false,"types":["string"]},{"many":false,"map":false,"name":"key","required":true,"types":["string"]},{"many":false,"map":false,"name":"mountAt","required":false,"types":["string"]},{"many":true,"map":false,"name":"operations","required":true,"types":["TransitOp"]},{"many":false,"map":false,"name":"rotation","required":false,"types":["Rotation"]}],"name":"TransitGrant"},{"features":[{"many":false,"map":false,"name":"claim","required":true,"types":["string"]},{"many":false,"map":false,"name":"durability","required":true,"types":["DurabilityClass"]},{"many":false,"map":false,"name":"mountAt","required":true,"types":["string"]},{"many":false,"map":false,"name":"size","required":false,"types":["string"]}],"name":"Volume"}],"vocabularies":[{"literals":["read","self-renew","self-roll","custody"],"name":"AccessTier"},{"literals":["business-hours","urgent","page"],"name":"AlertClass"},{"literals":["amd64","arm64"],"name":"Arch"},{"literals":["anonymous","authenticated","internal","lan"],"name":"Audience"},{"literals":["strict","admin","workflow"],"name":"ContentPolicy"},{"literals":["rolling","recreate"],"name":"Cutover"},{"literals":["database"],"name":"DatabaseEngine"},{"literals":["env","file","self"],"name":"Delivery"},{"literals":["reconstructible","recoverable","irreplaceable"],"name":"DurabilityClass"},{"literals":["postgres","rabbitmq","valkey","files"],"name":"Engine"},{"literals":["application","job"],"name":"Lifecycle"},{"literals":["prefix","exact"],"name":"Match"},{"literals":["nvme","ssd","hdd"],"name":"Media"},{"literals":["jvm","python","node","static","none"],"name":"Runtime"},{"literals":["restart","reload"],"name":"Tolerance"},{"literals":["transit"],"name":"TransitEngine"},{"literals":["sign","verify","encrypt","decrypt","rotate"],"name":"TransitOp"}]}
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/README.md b/spec/v1/examples/refusals/README.md
index cf999f4..bc737b7 100644
--- a/spec/v1/examples/refusals/README.md
+++ b/spec/v1/examples/refusals/README.md
@@ -9,17 +9,36 @@ one defect so the refusal has a single cause.
| fixture | expects | why |
|---|---|---|
| [`alert-class-without-signal.project.yml`](alert-class-without-signal.project.yml) | `E_ALERT_CLASS_WITHOUT_SIGNAL` | an `observability` block carrying a class and no `scrape`. A class states how loudly to wake someone and means nothing without a signal to wake them about ([chapter 10](../../10-project-intent.md#observability)) |
-| [`alert-class-unknown.project.yml`](alert-class-unknown.project.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before composition runs, so no new error code carries it |
+| [`alert-class-unknown.project.yml`](alert-class-unknown.project.yml) | schema validation | a value outside the closed `AlertClass` vocabulary, refused before any rule runs, so no error code carries it |
| [`cutover-rolling-over-rwo.project.yml`](cutover-rolling-over-rwo.project.yml) | `E_CUTOVER_UNHONOURABLE` | `cutover: rolling` over an RWO volume, which cannot surge ([chapter 10](../../10-project-intent.md#cutover-is-declared-not-promised)) |
| [`cutover-recreate-over-rwo.project.yml`](cutover-recreate-over-rwo.project.yml) | accepted | the same Process and storage with the cutover it can honour, the pair that makes the refusal above meaningful |
+| [`engine-without-durability.project.yml`](engine-without-durability.project.yml) | `E_ENGINE_WITHOUT_DURABILITY` | an `engine` over a volume whose durability derives no backup, so it names a method nothing asks for ([chapter 10](../../10-project-intent.md#process)) |
+| [`durability-without-engine.project.yml`](durability-without-engine.project.yml) | `E_DURABILITY_WITHOUT_ENGINE` | a volume that asks for a backup on a Process that names no engine, so the method would have to be guessed ([chapter 10](../../10-project-intent.md#process)) |
+| [`env-cannot-reload.project.yml`](env-cannot-reload.project.yml) | `E_ENV_CANNOT_RELOAD` | a grant delivered as an environment variable that tolerates a reload, which the process cannot see ([chapter 10](../../10-project-intent.md#zero-downtime-rotation)) |
+| [`illegal-delivery-for-access.project.yml`](illegal-delivery-for-access.project.yml) | `E_ILLEGAL_DELIVERY_FOR_ACCESS` | `custody` asked for as a file: there is nothing to project at render time ([chapter 10](../../10-project-intent.md#which-tier-may-use-which-delivery)) |
+| [`non-kv-delivery.project.yml`](non-kv-delivery.project.yml) | `E_NON_KV_DELIVERY` | a transit grant delivered as an environment variable, when a transit key is used rather than read ([chapter 10](../../10-project-intent.md#delivery)) |
+| [`duplicate-route-match.project.yml`](duplicate-route-match.project.yml) | `E_DUPLICATE_ROUTE_MATCH` | two routes of one exposure sharing a `path` and a `match`, which derived precedence cannot order ([chapter 10](../../10-project-intent.md#what-is-checked)) |
+
+Every refused fixture carries a committed `.diagnostics.json` beside it:
+the set of `(code, path)` pairs the model emits, where the path is the RFC 6901
+JSON Pointer of the object refused. Both implementations are held to that file
+([the parity contract](../../../../docs/architecture.md#the-parity-contract)),
+and it replaces the `expect:` header these fixtures used to carry.
+
+`alert-class-unknown.project.yml` carries no diagnostics oracle. A value outside
+a closed vocabulary is refused by each implementation's own front end, before a
+rule runs: the production parser can point at the field, and the model-driven
+parser refuses the token. The code is the same and the place it can name is not,
+so the case is not a parity oracle.
There is no fixture for "no monitoring". An Application that wants none omits the
`observability` block, which is an accepted input and appears in the worked set
as `platform-valkey` rather than here.
-These are **fixtures, not proof of rendered behaviour.** The compiler does not
-exist yet, so `test/simplification-contract.test.ts` asserts them at the layer
-that does: the shape of the input.
+These are **fixtures, not proof of rendered behaviour.** No renderer exists yet,
+so what is proven is the refusal itself: `test/model/refusals.test.ts` runs each
+through the production parser, and `emf/parity`'s `ParityTest` runs each through
+the model-driven one, both against the committed diagnostics.
Two things therefore remain **unproven until a renderer exists**, and are named
as blockers rather than described as verified:
@@ -31,5 +50,3 @@ as blockers rather than described as verified:
surface it points at, and that a block missing its `scrape` is refused at
composition rather than merely being absent from the input.
-The `expect:` key is fixture metadata. It is not part of the Project schema, and
-no accepted worked example carries it.
diff --git a/spec/v1/examples/refusals/alert-class-unknown.project.yml b/spec/v1/examples/refusals/alert-class-unknown.project.yml
index e51c224..4e4e1ab 100644
--- a/spec/v1/examples/refusals/alert-class-unknown.project.yml
+++ b/spec/v1/examples/refusals/alert-class-unknown.project.yml
@@ -13,7 +13,6 @@ apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
-expect: schema, alertClass is not a member of AlertClass
project: refusals
owner: joris
diff --git a/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json b/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json
new file mode 100644
index 0000000..6a15306
--- /dev/null
+++ b/spec/v1/examples/refusals/alert-class-without-signal.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_ALERT_CLASS_WITHOUT_SIGNAL","path":"/applications/0/observability"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/alert-class-without-signal.project.yml b/spec/v1/examples/refusals/alert-class-without-signal.project.yml
index b104a36..bf6852d 100644
--- a/spec/v1/examples/refusals/alert-class-without-signal.project.yml
+++ b/spec/v1/examples/refusals/alert-class-without-signal.project.yml
@@ -12,7 +12,6 @@ apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
-expect: E_ALERT_CLASS_WITHOUT_SIGNAL
project: refusals
owner: joris
diff --git a/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml
index 32b9b67..0577f70 100644
--- a/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml
+++ b/spec/v1/examples/refusals/cutover-recreate-over-rwo.project.yml
@@ -15,7 +15,6 @@ apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
-expect: accepted
project: refusals
owner: joris
@@ -29,7 +28,6 @@ applications:
lifecycle: application
image: recreate-over-rwo-store
runtime: static
- engine: valkey
provides:
redis: 6379
diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json b/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json
new file mode 100644
index 0000000..0fc0e91
--- /dev/null
+++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_CUTOVER_UNHONOURABLE","path":"/applications/0/processes/0"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml
index 3848c85..2d55251 100644
--- a/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml
+++ b/spec/v1/examples/refusals/cutover-rolling-over-rwo.project.yml
@@ -17,7 +17,6 @@ apiVersion: intent.jorisjonkers.dev/v1
kind: Project
schemaVersion: 1.0.0
-expect: E_CUTOVER_UNHONOURABLE
project: refusals
owner: joris
@@ -31,7 +30,6 @@ applications:
lifecycle: application
image: rolling-over-rwo-store
runtime: static
- engine: valkey
provides:
redis: 6379
diff --git a/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json b/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json
new file mode 100644
index 0000000..516c335
--- /dev/null
+++ b/spec/v1/examples/refusals/duplicate-route-match.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_DUPLICATE_ROUTE_MATCH","path":"/applications/0/exposure/0/routes/1"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/duplicate-route-match.project.yml b/spec/v1/examples/refusals/duplicate-route-match.project.yml
new file mode 100644
index 0000000..28b65b5
--- /dev/null
+++ b/spec/v1/examples/refusals/duplicate-route-match.project.yml
@@ -0,0 +1,37 @@
+# REFUSED: E_DUPLICATE_ROUTE_MATCH
+#
+# Route precedence is derived: exact before prefix, longer prefix before
+# shorter. Two routes sharing a path and a match cannot be ordered by that rule,
+# so the pair is refused rather than resolved by document order
+# (spec/v1/10-project-intent.md #what-is-checked).
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: duplicate-route-match
+ exposure:
+ - name: public
+ host: duplicate-route-match.jorisjonkers.dev
+ audience: anonymous
+ contentPolicy: strict
+ routes:
+ - { path: /api, match: prefix, process: duplicate-route-match-api, surface: http }
+ - { path: /api, match: prefix, process: duplicate-route-match-api, surface: http }
+ processes:
+ - name: duplicate-route-match-api
+ lifecycle: application
+ image: duplicate-route-match-api
+ runtime: node
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: rolling
diff --git a/spec/v1/examples/refusals/durability-without-engine.diagnostics.json b/spec/v1/examples/refusals/durability-without-engine.diagnostics.json
new file mode 100644
index 0000000..6b7de30
--- /dev/null
+++ b/spec/v1/examples/refusals/durability-without-engine.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_DURABILITY_WITHOUT_ENGINE","path":"/applications/0/processes/0/volumes/0"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/durability-without-engine.project.yml b/spec/v1/examples/refusals/durability-without-engine.project.yml
new file mode 100644
index 0000000..178dd60
--- /dev/null
+++ b/spec/v1/examples/refusals/durability-without-engine.project.yml
@@ -0,0 +1,34 @@
+# REFUSED: E_DURABILITY_WITHOUT_ENGINE
+#
+# The volume asks for a backup, and the backup method comes from the Process's
+# `engine`, which is absent. The platform would have to guess how to take the
+# backup, and a guessed backup is the kind nobody finds out about until a
+# restore (spec/v1/10-project-intent.md #process).
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: durability-without-engine
+ processes:
+ - name: durability-without-engine-store
+ lifecycle: application
+ image: durability-without-engine-store
+ runtime: static
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: recreate
+ volumes:
+ - claim: durability-without-engine-data
+ mountAt: /data
+ size: 1Gi
+ durability: irreplaceable
diff --git a/spec/v1/examples/refusals/engine-without-durability.diagnostics.json b/spec/v1/examples/refusals/engine-without-durability.diagnostics.json
new file mode 100644
index 0000000..0b5af71
--- /dev/null
+++ b/spec/v1/examples/refusals/engine-without-durability.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_ENGINE_WITHOUT_DURABILITY","path":"/applications/0/processes/0"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/engine-without-durability.project.yml b/spec/v1/examples/refusals/engine-without-durability.project.yml
new file mode 100644
index 0000000..299b477
--- /dev/null
+++ b/spec/v1/examples/refusals/engine-without-durability.project.yml
@@ -0,0 +1,38 @@
+# REFUSED: E_ENGINE_WITHOUT_DURABILITY
+#
+# `engine` names what the process is where the platform has to treat it
+# specially, and what it keys the backup method off. This Process declares one
+# over a volume whose durability derives no backup, so the engine names a method
+# nothing asks for (spec/v1/10-project-intent.md #process).
+#
+# Its counterpart is durability-without-engine.project.yml: the same pair with
+# the other half missing.
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: engine-without-durability
+ processes:
+ - name: engine-without-durability-store
+ lifecycle: application
+ image: engine-without-durability-store
+ runtime: static
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: recreate
+ engine: valkey
+ volumes:
+ - claim: engine-without-durability-data
+ mountAt: /data
+ size: 1Gi
+ durability: reconstructible
diff --git a/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json b/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json
new file mode 100644
index 0000000..09c0bd3
--- /dev/null
+++ b/spec/v1/examples/refusals/env-cannot-reload.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_ENV_CANNOT_RELOAD","path":"/applications/0/processes/0/secrets/0/rotation"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/env-cannot-reload.project.yml b/spec/v1/examples/refusals/env-cannot-reload.project.yml
new file mode 100644
index 0000000..de7e392
--- /dev/null
+++ b/spec/v1/examples/refusals/env-cannot-reload.project.yml
@@ -0,0 +1,34 @@
+# REFUSED: E_ENV_CANNOT_RELOAD
+#
+# An environment variable is read once, when the process starts. A grant that
+# delivers one and tolerates a reload rather than a restart promises a rotation
+# the process cannot see (spec/v1/10-project-intent.md #zero-downtime-rotation).
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: env-cannot-reload
+ processes:
+ - name: env-cannot-reload-api
+ lifecycle: application
+ image: env-cannot-reload-api
+ runtime: node
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: rolling
+ secrets:
+ - path: secret/data/refusals/env-cannot-reload
+ keys: [password]
+ access: read
+ delivery: env
+ rotation: { tolerates: reload }
diff --git a/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json b/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json
new file mode 100644
index 0000000..848419f
--- /dev/null
+++ b/spec/v1/examples/refusals/illegal-delivery-for-access.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_ILLEGAL_DELIVERY_FOR_ACCESS","path":"/applications/0/processes/0/secrets/0"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml b/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml
new file mode 100644
index 0000000..4a089ef
--- /dev/null
+++ b/spec/v1/examples/refusals/illegal-delivery-for-access.project.yml
@@ -0,0 +1,37 @@
+# REFUSED: E_ILLEGAL_DELIVERY_FOR_ACCESS
+#
+# `custody` creates and deletes secrets under a prefix at runtime, so there is
+# nothing to project when the render runs. Asking for it as a file asks the
+# renderer to sync paths that do not exist yet
+# (spec/v1/10-project-intent.md #which-tier-may-use-which-delivery).
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: illegal-delivery-for-access
+ processes:
+ - name: illegal-delivery-for-access-api
+ lifecycle: application
+ image: illegal-delivery-for-access-api
+ runtime: node
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: rolling
+ secrets:
+ - path: secret/data/refusals/custody
+ keys: [token]
+ access: custody
+ delivery: file
+ mountAt: /run/secrets/token
+ fileMode: "0400"
+ rotation: { tolerates: restart }
diff --git a/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json b/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json
new file mode 100644
index 0000000..a51e2de
--- /dev/null
+++ b/spec/v1/examples/refusals/non-kv-delivery.diagnostics.json
@@ -0,0 +1 @@
+[{"code":"E_NON_KV_DELIVERY","path":"/applications/0/processes/0/secrets/0"}]
\ No newline at end of file
diff --git a/spec/v1/examples/refusals/non-kv-delivery.project.yml b/spec/v1/examples/refusals/non-kv-delivery.project.yml
new file mode 100644
index 0000000..a7ba326
--- /dev/null
+++ b/spec/v1/examples/refusals/non-kv-delivery.project.yml
@@ -0,0 +1,35 @@
+# REFUSED: E_NON_KV_DELIVERY
+#
+# A transit key is used, never read: the engine signs and rotates on the
+# caller's behalf, and there is no value to write into a variable or a file. Its
+# only legal delivery is `self`
+# (spec/v1/10-project-intent.md #delivery).
+
+apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: non-kv-delivery
+ processes:
+ - name: non-kv-delivery-api
+ lifecycle: application
+ image: non-kv-delivery-api
+ runtime: node
+ provides:
+ http: 8080
+ placement:
+ memory: 128Mi
+ cpu: 25m
+ probes:
+ readiness: { path: /healthz/ready, port: 8080 }
+ liveness: { path: /healthz/live, port: 8080 }
+ startupBudget: 20s
+ cutover: rolling
+ secrets:
+ - engine: transit
+ key: non-kv-delivery-jwt
+ operations: [sign]
+ delivery: env
+ rotation: { tolerates: restart }
diff --git a/spec/v1/schemas/project-intent.schema.json b/spec/v1/schemas/project-intent.schema.json
index 06dce10..238ca4c 100644
--- a/spec/v1/schemas/project-intent.schema.json
+++ b/spec/v1/schemas/project-intent.schema.json
@@ -102,8 +102,7 @@
}
},
"required": [
- "alertClass",
- "scrape"
+ "alertClass"
],
"additionalProperties": false
},
diff --git a/src/domain/project-intent/model.ts b/src/domain/project-intent/model.ts
index 6284f40..e2e4396 100644
--- a/src/domain/project-intent/model.ts
+++ b/src/domain/project-intent/model.ts
@@ -37,7 +37,8 @@ export interface SurfaceRef {
export interface Observability {
readonly alertClass: AlertClass;
- readonly scrape: SurfaceRef & { readonly path: string };
+ /** Whole or absent in the model: a document with a class and no signal is refused. */
+ readonly scrape?: SurfaceRef & { readonly path: string };
}
export interface Exposure {
diff --git a/src/wire/project-intent/map.ts b/src/wire/project-intent/map.ts
index 487cb41..f7de953 100644
--- a/src/wire/project-intent/map.ts
+++ b/src/wire/project-intent/map.ts
@@ -6,6 +6,7 @@ import type {
Process,
Project,
} from "../../domain/project-intent/model.ts";
+import { ruleDiagnostics } from "./rules.ts";
import { projectIntent, type ProjectIntentDocument } from "./schema.ts";
type WireApplication = ProjectIntentDocument["applications"][number];
@@ -83,6 +84,8 @@ export function validateProjectIntent(
hint: "Correct the field against spec/v1/10-project-intent.md.",
})),
};
+ const refusals = ruleDiagnostics(parsed.data);
+ if (refusals.length > 0) return { ok: false, diagnostics: refusals };
const { project, owner, applications } = parsed.data;
return {
ok: true,
diff --git a/src/wire/project-intent/rules.ts b/src/wire/project-intent/rules.ts
new file mode 100644
index 0000000..64135c5
--- /dev/null
+++ b/src/wire/project-intent/rules.ts
@@ -0,0 +1,134 @@
+// The rules one authored document answers on its own: the counterpart of the
+// Complete OCL invariants the model-driven implementation evaluates, and the
+// same refusals at the same places. Each carries the code the specification
+// gives it and the JSON Pointer of the object it refuses
+// (docs/architecture.md#the-parity-contract), which is the object an invariant
+// takes as its context. A rule that needs more than one document belongs to
+// composition, not here.
+import type { Diagnostic } from "../../domain/diagnostic.ts";
+import type { ProjectIntentDocument } from "./schema.ts";
+
+type Application = ProjectIntentDocument["applications"][number];
+type Process = Application["processes"][number];
+type Grant = NonNullable[number];
+
+interface Refusal {
+ readonly code: string;
+ readonly path: string;
+ readonly message: string;
+ readonly hint: string;
+}
+
+/** The durability classes the platform derives a backup for, which need an engine. */
+const BACKED_UP = new Set(["recoverable", "irreplaceable"]);
+
+/** The access and delivery pairs chapter 10's matrix refuses. */
+const ILLEGAL_DELIVERY = new Set([
+ "self-renew/env",
+ "custody/env",
+ "custody/file",
+]);
+
+function grantRefusals(grant: Grant, at: string): Refusal[] {
+ const refusals: Refusal[] = [];
+ if (grant.delivery === "env" && grant.rotation?.tolerates === "reload")
+ refusals.push({
+ code: "E_ENV_CANNOT_RELOAD",
+ path: `${at}/rotation`,
+ message: "an environment variable cannot be reloaded without a restart",
+ hint: "Deliver the secret as a file or through the application itself, or tolerate a restart.",
+ });
+ if (
+ "access" in grant &&
+ ILLEGAL_DELIVERY.has(`${grant.access}/${grant.delivery}`)
+ )
+ refusals.push({
+ code: "E_ILLEGAL_DELIVERY_FOR_ACCESS",
+ path: at,
+ message: `access ${grant.access} cannot be delivered as ${grant.delivery}`,
+ hint: "See the access by delivery matrix in spec/v1/10-project-intent.md#which-tier-may-use-which-delivery.",
+ });
+ if ("engine" in grant && grant.delivery !== "self")
+ refusals.push({
+ code: "E_NON_KV_DELIVERY",
+ path: at,
+ message: `a ${grant.engine} grant is delivered by the application itself`,
+ hint: "Write `delivery: self`: the value is fetched, never projected.",
+ });
+ return refusals;
+}
+
+function processRefusals(process: Process, at: string): Refusal[] {
+ const refusals: Refusal[] = [];
+ const volumes = process.volumes ?? [];
+ if (process.cutover === "rolling" && volumes.length > 0)
+ refusals.push({
+ code: "E_CUTOVER_UNHONOURABLE",
+ path: at,
+ message: "a volume cannot attach to the surge a rolling cutover needs",
+ hint: "Declare `cutover: recreate`, which is what this storage can honour.",
+ });
+ const backedUp = volumes.filter((volume) => BACKED_UP.has(volume.durability));
+ if (process.engine !== undefined && backedUp.length === 0)
+ refusals.push({
+ code: "E_ENGINE_WITHOUT_DURABILITY",
+ path: at,
+ message: `engine ${process.engine} names a backup method nothing here derives`,
+ hint: "Declare a volume whose durability derives a backup, or drop the engine.",
+ });
+ if (process.engine === undefined)
+ for (const [index, volume] of volumes.entries())
+ if (BACKED_UP.has(volume.durability))
+ refusals.push({
+ code: "E_DURABILITY_WITHOUT_ENGINE",
+ path: `${at}/volumes/${index}`,
+ message: `durability ${volume.durability} derives a backup, and the backup method comes from the engine`,
+ hint: "Declare the Process's `engine`, or a durability that derives no backup.",
+ });
+ for (const [index, grant] of (process.secrets ?? []).entries())
+ refusals.push(...grantRefusals(grant, `${at}/secrets/${index}`));
+ return refusals;
+}
+
+function applicationRefusals(application: Application, at: string): Refusal[] {
+ const refusals: Refusal[] = [];
+ if (
+ application.observability !== undefined &&
+ application.observability.scrape === undefined
+ )
+ refusals.push({
+ code: "E_ALERT_CLASS_WITHOUT_SIGNAL",
+ path: `${at}/observability`,
+ message:
+ "a class states how loudly to wake someone, and no signal says what about",
+ hint: "Declare the `scrape` that carries the signal, or omit the block.",
+ });
+ for (const [index, exposure] of (application.exposure ?? []).entries()) {
+ const seen = new Set();
+ for (const [route, { path, match }] of exposure.routes.entries()) {
+ const pair = `${match} ${path}`;
+ if (seen.has(pair))
+ refusals.push({
+ code: "E_DUPLICATE_ROUTE_MATCH",
+ path: `${at}/exposure/${index}/routes/${route}`,
+ message: `two routes share the ${match} match of ${path}`,
+ hint: "Two routes that cannot be ordered are one route: delete or narrow one.",
+ });
+ seen.add(pair);
+ }
+ }
+ for (const [index, grant] of (application.secrets ?? []).entries())
+ refusals.push(...grantRefusals(grant, `${at}/secrets/${index}`));
+ for (const [index, process] of application.processes.entries())
+ refusals.push(...processRefusals(process, `${at}/processes/${index}`));
+ return refusals;
+}
+
+/** Every rule this document breaks, in the order the document reads. */
+export function ruleDiagnostics(
+ document: ProjectIntentDocument,
+): readonly Diagnostic[] {
+ return document.applications.flatMap((application, index) =>
+ applicationRefusals(application, `/applications/${index}`),
+ );
+}
diff --git a/src/wire/project-intent/schema.ts b/src/wire/project-intent/schema.ts
index d773c5a..24f939c 100644
--- a/src/wire/project-intent/schema.ts
+++ b/src/wire/project-intent/schema.ts
@@ -200,8 +200,10 @@ const scrape = z
.strictObject({ process: text, surface: text, path: text })
.meta({ id: "Scrape" });
+// `scrape` is optional in the shape, not in the model: a block carrying a class
+// and no signal is refused by a rule with its own code, not by the schema.
const observability = z
- .strictObject({ alertClass: alertClass, scrape })
+ .strictObject({ alertClass: alertClass, scrape: scrape.exactOptional() })
.meta({ id: "Observability" });
const application = z
diff --git a/test/model/project-intent.test.ts b/test/model/project-intent.test.ts
index 585670a..1879728 100644
--- a/test/model/project-intent.test.ts
+++ b/test/model/project-intent.test.ts
@@ -314,6 +314,14 @@ describe("parseProjectIntent", () => {
]);
});
+ it("accepts a grant that declares no rotation", () => {
+ const text = withApplications(
+ ` - id: batch\n processes:\n${PROCESS} secrets:\n - path: secret/data/batch\n keys: [password]\n access: read\n delivery: env\n`,
+ );
+
+ expect(parseProjectIntent(text).ok).toBe(true);
+ });
+
it("gives every diagnostic a hint", () => {
const diagnostics = ["", `${HEADER}applications: []\n`].flatMap((text) => {
const result = parseProjectIntent(text);
diff --git a/test/model/refusals.test.ts b/test/model/refusals.test.ts
new file mode 100644
index 0000000..50c1c3a
--- /dev/null
+++ b/test/model/refusals.test.ts
@@ -0,0 +1,153 @@
+// REQ-024 (docs/requirements.md): a document that breaks a rule is refused with
+// the code and the path its committed diagnostics oracle names.
+import { readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { canonicalJson, parseProjectIntent } from "../../src/index.ts";
+
+const REFUSALS = join(
+ import.meta.dirname,
+ "..",
+ "..",
+ "spec",
+ "v1",
+ "examples",
+ "refusals",
+);
+
+const fixtures = readdirSync(REFUSALS)
+ .filter((name) => name.endsWith(".project.yml"))
+ .map((name) => name.replace(".project.yml", ""))
+ .sort();
+
+const read = (name: string): string =>
+ readFileSync(join(REFUSALS, name), "utf8");
+
+const oracle = (stem: string): string | undefined => {
+ const file = `${stem}.diagnostics.json`;
+ return readdirSync(REFUSALS).includes(file) ? read(file) : undefined;
+};
+
+const refused = fixtures.filter((stem) => oracle(stem) !== undefined);
+
+const DOCUMENT = `apiVersion: intent.jorisjonkers.dev/v1
+kind: Project
+schemaVersion: 1.0.0
+project: refusals
+owner: joris
+applications:
+ - id: batch
+GRANTS processes:
+ - name: worker
+ lifecycle: job
+ image: worker
+ runtime: none
+ placement: { memory: 64Mi, cpu: 10m }
+ cutover: recreate
+`;
+
+/** The document above with `secrets` on the Application, indented as the file reads. */
+const withApplicationGrant = (grant: string): string =>
+ DOCUMENT.replace("GRANTS", ` secrets:\n${grant}`);
+
+const refusalsOf = (text: string): { code: string; path: string }[] => {
+ const result = parseProjectIntent(text);
+ return result.ok
+ ? []
+ : result.diagnostics.map(({ code, path }) => ({ code, path }));
+};
+
+describe("the access by delivery matrix", () => {
+ it.each([
+ ["self-renew", "env"],
+ ["custody", "env"],
+ ["custody", "file"],
+ ])("refuses access %s delivered as %s", (access, delivery) => {
+ const grant = ` - path: secret/data/batch\n keys: [password]\n access: ${access}\n delivery: ${delivery}\n mountAt: /run/secrets/password\n rotation: { tolerates: restart }\n`;
+
+ expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([
+ {
+ code: "E_ILLEGAL_DELIVERY_FOR_ACCESS",
+ path: "/applications/0/secrets/0",
+ },
+ ]);
+ });
+
+ it.each([
+ ["read", "env"],
+ ["read", "file"],
+ ["self-renew", "self"],
+ ["self-roll", "file"],
+ ])("accepts access %s delivered as %s", (access, delivery) => {
+ const grant = ` - path: secret/data/batch\n keys: [password]\n access: ${access}\n delivery: ${delivery}\n mountAt: /run/secrets/password\n rotation: { tolerates: restart }\n`;
+
+ expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([]);
+ });
+
+ it("points at the Application's own grant when the grant is the Application's", () => {
+ const grant = ` - path: secret/data/batch\n keys: [password]\n access: read\n delivery: env\n rotation: { tolerates: reload }\n`;
+
+ expect(refusalsOf(withApplicationGrant(grant))).toStrictEqual([
+ {
+ code: "E_ENV_CANNOT_RELOAD",
+ path: "/applications/0/secrets/0/rotation",
+ },
+ ]);
+ });
+});
+
+describe("the refusal fixtures", () => {
+ it("are the ones this chapter carries, refused but for the accepted counterpart", () => {
+ expect(fixtures).toStrictEqual([
+ "alert-class-unknown",
+ "alert-class-without-signal",
+ "cutover-recreate-over-rwo",
+ "cutover-rolling-over-rwo",
+ "duplicate-route-match",
+ "durability-without-engine",
+ "engine-without-durability",
+ "env-cannot-reload",
+ "illegal-delivery-for-access",
+ "non-kv-delivery",
+ ]);
+ expect(refused).toHaveLength(8);
+ expect(
+ fixtures.length - refused.length,
+ "the accepted counterpart and the vocabulary case carry no oracle",
+ ).toBe(2);
+ });
+
+ it.each(refused)(
+ "%s is refused with the codes and paths its oracle names",
+ (stem) => {
+ const result = parseProjectIntent(read(`${stem}.project.yml`));
+ const pairs = result.ok
+ ? []
+ : result.diagnostics
+ .map(({ code, path }) => ({ code, path }))
+ .sort((a, b) =>
+ `${a.code}${a.path}` < `${b.code}${b.path}` ? -1 : 1,
+ );
+
+ expect(canonicalJson(pairs)).toBe(oracle(stem));
+ },
+ );
+
+ it("accepts the counterpart that declares the cutover its storage can honour", () => {
+ expect(
+ parseProjectIntent(read("cutover-recreate-over-rwo.project.yml")).ok,
+ ).toBe(true);
+ expect(oracle("cutover-recreate-over-rwo")).toBeUndefined();
+ });
+
+ it.each(refused)("%s says what it refused and how to fix it", (stem) => {
+ const result = parseProjectIntent(read(`${stem}.project.yml`));
+ const diagnostics = result.ok ? [] : result.diagnostics;
+
+ expect(diagnostics).not.toHaveLength(0);
+ for (const { message, hint } of diagnostics) {
+ expect(message.trim()).not.toBe("");
+ expect(hint.trim()).not.toBe("");
+ }
+ });
+});
diff --git a/test/simplification-contract.test.ts b/test/simplification-contract.test.ts
index c86fff1..6733e6a 100644
--- a/test/simplification-contract.test.ts
+++ b/test/simplification-contract.test.ts
@@ -1,27 +1,31 @@
-// The v1 simplification's fixture-level proof.
+// The v1 simplification's proof, over the parsed model.
//
-// The compiler does not exist yet, so the handoff asks for executable
-// fixture-level checks at the narrowest layer available, with any renderer
-// proof reported as a blocker. This file is that check, per decision:
+// The decisions this file holds the example estate to:
//
// 1. Observability: one optional `observability` block per Application, whole or
// absent. A declared class names a scrape surface that its own Process
// provides, and no project file carries monitoring policy.
-// 2. Cutover: `zeroDowntime` is gone, every Process declares `cutover`, and an
-// RWO Process must declare `recreate` (rolling over RWO is the
-// E_CUTOVER_UNHONOURABLE case; there is no renderer yet to run it in).
+// 2. Cutover: `zeroDowntime` is gone, every Process declares `cutover`, and a
+// Process with a volume declares `recreate`, because rolling over an RWO
+// volume is `E_CUTOVER_UNHONOURABLE`.
// 3. Overrides: no `overrides` key anywhere, and a `replicas` block always
// carries a count above one with a reason.
// 4. Hardening: no Process or sidecar authors hardening at all.
//
+// Every check reads the parsed model rather than the file's indentation: a test
+// that reads YAML by column proves the layout, not the language (#38).
// spec/v1 is normative; the ADRs justify; this file proves the example estate
-// against them at the fixture layer.
-import { readFileSync } from "node:fs";
+// against them.
+import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "vitest";
+import { parseProjectIntent } from "../src/index.ts";
+import type { Application, Process, Project } from "../src/index.ts";
const repo = join(import.meta.dirname, "..");
const examples = join(repo, "spec", "v1", "examples");
+const refusals = join(examples, "refusals");
+const platform = join(examples, "platform", "platform.intent.yml");
const read = (path: string): string => readFileSync(path, "utf8");
const projectFiles = [
@@ -31,212 +35,96 @@ const projectFiles = [
"minimal/notes.project.yml",
].map((file) => join(examples, file));
-interface Slice {
- readonly name: string;
- text: string;
-}
+const refusalFiles = readdirSync(refusals)
+ .filter((name) => name.endsWith(".project.yml"))
+ .map((name) => join(refusals, name));
/**
- * The processes of a project file as {name, text} slices. Indentation-keyed:
- * a process starts at ` - name:` (six spaces) and runs to the next one.
- * Exposure `routes` and negative-fixture files do not reach six spaces with
- * `- name:`, but an Application's `exposure` entry is ` - name: public`, which
- * collides, so a slice that would open inside an `exposure:` block is skipped.
+ * The parsed project of a file, or nothing when the file is a fixture the model
+ * refuses: a refusal fixture's own oracle is what proves its refusal, and what
+ * is checked here is what the accepted half of the estate declares.
*/
-const PROCESS_RE = /^ {6}- name: (\S+)/;
-
-function processesOf(file: string): Slice[] {
- const out: Slice[] = [];
- let current: Slice | null = null;
- let inExposure = false;
- for (const line of read(file).split("\n")) {
- if (/^ {4}[a-zA-Z]/.test(line)) inExposure = /^ {4}exposure:/.test(line);
- const m = PROCESS_RE.exec(line);
- // An Application-level `exposure` entry sits at the same indent as a Process
- // under `processes:`; only slices opened outside the exposure block are
- // processes.
- if (m && !inExposure) {
- if (current) out.push(current);
- current = { name: m[1] ?? "", text: "" };
- } else if (current) {
- current.text += `${line}\n`;
- }
- }
- if (current) out.push(current);
- return out;
+function projectOf(file: string): Project | undefined {
+ const result = parseProjectIntent(read(file));
+ if (!result.ok && projectFiles.includes(file))
+ throw new Error(`${file}: ${JSON.stringify(result.diagnostics)}`);
+ return result.ok ? result.value.project : undefined;
}
+const applicationsOf = (file: string): readonly Application[] =>
+ projectOf(file)?.applications ?? [];
+
+const processesOf = (file: string): readonly Process[] =>
+ applicationsOf(file).flatMap((application) => application.processes);
+
+/** A file's declared lines, with whole-line and trailing comments removed. */
+const declarationsOf = (file: string): string =>
+ read(file)
+ .split("\n")
+ .map((line) => line.replace(/(^|\s)#.*$/, ""))
+ .join("\n");
+
test("every worked Process declares cutover, and zeroDowntime is gone", () => {
for (const file of projectFiles) {
const processes = processesOf(file);
+
expect(processes.length, `${file}: no processes parsed`).toBeGreaterThan(0);
- for (const w of processes) {
- const rel = `${file.split("/").pop() ?? file}#${w.name}`;
- expect(w.text, `${rel}: no cutover declaration`).toMatch(
- /^\s+cutover: (rolling|recreate)$/m,
- );
- expect(w.text, `${rel}: zeroDowntime present`).not.toMatch(
- /zeroDowntime/,
+ for (const process of processes)
+ expect(["rolling", "recreate"], `${process.name}: cutover`).toContain(
+ process.cutover,
);
- }
+ expect(declarationsOf(file), `${file}: zeroDowntime present`).not.toMatch(
+ /zeroDowntime/,
+ );
}
});
-test("RWO Processes declare recreate; volume-free Processes declare rolling", () => {
- for (const file of projectFiles) {
- for (const w of processesOf(file)) {
- const hasVolume = /^\s+volumes:$/m.test(w.text);
- const cutover = /^\s+cutover: (rolling|recreate)$/m.exec(w.text)?.[1];
- expect(cutover, `${w.name}: cutover missing`).toBeDefined();
- if (hasVolume)
- expect(
- cutover,
- `${w.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`,
- ).toBe("recreate");
- }
- }
+test("a Process with a volume declares recreate, because RWO cannot surge", () => {
+ for (const file of [...projectFiles, ...refusalFiles])
+ for (const process of processesOf(file).filter(
+ (candidate) => candidate.volumes.length > 0,
+ ))
+ expect(
+ process.cutover,
+ `${process.name}: an RWO volume cannot surge, so rolling is E_CUTOVER_UNHONOURABLE`,
+ ).toBe("recreate");
});
test("no project file carries overrides, and replicas is the sole capacity exception", () => {
for (const file of projectFiles) {
- const text = read(file);
+ const text = declarationsOf(file);
+
expect(text, `${file}: overrides key present`).not.toMatch(/^overrides:/m);
expect(text, `${file}: override entry syntax present`).not.toMatch(
/derivation:/,
);
- for (const w of processesOf(file)) {
- const replicas =
- /^\s+replicas:\s*$\n\s+count: (\d+)(?:\n\s+reason: (.+))?/m.exec(
- w.text,
- );
- if (!replicas) continue;
- expect(
- Number(replicas[1]),
- `${w.name}: count must exceed one`,
- ).toBeGreaterThan(1);
- expect(
- replicas[2]?.trim() ?? "",
- `${w.name}: reason required with replicas`,
- ).not.toBe("");
+ for (const { name, replicas } of processesOf(file)) {
+ if (replicas === undefined) continue;
+ expect(replicas.count, `${name}: count must exceed one`).toBeGreaterThan(
+ 1,
+ );
+ expect(replicas.reason, `${name}: reason required`).not.toBe("");
}
}
});
-/** A file's declared lines, with whole-line and trailing comments removed. */
-const declarationsOf = (file: string): string =>
- read(file)
- .split("\n")
- .map((line) => line.replace(/(^|\s)#.*$/, ""))
- .join("\n");
-
-const refusals = join(examples, "refusals");
-const platform = join(examples, "platform", "platform.intent.yml");
-
-interface Application {
- readonly id: string;
- text: string;
-}
-
-/**
- * The Applications of a project file as {id, text} slices. An Application starts at
- * ` - id:` (two spaces) and runs to the next one, so an Application's
- * `observability` block and its Processes are read together.
- */
-function applicationsOf(file: string): Application[] {
- const out: Application[] = [];
- let current: Application | null = null;
- for (const line of read(file).split("\n")) {
- const m = /^ {2}- id: (\S+)/.exec(line);
- if (m) {
- if (current) out.push(current);
- current = { id: m[1] ?? "", text: "" };
- } else if (current) {
- current.text += `${line}\n`;
- }
- }
- if (current) out.push(current);
- return out;
-}
-
-interface Observability {
- readonly alertClass: string | null;
- readonly hasScrape: boolean;
- readonly process: string | null;
- readonly surface: string | null;
- readonly path: string | null;
-}
-
-/** The `observability` block of an Application slice, or null when it declares none. */
-function observabilityOf(applicationText: string): Observability | null {
- const lines = applicationText.split("\n");
- const start = lines.findIndex((line) => /^ {4}observability:\s*$/.test(line));
- if (start === -1) return null;
- const body: string[] = [];
- for (const line of lines.slice(start + 1)) {
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
- if (!/^ {6}/.test(line)) break;
- body.push(line);
- }
- const value = (key: string): string | null => {
- const line = body.find((l) => l.trim().startsWith(`${key}:`));
- return line === undefined
- ? null
- : line.split(":").slice(1).join(":").replace(/#.*$/, "").trim();
- };
- return {
- alertClass: value("alertClass"),
- hasScrape: body.some((line) => /^ {6}scrape:\s*$/.test(line)),
- process: value("process"),
- surface: value("surface"),
- path: value("path"),
- };
-}
-
-/** The surface names a Process slice declares under `provides`. */
-function surfacesOf(processText: string): string[] {
- const lines = processText.split("\n");
- const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line));
- if (start === -1) return [];
- const out: string[] = [];
- for (const line of lines.slice(start + 1)) {
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
- const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line);
- if (!m) break;
- out.push(m[1] ?? "");
- }
- return out;
-}
-
-const ALERT_CLASSES = ["business-hours", "urgent", "page"];
-
test("the observability block is whole or absent, and never partial", () => {
let declared = 0;
let omitted = 0;
- for (const file of projectFiles) {
- for (const s of applicationsOf(file)) {
- const o = observabilityOf(s.text);
- if (o === null) {
- expect(
- s.text,
- `${s.id}: alertClass outside an observability block`,
- ).not.toMatch(/^\s+alertClass:/m);
+
+ for (const file of projectFiles)
+ for (const application of applicationsOf(file)) {
+ if (application.observability === undefined) {
omitted += 1;
continue;
}
expect(
- o.alertClass,
- `${s.id}: observability block with no alertClass`,
- ).toBeTruthy();
- expect(
- o.hasScrape,
- `${s.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`,
- ).toBe(true);
- expect(ALERT_CLASSES, `${s.id}: not a member of AlertClass`).toContain(
- o.alertClass,
- );
+ application.observability.scrape,
+ `${application.id}: a class with no scrape is E_ALERT_CLASS_WITHOUT_SIGNAL`,
+ ).toBeDefined();
declared += 1;
}
- }
+
expect(declared, "no Application declares observability").toBeGreaterThan(0);
expect(
omitted,
@@ -253,29 +141,32 @@ test("`none` is gone: an omitted block is the opt-out", () => {
});
test("a scrape names a surface its own Process provides, never a port", () => {
- for (const file of projectFiles) {
- for (const s of applicationsOf(file)) {
- const o = observabilityOf(s.text);
- if (o === null) continue;
- expect(o.process, `${s.id}: scrape names no process`).toBeTruthy();
- expect(o.surface, `${s.id}: scrape names no surface`).toBeTruthy();
- expect(o.path, `${s.id}: scrape names no path`).toBeTruthy();
- const w = processesOf(file).find((x) => x.name === o.process);
+ for (const file of projectFiles)
+ for (const application of applicationsOf(file)) {
+ const scrape = application.observability?.scrape;
+ if (scrape === undefined) continue;
+ const process = application.processes.find(
+ (candidate) => candidate.name === scrape.process,
+ );
+
expect(
- w,
- `${s.id}: scrape names a Process that does not exist`,
+ process,
+ `${application.id}: scrape names a Process that does not exist`,
).toBeDefined();
expect(
- surfacesOf(w?.text ?? ""),
- `${s.id}: its Process provides no surface of that name`,
- ).toContain(o.surface);
+ [...(process?.provides.keys() ?? [])],
+ `${application.id}: its Process provides no surface of that name`,
+ ).toContain(scrape.surface);
+ expect(scrape.path, `${application.id}: scrape names no path`).not.toBe(
+ "",
+ );
}
- }
});
test("no Process restates a scrape port, and no project carries alerting policy", () => {
for (const file of [...projectFiles, platform]) {
- const text = read(file);
+ const text = declarationsOf(file);
+
expect(
text,
`${file}: a scrape restates a port that provides already declares`,
@@ -291,6 +182,7 @@ test("no Process restates a scrape port, and no project carries alerting policy"
test("the monitor cadence is one estate-wide value in the Platform document", () => {
const text = read(platform);
+
expect(text, "platform intent declares no monitor cadence").toMatch(
/^monitors:$/m,
);
@@ -298,64 +190,13 @@ test("the monitor cadence is one estate-wide value in the Platform document", ()
expect(text, "no monitor timeout").toMatch(/^\s+timeout: \S+$/m);
for (const file of projectFiles)
expect(
- read(file),
+ declarationsOf(file),
`${file}: a project file restates the cadence`,
).not.toMatch(/interval:|scrapeTimeout:/);
});
-test("a class with no signal is refused, and an unknown class is not a member", () => {
- const noSignal = join(refusals, "alert-class-without-signal.project.yml");
- expect(read(noSignal)).toMatch(/^expect: E_ALERT_CLASS_WITHOUT_SIGNAL$/m);
- const a = observabilityOf(applicationsOf(noSignal)[0]?.text ?? "");
- expect(a?.alertClass, "the fixture must declare a class").toBeTruthy();
- expect(
- a?.hasScrape,
- "the fixture must declare no scrape: that is the refusal",
- ).toBe(false);
- expect(
- ALERT_CLASSES,
- "the class must be a valid member, so the missing signal is the only defect",
- ).toContain(a?.alertClass);
-
- const unknown = join(refusals, "alert-class-unknown.project.yml");
- expect(read(unknown)).toMatch(/^expect: schema\b/m);
- const b = observabilityOf(applicationsOf(unknown)[0]?.text ?? "");
- expect(b?.hasScrape, "the fixture must publish a signal").toBe(true);
- expect(
- ALERT_CLASSES,
- "a valid member would make this something other than the unknown-class case",
- ).not.toContain(b?.alertClass);
-});
-
-test("rolling over RWO is refused and recreate over RWO is accepted", () => {
- const refused = join(refusals, "cutover-rolling-over-rwo.project.yml");
- const accepted = join(refusals, "cutover-recreate-over-rwo.project.yml");
- expect(read(refused)).toMatch(/^expect: E_CUTOVER_UNHONOURABLE$/m);
- expect(read(accepted)).toMatch(/^expect: accepted$/m);
-
- const only = (file: string): Slice => {
- const processes = processesOf(file);
- expect(
- processes,
- `${file}: a refusal fixture carries one Process`,
- ).toHaveLength(1);
- const [process] = processes;
- if (process === undefined) throw new Error(`${file}: no Process`);
- return process;
- };
- const bad = only(refused);
- const good = only(accepted);
- for (const w of [bad, good])
- expect(w.text, `${w.name}: the pair must both hold an RWO volume`).toMatch(
- /^\s+volumes:$/m,
- );
- expect(bad.text).toMatch(/^\s+cutover: rolling$/m);
- expect(good.text).toMatch(/^\s+cutover: recreate$/m);
-
- // The refusal is the model's, not Kubernetes'. No Kubernetes rollout token
- // may appear as a declared value in either file: the adapter derives the
- // strategy. Comments may name the tokens to say who owns them.
- for (const file of [refused, accepted])
+test("no Kubernetes rollout token reaches Project Intent", () => {
+ for (const file of [...projectFiles, ...refusalFiles])
expect(
declarationsOf(file),
`${file}: a Kubernetes rollout token leaked into Project Intent`,
@@ -363,17 +204,9 @@ test("rolling over RWO is refused and recreate over RWO is accepted", () => {
});
test("no Process or sidecar authors hardening", () => {
- const inputs = [
- ...projectFiles,
- ...[
- "alert-class-without-signal.project.yml",
- "alert-class-unknown.project.yml",
- "cutover-rolling-over-rwo.project.yml",
- "cutover-recreate-over-rwo.project.yml",
- ].map((file) => join(refusals, file)),
- ];
- for (const file of inputs) {
+ for (const file of [...projectFiles, ...refusalFiles]) {
const text = declarationsOf(file);
+
expect(text, `${file}: a Process authors hardening`).not.toMatch(
/^\s+hardening:/m,
);
@@ -384,25 +217,17 @@ test("no Process or sidecar authors hardening", () => {
/^\s+- allow:/m,
);
}
+
// The posture itself is the platform's, and stays exactly one value.
expect(read(platform)).toMatch(/^hardening: restricted$/m);
});
test("no provides port below 1024, because there is no capability to declare", () => {
- for (const file of projectFiles) {
- for (const w of processesOf(file)) {
- const lines = w.text.split("\n");
- const start = lines.findIndex((line) => /^ {8}provides:\s*$/.test(line));
- if (start === -1) continue;
- for (const line of lines.slice(start + 1)) {
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
- const m = /^ {10}([a-zA-Z0-9-]+):\s*(\d+)/.exec(line);
- if (!m) break;
+ for (const file of projectFiles)
+ for (const process of processesOf(file))
+ for (const [surface, port] of process.provides)
expect(
- Number(m[2]),
- `${w.name}: ${m[1] ?? ""} on ${m[2] ?? ""} is E_PRIVILEGED_PORT_UNDER_NONROOT`,
+ port,
+ `${process.name}: ${surface} on ${port} is E_PRIVILEGED_PORT_UNDER_NONROOT`,
).toBeGreaterThanOrEqual(1024);
- }
- }
- }
});
diff --git a/vitest.config.ts b/vitest.config.ts
index f9aab16..9c54bdd 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -23,15 +23,16 @@ export default defineConfig({
// A ratchet, per docs/adr/architecture/0101-coverage-is-a-ratchet.md:
// set from what the suite reaches, and only ever raised.
//
- // Measured 2026-09-15, with the Project Intent metamodel under src/ at
- // 100%: statements 98.6%, branches 93.8%, functions 100%, lines 98.51%. What is left uncovered is the one-line command guard at the
+ // Measured 2026-09-15, with the Project Intent metamodel and its rules
+ // under src/ at 100%: statements 98.67%, branches 94.3%, functions 100%,
+ // lines 98.58%. What is left uncovered is the one-line command guard at the
// bottom of each gate and the branches for a tool that cannot be started
// at all.
thresholds: {
- statements: 98.6,
- branches: 93.8,
+ statements: 98.67,
+ branches: 94.3,
functions: 100,
- lines: 98.51,
+ lines: 98.58,
},
},
},
diff --git a/vitest.mutation.config.ts b/vitest.mutation.config.ts
index d5d2c15..af2b0b9 100644
--- a/vitest.mutation.config.ts
+++ b/vitest.mutation.config.ts
@@ -5,7 +5,11 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
- include: ["test/model/**/*.test.ts", "test/canonical-json.test.ts"],
+ include: [
+ "test/model/**/*.test.ts",
+ "test/canonical-json.test.ts",
+ "test/simplification-contract.test.ts",
+ ],
setupFiles: ["./test/setup.ts"],
restoreMocks: true,
unstubEnvs: true,