backend-skeleton 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ // D-business-rules (R1/R2): the I/O boundary for business rules. Paths, reading the hand-authored
2
+ // YAML source, and reading/writing the compiled JSON artifact with schema validation on BOTH
3
+ // sides -- the exact shape lib/cross-feature-collisions.mjs's
4
+ // loadCrossFeatureResolution/saveCrossFeatureResolution and contracts/completeness.mjs's
5
+ // loadResolution/saveResolution already hold ("refusing to write an invalid ...").
6
+ //
7
+ // rules/compile.mjs stays pure and filesystem-free; everything that touches disk lives here.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { parse as parseYaml } from 'yaml';
11
+ import { specPath } from '../lib/paths.mjs';
12
+ import { readJsonIfExists, writeFileAtomic } from '../lib/fsutil.mjs';
13
+ import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
14
+ import { RULES_SOURCE_SCHEMA } from './compile.mjs';
15
+
16
+ // The hand-authored file. Sits directly in the feature's spec dir alongside dependencies.json --
17
+ // both are human-authored declarations about this feature's own fields, so they are siblings.
18
+ export function rulesSourcePath(root, featureId) {
19
+ return specPath(root, featureId, 'rules.yaml');
20
+ }
21
+
22
+ // The compiled artifact. In its own `rules/` subdirectory, matching how `handles/migration.sql`
23
+ // already nests a generated artifact under the feature's spec dir.
24
+ export function rulesArtifactPath(root, featureId) {
25
+ return specPath(root, featureId, 'rules', `${featureId}.rules.json`);
26
+ }
27
+
28
+ /**
29
+ * Reads and parses specs/<featureId>/rules.yaml. Returns null when the file does not exist at all
30
+ * -- a legal, common state meaning "this feature declares no hand-authored rules", which still
31
+ * compiles to a real artifact from contract-projected rules alone (R4).
32
+ *
33
+ * Throws on malformed YAML or a wrong/missing `schema` key. Deliberately NOT schema-validated
34
+ * beyond that: rules/compile.mjs resolves every rule against the contract itself and produces
35
+ * strictly better refusals (naming real known fields, real declared types, real enum states) than
36
+ * a structural schema could -- see schemas/feature-rules.schema.json's own description.
37
+ */
38
+ export function loadRulesSource(root, featureId) {
39
+ const file = rulesSourcePath(root, featureId);
40
+ if (!fs.existsSync(file)) return null;
41
+ let parsed;
42
+ try {
43
+ parsed = parseYaml(fs.readFileSync(file, 'utf8'));
44
+ } catch (err) {
45
+ throw new Error(`${file}: not valid YAML -- ${err.message}`);
46
+ }
47
+ if (parsed === null || parsed === undefined) return { schema: RULES_SOURCE_SCHEMA, rules: [] };
48
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
49
+ throw new Error(`${file}: must be a YAML mapping with a "rules:" list, got ${Array.isArray(parsed) ? 'a list' : typeof parsed}`);
50
+ }
51
+ if (parsed.schema !== RULES_SOURCE_SCHEMA) {
52
+ throw new Error(`${file}: expected \`schema: ${RULES_SOURCE_SCHEMA}\` at the top of the file, got ${parsed.schema === undefined ? '(nothing)' : JSON.stringify(parsed.schema)}`);
53
+ }
54
+ if (parsed.rules !== undefined && !Array.isArray(parsed.rules)) {
55
+ throw new Error(`${file}: "rules" must be a list, got ${typeof parsed.rules}`);
56
+ }
57
+ return { schema: parsed.schema, rules: parsed.rules ?? [] };
58
+ }
59
+
60
+ export function loadRulesArtifact(root, featureId) {
61
+ const file = rulesArtifactPath(root, featureId);
62
+ const parsed = readJsonIfExists(file);
63
+ if (parsed === null) return null;
64
+ const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', parsed);
65
+ if (!ok) {
66
+ throw new Error(`${file}: does not match schemas/feature-rules.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
67
+ }
68
+ return parsed;
69
+ }
70
+
71
+ export function saveRulesArtifact(root, featureId, artifact) {
72
+ const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', artifact);
73
+ if (!ok) {
74
+ throw new Error(`refusing to write an invalid rules artifact for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
75
+ }
76
+ const file = rulesArtifactPath(root, featureId);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ // Tab-indented + trailing newline, matching every other generated JSON artifact in this repo
79
+ // (observe's own <feature>.observed-schema.json uses exactly this). Byte-stable across runs so
80
+ // a no-op recompile leaves the file untouched and the `rules` gate stays green.
81
+ writeFileAtomic(file, `${JSON.stringify(artifact, null, '\t')}\n`);
82
+ return artifact;
83
+ }
84
+
85
+ // A starter rules.yaml, written only when the user explicitly asks for one and only when no file
86
+ // exists. Deliberately contains ZERO real rules -- every example is commented out. Writing a live
87
+ // rule here would be `bskel` inventing a constraint about the user's domain, exactly the line
88
+ // D-greenfield-parameters draws ("does the generated file encode a claim about the user's domain
89
+ // that the user did not state?").
90
+ export function starterRulesSource(featureId) {
91
+ return `# Business rules for ${featureId}.
92
+ #
93
+ # Compiled by \`bskel rules check --feature ${featureId}\` into
94
+ # specs/${featureId}/rules/${featureId}.rules.json, which is what the generated
95
+ # runtime checkers actually execute. Nothing here is enforced until you run that command.
96
+ #
97
+ # Constraints your OpenAPI document already states (minLength, maximum, enum, ...) are picked up
98
+ # AUTOMATICALLY from the contract -- you do not need to repeat them here. This file is only for
99
+ # the rules no schema keyword can express.
100
+ schema: ${RULES_SOURCE_SCHEMA}
101
+ rules: []
102
+ #
103
+ # Uncomment and adapt any of these -- then run \`bskel rules check --feature ${featureId}\`, which
104
+ # verifies every pointer, type, and enum state against your own contract before compiling.
105
+ #
106
+ # - id: end-after-start
107
+ # kind: cross
108
+ # operation: createBooking
109
+ # pointers: [/startDate, /endDate]
110
+ # assert: lt
111
+ # reason: "a booking cannot end before it starts"
112
+ #
113
+ # - id: publish-flow
114
+ # kind: transition
115
+ # operation: updateArticle
116
+ # pointer: /status
117
+ # from: [draft]
118
+ # to: [published, archived]
119
+ # reason: "an article may only be published or archived out of draft"
120
+ #
121
+ # - id: discount-cap
122
+ # kind: field
123
+ # operation: createOrder
124
+ # pointer: /discountPercent
125
+ # assert: maximum
126
+ # value: 50
127
+ # reason: "policy cap, not expressible in the public API schema"
128
+ #
129
+ # - id: order-total
130
+ # kind: derived
131
+ # resource: Order
132
+ # field: total
133
+ # expr:
134
+ # op: sub
135
+ # args:
136
+ # - op: mul
137
+ # args: [{ ref: price }, { ref: quantity }]
138
+ # - { ref: discount }
139
+ # reason: "total = price * quantity - discount; compiles to a real Order Rules pure function"
140
+ `;
141
+ }
@@ -0,0 +1,172 @@
1
+ // D-business-rules (R3): the closed vocabulary of business-rule assertions this project compiles
2
+ // into target-language code. Frozen, small, and every member mechanically executable in Java,
3
+ // Python, and TypeScript without interpretation -- that constraint is the whole design, not a
4
+ // convenience. See DECISIONS.md's D-business-rules, R3.
5
+ //
6
+ // This module is PURE DATA plus pure predicates over it. It imports nothing, so it can be read by
7
+ // the compiler, the CLI, and every provider emitter without any risk of the import cycle
8
+ // D-zero-config-scan hit (adapter -> lib/doctor.mjs -> lib/verify.mjs -> scanners/registry.mjs).
9
+ //
10
+ // DELIBERATE NON-OVERLAP with `handles/observe-schema-projection.mjs`. That module already
11
+ // projects `required`/`type`/`pattern` out of a contract's own requestBodySchema, and every
12
+ // provider's generated checker already enforces those three. This vocabulary covers exactly the
13
+ // constraints that projection DROPS -- so a single violation is never reported twice by two
14
+ // different checkers, and neither module has to know what the other kept. `required` as an
15
+ // unconditional assertion is therefore deliberately absent here: it is the contract's/OpenAPI
16
+ // document's own statement, not a business rule layered on top. Its genuinely-new sibling,
17
+ // `requiredIf` (conditional on another field), IS here, because no schema keyword expresses it.
18
+
19
+ // ---- field assertions: one scalar JSON Pointer, one bounded comparison ----------------------
20
+ //
21
+ // `valueType` is what the rule's own `value` must be (validated at compile time, so a generated
22
+ // runtime never has to defend against a malformed rule). `appliesTo` is the set of projected
23
+ // scalar types the assertion is meaningful against -- checked against the contract's own
24
+ // requestBodySchema at compile time (R6), which is how a rule that says "minLength on an integer"
25
+ // is refused with the real type named instead of silently never firing.
26
+ export const FIELD_ASSERTS = Object.freeze({
27
+ minLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length >= value' }),
28
+ maxLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length <= value' }),
29
+ minimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number >= value' }),
30
+ maximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number <= value' }),
31
+ exclusiveMinimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number > value' }),
32
+ exclusiveMaximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number < value' }),
33
+ multipleOf: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number is an exact multiple of value' }),
34
+ enum: Object.freeze({ valueType: 'array', appliesTo: Object.freeze(['string', 'number', 'integer', 'boolean']), summary: 'value is one of a fixed list' }),
35
+ });
36
+
37
+ // ---- cross-field assertions: two or more pointers, compared to each other -------------------
38
+ //
39
+ // `arity: 2` means exactly two pointers; `arity: 'n'` means two or more. `comparison: true` marks
40
+ // the members whose operands must be mutually comparable (both numeric, or both string) -- a
41
+ // `lt` between a string and an integer is refused at compile time rather than given some
42
+ // language-specific coercion behavior that would differ across the three runtimes.
43
+ export const CROSS_ASSERTS = Object.freeze({
44
+ lt: Object.freeze({ arity: 2, comparison: true, summary: 'first < second' }),
45
+ lte: Object.freeze({ arity: 2, comparison: true, summary: 'first <= second' }),
46
+ gt: Object.freeze({ arity: 2, comparison: true, summary: 'first > second' }),
47
+ gte: Object.freeze({ arity: 2, comparison: true, summary: 'first >= second' }),
48
+ eq: Object.freeze({ arity: 2, comparison: true, summary: 'first == second' }),
49
+ neq: Object.freeze({ arity: 2, comparison: true, summary: 'first != second' }),
50
+ requiredIf: Object.freeze({ arity: 2, comparison: false, summary: 'if the first is present, the second must be too' }),
51
+ mutuallyExclusive: Object.freeze({ arity: 'n', comparison: false, summary: 'at most one of these may be present' }),
52
+ });
53
+
54
+ // ---- the three predicate rule kinds -----------------------------------------------------------
55
+ //
56
+ // `derived` is deliberately NOT here: it produces a value rather than answering true/false about
57
+ // one, so it cannot be executed by a predicate checker at all and takes a different compilation
58
+ // path entirely (R5). Keeping the predicate kinds in their own frozen list is what lets the
59
+ // compiler and every checker iterate them generically.
60
+ export const PREDICATE_KINDS = Object.freeze(['field', 'cross', 'transition']);
61
+
62
+ // Every kind an authored rule may declare -- PREDICATE_KINDS plus `derived`. Used only at the
63
+ // "is this kind even real" dispatch point in rules/compile.mjs; every other consumer (the
64
+ // checkers, `rules explain`'s search) still branches on PREDICATE_KINDS vs. `derived` separately,
65
+ // because the two are executed through genuinely different mechanisms (R5).
66
+ export const ALL_RULE_KINDS = Object.freeze([...PREDICATE_KINDS, 'derived']);
67
+
68
+ // R5/Phase 3: the closed arithmetic vocabulary a `derived` rule's expr tree may use. Deliberately
69
+ // tiny and binary-only (exactly 2 args per op) -- the explicit "no arbitrary arithmetic beyond the
70
+ // frozen operator set" scope boundary this item's own plan named. A third operand is expressed by
71
+ // nesting (`mul(mul(a,b),c)`), never by widening arity.
72
+ export const DERIVED_OPS = Object.freeze(['add', 'sub', 'mul', 'div']);
73
+
74
+ // PascalCase for Java/TypeScript method names (`computeTotal`), snake_case for Python
75
+ // (`compute_total`) -- pure string transforms, reused by all three provider emitters so a
76
+ // resource/field name is capitalized identically everywhere rather than three subtly different
77
+ // regexes drifting apart.
78
+ export function pascalCase(name) {
79
+ return String(name).replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, '').toUpperCase());
80
+ }
81
+
82
+ export function snakeCase(name) {
83
+ return String(name)
84
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
85
+ .replace(/[-\s]+/g, '_')
86
+ .toLowerCase();
87
+ }
88
+
89
+ // Where a compiled rule came from. `contract` = projected from the feature's own
90
+ // requestBodySchema, i.e. a fact the user's OpenAPI document already asserts, transported (R4 --
91
+ // the same "copying is not synthesizing" posture D-openapi-passthrough established). `declared` =
92
+ // a human wrote it in rules.yaml. Kept distinct in the compiled artifact so an auditor can always
93
+ // tell which constraints the document licensed and which a human added on top.
94
+ export const RULE_ORIGINS = Object.freeze(['contract', 'declared']);
95
+
96
+ // The scalar types a rule can address, matching observe-schema-projection.mjs's own SCALAR_TYPES
97
+ // exactly -- a rule can only constrain something that module already projects as a scalar leaf.
98
+ export const RULE_SCALAR_TYPES = Object.freeze(['string', 'number', 'integer', 'boolean']);
99
+
100
+ export const FIELD_ASSERT_NAMES = Object.freeze(Object.keys(FIELD_ASSERTS).sort());
101
+ export const CROSS_ASSERT_NAMES = Object.freeze(Object.keys(CROSS_ASSERTS).sort());
102
+
103
+ export function getFieldAssert(name) {
104
+ return Object.hasOwn(FIELD_ASSERTS, name) ? FIELD_ASSERTS[name] : null;
105
+ }
106
+
107
+ export function getCrossAssert(name) {
108
+ return Object.hasOwn(CROSS_ASSERTS, name) ? CROSS_ASSERTS[name] : null;
109
+ }
110
+
111
+ // The typo-defense points, same shape as lib/gate-definitions.mjs's requireGateDefinition() and
112
+ // contracts/completeness.mjs's requireWarningCode(): name the known values rather than letting a
113
+ // misspelled assertion silently compile to a rule that can never fire.
114
+ export function requireFieldAssert(name) {
115
+ const spec = getFieldAssert(name);
116
+ if (!spec) throw new Error(`unknown field assertion "${name}" -- known field assertions: ${FIELD_ASSERT_NAMES.join(', ')}`);
117
+ return spec;
118
+ }
119
+
120
+ export function requireCrossAssert(name) {
121
+ const spec = getCrossAssert(name);
122
+ if (!spec) throw new Error(`unknown cross-field assertion "${name}" -- known cross-field assertions: ${CROSS_ASSERT_NAMES.join(', ')}`);
123
+ return spec;
124
+ }
125
+
126
+ // Is `value` the right shape for this assertion's declared valueType? Deliberately strict:
127
+ // `integer` rejects 1.5 AND rejects a numeric string, because a rule whose operand needs coercing
128
+ // is a rule whose behavior would differ between Java, Python, and JS.
129
+ export function valueMatchesType(valueType, value) {
130
+ switch (valueType) {
131
+ case 'integer': return typeof value === 'number' && Number.isInteger(value);
132
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
133
+ case 'array': return Array.isArray(value) && value.length > 0;
134
+ case 'none': return value === undefined;
135
+ default: return false;
136
+ }
137
+ }
138
+
139
+ // Two projected scalar types are comparable if they are both numeric or both string. Booleans are
140
+ // deliberately never comparable with `lt`/`gt` (ordering booleans is a language-specific accident,
141
+ // not a business rule); `eq`/`neq` between two booleans is still refused here for the same reason
142
+ // consistency matters more than convenience -- use a field `enum` assertion instead.
143
+ export function typesAreComparable(a, b) {
144
+ const numeric = new Set(['number', 'integer']);
145
+ if (numeric.has(a) && numeric.has(b)) return true;
146
+ return a === 'string' && b === 'string';
147
+ }
148
+
149
+ // Renders one compiled rule as a plain-English sentence, for `bskel rules explain`. Pure and
150
+ // exported (rather than built inline in the CLI) so it has a direct unit test, and so the same
151
+ // wording can be reused by any future renderer -- the same call lib/workflow.mjs made when it
152
+ // exported isMutatingCommand() instead of inlining it.
153
+ export function explainRule({ kind, rule }) {
154
+ if (kind === 'field') {
155
+ const spec = getFieldAssert(rule.assert);
156
+ const what = spec ? spec.summary.replace('value', JSON.stringify(rule.value)) : `${rule.assert} ${JSON.stringify(rule.value)}`;
157
+ return `${rule.pointer} must satisfy: ${what}`;
158
+ }
159
+ if (kind === 'cross') {
160
+ const spec = getCrossAssert(rule.assert);
161
+ if (rule.assert === 'requiredIf') return `if ${rule.pointers[0]} is present, ${rule.pointers[1]} must be present too`;
162
+ if (rule.assert === 'mutuallyExclusive') return `at most one of ${rule.pointers.join(', ')} may be present`;
163
+ return `${rule.pointers[0]} must be ${spec ? spec.summary.replace('first ', '').replace(' second', '') : rule.assert} ${rule.pointers[1]}`;
164
+ }
165
+ if (kind === 'transition') {
166
+ return `${rule.pointer} may only change from {${rule.from.join(', ')}} to {${rule.to.join(', ')}}`;
167
+ }
168
+ if (kind === 'derived') {
169
+ return `${rule.resource}.${rule.field} is computed from (${rule.params.join(', ')}) -- see \`rules/derived.mjs\`'s renderExprInfix() for the exact formula, or the generated <Resource>Rules class itself`;
170
+ }
171
+ return `(no explanation available for kind "${kind}")`;
172
+ }
@@ -0,0 +1,139 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:feature-rules:1",
4
+ "title": "backend-skeleton compiled feature business rules",
5
+ "description": "Validates specs/<feature_id>/rules/<feature_id>.rules.json -- the DETERMINISTIC, COMPILED artifact rules/compile.mjs produces from a feature contract plus an optional hand-authored specs/<feature_id>/rules.yaml. This is the only rules file any provider emitter reads. See D-business-rules in DECISIONS.md. The authored YAML source is deliberately NOT validated by a schema: rules/compile.mjs resolves every rule against the contract's own requestBodySchema and produces far more actionable refusals than a structural schema could (naming the real known fields, the field's real declared type, the real enum states), so a second, weaker structural gate in front of it would only ever produce worse messages for the same input.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["sbf_feature_rules", "feature_id", "feature_uid", "contract_ref", "operations", "derived", "unsupported"],
9
+ "$defs": {
10
+ "derivedExpr": {
11
+ "description": "R5/Phase 3's closed expression grammar for a derived field's formula -- exactly one of an op node (binary, add/sub/mul/div only -- rules/vocabulary.mjs's DERIVED_OPS), a `ref` leaf (a named input, becomes a generated function parameter), or a `const` leaf (a compile-time literal). Deliberately not a general expression language: no loops, no function calls, no I/O, no operator beyond this frozen set.",
12
+ "oneOf": [
13
+ {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["op", "args"],
17
+ "properties": {
18
+ "op": { "enum": ["add", "sub", "mul", "div"] },
19
+ "args": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/derivedExpr" } }
20
+ }
21
+ },
22
+ {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["ref"],
26
+ "properties": { "ref": { "type": "string", "minLength": 1 } }
27
+ },
28
+ {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": ["const"],
32
+ "properties": { "const": { "type": "number" } }
33
+ }
34
+ ]
35
+ }
36
+ },
37
+ "properties": {
38
+ "sbf_feature_rules": { "const": "1" },
39
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
40
+ "feature_uid": { "type": "string", "format": "uuid" },
41
+ "contract_ref": {
42
+ "description": "sha256 of the contract file this artifact was compiled against. A rule is only meaningful against the contract whose pointers/types/enums it was verified against, so pairing a compiled artifact with a different contract must be detectable -- the `rules` gate hashes both, and every provider stamps this value into the emitted runtime resource (the same role observe's own contract_ref plays in <feature>.observed-schema.json).",
43
+ "type": "string"
44
+ },
45
+ "operations": {
46
+ "description": "Predicate rules, keyed by the contract's own operationId. An operation with no rules is absent entirely rather than present-and-empty.",
47
+ "type": "object",
48
+ "additionalProperties": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "field": {
53
+ "description": "Single-pointer constraints. `origin: contract` entries were projected from the operation's own requestBodySchema (facts the user's OpenAPI document already asserts, transported -- see R4); `origin: declared` entries were hand-authored. Deliberately excludes required/type/pattern, which handles/observe-schema-projection.mjs already projects and every generated checker already enforces -- so one violation is never reported twice.",
54
+ "type": "array",
55
+ "items": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["id", "pointer", "assert", "value", "origin"],
59
+ "properties": {
60
+ "id": { "type": "string", "minLength": 1 },
61
+ "pointer": { "type": "string", "pattern": "^/[^/]+$" },
62
+ "assert": { "enum": ["minLength", "maxLength", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "enum"] },
63
+ "value": {},
64
+ "origin": { "enum": ["contract", "declared"] }
65
+ }
66
+ }
67
+ },
68
+ "cross": {
69
+ "description": "Multi-pointer constraints. `types` records each pointer's contract-declared scalar type at compile time, so a generated runtime never has to re-derive it -- and so a later contract change that alters a type is visible as a real diff in this artifact.",
70
+ "type": "array",
71
+ "items": {
72
+ "type": "object",
73
+ "additionalProperties": false,
74
+ "required": ["id", "pointers", "assert", "types", "origin"],
75
+ "properties": {
76
+ "id": { "type": "string", "minLength": 1 },
77
+ "pointers": { "type": "array", "minItems": 2, "items": { "type": "string", "pattern": "^/[^/]+$" } },
78
+ "assert": { "enum": ["lt", "lte", "gt", "gte", "eq", "neq", "requiredIf", "mutuallyExclusive"] },
79
+ "types": { "type": "array", "minItems": 2, "items": { "enum": ["string", "number", "integer", "boolean"] } },
80
+ "origin": { "enum": ["contract", "declared"] }
81
+ }
82
+ }
83
+ },
84
+ "transition": {
85
+ "description": "State-transition guards: a literal from[] -> to[] allow-list over one scalar pointer whose contract schema declares an enum. Compilation refuses a transition on a field with no enum, because without a closed state set a typo'd state name becomes a guard that silently never fires.",
86
+ "type": "array",
87
+ "items": {
88
+ "type": "object",
89
+ "additionalProperties": false,
90
+ "required": ["id", "pointer", "from", "to", "origin"],
91
+ "properties": {
92
+ "id": { "type": "string", "minLength": 1 },
93
+ "pointer": { "type": "string", "pattern": "^/[^/]+$" },
94
+ "from": { "type": "array", "minItems": 1, "items": { "type": "string" } },
95
+ "to": { "type": "array", "minItems": 1, "items": { "type": "string" } },
96
+ "origin": { "enum": ["contract", "declared"] }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ },
103
+ "derived": {
104
+ "description": "Derived/computed field rules. Unlike the three predicate kinds above, a derived rule PRODUCES a value rather than answering true/false about one, so it cannot be executed by a predicate checker and compiles to a generated pure-function class instead (R5). Resource-scoped, not operation-scoped -- unlike field/cross/transition, a derived rule has no `operation` and is never checked against the contract's requestBodySchema.",
105
+ "type": "array",
106
+ "items": {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["id", "resource", "field", "params", "expr", "origin"],
110
+ "properties": {
111
+ "id": { "type": "string", "minLength": 1 },
112
+ "resource": { "type": "string", "minLength": 1 },
113
+ "field": { "type": "string", "minLength": 1 },
114
+ "params": {
115
+ "description": "Every distinct {ref} name in `expr`, in first-appearance order -- this becomes the generated pure function's own parameter list (rules/derived.mjs's collectParams()).",
116
+ "type": "array",
117
+ "items": { "type": "string", "minLength": 1 }
118
+ },
119
+ "expr": { "$ref": "#/$defs/derivedExpr" },
120
+ "origin": { "const": "declared" }
121
+ }
122
+ }
123
+ },
124
+ "unsupported": {
125
+ "description": "Every constraint found in the contract that this vocabulary cannot express, recorded rather than silently dropped -- the same honesty mechanism handles/observe-schema-projection.mjs's own `unsupported[]` JSON-Pointer list provides. An auditor reading this artifact can always tell what is NOT enforced.",
126
+ "type": "array",
127
+ "items": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": ["code", "subject", "reason"],
131
+ "properties": {
132
+ "code": { "type": "string", "pattern": "^RULE_[A-Z_]+$" },
133
+ "subject": { "type": ["string", "null"] },
134
+ "reason": { "type": "string" }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }