/*
RFC-0211: the Content Knowledge Lifecycle (CKL) claim model. A claim is the
atomic unit of durable site truth — the binding of a value to a subject field,
qualified by provenance and time. Framework-free types + the canonical subject
address parser/formatter shared by every CKL command and validator (RFC-0212..0218).
- Do not read the filesystem, evaluate freshness, hash derivations, or emit Diagnostics — those live in the kernel checks and later CKL modules.
- Do not define the source descriptor (RFC-0214) or the ledger event (RFC-0217) — only the in-content claim shape.
- RFC-0211: initial claim model + subject address codec.
*/
/** RFC-0211: how a value is known. */
export type ClaimProvenanceKind = "external" | "derived" | "asserted" | "generated";
export const CLAIM_PROVENANCE_KINDS: readonly ClaimProvenanceKind[] = [
"external",
"derived",
"asserted",
"generated",
] as const;
/** Confidence an author/agent attaches to an asserted value. */
export type ClaimConfidence = "high" | "medium" | "low";
/**
* A content address — the subject a claim is about. Reuses RFC-0045 coordinates:
* a `collection`, a locale-independent `file` stem (RFC-0044), and a dotted
* `fieldPath` into the record. `lang` is present for language-scoped collections
* (e.g. `business/{lang}/…`).
*/
export interface ClaimSubject {
collection: string;
file: string;
fieldPath: string[];
lang?: string;
}
/** The temporal validity window of a claim (RFC-0212/0213). */
export interface ClaimValidity {
/** ISO date the value was last verified / asserted. */
asOf: string;
/** ISO date after which the claim is stale. */
validUntil?: string;
/** ISO 8601 duration, e.g. "P3M" — recurring review cadence. */
reviewEvery?: string;
}
/**
* A claim: the binding of a value to a subject field, qualified by provenance and
* time. Stored per-field in a `.claims.yaml` sidecar (RFC-0212); the value
* itself stays in the record body — a claim annotates, it never wraps the value.
*/
export interface Claim {
subject: ClaimSubject;
provenance: ClaimProvenanceKind;
validity?: ClaimValidity;
/** → source descriptor id (RFC-0214) when provenance = external. */
sourceRef?: string;
/** Source subject (RFC-0215) when provenance = derived. */
derivedFrom?: ClaimSubject;
/** Normalized hash of the source value at derivation time (RFC-0215). */
sourceHash?: string;
/** Responsible agent/human handle — routing (RFC-0216). */
owner?: string;
confidence?: ClaimConfidence;
}
// ─── Canonical subject address codec ────────────────────────────────────────
// String form: `/[/]#`
// business/de/location#residents
// business/uk/offer#price.monthly
// surface#someField (no lang, no nested path)
// `lang` is recognized as a 2-letter segment immediately after the collection.
const TWO_LETTER_LANG = /^[a-z]{2}$/;
export class ClaimSubjectParseError extends Error {}
/** Parse the canonical subject string into a ClaimSubject. Throws on malformed input. */
export function parseClaimSubject(input: string): ClaimSubject {
const hashIndex = input.indexOf("#");
if (hashIndex < 0) {
throw new ClaimSubjectParseError(
`Subject "${input}" is missing the "#fieldPath" part (expected /[/]#)`,
);
}
const pathPart = input.slice(0, hashIndex);
const fieldPart = input.slice(hashIndex + 1);
const segments = pathPart.split("/").filter((s) => s.length > 0);
if (segments.length < 2) {
throw new ClaimSubjectParseError(
`Subject "${input}" must have at least / before "#"`,
);
}
if (fieldPart.length === 0) {
throw new ClaimSubjectParseError(`Subject "${input}" has an empty fieldPath after "#"`);
}
const collection = segments[0]!;
let lang: string | undefined;
let fileSegments = segments.slice(1);
if (fileSegments.length >= 2 && TWO_LETTER_LANG.test(fileSegments[0]!)) {
lang = fileSegments[0]!;
fileSegments = fileSegments.slice(1);
}
return {
collection,
lang,
file: fileSegments.join("/"),
fieldPath: fieldPart.split("."),
};
}
/** Render a ClaimSubject back to its canonical string. Inverse of parseClaimSubject. */
export function formatClaimSubject(subject: ClaimSubject): string {
const langPart = subject.lang ? `${subject.lang}/` : "";
return `${subject.collection}/${langPart}${subject.file}#${subject.fieldPath.join(".")}`;
}
/** Structural equality on subjects (ignores undefined-vs-absent lang differences). */
export function claimSubjectsEqual(a: ClaimSubject, b: ClaimSubject): boolean {
return formatClaimSubject(a) === formatClaimSubject(b);
}