Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions packages/adapters/catalog-backstage/src/admissibility/classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* T051 · T052 — `inadmissible-descriptor` classification, and the record that must
* carry all three attributions.
*
* # Failure semantics (`admissibility.md` §5)
*
* `inadmissible-descriptor` is a **fatal, whole-operation** trigger class — the
* **fifteenth** member of this feature's enumeration. On any inadmissible
* descriptor the entire run aborts, no envelope is written including a partial one,
* no entity from the same run is emitted including entities already determined
* admissible, and the process exits non-zero.
*
* An inadmissible descriptor is **never skipped**, never downgraded to a warning,
* and never excluded-and-continued. *"Continue past the bad one"* is the precise
* behaviour §5 forbids. This module therefore produces a rejection and never a
* filtered collection: there is no function here that takes descriptors and returns
* the good ones.
*
* # All three attributions, and why one merged one will not do (§3, §5.1, FR-020)
*
* A record that says only "descriptor invalid", without naming which of the four
* fields and which validator produced the false, "does not satisfy FR-020 and MUST
* be treated as a reporting defect rather than as a determination" (§3). So every
* {@link InadmissibilityAttribution} carries the descriptor path, the failing field,
* and the rejecting validator, and the composition evaluates **all four** predicates
* rather than stopping at the first — §3: "Two fields failing produce two
* attributions, not one merged one."
*
* # The count
*
* Fifteen. `atomic-fail-closed.md` §4 fixes spike 009's enumeration at fourteen and
* that remains correct *as a statement about spike 009*; `inadmissible-descriptor`
* appears nowhere under `specs/009-catalog-binding-viability/`. Writing fourteen as
* *this feature's* count is an error against FR-035 (`admissibility.md` §5.1).
*
* @see `specs/010-catalog-backstage/contracts/admissibility.md` §3, §5, §5.1
*/

import type { Rejection } from '../diagnostics.ts';
import type { DescriptorDocument } from '../descriptor/read.ts';
import {
type AdmissibilityField,
type AdmissibilityValidatorName,
PINNED_BACKSTAGE_COMMIT,
PINNED_VALIDATOR_BINDINGS,
VALIDATORS,
VALIDATOR_FIELDS,
} from './validators.ts';

/** One field's rejection, attributed as FR-020 requires. */
export interface InadmissibilityAttribution {
/** FR-020 attribution 1 — the offending source path. */
readonly sourcePath: string;
/** Which document in that file, since a file may hold several. */
readonly documentIndexInFile: number;
/** FR-020 attribution 2 — the failing field. */
readonly field: AdmissibilityField;
/** FR-020 attribution 3 — the rejecting validator. */
readonly validator: AdmissibilityValidatorName;
/** The pinned Backstage binding that validator reproduces (ADR-0015's table). */
readonly pinnedBinding: string;
/** The commit the predicate is pinned to. A different commit is a different contract. */
readonly pinnedCommit: string;
/** What was actually authored, rendered for a reader. Never load-bearing. */
readonly observed: string;
}

/**
* `data-model.md` §4, plus the attributions FR-020 requires.
*
* `failedFields` is empty **iff** `admissible`, which is the invariant §2's "there
* is no partial admissibility, no warning tier, and no 'admissible except for'
* state" reduces to in code.
*/
export interface AdmissibilityResult {
readonly admissible: boolean;
readonly failedFields: readonly AdmissibilityField[];
readonly attributions: readonly InadmissibilityAttribution[];
}

/** The one fine-grained reason. 1:1 with its trigger class. */
export type InadmissibilityReason = 'inadmissible-descriptor';

function render(value: unknown): string {
if (value === undefined) return '<absent>';
if (typeof value === 'string') return JSON.stringify(value);
return `<${value === null ? 'null' : typeof value}>`;
}

/** The four field values a descriptor presents to the validators, as authored. */
export interface AuthoredFields {
readonly apiVersion: unknown;
readonly kind: unknown;
readonly name: unknown;
/**
* `undefined` when `metadata.namespace` is wholly omitted.
*
* ADR-0015: the namespace is "validated as authored, never after defaulting", so
* the `default` substitution must not have happened yet when this value is read.
*/
readonly namespace: unknown;
readonly namespacePresent: boolean;
}

/** Read the four authored fields off a parsed descriptor document. */
export function authoredFields(document: DescriptorDocument): AuthoredFields {
const metadata = document.rawMetadata;
const isRecord =
typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata);
const record = isRecord ? (metadata as Record<string, unknown>) : undefined;

return {
apiVersion: document.rawApiVersion,
kind: document.rawKind,
name: record?.['name'],
namespace: record?.['namespace'],
namespacePresent: record !== undefined && Object.hasOwn(record, 'namespace'),
};
}

/**
* Apply all four predicates and collect every failure.
*
* Deliberately **not** short-circuiting. §3 requires two failing fields to produce
* two attributions, and a short-circuit would silently make the reported field a
* function of evaluation order.
*
* The namespace validator is invoked **only when the field is present**. An omitted
* `metadata.namespace` is admissible (ADR-0015, "Decision"), and the `default`
* substitution applies afterwards — in `identity/canonicalize.ts`, never here.
*/
export function classifyAdmissibility(document: DescriptorDocument): AdmissibilityResult {
const fields = authoredFields(document);

const invocations: readonly { name: AdmissibilityValidatorName; value: unknown }[] = [
{ name: 'validateApiVersion', value: fields.apiVersion },
{ name: 'validateKind', value: fields.kind },
{ name: 'validateEntityName', value: fields.name },
...(fields.namespacePresent
? [{ name: 'validateNamespace' as const, value: fields.namespace }]
: []),
];

const attributions: InadmissibilityAttribution[] = [];

for (const { name, value } of invocations) {
if (VALIDATORS[name](value)) continue;
attributions.push({
sourcePath: document.sourcePath,
documentIndexInFile: document.documentIndexInFile,
field: VALIDATOR_FIELDS[name],
validator: name,
pinnedBinding: PINNED_VALIDATOR_BINDINGS[name],
pinnedCommit: PINNED_BACKSTAGE_COMMIT,
observed: render(value),
});
}

return {
admissible: attributions.length === 0,
failedFields: attributions.map((attribution) => attribution.field),
attributions,
};
}

/**
* Build the fatal rejection an inadmissible descriptor raises.
*
* `detail` names every attribution, so a run aborting on a descriptor that failed
* two fields reports both. It is distinguishable from a `duplicate-canonical-id`
* record by `triggerClass` alone — a caller never has to parse `detail` to tell
* them apart, which is what FR-020's "MUST be distinguishable" requires in
* practice.
*/
export function inadmissibleRejection(
result: AdmissibilityResult,
): Rejection<InadmissibilityReason> {
if (result.admissible) {
throw new Error(
'inadmissibleRejection was called for an admissible result. Building a rejection ' +
'for a descriptor that passed would fabricate a determination that never happened.',
);
}

const detail = result.attributions
.map(
(attribution) =>
`${attribution.sourcePath}[${attribution.documentIndexInFile}]: ` +
`${attribution.field} rejected by ${attribution.validator} ` +
`(${attribution.pinnedBinding}, pinned at ${attribution.pinnedCommit}); ` +
`observed ${attribution.observed}`,
)
.join(' | ');

return { reason: 'inadmissible-descriptor', triggerClass: 'inadmissible-descriptor', detail };
}
145 changes: 145 additions & 0 deletions packages/adapters/catalog-backstage/src/admissibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* T048 · T053 — admissibility runs **before** canonicalization, and no inadmissible
* descriptor ever participates in a uniqueness comparison. Both enforced
* structurally rather than by convention.
*
* # Why "structurally" is the requirement, not "in the right order"
*
* `admissibility.md` §4.1: reversing the order "would make some descriptors collide
* *before* being found inadmissible, and the trigger class reported for the run
* would then depend on document order within the manifest. Order-dependence of the
* reported trigger is exactly the failure mode ADR-0015's ordering rule exists to
* prevent."
*
* A comment saying "call `admit` first" would not prevent that. The mechanism used
* here does:
*
* - {@link AdmittedDescriptor} carries a private brand that **cannot be constructed
* outside this module**. The only value of that type in existence comes from
* {@link admit} returning `admissible: true`.
* - `identity/canonicalize.ts` accepts an {@link AdmittedDescriptor} and nothing
* else. A caller holding a raw `DescriptorDocument` cannot canonicalize it — not
* because it is discouraged, but because the program does not typecheck.
* - {@link collectAdmitted} is **all-or-nothing**: handed a batch containing one
* inadmissible descriptor it returns the rejection and no descriptors at all. It
* has no "and the rest" branch to be tempted by, so the uniqueness comparison
* downstream is never reachable with an inadmissible member.
*
* # Duplicate detection is not a validity check (§6, FR-021)
*
* Two determinations, independent. Conformance evidence "MUST demonstrate that
* independence rather than assert it", and specifically must include a descriptor
* that is **inadmissible and canonically unique** — one that fails §2 while
* colliding with nothing. Without such a case a passing suite is "equally consistent
* with an implementation that has silently fused the two checks". That case is
* `test/inadmissible-and-unique.test.ts` (T054), and it is retained permanently.
*
* @see `specs/010-catalog-backstage/contracts/admissibility.md` §4, §4.1, §6
*/

import type { Rejection } from '../diagnostics.ts';
import type { DescriptorDocument } from '../descriptor/read.ts';
import {
type AdmissibilityResult,
type AuthoredFields,
type InadmissibilityReason,
authoredFields,
classifyAdmissibility,
inadmissibleRejection,
} from './classify.ts';

/**
* The brand.
*
* A real runtime symbol rather than a `declare`d type-only one, and **not
* exported**. Type-only branding would be erased at runtime, so a plain object cast
* with `as` would produce a value indistinguishable from a genuine admission once
* compiled. This way the guarantee holds in both directions: TypeScript refuses to
* construct the type outside this module, and at runtime the property key is a
* symbol no other module holds a reference to.
*/
const ADMITTED: unique symbol = Symbol('adrkit.catalog-backstage.admitted');

/**
* A descriptor that has passed all four validators.
*
* The only route to one is {@link admit}. This is the type-level expression of
* ADR-0015's ordering rule: canonicalization consumes this, so canonicalization
* cannot precede admissibility.
*/
export interface AdmittedDescriptor {
readonly [ADMITTED]: true;
readonly document: DescriptorDocument;
/** The four fields **as authored** — the namespace has not been defaulted yet. */
readonly fields: AuthoredFields;
}

/** The outcome of admitting one descriptor. */
export type AdmissionOutcome =
| { readonly admissible: true; readonly admitted: AdmittedDescriptor }
| {
readonly admissible: false;
readonly result: AdmissibilityResult;
readonly rejection: Rejection<InadmissibilityReason>;
};

/**
* Decide admissibility for one parsed descriptor.
*
* Note what this does **not** return on the failure branch: an
* {@link AdmittedDescriptor}, a canonical id, or a partially-usable descriptor.
* There is nothing on that branch a caller could canonicalize even by mistake.
*/
export function admit(document: DescriptorDocument): AdmissionOutcome {
const result = classifyAdmissibility(document);

if (!result.admissible) {
return { admissible: false, result, rejection: inadmissibleRejection(result) };
}

return {
admissible: true,
admitted: {
[ADMITTED]: true,
document,
fields: authoredFields(document),
},
};
}

/** All-or-nothing admission over a batch. */
export type BatchAdmission =
| { readonly ok: true; readonly admitted: readonly AdmittedDescriptor[] }
| {
readonly ok: false;
readonly result: AdmissibilityResult;
readonly rejection: Rejection<InadmissibilityReason>;
};

/**
* Admit a whole batch, or none of it.
*
* `admissibility.md` §5: on any inadmissible descriptor, "**no** entity from the
* same run is emitted, including entities already determined admissible". The
* failure branch carries no descriptors, so a caller cannot proceed to a uniqueness
* comparison over a partially-admitted set — the set does not exist.
*
* Descriptors are processed in the order given, so the reported attribution for a
* batch with two inadmissible members is deterministic rather than a function of
* iteration order.
*/
export function collectAdmitted(
documents: readonly DescriptorDocument[],
): BatchAdmission {
const admitted: AdmittedDescriptor[] = [];

for (const document of documents) {
const outcome = admit(document);
if (!outcome.admissible) {
return { ok: false, result: outcome.result, rejection: outcome.rejection };
}
admitted.push(outcome.admitted);
}

return { ok: true, admitted };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* T050 — the separator rule inside `isValidApiVersion`.
*
* ADR-0015 states two properties of the `apiVersion` binding "since the split is
* not obvious from the predicate alone", quoted here verbatim:
*
* > `isValidPrefixAndOrSuffix` splits on `/` and **rejects any value containing two
* > or more separators**, and a value with **no** separator is validated against
* > the suffix predicate alone — so a bare `v1` passes without the subdomain rule
* > ever being consulted.
*
* Both halves matter and both are easy to get wrong in opposite directions. An
* implementation that split on the *first* `/` and ignored the rest would accept
* `a/b/c`. One that required a prefix would reject the bare `v1` that ADR-0015
* explicitly says passes.
*
* @see `docs/adr/0015-validate-descriptors-against-backstage-field-formats-before-canonicalizing.md`
* @see `specs/010-catalog-backstage/spec.md` FR-017
*/

/** How a value splits across the `/` separator. */
export type SeparatorSplit =
/** No separator: the whole value is a suffix, and no prefix rule is consulted. */
| { readonly kind: 'suffix-only'; readonly suffix: string }
/** Exactly one separator: prefix and suffix are each validated by their own rule. */
| { readonly kind: 'prefix-and-suffix'; readonly prefix: string; readonly suffix: string }
/** Two or more separators: rejected outright, whatever the parts contain. */
| { readonly kind: 'too-many-separators'; readonly separatorCount: number };

/**
* Split `value` on `/` according to ADR-0015's separator rule.
*
* Counting separators *before* deciding anything is what makes the two-or-more
* rejection independent of what the parts happen to be.
*/
export function splitOnSeparator(value: string): SeparatorSplit {
const parts = value.split('/');
const separatorCount = parts.length - 1;

if (separatorCount >= 2) return { kind: 'too-many-separators', separatorCount };
if (separatorCount === 0) return { kind: 'suffix-only', suffix: value };

return {
kind: 'prefix-and-suffix',
prefix: parts[0] as string,
suffix: parts[1] as string,
};
}
Loading