From 321daf629f88062f3b6a336a1aef0b8a9b2a3deb Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 23 Aug 2026 09:06:36 -0500 Subject: [PATCH 1/2] feat(reference): publish what an artifact CONTAINS, not just that it has a shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry names an artifact and points at its schema's $id. A consumer therefore learns that a PRD is required in discovery and still cannot find out what a PRD is supposed to contain: an $id is an identity, deliberately not a location, and nothing dereferences it. That gap is not academic. The satellite waiting on it evaluates gate criteria against a flat field map, and with no fields a tenant can configure a rule over a document that nothing will ever read — the gate ends up checking that a file exists and never what it says. Measured on a live tenant before this: 34 catalogued artifacts, zero field schemas with any field, zero criteria. So each registry entry now carries its FIELDS, derived from the schema the Core already ships. The schemas are not rewritten flat: they stay the source and this is a projection, so a schema change propagates on the next read instead of needing a second file kept in sync. Across the corpus that is 529 fields from 50 schemas — the PRD alone goes from nothing to 18, with types and requiredness. The type vocabulary is small on purpose: exactly what the existing criterion operators can judge. A type outside it yields a field no criterion can evaluate, which is worse than a missing one because it can be selected and never satisfied. Collections are omitted for the same reason — gte, in-set and regex all assume a single value — and REPORTED rather than dropped quietly, so someone counting 13 sections against 18 fields can see the difference is arrays and not a truncated schema. An unreadable schema leaves that one artifact without fields instead of failing the registry: one malformed file must not take down the catalogue every other artifact needs. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../artifact-field-derivation.spec.ts | 128 +++++++++++++ .../services/artifact-field-derivation.ts | 175 ++++++++++++++++++ .../services/core-reference-query.service.ts | 76 +++++++- 3 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts create mode 100644 src/apps/core-api/src/application/services/artifact-field-derivation.ts diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts new file mode 100644 index 00000000..ae6773a5 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts @@ -0,0 +1,128 @@ +import { + deriveArtifactFields, + schemaFileNameFromId, +} from './artifact-field-derivation'; + +/** + * The half of the contract a satellite could not use. + * + * Publishing a schema `$id` told a consumer that a PRD has a canonical shape somewhere; it never + * told it what a PRD contains, and an `$id` is an identity that nothing dereferences. These pin + * the derivation that closes it — and, just as importantly, what it refuses to publish, because a + * field no criterion can evaluate is worse than a missing one: it can be selected and never + * satisfied. + */ +describe('artifact field derivation', () => { + it('flattens nested objects into the dotted paths a criterion addresses', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + required: ['metadata'], + properties: { + metadata: { + type: 'object', + required: ['identifier'], + properties: { + identifier: { type: 'string', description: 'PRD identifier' }, + product: { type: 'string' }, + }, + }, + }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['metadata.identifier', 'metadata.product']); + expect(fields.find((f) => f.fieldPath === 'metadata.identifier')?.required).toBe(true); + expect(fields.find((f) => f.fieldPath === 'metadata.product')?.required).toBe(false); + }); + + it('does not publish the container itself, only its leaves', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { metadata: { type: 'object', properties: { a: { type: 'string' } } } }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['metadata.a']); + expect(fields.some((f) => f.fieldPath === 'metadata')).toBe(false); + }); + + it('maps each schema type onto something an operator can judge', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + ready: { type: 'boolean' }, + due: { type: 'string', format: 'date' }, + link: { type: 'string', format: 'uri' }, + status: { type: 'string', enum: ['Draft', 'Approved'] }, + body: { type: 'string', maxLength: 4000 }, + }, + }); + + const byPath = Object.fromEntries(fields.map((f) => [f.fieldPath, f.type])); + expect(byPath).toEqual({ + name: 'text', + count: 'number', + ready: 'boolean', + due: 'date', + link: 'url', + status: 'enum', + body: 'rich-text', + }); + expect(fields.find((f) => f.fieldPath === 'status')?.enumValues).toEqual(['Draft', 'Approved']); + }); + + /** + * A list cannot be compared by `gte`, `in-set` or `regex` — every operator assumes one value — + * so publishing it would hand a consumer a field it can select and never satisfy. It is + * REPORTED rather than dropped quietly, so someone counting 13 sections against 9 fields can + * see the difference is collections and not a truncated schema. + */ + it('omits collections, and says so', () => { + const { fields, omitted } = deriveArtifactFields({ + type: 'object', + properties: { + title: { type: 'string' }, + risks: { type: 'array', items: { type: 'string' } }, + }, + }); + + expect(fields.map((f) => f.fieldPath)).toEqual(['title']); + expect(omitted).toEqual([ + { fieldPath: 'risks', reason: 'collection — no criterion operator can evaluate a list' }, + ]); + }); + + it('gives a readable label when the schema offers none', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + executiveSummary: { type: 'string' }, + titled: { type: 'string', title: 'A proper title' }, + }, + }); + + expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive Summary'); + expect(fields.find((f) => f.fieldPath === 'titled')?.label).toBe('A proper title'); + }); + + it('survives a schema with nothing in it', () => { + expect(deriveArtifactFields({}).fields).toEqual([]); + expect(deriveArtifactFields(null).fields).toEqual([]); + }); + + /** + * The one place that knows both the identity and where it lives today. Matching on the last + * segment is what lets the host change without breaking resolution — which is the churn `$id` + * exists to absorb in the first place. + */ + it('resolves a schema id to its file without depending on the host', () => { + expect(schemaFileNameFromId('https://evolith.dev/schema/prd.schema.json')).toBe( + 'prd.schema.json', + ); + expect(schemaFileNameFromId('https://example.test/elsewhere/prd.schema.json')).toBe( + 'prd.schema.json', + ); + expect(schemaFileNameFromId('not-a-schema')).toBeUndefined(); + expect(schemaFileNameFromId('')).toBeUndefined(); + }); +}); diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.ts new file mode 100644 index 00000000..00cf35c9 --- /dev/null +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -0,0 +1,175 @@ +/** + * Derives the FLAT FIELD LIST of an artifact from the JSON Schema the Core already publishes. + * + * WHY THIS EXISTS. The registry names an artifact and points at its schema's `$id`. A consumer + * therefore learns that a PRD is required in discovery and still cannot find out what a PRD is + * supposed to contain — the `$id` is an identity, not a location, and nothing dereferences it. + * The satellite waiting on this (`evolith_tracker`) evaluates gate criteria against a flat field + * map, so «a PRD has a field called metadata.identifier, it is a string, and it is required» is + * the fact it needs. Without it a tenant can configure a criterion over a document and nothing + * will ever read it, which makes the gate a presence check. + * + * The schemas are NOT rewritten to a flat shape. They stay the source; this derives a projection, + * so a schema change propagates on the next read rather than needing a second file kept in sync. + */ + +/** The field types a consumer's criteria can actually evaluate. */ +export type ArtifactFieldType = + | 'text' + | 'rich-text' + | 'number' + | 'date' + | 'boolean' + | 'enum' + | 'url'; + +export interface ArtifactField { + /** Dotted path from the document root — `metadata.identifier`. Stable: criteria reference it. */ + fieldPath: string; + type: ArtifactFieldType; + label: string; + required: boolean; + enumValues?: string[]; + description?: string; +} + +export interface ArtifactFieldDerivation { + fields: ArtifactField[]; + /** + * Paths deliberately left out, and why. Collections have no operator that can judge them — + * `gte`, `in-set` and `regex` all assume a single value — so publishing them as fields would + * offer a consumer something it can select and never satisfy. + * + * Reported rather than dropped in silence: a caller comparing 13 sections against 9 fields + * deserves to know the difference is arrays, not an incomplete schema. + */ + omitted: { fieldPath: string; reason: string }[]; +} + +interface JsonSchemaNode { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + required?: string[]; + enum?: unknown[]; + format?: string; + maxLength?: number; + items?: JsonSchemaNode; +} + +/** A humane label when the schema gives none: `executiveSummary` → `Executive Summary`. */ +function labelFor(key: string, node: JsonSchemaNode): string { + if (node.title) return node.title; + const spaced = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[-_.]/g, ' ') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Maps a JSON Schema node onto the consumer's vocabulary. + * + * The vocabulary is deliberately small: it is exactly what the existing criterion operators can + * judge. A type outside it produces a field no criterion can evaluate, which is the same as no + * field at all. + */ +function typeFor(node: JsonSchemaNode): ArtifactFieldType | null { + const raw = Array.isArray(node.type) ? node.type.find((t) => t !== 'null') : node.type; + + if (Array.isArray(node.enum) && node.enum.length > 0) return 'enum'; + + switch (raw) { + case 'integer': + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'string': + if (node.format === 'date' || node.format === 'date-time') return 'date'; + if (node.format === 'uri' || node.format === 'url') return 'url'; + // Long free text is still text to a criterion; the distinction is for the editor, which + // should give it room rather than a single line. + if ((node.maxLength ?? 0) > 500) return 'rich-text'; + return 'text'; + default: + return null; + } +} + +/** + * Walks a JSON Schema and produces the flat field list. + * + * Nested objects are flattened with dotted paths because that is how a criterion addresses them. + * Arrays are omitted and reported — see {@link ArtifactFieldDerivation.omitted}. + */ +export function deriveArtifactFields(schema: unknown): ArtifactFieldDerivation { + const fields: ArtifactField[] = []; + const omitted: { fieldPath: string; reason: string }[] = []; + + const walk = (node: JsonSchemaNode, prefix: string, requiredHere: Set): void => { + const properties = node.properties; + if (!properties) return; + + for (const [key, child] of Object.entries(properties)) { + const fieldPath = prefix ? `${prefix}.${key}` : key; + const required = requiredHere.has(key); + const childType = Array.isArray(child.type) + ? child.type.find((t) => t !== 'null') + : child.type; + + if (childType === 'array') { + omitted.push({ + fieldPath, + reason: 'collection — no criterion operator can evaluate a list', + }); + continue; + } + + if (childType === 'object' && child.properties) { + // An object is not a field: its LEAVES are. Publishing the container as well would offer + // a path whose value is a document, which no operator can compare. + walk(child, fieldPath, new Set(child.required ?? [])); + continue; + } + + const type = typeFor(child); + if (!type) { + omitted.push({ fieldPath, reason: `unsupported type: ${String(childType ?? 'unknown')}` }); + continue; + } + + fields.push({ + fieldPath, + type, + label: labelFor(key, child), + required, + ...(type === 'enum' && Array.isArray(child.enum) + ? { enumValues: child.enum.map((v) => String(v)) } + : {}), + ...(child.description ? { description: child.description } : {}), + }); + } + }; + + const root = (schema ?? {}) as JsonSchemaNode; + walk(root, '', new Set(root.required ?? [])); + + return { fields, omitted }; +} + +/** + * Resolves a schema `$id` to the file that publishes it. + * + * The `$id` is an identity and the filename is where it lives today; this is the ONE place that + * knows both, so the rest of the code can keep using the identity. Matching on the last path + * segment survives the host changing, which is precisely the kind of churn `$id` exists to + * absorb. + */ +export function schemaFileNameFromId(schemaId: string): string | undefined { + const trimmed = (schemaId ?? '').trim(); + if (!trimmed) return undefined; + const last = trimmed.split('/').filter(Boolean).pop(); + return last && last.endsWith('.json') ? last : undefined; +} diff --git a/src/apps/core-api/src/application/services/core-reference-query.service.ts b/src/apps/core-api/src/application/services/core-reference-query.service.ts index ec645b98..adf5c544 100644 --- a/src/apps/core-api/src/application/services/core-reference-query.service.ts +++ b/src/apps/core-api/src/application/services/core-reference-query.service.ts @@ -1,4 +1,9 @@ import * as path from 'path'; +import { + deriveArtifactFields, + schemaFileNameFromId, + type ArtifactField, +} from './artifact-field-derivation'; import { Injectable, Inject } from '@nestjs/common'; import type { IFileSystem } from '@beyondnet/evolith-core-domain/domain/interfaces'; import { @@ -40,6 +45,20 @@ export interface RegistryArtifact { schemaId?: string; templateRef?: string; producedBy?: { format: string; note?: string }; + + /** + * The artifact's fields, derived from the schema its `schemaId` names. + * + * Absent when the artifact publishes no schema — a tool's own output declares `producedBy` + * instead, and restating what the tool already publishes would rot the day the tool changes. + */ + fields?: ArtifactField[]; + + /** + * Paths the derivation deliberately left out, with the reason. Reported so a consumer counting + * sections against fields can see the difference is collections, not a truncated schema. + */ + omittedFields?: { fieldPath: string; reason: string }[]; } export interface ArtifactRegistry { @@ -118,14 +137,57 @@ export class CoreReferenceQueryService { if (!(await this.fs.exists(file))) return undefined; const registry = JSON.parse(await this.fs.readFile(file)) as ArtifactRegistry; - if (!phase) return registry; - // An unknown phase yields an EMPTY artifact list, never the whole registry. Falling back to - // everything would answer a question nobody asked and read as "this phase requires all of it". - return { - ...registry, - artifacts: registry.artifacts.filter((a) => a.phases.includes(phase)), - }; + const scoped = phase + // An unknown phase yields an EMPTY artifact list, never the whole registry. Falling back to + // everything would answer a question nobody asked and read as "this phase requires all of it". + ? { ...registry, artifacts: registry.artifacts.filter((a) => a.phases.includes(phase)) } + : registry; + + return { ...scoped, artifacts: await this.withFields(rulesetsRoot, scoped.artifacts) }; + } + + /** + * Attaches each artifact's FIELDS, derived from the schema its `schemaId` names. + * + * This closes the half of the contract a satellite could not use. Publishing the `$id` told a + * consumer that a PRD has a canonical shape somewhere; it did not tell it what a PRD contains, + * and nothing dereferences an identity. Gate criteria resolve a field path, so without this the + * tenant can configure a rule over a document that nothing will ever read — the gate checks that + * a file exists and never what it says. + * + * A schema that cannot be read leaves the artifact WITHOUT fields rather than failing the whole + * registry: one unreadable file must not take down the catalogue every other artifact needs. + */ + private async withFields( + rulesetsRoot: string, + artifacts: RegistryArtifact[], + ): Promise { + return Promise.all( + artifacts.map(async (artifact) => { + if (!artifact.schemaId) return artifact; + + const fileName = schemaFileNameFromId(artifact.schemaId); + if (!fileName) return artifact; + + const schemaFile = path.join(rulesetsRoot, 'schema', fileName); + if (!(await this.fs.exists(schemaFile))) return artifact; + + try { + const schema = JSON.parse(await this.fs.readFile(schemaFile)); + const { fields, omitted } = deriveArtifactFields(schema); + return { + ...artifact, + fields, + ...(omitted.length > 0 ? { omittedFields: omitted } : {}), + }; + } catch { + // Malformed schema: the artifact still exists and is still demanded, it just cannot say + // what it contains yet. + return artifact; + } + }), + ); } /** From e91fdc650e22829b51d34ed84d52336afce18a36 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 23 Aug 2026 10:30:15 -0500 Subject: [PATCH 2/2] fix(reference): a derived label reads like a question, not like a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels these produce become the labels of a FORM — the satellite renders one input per field — so the case matters. Title Case makes a form read like a menu of commands rather than a set of questions, and it clashes with the sentence case the rendering surfaces use everywhere else; two cases on one screen look like two systems sharing it. The acronym list exists because there is no rule to replace it. Lowercasing every word turns `technicalFeasibilityId` into a label ending in "id", which reads as a mistake, and leaving the camel case alone gives "Id", which reads as a typo. Nothing in the spelling separates `id` from `is`, so the terms that get shouted are named one by one. The list is short deliberately: a term missing from it comes out as an ordinary word, which is merely plain, while a term wrongly in it comes out shouting. None of this runs for a schema that publishes a `title`. That is words chosen by whoever owns the shape, and no amount of string-splitting here improves on them. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../artifact-field-derivation.spec.ts | 29 +++++++++++- .../services/artifact-field-derivation.ts | 47 +++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts index ae6773a5..ed4524c5 100644 --- a/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.spec.ts @@ -101,10 +101,37 @@ describe('artifact field derivation', () => { }, }); - expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive Summary'); + // Sentence case: these become the labels of a FORM, and Title Case makes a form read like a + // menu of commands rather than a set of questions. + expect(fields.find((f) => f.fieldPath === 'executiveSummary')?.label).toBe('Executive summary'); expect(fields.find((f) => f.fieldPath === 'titled')?.label).toBe('A proper title'); }); + /** + * `technicalFeasibilityId` ending in «Id» looks like a typo, and «id» like a mistake. There is + * no rule that separates an acronym from a short word — `id` is one and `is` is not — so the + * list is explicit and short. + */ + it('shouts an acronym instead of lowercasing it into a typo', () => { + const { fields } = deriveArtifactFields({ + type: 'object', + properties: { + technicalFeasibilityId: { type: 'string' }, + cpuCoreLimit: { type: 'integer' }, + apiBaseUrl: { type: 'string', format: 'uri' }, + 'is-approved': { type: 'boolean' }, + }, + }); + + const label = (path: string) => fields.find((f) => f.fieldPath === path)?.label; + + expect(label('technicalFeasibilityId')).toBe('Technical feasibility ID'); + expect(label('cpuCoreLimit')).toBe('CPU core limit'); + expect(label('apiBaseUrl')).toBe('API base URL'); + // A word that merely looks like one is left alone. + expect(label('is-approved')).toBe('Is approved'); + }); + it('survives a schema with nothing in it', () => { expect(deriveArtifactFields({}).fields).toEqual([]); expect(deriveArtifactFields(null).fields).toEqual([]); diff --git a/src/apps/core-api/src/application/services/artifact-field-derivation.ts b/src/apps/core-api/src/application/services/artifact-field-derivation.ts index 00cf35c9..858b1714 100644 --- a/src/apps/core-api/src/application/services/artifact-field-derivation.ts +++ b/src/apps/core-api/src/application/services/artifact-field-derivation.ts @@ -58,14 +58,51 @@ interface JsonSchemaNode { items?: JsonSchemaNode; } -/** A humane label when the schema gives none: `executiveSummary` → `Executive Summary`. */ +/** + * Words that are ALWAYS shouted, because lowercasing them makes a label look misspelt: + * `technicalFeasibilityId` should end in «ID», not «Id» and not «id». + * + * A list rather than a rule, because there is no rule: `id` is an acronym and `is` is not, and + * nothing in the spelling separates them. It is short on purpose — a term that is not here comes + * out as an ordinary word, which is merely plain, whereas a term wrongly here comes out shouting. + */ +const ACRONYMS = new Set([ + 'id', 'api', 'url', 'uri', 'cpu', 'gpu', 'ram', 'gb', 'mb', 'tb', 'ms', + 'qa', 'ci', 'cd', 'ui', 'ux', 'db', 'sql', 'http', 'https', 'json', 'xml', 'yaml', + 'sla', 'slo', 'sli', 'kpi', 'okr', 'roi', 'tco', 'rto', 'rpo', 'mttr', 'cfr', + 'prd', 'adr', 'sdlc', 'pii', 'dns', 'tls', 'sso', 'rbac', 'abac', 'vpc', +]); + +/** + * A humane label when the schema gives none: `executiveSummary` → `Executive summary`. + * + * SENTENCE case, not Title Case. A form whose labels are Title Cased reads like a menu of + * commands rather than a set of questions, and it is the house style of the surfaces that render + * these — mixing the two would look like two systems sharing one screen. + * + * This is the fallback. A schema that publishes a `title` has already been given words by whoever + * owns the shape, and no amount of string-splitting here can improve on them. + */ function labelFor(key: string, node: JsonSchemaNode): string { if (node.title) return node.title; - const spaced = key + + const words = key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[-_.]/g, ' ') - .trim(); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[-_.]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => (ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.toLowerCase())); + + if (words.length === 0) return ''; + + const [first, ...rest] = words; + const head = ACRONYMS.has(first.toLowerCase()) + ? first + : first.charAt(0).toUpperCase() + first.slice(1); + + return [head, ...rest].join(' '); } /**