import { z } from 'zod'; import { ErrorObject } from 'ajv'; /** * SYS-3263: the jurisdiction registry, in the package that every app shares. * * WHY IT LIVES HERE AND NOT IN finsys-api * * finsys-api has carried `JURISDICTION` since SYS-2872, and that is where it * was needed first — extraction routing and spec dispatch. But finsys-api * predates this package: @finsys/core came later, to bridge contracts across * the apps that sit on it. A jurisdiction is now something a FORM declares * (below), something a PROGRAM declares, and something FinHub and * finsys-client both have to render. That makes it a shared contract, and a * shared contract stranded in one consumer is how two copies start. * * The definitive corrective example is one repo over: `IhsFieldProvenance` is * declared BOTH here and in finsys-api, with nothing detecting drift between * them. This registry is deliberately not going the same way. * * SO: finsys-api's copy must import from here rather than keep its own. That * de-duplication is SYS-3258's, not this ticket's — but it is the reason this * file exists here rather than being copied. Until it lands the two sets * agree by inspection, which is exactly the fragile state that ends the * moment somebody adds Thailand to one of them. * * A jurisdiction is a Program-level FIXED property: it determines which * document types exist, which extraction specs apply, and which regulations * govern. It is a different axis from document language (per-file, chooses an * extraction endpoint) and from display language (per-user i18n). */ declare const JURISDICTION: { readonly MALAYSIA: "MY"; readonly VIETNAM: "VN"; readonly THAILAND: "TH"; }; type Jurisdiction = (typeof JURISDICTION)[keyof typeof JURISDICTION]; /** * The platform was Malaysia-only before SYS-2872, so an absent jurisdiction * always means Malaysia — on a program, on an IHS row, and now on a form * spec. Readers must treat absence as MY rather than expecting a backfill: * every form authored before this shipped has no declaration and none will * be added retroactively. */ declare const DEFAULT_JURISDICTION: Jurisdiction; /** Every declarable jurisdiction code, for validation and iteration. */ declare const JURISDICTION_CODES: ReadonlyArray; /** * Narrows an arbitrary value to a declarable jurisdiction code. * * Deliberately strict about case: "my" is not accepted as Malaysia. A form * spec is authored data, and silently normalising it would let two spellings * of the same jurisdiction coexist — which reads as agreement until something * compares them literally. */ declare function isJurisdiction(value: unknown): value is Jurisdiction; /** * Resolves a possibly-absent declaration to the jurisdiction that actually * applies. Absent means Malaysia; a present-but-unknown value is NOT silently * defaulted — callers that accept unvalidated input should reject it instead, * because defaulting an unrecognized jurisdiction to MY would route a foreign * form into Malaysian handling, which is the failure this whole axis exists * to prevent. */ declare function resolveJurisdiction(value: string | null | undefined): Jurisdiction | null; /** * SYS-3266: why a form and a program are not compatible. * * An enum rather than a string union — the set will grow (a form declaring a * jurisdiction the deployment has retired is the obvious next member), and a * consumer switching on it should get an exhaustiveness error rather than a * silently-unhandled string. */ declare enum IncompatibilityReason { /** Both resolved, and they are different jurisdictions. */ Mismatch = "mismatch", /** The FORM declares something that is not a jurisdiction we recognize. */ UnresolvableForm = "unresolvable_form", /** The PROGRAM declares something that is not a jurisdiction we recognize. */ UnresolvableProgram = "unresolvable_program" } /** * The outcome of comparing a form's jurisdiction with a program's. * * Deliberately not a boolean. Every caller that refuses a pairing has to tell * somebody WHICH pairing and WHY — "this form is declared MY, that program is * VN" — and a bare boolean makes each of them re-derive that from inputs they * then have to keep in scope. Returning the resolved values means the message * is built from what the decision was actually made on, not from a caller's * second look at the same data. */ type JurisdictionCompatibility = { readonly compatible: true; readonly jurisdiction: Jurisdiction; } | { readonly compatible: false; readonly reason: IncompatibilityReason; /** Resolved where possible; the raw declaration where it could not be. */ readonly form: Jurisdiction | string | null | undefined; readonly program: Jurisdiction | string | null | undefined; }; /** * SYS-3266: is this form usable to submit to this program? * * THE ONE PREDICATE. Kain's rule is symmetric, so this is called from three * places rather than reimplemented in each: filtering forms for a chosen * program, filtering programs for a chosen form, and refusing a mismatch at * submit. The first two are the reason nobody reaches the third. * * Consumed by FinHub (the manager tier, SYS-3265) and @finsys/borrower-client * (the SDK tier, SYS-3269). Raw HTTP callers are unguarded by design. * * ABSENCE resolves to Malaysia on both sides, via `resolveJurisdiction`, so * there is ONE definition of "absent" rather than a fourth — SYS-3263's review * found three implementations of that question disagreeing on null and ''. * Note that an empty string is NOT absent: finsys-api fails closed on it, and * a form or program carrying '' is unresolvable, not Malaysian. * * AN UNRECOGNIZED VALUE IS COMPATIBLE WITH NOTHING — including an identical * unrecognized value on the other side. Two forms both declaring "VM" are not * evidence of agreement, they are evidence of the same typo twice, and * treating them as a match would let a pair of mistakes authorise each other. */ declare function checkJurisdictionCompatibility(formJurisdiction: string | null | undefined, programJurisdiction: string | null | undefined): JurisdictionCompatibility; /** * A message a consumer can show without re-deriving the decision. * * Kept beside the predicate so every tier phrases a refusal identically — * FinHub, the SDK and any future caller. A user hitting the same wall in two * products should not have to work out that it is the same wall. */ declare function describeIncompatibility(result: JurisdictionCompatibility): string | null; /** * SYS-3284/SYS-3285: the currency a jurisdiction's amounts are DISPLAYED in * when the value itself does not say. * * READ THIS BEFORE USING IT — it is narrower than it looks. * * SYS-3249 established that a currency belongs to the OBSERVATION, not to a * field, a table or a country: one document can legitimately report several * currencies, so nothing may stamp a currency across a record. That still * holds and this does not weaken it. * * What this map is: a DISPLAY DEFAULT for the case where a value carries no * recorded currency of its own. Today that is every value, because * `IhsFieldProvenance.currency` has no producer yet — so FinHub and * finsys-client were left choosing between printing a hardcoded "RM" (wrong * outside Malaysia) and printing a bare number (ambiguous everywhere). This * gives them a third option that is right in the common case and overridable * in the uncommon one. * * What it is NOT: a fact about the record. It must never be written to a * row, returned from an API as the record's currency, or used to decide * anything but rendering. The moment a value carries its own currency, that * wins — see `resolveDisplayCurrency`. * * A jurisdiction whose amounts are commonly reported in a currency other than * its own is exactly why the override exists rather than why this map should * grow conditionals. */ declare const JURISDICTION_DISPLAY_CURRENCY: Readonly>; /** * Whether a jurisdiction's national identity number ENCODES THE HOLDER'S BIRTH * DATE in a position software can read. * * This exists because a Malaysia-specific parsing rule was running on every * application regardless of jurisdiction: the first six digits of an NRIC are * the birth date as YYMMDD, and that is true of no other national id we * accept. Measured against real id shapes (SYS-3257): * * MY NRIC 900115085432 -> 1990-01-15 correct * VN CCCD 001201123456 -> 2000-12-01 plausible and WRONG * TH natID 1103701234567 -> "Invalid date" * TH natID 5501200098765 -> 2055-01-20 a birth date thirty years hence * * A Vietnamese CCCD leads with a province code, a gender/century digit and a * two-digit birth YEAR — not a date. A Thai national id encodes no birth date * anywhere: digit 1 is person type, 2–5 province and district, the rest * sequence and a check digit. So the failure was silent in both directions — * sometimes an unparseable string written to a date column, sometimes a * believable date that is simply false. * * A registry entry rather than an equality check at the call site, so a fourth * jurisdiction is a line here instead of an edit to whatever function happens * to parse ids that week. */ declare const JURISDICTION_NATIONAL_ID_ENCODES_BIRTH_DATE: Readonly>; /** * Whether a birth date may be derived from a national id under this * jurisdiction. * * ABSENCE RESOLVES TO MALAYSIA, deliberately, via `resolveJurisdiction` — the * same platform rule the rest of this module follows. That is load-bearing * here rather than merely consistent: rows created without a resolved program * carry `jurisdiction = null`, and every one of them is Malaysian. A literal * `=== 'MY'` check at the call site would silently stop deriving for all of * them, turning a fix for Vietnam into a regression for Malaysia. * * An UNRESOLVABLE jurisdiction returns false. Deriving on a code we do not * recognise is exactly the guess this replaces. */ declare function nationalIdEncodesBirthDate(jurisdiction: string | null | undefined): boolean; /** * The currency to render a value in: the value's own if it has one, otherwise * the jurisdiction's display default, otherwise nothing. * * Returning `undefined` is a real answer and callers must handle it — it means * "no basis to name a currency", and printing a bare number is then correct. * Substituting a guess here is how a Vietnamese figure ends up labelled MYR, * which is the bug this exists to fix. * * @param valueCurrency ISO 4217 recorded on the observation, when one exists. * @param jurisdiction the record's jurisdiction; absence resolves to Malaysia * the same way it does everywhere else on this axis. */ /** * What a caller passes as `jurisdiction` when it has NO BASIS to name one. * * There are two different "I don't have a jurisdiction", and collapsing them * is a real bug: * * - A RECORD whose jurisdiction column is null. The platform rule says * Malaysia, and that rule is right — the column predates jurisdictions * and every such row is Malaysian. Pass `null`. * * - A CALL SITE with no record at all: a form with no program selected, a * total spanning several countries, a page that simply was not given one. * Malaysia is not a default here, it is a fabrication. Pass this. * * Both consumers of the 5.4.0 money helpers invented this concept privately * before it existed — one made the parameter required with a warning comment, * the other defined its own empty-string sentinel — which is what naming it * here is a response to. Two independent workarounds for one missing idea. * * It is the empty string because `resolveJurisdiction('')` already fails * closed; this gives that behavior a name and a contract instead of leaving * it an implementation detail two repos happened to discover. */ declare const NO_JURISDICTION_BASIS = ""; declare function resolveDisplayCurrency(valueCurrency: string | null | undefined, jurisdiction: string | null | undefined): string | undefined; /** * Survey Generator - Converts unified form configurations into SurveyJS-compatible JSON * * This module converts a unified form-config.json into SurveyJS-compatible JSON, * resolving field references, applying dynamic titles, and grouping by category. */ interface Category { id: string; name: string; } interface Choice { value: string; text: string; } interface Validator { type: "regex" | "email" | "numeric" | "text" | "expression" | "answercount" | "custom"; text?: string; regex?: string; minValue?: number; maxValue?: number; minLength?: number; maxLength?: number; minCount?: number; maxCount?: number; expression?: string; validator?: string; } /** * FieldData - Field definition in the unified form config */ interface FieldData { name?: string; displayName?: string; type: string; inputType?: "text" | "number" | "tel" | "date" | "email" | "password" | "url"; category?: string; maxLength?: number | string; min?: number | string; max?: number | string; step?: number; html?: string; choices?: Choice[]; validators?: Validator[]; visible?: boolean; visibleIf?: string; required?: boolean; isRequired?: boolean; placeholder?: string; defaultValue?: string | number | boolean | string[]; readOnly?: boolean; startWithNewLine?: boolean; enableIf?: string; titleLocation?: "default" | "top" | "bottom" | "left" | "hidden"; /** * Semantic refinement of `type`, using the SAME vocabulary as the * canonical field registry's `CanonicalFieldSpec.kind` (SYS-3249). One * word, one meaning, on both halves of the system. * * `kind: "money"` says the field holds an amount of money. It does NOT * say WHICH money — the denomination comes from the program's * jurisdiction at render time, or from the value's own provenance * envelope once it has one. * * The point is that a label must never carry the currency. Writing * "Financing Amount (RM)" makes the field Malaysian by typography: it * renders identically under a Vietnamese program, where it is simply * false, and no code can correct it because the currency is prose. A * declared kind is something a renderer can act on. */ kind?: "money"; ihs_column_names?: string[]; requiredForEvaluation?: boolean; [key: string]: unknown; } /** * FieldReference - How fields are referenced in pages */ type FieldReference = string | { ref: string; [key: string]: unknown; } | { definition: FieldData & { name: string; }; }; interface PageConfig { id: string; title?: string; description?: string; showTOC?: boolean; showProgressBar?: boolean; showCategoryHeadings?: boolean; layout?: "default" | "grid" | "vertical"; fields: FieldReference[]; } /** * UnifiedFormConfig - The main configuration type for the unified form format */ interface UnifiedFormConfig { $schema?: string; schemaVersion?: string; displayName?: string; templateIcon?: string; categories: Category[]; fields: Record; pages?: PageConfig[]; /** * SYS-3263: the single jurisdiction this form is valid for; absent means * Malaysia. Declared HERE and not only on FormSpec because FinHub never * constructs a FormSpec — it handles form configs exclusively as raw * UnifiedFormConfig (validates, stores in a JSONB column, ships to React). * Without this the declaration is invisible to two of the three consumers, * and SYS-3265 enforcement would need a cast to read it. */ jurisdiction?: Jurisdiction; } interface SurveyElementJSON { type: string; name: string; title?: string; isRequired?: boolean; maxLength?: number; storeDataAsText?: boolean; category?: string; [key: string]: unknown; } interface SurveyPageJSON { name: string; title: string; elements: Array<{ type: string; name: string; elements: SurveyElementJSON[]; }>; } interface SurveyJSON { title: string; logoPosition?: string; pages: SurveyPageJSON[]; showQuestionNumbers?: string; questionErrorLocation?: string; completedHtml?: string; showTOC?: boolean; completeText?: string; showPreviewBeforeComplete?: string; showProgressBar?: string; widthMode?: string; width?: string; } interface ResolvedField extends FieldData { name: string; } /** * Apply dynamic title logic (e.g., bank statements with dynamic month labels, * financial statements with dynamic year labels) */ declare function applyDynamicTitles(field: ResolvedField): ResolvedField; /** * Generate SurveyJS JSON from unified form config */ declare function generateSurveyJson(config: UnifiedFormConfig): SurveyJSON | Record; /** * Resolve all fields from a page to full field definitions */ declare function resolvePageFields(page: PageConfig, fields: Record): ResolvedField[]; /** * Get category name from category ID (supports string or number IDs) */ declare function getCategoryName(categoryId: string | undefined, categories: Category[]): string; /** * Group fields by their category, maintaining order and creating section breaks */ interface FieldGroup { category: string; categoryName: string; fields: ResolvedField[]; } declare function groupFieldsByCategory(fields: ResolvedField[], categories: Category[]): FieldGroup[]; /** * Utility functions for @finsys/core */ declare function getPastMonthLabel(monthsAgo: number): string; declare function getPastYearLabel(yearsAgo: number): string; /** * Evaluate a SurveyJS-style expression against a data object. * e.g. "{totalFinancing} > 50000" * * NOTE: This is a simplified evaluator that replaces {key} with data[key] access. * It does not support complex SurveyJS functions like age() or complicated nested paths unless handled by JS. */ declare function evaluateExpression(expression: string, data: any): boolean; interface RHFStep { id: number; title: string; description?: string; fields: ResolvedField[]; showCategoryHeadings: boolean; layout?: "default" | "grid" | "vertical"; } interface RHFSchemaOutput { zodSchema: z.ZodTypeAny; baseSchema: z.ZodObject; defaultValues: Record; fields: ResolvedField[]; groupedFields: FieldGroup[]; steps: RHFStep[]; displayName: string; categories: Category[]; templateIcon?: string; } declare function generateRHFSchema(config: UnifiedFormConfig): RHFSchemaOutput; /** * Get Zod schema for a specific step */ declare function getStepSchema(step: RHFStep, fullSchema: any): z.ZodTypeAny; /** * Extract default values for a specific step */ declare function getStepDefaultValues(step: RHFStep, allDefaults: Record): Record; interface ValidationResult { valid: boolean; errors?: ErrorObject[]; message?: string; } /** * Validates a unified form-config.json object against its schema */ declare function validateFormConfig(data: unknown): ValidationResult; declare class FormFieldCategory { private _id; private _name; constructor(id: string | number, name: string); get id(): string; get name(): string; set name(value: string); toJSON(): { id: string; name: string; }; } declare enum FieldType { FILE = "file", TEXT = "text", DROPDOWN = "dropdown", BOOLEAN = "boolean", CHECKBOX = "checkbox", RADIOGROUP = "radiogroup", SLIDER = "slider", HTML = "html", TAGBOX = "tagbox", NUMBER = "number", EMAIL = "email", COMMENT = "comment", RANGE = "range", UNKNOWN = "unknown" } interface FormFieldType { name: string; displayName: string; } interface FormFieldInputType { name: string; displayName: string; } interface FormFieldTypeDefinitions { version: number; types: FormFieldType[]; input_types: FormFieldInputType[]; } interface DropdownOption { id: number; name: string; displayName: string; choices: Choice[]; } interface EditorValidator { id: number; displayName: string; class: string; description: string; isRequired: boolean; validators: Validator[]; } declare abstract class FormField { fieldObject: FieldData; constructor(name: string, displayName: string, type: string, category: string); static fromObject(fieldObject: FieldData): FormField; abstract getFieldNames(): string[]; protected cleanObject(obj: any): any; toJSON(): any; get displayName(): string; set displayName(newDisplayName: string); get name(): string; get type(): FieldType; get inputType(): string; get categoryId(): string; set categoryId(categoryId: string); get defaultValue(): any; set defaultValue(value: any); get placeholder(): string | undefined; get validators(): Validator[] | undefined; get choices(): Choice[]; set choices(choices: Choice[]); get visible(): boolean; get visibleIf(): string | undefined; get isRequired(): boolean; get startWithNewLine(): boolean; get requiredForEvaluation(): boolean; get maxLength(): number | undefined; get min(): number | undefined; get max(): number | undefined; } declare class BasicFormField extends FormField { constructor(name: string, displayName: string, type: string, category: string); getFieldNames(): string[]; } declare class FileFormField extends FormField { constructor(name: string, displayName: string, type: string, category: string, ihs_column_names: string[]); getFieldNames(): string[]; } declare class FormSpec { private _categories; private _fields; private _displayName; private _templateIcon?; private _schemaVersion; private _pages; /** * SYS-3263: the single jurisdiction this form is valid for. * * SINGLE, not a list, decided by Kain 2026-08-06: a form for jurisdiction X * may be used by many programs in jurisdiction X. Forms are NOT scoped to a * program and do not become so — the rule is that a form's jurisdiction and * its target program's jurisdiction must AGREE. * * Optional, and absent means Malaysia. Every form authored before this * shipped has no declaration, and none is being backfilled — the same * null-means-MY precedent SYS-2872 set for Program and Ihs. * * The accepted cost of a single value: a genuinely jurisdiction-neutral * form — a pure document-upload flow with no country-specific fields — has * no way to say so, and must either pick one jurisdiction or be duplicated. * No such form exists today. Widening a scalar to a list later is additive * and needs no backfill, which is why the cheaper option is the one taken. * * Nothing ENFORCES agreement yet; SYS-3265 does that, and SYS-3264 supplies * the missing half it needs (a submission does not currently record which * form produced it). This field only makes the fact declarable. */ private _jurisdiction?; constructor(displayName: string, categories: FormFieldCategory[], fields: FormField[], pages?: PageConfig[], templateIcon?: string, schemaVersion?: string, jurisdiction?: Jurisdiction); static readonly MIN_PARSER_SCHEMA_VERSION = "v1.0.0"; static readonly MAX_PARSER_SCHEMA_VERSION = "v2.0.0"; get fieldNames(): string[]; get schemaVersion(): string; get requiresUpgrade(): boolean; upgradeToV2(): void; get displayName(): string; set displayName(newDisplayName: string); /** * The declared jurisdiction, or undefined if the form does not declare one. * Prefer `effectiveJurisdiction` for any decision — a reader that treats * undefined as "no jurisdiction" rather than "Malaysia" will refuse every * form authored before SYS-3263. */ get jurisdiction(): Jurisdiction | undefined; set jurisdiction(value: Jurisdiction | undefined); /** * The jurisdiction that actually applies: the declaration, or Malaysia when * absent — and NULL when the declaration is present but unrecognized. * * Delegates rather than using `?? DEFAULT_JURISDICTION`. `??` is nullish-only, * so it returned '' for an empty declaration and 'my' for a miscased one — * values outside the Jurisdiction union, handed back typed AS Jurisdiction. * This is the accessor SYS-3265 compares against a program's jurisdiction, so * it was the one place the "unknown is not the default" rule failed open. * * Callers must handle null. It means "this form declares something we do not * recognize", which is not the same as "this form is Malaysian". */ get effectiveJurisdiction(): Jurisdiction | null; get templateIcon(): string | undefined; set templateIcon(value: string | undefined); get categories(): FormFieldCategory[]; set categories(categories: FormFieldCategory[]); get fields(): FormField[]; set fields(fields: FormField[]); get pages(): PageConfig[]; set pages(value: PageConfig[]); addField(field: FormField): void; updateField(field: FormField): void; removeField(fieldName: string): void; getFieldsByCategory(categoryName: string): FormField[]; validate(): { valid: boolean; errors?: any[]; }; private cleanObject; toJSON(): object; static fromJSON(jsonString: string): FormSpec; } interface FormValidatorDefinitions { options: EditorValidator[]; version: number; } declare class FormFieldValidator implements EditorValidator { id: number; displayName: string; class: string; description: string; isRequired: boolean; validators: Validator[]; constructor(validatorData: EditorValidator); validate(value: any): boolean; private validateNumeric; private validateText; } declare const BASE_FIELD_SPECS: { schemaVersion: string; categories: Category[]; fields: FieldData[]; }; declare const FIELD_TYPE_DEFINITIONS: FormFieldTypeDefinitions; declare const DEFAULT_VALIDATOR_DEFINITIONS: FormValidatorDefinitions; declare function getBaseFieldSpecs(): FieldData[]; declare function getBaseCategories(): Category[]; /** * Returns a cached Map of base field name → FieldData. * Keyed by each field's `name` property from the base specs. */ declare function getBaseFieldSpecMap(): Map; declare function getBaseFieldNames(): string[]; declare enum IhsValueFormat { STRING = "string", CURRENCY = "currency", NUMBER = "number", PERCENTAGE = "percentage", DATE = "date", TABLE = "table" } interface IhsFieldDetail { name: string; displayName: string; category: string; value: unknown; valueFormat: IhsValueFormat; } interface IhsDetailCategory { category: string; items: { name: string; displayName: string; value: unknown; valueFormat: IhsValueFormat; }[]; } declare enum FileFieldTableType { TIME_SERIES = "timeSeries", KEY_VALUE = "keyValue" } /** * Per-field extraction provenance (SYS-2737/SYS-2741). The single canonical * envelope — finsys-api writes it (ihs_field_metadata), finsys-core surfaces it * onto detail cells, both consumers render it. Vocabulary mirrors adapter_runs * (SYS-2441) so FinXtract + adapter provenance converge at SYS-2499 Phase 5. * * source — who produced the value, e.g. "finxtract:ssm" * confidence — normalized 0..1; null when derived/computed (no score) * observedAt — ISO wall-clock of the extraction run * sourceRunId — the extraction run/job id that wrote it * origin — "extracted" (a real {value,confidence} leaf) vs "derived" * (computed / no-confidence path) vs "manual" (SYS-2806, a * lender-entered correction, committed once its edit overlay * is approved). A "derived" field must render as "no * confidence available", never a fabricated low score; a * "manual" field must render as an edit indicator, not a * confidence dot — confidence is always null for it too. */ /** * Canonical origin values, single source of truth (Gemini-review finding: * avoids duplicating the literal strings between the type and the runtime * guard) -- mirrors the IhsStatus/IHS_VALID_STATUSES pattern in * ihs-status.ts. */ declare const IHS_FIELD_ORIGINS: readonly ["extracted", "derived", "manual"]; type IhsFieldOrigin = (typeof IHS_FIELD_ORIGINS)[number]; interface IhsFieldProvenance { source: string; confidence: number | null; /** * ISO wall-clock of the extraction run. * * SYS-3334 F7/F10 (round 2): `fieldProvenanceFromView` (ihs-processing.ts) * writes `''` here when the source instance carries no `observedAt` at * all — kept a required `string` rather than made optional, because * loosening this type ripples into every consumer that already * destructures it as one (a required→optional change is exactly the kind * of narrowing-at-the-read-site this package's semver discipline tries to * avoid inflicting on downstream `tsc` builds). A reader that formats this * value must treat `''` the same as "unknown", exactly as it must already * treat a value that never arrived: `new Date('')` is `Invalid Date`, not * a thrown error, so a caller that skips the guard fails quietly rather * than loudly. Documented here rather than fixed silently, so the sentinel * is a decision on record instead of a bug waiting to be rediscovered. */ observedAt: string; sourceRunId: string | null; origin: IhsFieldOrigin; /** * SYS-3249: the denomination of a monetary value (ISO 4217, e.g. "MYR", * "VND", "THB"). Present only for `kind: "money"` canonical fields. * * It lives here rather than on the field definition because currency is * a property of the OBSERVATION, not of the field: one source can report * several currencies in a single document, so a field-level currency * could never be right for more than one of them. This envelope is * already written per-field, by the same call that writes the value, at * the same instant — which is exactly the granularity a denomination * needs. * * OPTIONAL, and absence is not "no currency" — it is "written before * SYS-3249, or by a producer that does not yet record one". Readers must * treat absence as unknown and say so, never as a default currency. A * value silently rendered in the wrong denomination is the failure this * field exists to prevent, and defaulting would reintroduce it wearing a * different hat. * * PRECEDENCE against the document-level `currency` canonical field. * `finxtract-financial-statement` also declares a canonical field named * `currency` ("Reporting currency stated on the document"). The two are * not rivals and must not be merged: * * - that field is the DOCUMENT's declared reporting currency, one value * per statement, and is a data point in its own right; * - this is the denomination of ONE observed value. * * This wins wherever both exist, because the whole reason currency sits on * the observation is that a single document can report several — an FX * transaction, a foreign-currency balance. When this is absent the document * field is a reasonable SOURCE for a producer populating it, but it is not * a substitute at read time: a reader that falls back to it would state a * denomination the value never claimed. */ currency?: string; } /** * `IhsFieldProvenance` plus the value an overlay projection REPLACED. * * SYS-3334 F3 (round 2): `fieldProvenanceFromView` synthesizes provenance * from `CanonicalView` envelopes, which — under a lender-overlay projection * (`?overlay=mine`) — can carry `originalValue` (`CanonicalFieldEnvelope`'s * own member: the attested value a staged edit is standing in for). Plain * `IhsFieldProvenance` has nowhere to put that; widening it for every * consumer to carry an almost-always-absent member is the wrong trade for * what only this one path needs, so it is a separate, ADDITIVE type instead. * `originalValue` is `unknown` rather than the envelope's own * `number | boolean | string`, because a table cell already renders `unknown` * data (`FileFieldTableItem.data`) and narrowing it here would just move the * cast to every caller. */ type IhsFieldProvenanceWithOriginal = IhsFieldProvenance & { originalValue?: unknown; }; /** * Type guard: narrows an unknown value to `IhsFieldOrigin` iff it is one of * the three canonical origin strings. `origin` was previously validated * only by the TS union — this is the first runtime check. */ declare function isValidIhsFieldOrigin(origin: unknown): origin is IhsFieldOrigin; interface FileFieldTableItem { displayName: string; timePeriods: string[]; data: Record; formattedData: Record; type: FileFieldTableType; isNumeric: boolean; /** * SYS-2741: per-cell extraction provenance, keyed exactly like `data` (period * key for TIME_SERIES, `'value'` for KEY_VALUE). `confidence` carries only * scored (origin 'extracted') cells so a numeric dot never renders for a * derived value; `provenance` carries the full envelope for every known cell. */ confidence?: Record; provenance?: Record; } interface FileFieldTableData { name: string; displayName: string; type: FileFieldTableType; items: FileFieldTableItem[]; hasData: boolean; } /** * One sibling-table row as finsys-api's `docInstanceStorageService` writes * it (SYS-2842) -- the unbounded counterpart to a T{n}-suffixed wide-table * slot. `instanceKey` is the real, collision-free per-document key; * `sourceLabel` is a human label when the doc type has one (e.g. a bank * name); `timePeriod` is the same descriptive value column the legacy * T{n} scheme used, kept for period-labeling and legacy provenance lookup * (SYS-2886 Phase 5) -- no longer the row's key. Metric fields are the * category's own base (unsuffixed) column names. */ interface InstanceRow { instanceKey: string; sourceLabel?: string | null; timePeriod?: string | null; [metricKey: string]: unknown; } /** * Per-document File metadata attached by finsys-api `getIhsDetailsById` as the * `documentMetadata` sibling map (SYS-2765), keyed by the document's raw stored * path. The single source for the documents table's Type / Size / Uploaded * columns — the IHS doc fields themselves carry only the path. */ interface DocumentFileMetadata { fileName: string | null; fileType: string | null; fileSize: number | null; createdAt: string | null; } /** * Intrinsic, data-derived capability eligibility for a document row. Reflects * ONLY what the IHS payload implies (doc type + a resolvable file); the consumer * ANDs in its own runtime gates (readOnly, allowReupload, and — for viewJson — * whether extraction has actually completed) before showing a control. */ interface DocumentRowCapabilities { /** A downloadable original exists (a resolvable documentId). */ download: boolean; /** The doc type produces extracted JSON (consumer still gates on "Extracted"). */ viewJson: boolean; /** The doc type can be re-extracted. */ reExtract: boolean; /** The doc type accepts a replacement upload (finsys-api /ihs/client/update/document). */ reUpload: boolean; } /** * One uploaded document, presentation-agnostic — built by `buildDocumentRows` * from the IHS payload + its `documentMetadata` map. Rendered identically by * FinHub (React) and finsys-client (Edge). Live extraction status + confidence * are NOT payload-derived; the consumer overlays them by matching * `(docType, timePeriod)`. */ interface DocumentRow { /** IHS field key, e.g. 'bankStatements'. */ docType: string; /** Human label for the doc-type group, e.g. 'Bank Statements'. */ label: string; /** 0-based position within the docType group. */ index: number; /** Best display name (resolved file name, or a label + period/index fallback). */ displayName: string; /** Id for the download link; null when no file path is resolvable. */ documentId: string | null; /** Raw stored path — the join key back to `documentMetadata`. */ path: string | null; /** Extraction time-period token: 'T{month|year}' or 'ALL'. */ timePeriod: string; /** Human period label ('Jan 2026' / 'Year 2024') or null. */ periodLabel: string | null; /** Resolved file name; null when the stored name looks like an opaque id. */ fileName: string | null; /** File type label (extension → MIME subtype, upper-cased) or null. */ fileType: string | null; /** File size in bytes, or null when unknown. */ fileSize: number | null; /** ISO upload timestamp (File.createdAt) or null. */ uploadedAt: string | null; /** Intrinsic capability eligibility (consumer overlays its runtime gates). */ capabilities: DocumentRowCapabilities; } /** * GENERATED FILE — DO NOT EDIT. * * Regenerate with `npm run gen:vocabulary`; `npm run gen:vocabulary -- --check` * fails if this file has drifted from src/data/adapter-categories.json, and * runs in the test suite. * * Source of truth is the JSON. This exists so that a name the platform has * retired is a COMPILE error at every consumer rather than a permissive * runtime miss — the registry's own lookups fail open by design, which is * exactly why a rename previously had to be found by hand. * * 24 categories · 288 canonical fields · 105 retired names */ /** Every category id the registry declares. */ type AdapterCategoryId = "applicant-address" | "applicant-collateral" | "applicant-contact" | "applicant-demographics" | "applicant-employment" | "applicant-identity" | "applicant-income" | "applicant-obligations" | "bank-statement" | "company-profile" | "company-registration" | "document-intake" | "epf-statement" | "financial-statement" | "finxtract-bank-statement" | "geolocation" | "payment-network" | "payslip" | "person-identity" | "related-person" | "social-media" | "subject-company" | "telco-carrier" | "trade-credit"; /** Every canonical field name any category declares. */ type CanonicalFieldNameLiteral = "accountFlags24m" | "accountNumber" | "accountTenureMonths" | "accountingRevenue12m" | "accuracyMeters" | "activeTenureMonths" | "addressCity" | "addressCountry" | "addressLine1" | "addressLine2" | "addressLine3" | "addressMatchScore" | "addressPostcode" | "addressStateCode" | "apDaysPayableOutstanding" | "arCurrentRatio" | "arDaysSalesOutstanding" | "arOverdue90PlusRatio" | "arTotalOutstanding" | "arpu" | "arpuStability12m" | "basicPay" | "bouncedTransactionsCount" | "bucket" | "businessCommencementDate" | "businessNature" | "businessNatureCode" | "businessOrigin" | "businessSector" | "capitalReserves" | "cashConversionCycleDays" | "closingBalance" | "collateralMarketValue" | "collateralPurchasePriceOnTheRoad" | "commuteRegularityRatio" | "companyEntityType" | "companyIncorporationDate" | "companyLastOldName" | "companyName" | "companyNameDateOfChange" | "companyRegNo" | "companySizeCode" | "companyStatus" | "computationMode" | "consolidated" | "contactAreaCode" | "contactExtension" | "contactName" | "contactRelationship" | "contactValue" | "costOfGoodsSold" | "currency" | "currentAssetAmountDueFromAssociatedCompanies" | "currentAssetAmountDueFromCustomer" | "currentAssetAmountDueFromDirector" | "currentAssetAmountDueFromHoldingCompany" | "currentAssetAmountDueFromSubsidiaryCompanies" | "currentAssetAmountOwingByShareholders" | "currentAssetCashAndBankBalances" | "currentAssetCashAndCashEquivalents" | "currentAssetCashAtBanks" | "currentAssetCashOnHand" | "currentAssetContractAssets" | "currentAssetCurrentTaxAssets" | "currentAssetDepositsAndPrepayments" | "currentAssetDepositsCashAndBankBalances" | "currentAssetDerivative" | "currentAssetDevelopmentPropertiesOrExpenditure" | "currentAssetFinancialAssetAtFairValue" | "currentAssetInventories" | "currentAssetLandUseRight" | "currentAssetOtherReceivables" | "currentAssetOtherReceivablesAndDeposits" | "currentAssetOtherReceivablesAndPrepayments" | "currentAssetOtherReceivablesDepositsAndPrepayments" | "currentAssetPrepayments" | "currentAssetRestrictedCash" | "currentAssetShortTermDeposits" | "currentAssetShortTermInvestments" | "currentAssetTaxRecoverable" | "currentAssetTradeAndOtherReceivables" | "currentAssetTradeDebtors" | "currentLiabilitiesAmountOwingToAssociatedCompanies" | "currentLiabilitiesAmountsOwingToDirector" | "currentLiabilitiesAmountsOwingToHoldingCompany" | "currentLiabilitiesAmountsOwingToSubsidiaryCompanies" | "currentLiabilitiesBankAcceptance" | "currentLiabilitiesBankOverdraft" | "currentLiabilitiesBankborrowings" | "currentLiabilitiesBorrowingsOrTermLoans" | "currentLiabilitiesContractLiabilities" | "currentLiabilitiesDepositsFromCustomers" | "currentLiabilitiesDerivative" | "currentLiabilitiesDividendPayables" | "currentLiabilitiesFinanceLeasePayables" | "currentLiabilitiesHirePurchasePayables" | "currentLiabilitiesLeasePayables" | "currentLiabilitiesOtherPayables" | "currentLiabilitiesOtherPayablesAccrualsAndDeposits" | "currentLiabilitiesOtherPayablesAndAccruals" | "currentLiabilitiesProvisionForTaxation" | "currentLiabilitiesTradeAndOtherPayables" | "currentLiabilitiesTradeCreditors" | "customerConcentrationTop5Pct" | "customerRatingAvg" | "dateJoined" | "dateOfStatement" | "debtorConcentrationTop5Ratio" | "deduction" | "dependantsCount" | "depreciation" | "depreciationOfInvestmentProperties" | "depreciationOfInvestmentSecurities" | "depreciationOfPropertyPlantEquipment" | "depreciationRightOfUseAssets" | "directors" | "disputeRate12m" | "distressTier" | "documentType" | "ebitda" | "educationLevel" | "employeeEisContribution" | "employeeEpfContribution" | "employeeSocsoContribution" | "employeeTax" | "employerName" | "employerNumber" | "employmentSector" | "employmentStatus" | "employmentType" | "endOfYearCash" | "engagementRate90d" | "entityTypeCode" | "exchangeEqualisationOrFluctuationReserves" | "financeCost" | "financialYearEnd" | "fixedAllowances" | "followerCount" | "genderCode" | "grossMarginPct" | "grossPay" | "grossProfit" | "handsetFinancingActive" | "handsetFinancingDelinquent" | "handsetRiskTier" | "hotspotDwellRatio" | "issuingBankName" | "kwspMemberNumber" | "largestSingleCredit" | "lateDays24m" | "latitude" | "lengthOfServiceMonths" | "lengthOfServiceYears" | "localNo" | "locationStabilityScore" | "longitude" | "maritalStatus" | "monthlyVolume12m" | "monthlyVolume3m" | "negativeSentimentRatio90d" | "netOperatingCashFlow" | "netPay" | "netProfit" | "nonCurrentAssetDeferredAssets" | "nonCurrentAssetDeposits" | "nonCurrentAssetFinancialAssetAtFairValue" | "nonCurrentAssetGoodwillOnConsolidation" | "nonCurrentAssetIntangibleAssets" | "nonCurrentAssetInventories" | "nonCurrentAssetInvestmentProperties" | "nonCurrentAssetInvestmentSecurities" | "nonCurrentAssetLandUseRight" | "nonCurrentAssetOtherReceivables" | "nonCurrentAssetOtherReceivablesDepositsAndPrepayments" | "nonCurrentAssetOwnGoodwill" | "nonCurrentAssetPrepaidLeasePayments" | "nonCurrentAssetPropertyPlantEquipment" | "nonCurrentAssetRightOfUseAssets" | "nonCurrentAssetSinkingFund" | "nonCurrentAssetTradeAndOtherReceivables" | "nonCurrentAssetTradeReceivables" | "nonCurrentAssetsAssociatedCompanies" | "nonCurrentAssetsDevelopmentPropertiesOrExpenditure" | "nonCurrentAssetsInvestments" | "nonCurrentAssetsSubsidiaryCompanies" | "nonCurrentLiabilitiesBankBorrowings" | "nonCurrentLiabilitiesBorrowingsOrTermLoans" | "nonCurrentLiabilitiesContractLiabilities" | "nonCurrentLiabilitiesDeferredTaxation" | "nonCurrentLiabilitiesDepositsFromCustomers" | "nonCurrentLiabilitiesFinanceLeaseObligations" | "nonCurrentLiabilitiesHirePurchasePayables" | "nonCurrentLiabilitiesLeaseObligations" | "nonCurrentLiabilitiesRetirementBenefits" | "obligationMonthlyInstallment" | "obligationType" | "occupancyMonths" | "occupancyYears" | "occupation" | "onTimePaymentRatio24m" | "openingBalance" | "otherDeduction" | "otherIncome" | "paidUpCapital" | "pathInDms" | "payDate" | "payPeriod" | "paymentReliabilityTier" | "personAddress" | "personDateOfBirth" | "personGender" | "personIdNumber" | "personName" | "personNationality" | "personPlaceOfBirth" | "personRace" | "personReligion" | "placeLabel" | "postingConsistency12m" | "preferenceShareCapital" | "preferredLanguage" | "previousDirectors" | "primaryStateCode" | "profitAfterTax" | "profitFromOperations" | "raceCode" | "registeredAddress" | "relatedPersonDateOfBirth" | "relatedPersonEmail" | "relatedPersonIdNumber" | "relatedPersonIdType" | "relatedPersonName" | "relatedPersonPhone" | "relatedPersonPhoneAreaCode" | "relatedPersonRole" | "reserves" | "residenceType" | "retainedEarnings" | "revaluationReserves" | "revenue" | "shareCapital" | "sharePremium" | "shareholders" | "shortTermLiabilities" | "sourceOfFund" | "sourceOfWealth" | "statedAge" | "statementDate" | "statementMonth" | "statementType" | "statementYear" | "subEmploymentSector" | "suspensionsCount24m" | "tangibleAssets" | "taxes" | "tenureMonths" | "tenureTier" | "totalAssets" | "totalContribution" | "totalCredits" | "totalCurrentAssets" | "totalCurrentContribution" | "totalCurrentLiabilities" | "totalDebits" | "totalEquity" | "totalLiabilities" | "totalNonCurrentAssets" | "totalNonCurrentLiabilities" | "totalShareIssued" | "tradeReferenceDefaults12m" | "uploadedAt" | "uploadedBy" | "vacationDays90d" | "variableIncome" | "vehicleChassisNo" | "vehicleConditionCode" | "vehicleEngineNo" | "vehicleLadenWeightOver2500kg" | "vehicleMake" | "vehicleModel" | "vehicleRegistrationDate" | "vehicleRegistrationNo" | "vehicleYearMake" | "verifiedBusinessAccount" | "workAttendanceRatio30d" | "workDailyHoursAvg30d" | "year" | "zakat"; /** * Adapter category identifier. * * A LITERAL UNION, generated from the registry (SYS-3347). It was an open * `string` from the registry-loaded rework, on reasoning that was sound while the set only * GREW: growth is backwards-compatible, so a runtime check sufficed. * * That stopped holding the moment names get RETIRED. The registry's own * lookups fail open by design — `resolveCanonicalCategoryId` answers null and * every caller treats null as "not a rename", permissively — so a retired id * left in a consumer is a silent miss at runtime with nothing to observe. A * rename therefore had to be found by hand, which is exactly what it cost. * * Compile-time membership converts that into a build failure at the site that * needs editing, and tsc suggests the replacement by proximity. Boundary * validation with `isAdapterCategory()` / `assertAdapterCategory()` is still * required for values arriving from OUTSIDE the build — types are erased, so * they say nothing about a JSON payload or a database row. */ type AdapterCategory = AdapterCategoryId; /** * Every canonical field name any category declares — a LITERAL UNION, * generated from the registry (SYS-3347). See `AdapterCategory` above for why * this is no longer `string`. * * Adapter `produces` lists are typed `ReadonlyArray`, so a * manifest naming a retired field now fails to compile. The host still * validates membership against the adapter's CATEGORY at registration — the * union says the name exists somewhere, not that it belongs to this category, * and only the runtime check can say the latter. */ type CanonicalFieldName = CanonicalFieldNameLiteral; /** * Per-field metadata for a canonical field declared by a category. * Frozen at module load — the data file is authoritative. */ interface CanonicalFieldSpec { readonly name: CanonicalFieldName; readonly type: "number" | "boolean" | "string"; readonly unit?: string; readonly range?: readonly [number, number]; readonly description: string; /** * Global fact identifier marking this field as an ATTESTATION of a * shared real-world fact (e.g. a company has exactly one * incorporation date, no matter which document it was extracted * from). Multiple categories may declare the SAME field name iff * every declaring category carries the same `fact` id — each * category's value is then an independent attestation of one fact, * comparable across sources (the future disagreement-comparison * feature's unit of comparison). A `fact` id is bound to exactly one * field name registry-wide; by convention the field name IS the fact * id. Uniquely-declared fields need no `fact` (but may carry one). */ readonly fact?: string; /** * SYS-3333: the name this field had in the LEGACY flat vocabulary, when * the canonical rename moved it. * * It exists because the two vocabularies were identical until the rename, * and code quietly relied on that. `isMonetaryField` in ihs-processing.ts * is the worked example: it is handed a FLAT column name and matches it * against the set of CANONICAL names declared `kind: "money"`. That worked * only while `payslipGrossPay` was both. Rename the canonical side alone * and the lookup silently misses — a money value renders as a bare number * beside its denominated neighbours, which is the precise failure * SYS-3249's denomination work exists to prevent. No exception, no log * line, just a wrong-looking table. * * So the alias lives ON the field it renames rather than in a side map: a * side map can drift, and this one is load-bearing for as long as the flat * columns exist (Phase 6 drops them, and this goes with them). * * A legacyName is NOT a second canonical name. It never widens what an * adapter may `produce`, it is not addressable in an eval model, and it * carries no fact — it is a one-way lookup for code bridging the two * vocabularies during the transition. */ readonly legacyName?: string; /** * Field kind — a semantic refinement of `type`. * * `"enum"`: the field's value is one label out of a closed set. The * category declares ONLY that the field is enumerated — never the * values, never an ordering. Value sets are vendor territory: each * adapter declares the exact labels it emits in its manifest's * `enumValues` (host-validated), because two vendors implementing the * same category may bucket differently. Ordering and scoring * interpretation live even further out, in the consumer (an eval * model's per-value mapping) — an enum label is data, what it is * worth is opinion, and opinions don't belong in the data contract. * An enum field MUST be `type: "string"` (labels are * string-normalized) and MUST NOT declare a `range` (labels are * unordered). * * `"money"` (SYS-3249): the field's value is a monetary amount, and is * therefore INCOMPLETE ON ITS OWN. The primitive is still a number — * which is why this is a refinement of `type` rather than a member of * it — but the number means nothing without a denomination, and the * denomination belongs to the observation, not to the field. One * source can report several currencies in a single document, so a * field-level currency could never be right for more than one of them. * The denomination travels with the value on its provenance envelope * (`IhsFieldProvenance.currency`). * * A money field MUST be `type: "number"`, MUST NOT declare a `unit` * (there is no unit that is true of the quantity — see * `VALID_FIELD_UNITS`), and MUST NOT declare a `range`: a numeric * bound on money is denominated by definition, so it can only ever be * correct in one currency. `telcoArpuMyr [0, 10000]` was a sane * Malaysian bound and is roughly twenty times too small in VND, where * ARPU runs about 200,000 — every non-MY row would have failed a * constraint the data contract asserted about all of them. */ readonly kind?: "enum" | "money"; /** * SYS-3164: the field's confidentiality class. The ONLY way to declare * one is to opt OUT. * * ABSENT MEANS SENSITIVE. There is deliberately no `"sensitive"` * spelling: a field is sensitive unless someone has looked at it and * said otherwise, so the failure mode of forgetting is a field that is * over-protected, never one that is silently exposed. That polarity is * not a preference — it is the lesson already recorded in finhub's * SYS-2806 audit-redaction allowlist, where "a newly added IHS column * that's never classified here defaults to sensitive, not safe" is the * property that makes the list safe to add columns around. An opt-in * `sensitive: true` flag inverts exactly that: every field anyone * forgot to mark ships in the clear, and neither the data nor the * types surface it. * * What consumes it: canonical storage encrypts sensitive fields at * rest. Access-time gating (`fieldAuthorizations`) is its sibling — * that says who may read a field, this says how it is held when * nobody is reading it. * * TWO LIMITS, so this is not read as more coverage than it is: * * It reaches CANONICAL data only. `confidentiality` lives on a * category's field spec, and no category is canonical over the legacy * wide `ihs` table — the categories cover the `ihs_alt_data_*` tables * and the promoted document tables. A field still living in an `ihs` * column is untouched by this until the adapter transition relocates * it. * * And the raw payload is a sibling hole. The same values are stored * again, unencrypted, in the host's raw-payload table (see * docs/security-model.md: "Assume the raw payload is durably stored"), * under its own retention window. Encrypting the canonical column * while the identical value sits in the clear next to it makes the * guarantee "at rest, in one of two places" — so the consumer that * honours this must cover both, or say plainly that it does not. * * Shared-fact attestations must AGREE on this (enforced at load): one * real-world fact cannot be sensitive when a document attests it and * non-sensitive when a form does. * * SYS-3171: ALWAYS PRESENT on a built spec, even though authoring stays * opt-out-only (see RawCategoryField, where it remains optional and * `"sensitive"` is still unspellable). The asymmetry is the point. * * The registry is serialised verbatim to finhub and finsys-client, and * on that wire "absent means sensitive" is carried by NOTHING — a * consumer writing `if (field.confidentiality) protect()` reads exactly * backwards and compiles clean. Emitting the value explicitly makes the * invariant structural rather than documented: every read surface, * including the JSON, states the class outright. */ readonly confidentiality: "sensitive" | "non-sensitive"; } /** * Per-category schema bundle. Used by: * - host app: validate adapter `produces` lists at registration * - clients (finhub, finsys-client): render UI conditionally on * category activation * - CRA report: render provenance metadata */ interface CategorySchema { readonly id: AdapterCategory; readonly displayName: string; readonly description: string; readonly canonicalTable: string; /** * SYS-3333: the id this category had before it was renamed. * * The transition has to be COMPATIBLE: a manifest that registered yesterday * must register today. So a legacy id is not decoration — the host resolves * it, warns, and proceeds, rather than refusing a manifest at boot for a * name the operator never chose to change. * * A legacy id may never collide with a LIVE id (enforced below). That rule * is why the two bank categories kept their names in this release: reusing * `bank-statement` for the document category would have made a pre-sweep * manifest saying `bank-statement` genuinely ambiguous — the partner feed * before, the document after — with no correct resolution. Renaming them * waits for the deprecation window to close. */ readonly legacyId?: string; readonly fields: ReadonlyArray; } /** * Every category id declared by this version of finsys-core, in * data-file order. The runtime equivalent of the old hardcoded union — * derived from the single source of truth rather than maintained by * hand. Treat order as unstable across versions. */ declare const ADAPTER_CATEGORY_IDS: ReadonlyArray; /** * Look up a category schema by id. Throws if the id is unknown. */ declare function categorySchemaOf(id: AdapterCategory): CategorySchema; /** * The canonical field names a given category produces. Convenience * for callers that don't need the full schema — common case in the * eval engine ("does this policy reference any fields from a category * that isn't loaded?"). */ declare function categoryFieldsOf(id: AdapterCategory): ReadonlyArray; /** * SYS-3164: is this field of this category sensitive? * * Answers TRUE for anything not explicitly declared `"non-sensitive"` — * including a field name the category does not declare at all. That last * part is deliberate: a caller asking about an unknown field is either * mid-rename or wrong, and the safe answer to "should I protect this?" * when you do not recognize it is yes. `categorySchemaOf` still throws * for an unknown CATEGORY, because that is a wiring error rather than a * data question. */ declare function isFieldSensitive(id: AdapterCategory, field: CanonicalFieldName): boolean; /** * SYS-3164: every field of this category that is sensitive — i.e. every * field that did not opt out. The complement of the declared * `"non-sensitive"` set, so a newly added field appears here until * someone classifies it. */ declare function sensitiveFieldsOf(id: AdapterCategory): ReadonlyArray; /** * Every category currently declared. Order is data-file order; treat * as unstable across versions. */ declare function allCategories(): ReadonlyArray; /** * Reverse lookup: which category declares a given canonical field? * Returns null if the field name isn't declared by any category in * this version of finsys-core — AND for shared-fact names (declared by * more than one category), where "the" category is genuinely ambiguous. * The null is deliberate: a caller holding a shared-fact name must * decide per-attestation, via `categoriesAttestingFact(factOf(name))`, * rather than being handed one arbitrary declarer. Useful when the * host app is reading canonical field values back from storage + wants * to identify the producing category for rendering. O(1). */ declare function categoryForField(field: CanonicalFieldName): AdapterCategory | null; /** * The fact id a canonical field attests, or null when the field * declares no fact (or isn't declared at all). By convention the fact * id equals the field name, but callers must not assume it — read it * from here. O(1). */ declare function factOf(field: CanonicalFieldName): string | null; /** * SYS-3333: resolve a category id from EITHER vocabulary to the canonical one. * * Returns `id` unchanged when it is already a live category id, the canonical * id when `id` is a recorded `legacyId`, and null when it is neither. The * identity case is deliberate: a caller bridging the two vocabularies is handed * a mix, and forcing each one to try live-first-then-alias is how a call site * ends up with its own private rename table. * * The main callers are boundaries where a FOREIGN id arrives and its vintage is * not ours to choose — an adapter manifest submitted by a partner, an adapter * registry read back from another service. * * TRANSITIONAL, and it goes when the legacy ids do. Use `isLegacyCategoryId` to * decide whether the resolution deserves a deprecation warning. */ declare function resolveCanonicalCategoryId(id: string): AdapterCategory | null; /** * SYS-3333: true when `id` is a RETIRED category id rather than a live one. * * Callers use this to decide whether to emit a deprecation warning — the * resolution itself is the same either way, and a caller that cannot tell the * two apart either warns on every call or on none. */ declare function isLegacyCategoryId(id: string): boolean; /** * SYS-3333: resolve a field name from EITHER vocabulary to the canonical one. * * Returns `name` unchanged when it is already canonical, the canonical name * when `name` is a recorded `legacyName`, and null when it is neither — so a * caller can tell a rename it must honour from a typo it must refuse. * * TRANSITIONAL. It exists because artifacts written under the old vocabulary * are still in circulation — partner manifests, pushed assertions, eval models * held by lenders — and none of them move on our schedule. Do not build new * addressing on it: a legacy name carries no fact, is not `produce`-able by an * adapter, and is not addressable in an eval model. */ declare function resolveCanonicalFieldName(name: string): CanonicalFieldName | null; /** * Every category attesting a given shared fact, in data-file order. * Empty for an unknown fact id. This is the lookup the disagreement- * comparison feature keys on: each attesting category's value for the * fact's field is an independent observation of the same real-world * fact, so cross-category mismatches are surfaceable. */ declare function categoriesAttestingFact(factId: string): ReadonlyArray; /** * Runtime membership check — is `id` a category declared by this * version of finsys-core? Use this at trust boundaries (parsing a * manifest, validating an API parameter) now that `AdapterCategory` is * an open `string` and the compiler can no longer reject unknown ids. */ declare function isAdapterCategory(id: string): boolean; /** * Assert membership: returns `id` if it names a declared category, * throws otherwise. Note this is a RUNTIME guard only — `AdapterCategory` * is an open `string`, so there is no type-level narrowing to apply. * The companion to `isAdapterCategory` for call sites that want a hard * failure (e.g. the host rejecting a manifest whose category isn't in * this finsys-core version's catalogue). */ declare function assertAdapterCategory(id: string): AdapterCategory; /** * SYS-3334 — the v2 canonical envelope, as a shape this package owns. * * WHY IT MOVED HERE. These types described the wire shape of a published API * and lived only in `@finsys/lender-client`, which is the SDK built for * EXTERNAL lenders. Every other consumer therefore had two bad options: take a * dependency on an SDK meant for someone else, or re-declare the shape. finhub * reads finsys-api through its own gateway and would have re-declared it; * FHD's portal would have been the third declaration. * * Two declarations of one wire shape drifting apart, with nothing comparing * them, is this estate's signature defect. So the shape lives once, in the * package that already owns published vocabulary — the category registry, the * field catalogue, the v1 migration map — and `@finsys/lender-client` * re-exports it. Member-for-member identical to what the SDK's 2.5.0 * declared — but 2.5.0 never exported these names from its index (TS2305 on * `import type { CanonicalView } from '@finsys/lender-client'`), so the SDK's * re-export is the first release in which a consumer can name them. Additive * either way; the members and their meaning are unchanged. * * SEMVER CONTRACT (SYS-3420, from the review of @finsys/lender-client 2.6.0). * These five interfaces are re-exported by the lender SDK, so a change here * reaches external consumers on THEIR next install, not on an SDK release. * Therefore: adding a REQUIRED member, removing or renaming ANY member * (optional included — `confidence?`, `origin?`, `runId?` are what a consumer * uses to judge a value), or narrowing a member's type is a MAJOR of this * package. Adding an OPTIONAL member is a minor. The SDK's own compat suite * pins both directions, `keyof` included, against its previous release. * * WHAT THIS FILE IS NOT. It is a description of a payload, not a client. There * is no fetching here and no instance-selection rule — selection is one * decision that every consumer must make identically, so it belongs with the * code that reads the envelope rather than with the types that describe it. */ /** One canonical value, with everything needed to judge it. */ interface CanonicalFieldEnvelope { value: number | boolean | string; /** Present only when it can be attributed to this instance's run. */ confidence?: number; origin?: string; confidentiality: string; /** * SYS-3415: present ONLY under a lender-overlay projection (`?overlay=mine`) * on a field the calling lender has a staged, uncommitted edit for — the * attested value this envelope's `value` is standing in for. `origin` is * 'manual' on such an envelope and `confidence` is absent. Never present on * the facts-only view; a committed correction is a fact (a manual-override * run) and carries no originalValue. */ originalValue?: number | boolean | string; /** * SYS-3421 (the SYS-3392 decision, CONFLICT SURFACES): when more than one * attestation exists for this field — an extraction and a committed manual * correction — `value` is the RESOLVED one and this lists every attestation * behind it, the winner included, so the disagreement is visible rather * than the loser silently discarded. Absent when there is exactly one. * Scoring consumes `value`, never this list. */ attestations?: CanonicalAttestation[]; /** * SYS-3421: the policy that chose `value` when `attestations` is present — * `'@'`, e.g. `class-precedence@1`. A policy VERSION * bump changes this visibly rather than changing values silently. Absent * when nothing needed resolving. */ resolvedBy?: string; } /** * One attestation of a field's value — who said it, when, from which run. * The same information an instance carries, at field grain, so a manual * correction can stand beside the extraction it corrects. */ interface CanonicalAttestation { /** * `null` is a real attestation: a lender CLEARED the field (SYS-3421). The * envelope's own `value` never carries null — a resolved clear makes the * field ABSENT from the instance — but the attestation list must still * show that someone said "nothing", or a later, lower-ranked value would * appear uncontested. Unlike the envelope, this is the ledger, not the * answer. */ value: number | boolean | string | null; /** 'extraction' | 'form-intake' | 'manual' | … — the assertion class the resolver ranks. */ origin: string; adapterId: string; adapterVersion: number; runId?: number; observedAt?: string; /** Present on a manual attestation: the lender that committed it. */ lenderId?: number; } interface CanonicalInstance { /** '' for a single-cardinality category. */ instanceKey: string; adapterId: string; adapterVersion: number; runId?: number; /** * ISO-8601, UTC (`Z` offset), e.g. `'2026-08-19T00:00:00.000Z'`. Carried * verbatim onto a synthesized `IhsFieldProvenance.observedAt` entry * (`fieldProvenanceFromView`, `ihs-processing.ts`) — a plain string, never * parsed there. SYS-3334 M-5 (round 5) removed the one place this used to * be COMPARED as a string (a temporal tie-break for two instances * contending on one slot): every key that comparison was ever consulted on * has its provenance entry deleted regardless of the result (H2, round 4), * so the comparison was computing an answer nobody could read. * * SYS-3542 REINTRODUCED ordering by this field — `subjectViewFromRecords` * (`subject-canonical-view.ts`) sorts a subject's merged instances by it. * It does NOT reintroduce the string comparison the paragraph above * describes as removed: a raw string comparison silently breaks on two * inputs this package cannot police at the type level (mixed UTC offsets — * `+08:00`, this estate's own timezone, sorts as "later" than an earlier * moment written as `Z`; mixed precision — a no-millis `Z` timestamp * outsorts every millisecond-precision one, because `'Z'` > `'.'` * byte-for-byte) — so that function parses with `Date.parse` instead and * documents its own rule for a value that fails to parse. Read that * function's own doc before adding a second ordering consumer of this * field; the requirement is "parse it", not "trust the format" — a single * drifting producer is exactly what a raw string comparison cannot catch. */ observedAt?: string; fields: Record; /** * The v1 wide-table slot this instance projected to (`'T1'`..`'T6'`, or a * financial-statement `'T3'`), carried through the migration window ONLY: * present when the source row stored one, absent when it did not (a * post-cutover financial-statement coordinate v1 discarded, an alt-data * instance that never had a wide column). Consumers reproducing a v1 * shape read THIS, never a derived position; consumers on v2 ignore it. * Retires with the wide table. * * SHAPE CONTRACT (M4, SYS-3334 round 4): `/^T[1-9]\d*$/` — bare `T` * followed by a positive integer, no leading zero, no whitespace, no * suffix. finsys-api has not shipped this member as of 2026-08-19, so no * producer has violated it yet; every consumer (`timePeriodOf`, * `ihs-processing.ts`, the cheapest place to pin it before one does) * REJECTS a value outside this shape rather than trimming or coercing it * — a rejected value is treated as ABSENT, and is therefore never accepted * verbatim into a column header or a provenance key. * * WHAT "ABSENT" RESOLVES TO depends on whether the instance carries a * `periodPosition`, and this doc said otherwise until SYS-3517. It used to * promise "falling through to a derived period, so a producer bug is * visible as a DIFFERENT period". That is still true for an instance with * NO coordinate. On a COORDINATE-bearing instance it is not: absence is an * assertion there (see `periodPosition` below), so the period resolves to * NULL rather than to something derived. For a position-1 row that * genuinely owned T1, a malformed value therefore renders as * `timePeriod: null, periodPosition: 1` — the same shape a consumer's * coordinate branch selects for, so it would read as a true current-year * row rather than as a visible anomaly. No producer emits an out-of-shape * value today (probed against every sibling table, 2026-08-22); a producer * that starts to should fail loudly at the emitter, because no consumer * rule can make a malformed slot safe on a coordinate row. */ legacySlot?: string; /** A per-instance human label the source row carried (a bank statement's issuing bank). */ sourceLabel?: string; /** * The 1-based period coordinate of a period-aware document instance * (financial statements: 1 = the document's own current fiscal year, 2 = * its prior comparative year), when the source row stores one. Distinct * from `legacySlot`: a year-2 document's own current-year column has * position 1 and NO legacy slot (v1 discarded it) — the coordinate is how * a consumer selects "the true current-year row" without the T-slot * projection guessing for it. Absent for rows that carry none (bank, * payslip, EPF, alt-data). Additive; the migration-window bridge copies * it onto `InstanceRow.periodPosition` verbatim. * * SHAPE CONTRACT: a finite number >= 1. Out of range is treated as absent * — never coerced to 1, never rounded. The same test finsys-api's own * projector applies on the way out, deliberately character for character: * a receiver stricter than its producer discards values that were sent, * which here would silently re-enable the fabricated slot below. * * PRESENCE OF THIS MEMBER CHANGES WHAT AN ABSENT `legacySlot` MEANS, and * that is a real obligation on producers, not a consumer detail (SYS-3517 * rule 1b). On an instance with NO coordinate, an absent `legacySlot` is * SILENCE: the consumer derives a period from the key or the ordering, as * it always has. On an instance WITH one, it is an ASSERTION — the * producer saying "the v1 model had no slot for this row" — and the * period resolves to null instead of being derived. * * So a producer that stamps this member takes on the duty to stamp * `legacySlot` on every row that HAS a v1 slot. Omitting one there no * longer reads as "unknown, please derive"; it reads as "there is none", * and the row goes slotless. The row this protects is a year-2 financial * statement's own current fiscal year: stored at position 1 with no slot, * under a HISTORICAL key ending `#T1` that a re-extraction adopted. Read * the period off that key and the one null the coordinate model exists to * preserve becomes a second claimant on T1, which is exactly the overlap * projection DEVOPS-535 removed. * * This is the first member of this interface whose ABSENCE is meaningful, * which is why it is stated here rather than left to the consumer's own * cascade doc: a producer cannot honor a contract it can only read in a * consumer's source. */ periodPosition?: number; } interface CanonicalCategory { /** * From the producing adapter's manifest, and it describes ONE RECORD: * `single` means at most one instance per application. It does NOT mean the * subject has one value — see the note on CanonicalView. */ cardinality?: 'single' | 'multi'; instances: CanonicalInstance[]; } /** * THE SCOPE OF THIS RESPONSE IS ONE APPLICATION. Every instance below comes * from the record named by `ihsId`, which is why instances carry no * per-instance source reference — at this scope it would be a constant. * * Do not write code that assumes this is interchangeable with a subject-scoped * view. That response would carry source attribution per instance and would * re-scope or omit `cardinality`; a consumer that read `single` as licence to * take instances[0] is correct here and wrong there. */ interface CanonicalView { ihsId: number; categories: Record; /** * SYS-3415: present iff the view was read under a lender-overlay projection * (`?overlay=mine`). Its presence is the signal that `value`s in this view * may be the calling lender's staged edits rather than attested facts — the * signal the SDK's 2.5.0 notes said neither payload carried. `applied` counts * fields overlaid; `unprojected` names any staged column that could not be * placed on the canonical plane (a legacy column with no address, or a * T-slot the sibling storage cannot resolve) — surfaced, never dropped. * ABSENT on the facts-only view, and absent for a caller with no active * overlay only if the projection was not requested at all. */ overlay?: { lenderId: number; applied: number; updatedAt: string | null; unprojected: string[]; }; } /** * Where a v1 field lives on the canonical plane. Resolve it with a shared * resolver, never by hand — instance selection is the part consumers get * subtly different from each other, and a wrongly chosen instance is a * plausible value rather than an error. */ interface CanonicalAddress { category: string; field: string; /** * Present: resolve to exactly this instance. * Absent: latest by observedAt, which is what v1's flat mirror actually did. */ instanceKey?: string; } /** * SYS-3554 — WHO furnished an observation, as the bureau knows it. * * THE PAIR IS THE IDENTITY. A contributed observation is identified by * `(furnisherId, recordRef)` and by nothing else. Neither half identifies * anything on its own: a furnisher contributes many records, and a record ref * is scoped to the furnisher that issued it, so two furnishers using the same * ref have said nothing to each other. This replaces `{ sourceIhsId: number }`, * which was one furnisher's auto-increment primary key doing the work of a * global identity — the SYS-3554 defect. A bureau aggregating many furnishers * is the product, not an edge case, and lender A's application 7 and lender * B's application 7 are the same number. * * BOTH MEMBERS ARE STRINGS, AND THAT IS DELIBERATE RATHER THAN INCIDENTAL. * `recordRef` is opaque BY CONTRACT: it is the furnisher's own identifier, * meaningful only to the furnisher that minted it, and the bureau's only * legitimate operations on it are equality and handing it back on the s.31 * correction channel. A numeric type would invite exactly the reasoning that * produced the defect being fixed here — `b.source.sourceIhsId - * a.source.sourceIhsId` was a subtraction of two furnishers' unrelated * sequence numbers, and it typechecked. A string cannot be subtracted, so the * old tie-break is not merely removed from this package: it no longer * COMPILES in any consumer either. The same argument covers `furnisherId`: * bureau-minted and stable, but never an ordinal. * * NEVER JOIN THE TWO INTO A STRING. Both halves are opaque, so no delimiter is * safe: `'a' + '#' + 'b#c'` and `'a#b' + '#' + 'c'` are one value, and code * that splits a joined key back apart recovers the wrong half without any * signal that it did. Consumers key on the PAIR — a `Map>`, or `sameSubjectSource` (`subject-canonical-view.ts`) * on a linear scan. This is not stylistic: finsys-client's subject seat * de-qualifies today by slicing at the first `#`, and its own docblock states * the argument that made that safe — "sourceIhsId is numeric, so the FIRST `#` * is unambiguously the qualification boundary even when the raw key itself * contains one." Once the qualifier is an opaque string that argument * collapses, which is why the qualification scheme is gone rather than * re-delimited (see `SubjectInstance.instanceKey` below). * * IT NEVER APPEARS IN REPORT CONTENT. s.25(1)(a) wants the source's NAME AND * ADDRESS, which live on the bureau's furnisher registry keyed by * `furnisherId`; `recordRef` flows back to the furnisher that issued it on the * s.31 correction path and nowhere else. An identifier is only ever presented * to the party that minted it — the same rule that removed `ihsId` from the * subscriber wire. */ interface SubjectSource { /** * BUREAU-MINTED and stable — the bureau's own furnisher-registry id, which * is local to the bureau and shares a keyspace with nothing outside it. * * IT IS STAMPED FROM THE CREDENTIALED CHANNEL, NEVER FROM PAYLOAD CONTENT. * Every pull is bureau-initiated against one furnisher's endpoint with that * furnisher's credential, so the bureau already knows who it is pulling from * at the moment of authentication. No furnisher self-declares its identity * here and no payload can spoof it — which is why `subjectViewFromRecords` * takes this on `SubjectViewRecord` rather than reading it off the * `CanonicalView` it is given. */ furnisherId: string; /** * The furnisher's own identifier for the record this instance came from — * OPAQUE, furnisher-scoped, and never parsed, ordered or arithmetically * compared. At a finsys-api furnisher it happens to be an application id; * that is a fact about one furnisher's implementation and not a contract, * and every consumer that reasons from it is reintroducing SYS-3554. */ recordRef: string; } /** * SYS-3542 (SYS-3463a) — the subject-scoped view `CanonicalView`'s own doc * anticipates and declines to be: "Do not write code that assumes this is * interchangeable with a subject-scoped view. That response would carry * source attribution per instance and would re-scope or omit `cardinality`." * This is that response. * * A `CanonicalInstance` widened with the source that furnished it. Subject * scope means MULTIPLE records, from MULTIPLE furnishers, can each contribute * an instance to one category, so source attribution — a constant at * `CanonicalView`'s single-application scope, and therefore absent there — * becomes per-instance information here. * * `instanceKey` IS THE RAW, UNQUALIFIED KEY (SYS-3554), exactly as * `CanonicalInstance` documents it — `''` for a single-cardinality category * included. UNIQUENESS IS THE TUPLE `(source.furnisherId, source.recordRef, * instanceKey)`, and a consumer that keys on `instanceKey` alone is wrong at * this scope: two records will legitimately both key an instance `''`. * * This REVERSES SYS-3542, which rewrote every key to * `${sourceIhsId}#${rawInstanceKey}` so that a lookup over the category's * instances could stay one-dimensional. That scheme depended on the qualifier * being numeric to be reversible, and on one furnisher's id space being * global to be unique — neither survives a second furnisher. Re-delimiting it * was considered and rejected: both halves of `(furnisherId, recordRef)` are * opaque strings, so there is no character guaranteed absent from both, and a * scheme whose correctness rests on "this delimiter probably will not appear" * fails SILENTLY, which is the same failure class as the collision it would be * fixing. The source is therefore carried STRUCTURALLY, in `source` below, and * nobody parses anything. * * TWO `CanonicalInstance` MEMBERS ARE OMITTED (SYS-3542 review, F12), for the * same reason `SubjectCanonicalCategory` omits `cardinality` — republishing * them here would tell a consumer the opposite of what is true, because each * describes exactly one application's structure and stops meaning anything * once merged across several: * - `legacySlot` — the v1 wide-table slot THIS INSTANCE projected to. * Three applications can each legitimately own `T1`; carried through * unchanged that reads as the "two instances contending for one slot" * defect shape `ihs-processing.ts` documents as a real collision. * - `periodPosition` — orders periods WITHIN one application's own v1 * reconstruction; across applications it orders nothing. * `source` (below) is how a consumer that genuinely needs either value gets it * back: ask that furnisher for that record's own `CanonicalView`, which is * where the field means something. */ interface SubjectInstance extends Omit { source: SubjectSource; } /** * SYS-3464 — ONE canonical field's winning observation, with the provenance of * THAT OBSERVATION rather than of the row it happened to arrive in. * * WHY THIS TYPE EXISTS. Until SYS-3464 the only way to read a field at subject * scope was `instances[0].fields[name]` — latest ROW wins, spread flat. For a * lending application that is correct: one document produces one coherent row * and the newest document supersedes the older wholesale. For a bureau merging * contributed lender data, CCRIS, SSM and adapter signals into ONE subject * picture it is not, and the failure is silent: a fresher PARTIAL row from one * source erases every field of that category the other sources supplied, and * nothing errors. `ihs_field_attestation`'s own docblock states the same * consequence at application scope — an attestation written as an ordinary * canonical row with a fresh `observedAt` "would become latest and blank every * OTHER field of that category". s.29 RACUN requires Complete and Not * misleading; a subject file that drops a field nobody retracted fails both on * its face. The duty binds the licensee, not SI — this is the module the * licensee discharges it with. * * `envelope` IS THE VALUE, and it is named `envelope` rather than `value` * deliberately: `selection.value` would sit one dot away from * `envelope.value` and mean something different, which is the kind of * near-miss that reads correctly in review and returns an object where a * string was expected. */ interface SubjectFieldSelection { /** * The winning `CanonicalFieldEnvelope` — the SAME object reference the * contributing `SubjectInstance` holds, not a copy. See * `subjectViewFromRecords`'s own ALIASING section: nothing in this package * mutates an envelope in place, and this member is deliberately consistent * with `SubjectInstance.fields` rather than deep-cloning a wire payload. */ envelope: CanonicalFieldEnvelope; /** * WHO furnished THIS FIELD's winning observation. It is routinely NOT the * source of `instances[0]` — that is the entire point of per-field * selection, and a renderer that labels a merged category with one source * is misattributing every field that came from another. */ source: SubjectSource; /** * The raw `instanceKey` of the instance this value came from. Always equal * to the key this selection is filed under in `fieldsByInstanceKey`; * repeated on the selection so that a selection passed around on its own * still names its own instance. */ instanceKey: string; /** * The winning observation's own `observedAt`, verbatim — absent when that * instance carried none, exactly as `CanonicalInstance.observedAt` is * absent. A value that failed to parse is carried through here unchanged * even though it ranked oldest; this member is provenance, not the rank. */ observedAt?: string; /** * SYS-3464 — present IFF the observations that tie at the top rank FOR THIS * FIELD come from more than one source AND do not all carry the same * `envelope.value`. Lists the distinct sources involved, in the order they * appear in `instances`. NOTE what the list is: every source tied at this * field's top rank, INCLUDING any that agree with the winning value. It * is "who was in the tie", not "who dissented" — a renderer saying "a, b * and c disagree" would misattribute an agreeing source. Deliberately the * same shape as * `SubjectCanonicalCategory.contestedLead` — one mechanism for reporting a * conflict, read the same way at both grains. * * THE TWO PREDICATES ARE DIFFERENT, AND NEITHER IMPLIES THE OTHER. Do not * read this one as nested inside `contestedLead` — a consumer that checks * `contested` only when `contestedLead` is present WILL render a disputed * field as uncontested, which is the "not misleading" failure this exists * to prevent. Each flag looks at its own top rank, and those differ: if * the newest ROW is from one source alone, `contestedLead` is absent — * yet an older field whose own newest observation ties between two other * sources still reports `contested` here. It also runs the other way: a * cross-source tie at row grain where every source states the same value * gives `contestedLead` with nothing contested. * * WHY THIS ONE ALSO REQUIRES DISAGREEMENT. * At row grain the merge cannot know whether two tied rows disagree — * "the value" is not defined for a row. At field grain it can see the * values, so an AGREEMENT is not reported as a finding: SYS-3464's * principle is that "the disagreement is itself the finding", and a flag * that fires on two sources saying the same thing is the "always on, so * ignored" failure `contestedLead`'s own doc warns about. So * `contestedLead` present with no field contested is a coherent, useful * answer — "the lead ROW is arbitrary, and no field is actually disputed" * — not an inconsistency between the two flags. * * A CONSUMER'S OBLIGATION IS TO SURFACE IT, NOT RESOLVE IT. `envelope` still * holds one of the tied values (whichever the deterministic, temporally * meaningless source order put first), because a report needs something to * render; presenting it as uncontested is what s.29's "not misleading" * forbids. Absent means only that no tie SPANS sources with differing * values: a tie confined to ONE source is also absent, because one producer * ordering its own instances is not a disagreement between sources. */ contested?: { sources: SubjectSource[]; }; } /** * `CanonicalCategory` re-scoped to a subject: `cardinality` is OMITTED, not * carried through unchanged — `CanonicalView`'s own doc names this exact * requirement. `cardinality` described one adapter's per-APPLICATION * contract ('single' = at most one instance per application). At subject * scope a subject with three applications legitimately carries three * instances of a 'single'-cardinality category, one per source — republishing * the flag here would tell a consumer the opposite of what is true, which is * worse than omitting it: `'single'` used to license reading `instances[0]` * as THE value, full stop. That license does not survive the re-scoping — * `instances` can legitimately hold more than one CURRENT instance (a * subject with a live application in two lenders' pipelines, several bank * accounts each with their own statement) — but `instances[0]` is not * meaningless; see its own doc below for what it IS. */ interface SubjectCanonicalCategory { /** * Sorted LATEST-FIRST by `observedAt`, across every source record, * regardless of the order records were merged in — see * `subjectViewFromRecords`'s own doc (`subject-canonical-view.ts`) for the * parsing rule and for what happens when `observedAt` cannot separate two * instances. * * `instances[0]` is the single instance `CanonicalAddress`'s existing * "latest wins" rule would pick for an unaddressed field — BUT ONLY WHEN * `contestedLead` IS ABSENT. When it is present, `[0]`'s position ahead of * the other tied instances is arbitrary, and reading it as "the latest" is * the SYS-3554 misordering under a new name. Check `contestedLead` before * treating `[0]` as an answer. * * SYS-3464: TO READ A FIELD'S VALUE, READ `fieldsByInstanceKey`, NOT * `instances[0].fields`. This array is the LEDGER — every contributed * observation, latest-first — and a row is only ever wholly superseded by a * later row within one furnisher's own world. Spreading `[0].fields` flat at * subject scope drops every field the newer, partial row did not mention, * which is the SYS-3464 defect. `instances` remains what a consumer walks to * show the disagreement, the history, or a field's full attestation trail. * * It is never license to ignore `instances[1..]` either — see this * interface's own doc above for why `'single'`'s old licence to do that does * not survive the re-scoping. */ instances: SubjectInstance[]; /** * SYS-3464 — the per-field selection: `instanceKey` → field name → * `SubjectFieldSelection`. ALWAYS PRESENT (an empty object for a category * with no instances), so a consumer never has to branch on its absence. * * FOR EACH FIELD, the most recent observation ACROSS ALL SOURCES, carrying * its own provenance rather than the row's. This is the member that fixes * the erasure `instances[0].fields` causes at subject scope — read that * paragraph above first. * * IT IS KEYED ON `instanceKey`, AND THAT NESTING IS A DECISION, NOT * PACKAGING. Fields from two DIFFERENT instance keys describe two different * things — two bank statements in one category are two accounts, not two * observations of one — so fusing them would fabricate a row nobody * attested: an account number from one statement beside a closing balance * from another. That is a worse "not misleading" failure than the erasure * this member exists to fix, so per-field recency is resolved WITHIN a key * and never across keys. `''` (a single-cardinality category, the erasure * case) is an ordinary key here and is where two furnishers' observations * legitimately meet. * * NOTE WHAT THAT MAKES THE KEY MEAN, because it is not what `SubjectInstance` * uses it for. UNIQUENESS at subject scope is still the tuple * `(furnisherId, recordRef, instanceKey)` — two records legitimately both key * an instance `''`, and keying uniqueness on `instanceKey` alone is wrong. * COMPARABILITY is a different question with a different answer: two * observations filed under one `instanceKey` are two statements about the * same slot, which is exactly the pair per-field recency must resolve. The * cross-furnisher comparability of a NON-EMPTY key is the producers' * assertion and not this package's — a key carrying a document hash compares * across furnishers, a bare ordinal (`bankStatements#1`) does not — which is * why every selection names its own `source` and `instanceKey`: a fusion * here is inspectable field by field rather than silent. * * A null-prototype object, like `SubjectCanonicalView.categories`: both the * instance key and the field name are producer-supplied text off the wire, * and a plain `{}` lets either one named `__proto__` read back * `Object.prototype` — so a "have I already claimed this field?" check * answers for something nobody furnished and the real observation is * silently dropped. */ fieldsByInstanceKey: Record>; /** * SYS-3554 — present IFF the instances tied at the top of `instances` come * from MORE THAN ONE source, i.e. `observedAt` (the only evidential ordering * key this package has) ranks them equal and the merge has no honest way to * say which is later. Lists the distinct sources involved, in the order they * appear in `instances`. Absent means only that no tie SPANS sources: it * does NOT mean `instances[0]` won on evidence. A tie confined to one * source is also absent, and there `[0]` won on the producer's own array * order — one producer ordering its own instances is not a disagreement * between sources, which is the only thing this flag reports. * * HOW OFTEN IT FIRES depends on the category, and "normal case" would be * the wrong word for some of them. `observedAt` is optional on adapter * output, so on a category where no producer supplies it EVERY * multi-source category ties and carries this flag. A flag that is always * on gets ignored, so a renderer should say WHICH sources disagree, not * merely that they do. * * WHY THIS EXISTS RATHER THAN A TIE-BREAK THAT PICKS ONE. Until SYS-3554 * the merge broke such a tie on `sourceIhsId` descending, a recency proxy * whose own docblock stated the assumption that killed it: applications are * "id-ordered by creation in every producer this package has observed" — * singular producer. Across furnishers that proxy systematically prefers * whoever has higher sequence numbers, silently, with no signal to any * consumer that an ordering had been invented. There is no replacement * proxy available: the only per-record axis a furnisher could have ordered * by is `recordRef`, which this package makes opaque precisely so nobody * reasons from it. So the merge stops inventing an answer and reports the * question instead, per SYS-3464's principle — two records that can * disagree about a disputed value are worse than one incomplete record, and * the disagreement is itself the finding. * * A CONSUMER'S OBLIGATION IS TO SURFACE IT, NOT TO RESOLVE IT. Rendering a * contested lead as a single uncontested value is what s.29 RACUN's "not * misleading" forbids. * * SYS-3464 SHIPPED the per-field resolution this doc used to defer to, and * it NARROWS what this flag should make a consumer do rather than replacing * it. This one still answers only "is the lead ROW arbitrary" — it cannot * see values, so it fires on two sources that tie at the top even when they * agree about every field. `SubjectFieldSelection.contested` answers the * sharper question per field, and fires only on an actual disagreement. This * flag present with no field contested is therefore the ordinary, * informative case, not a contradiction: the row order is arbitrary and * nothing is disputed. */ contestedLead?: { sources: SubjectSource[]; }; } /** * THE SCOPE OF THIS RESPONSE IS ONE SUBJECT — every application the subject * registry (IC / SSM regno) has attached to them, merged. Where * `CanonicalView` is deliberately silent about which application produced an * instance (a per-response constant at that scope), this type is deliberately * silent about `cardinality` (see `SubjectCanonicalCategory`) and explicit * about source (see `SubjectInstance`) — the two swap places, which is the * whole reason the two types cannot be interchangeable and must not drift * apart from each other unnoticed. `subjectViewFromRecords` in * `subject-canonical-view.ts` is the one function that builds this shape; * read that file for the merge and ordering rules, which are a runtime * decision this file deliberately does not make (see "WHAT THIS FILE IS NOT" * on `CanonicalView`, above). */ interface SubjectCanonicalView { subjectKind: string; categories: Record; } /** Returns the full display name registry (extraction column → human label). */ declare function getDisplayNames(): Record; /** Looks up a display name. Falls back to camelCase → Title Case conversion. */ declare function getDisplayName(fieldName: string): string; declare function extractTimePeriods(columnNames: string[]): string[]; declare function groupColumnsByTimePeriod(columnNames: string[]): Record>; /** * Groups fields by their catalog-declared document_group (falling back to * the field's own name for anything untagged). Was a hardcoded, closed * prefix-matching table (FIELD_GROUP_PREFIXES) requiring a code edit for * every new document type; now reads the same document_group tag the * catalog already carries for document-types.ts, so adding a document * type is a catalog-only data change. */ declare function groupFieldsByPattern(fields: FieldData[]): Record; declare function getGroupDisplayNames(): Record; /** * SYS-3284/SYS-3285: the ONE money formatter the apps render through. * * FinHub and finsys-client each had their own. FinHub's hardcoded * `Intl.NumberFormat('en-MY', { currency: 'MYR' })` in the IHS views, so a * Vietnamese application's amounts read as ringgit. finsys-client had five * separate no-currency implementations plus two copy-pasted `myr()` helpers * that stamped "RM" unconditionally — including into the text fed to the AI * analyst, so the model reasoned about a Vietnamese company in ringgit. * * Both were reinventing something this package already did correctly for the * IHS detail path: a cached formatter using `currencyDisplay: 'code'`, which * matters more than it sounds — under en-US, USD renders as "$" while * MYR/VND/THB all render as codes, so symbol display gives a bare glyph to * the one currency whose glyph four others also use. * * Currency precedence is `resolveDisplayCurrency`'s: the value's own recorded * currency wins, then the jurisdiction's display default, then nothing — and * "nothing" prints a grouped number with no denomination, which is honest * rather than a guess. */ declare function formatMoney(value: unknown, opts: { currency?: string | null; /** * REQUIRED, and deliberately not optional. * * `formatMoney(v, {})` used to return MYR — indistinguishable from * `formatMoney(v, { jurisdiction: null })`, which is a different claim. * `null` means "a record said nothing, and the platform rule is * Malaysia"; an absent argument means "I was never told", and answering * that with a confident MYR is the exact bug these helpers replace. * * Making the key required does not make the caller think harder — it * makes the careless call fail to compile. Pass `NO_JURISDICTION_BASIS` * when there is genuinely no basis; the result then carries no currency. */ jurisdiction: string | null; }): string; declare function buildFileFieldTables(ihsData: Record, fieldProvenance?: Record): Record; /** * Groups instance rows by base metric name -- the unbounded analog of * groupColumnsByTimePeriod. `baseColumnNames` is the category's base * (unsuffixed) field list; each returned group maps instance column * label -> that metric's value on that row. Labels are disambiguated * (see instanceColumnLabels) so two rows can never collide into the * same key. */ declare function groupColumnsByInstance(baseColumnNames: string[], instanceRows: InstanceRow[]): Record>; /** * Explicit base-column declaration for a category with NO catalog `file` * spec -- e.g. invoice (SYS-2842 Phase 3), which was deliberately never * registered in form-field-base-specs.json because getDocumentTypeGroups() * is shared with resolveExtractionStatus, which assumes a category's wide- * table columns exist to check "is this populated" against -- invoice has * none (sibling-table only, no wideTableMirror). Registering it there would * silently break resolveExtractionStatus's invoice status reporting. This * override lets a category be instance-rendered without entering that * shared registry at all. */ interface CategorySpec { displayName: string; baseColumnNames: string[]; } declare function buildFileFieldTablesFromInstances(instancesByCategory?: Record, fieldProvenance?: Record, categoryOverrides?: Record): Record; /** IHS doc-field key → human label (the fields that become document sections). */ declare function getDocDisplayNames(): Record; /** Doc types eligible for extraction (re-extract / view-JSON). */ declare function getExtractableDocTypes(): Set; /** Doc types eligible for a replacement upload. */ declare function getReuploadableDocTypes(): Set; /** * One entry inside a document-pointer field. * * EXPORTED as of SYS-3174, and that is the point of exporting it. The host is * about to attest these entries as canonical `document-intake` rows, and the * alternative to naming the shape here was for it to declare a private copy — * which is precisely how this codebase ended up with seven hand-written, * mutually-disagreeing lists of "the document fields". Every property is * optional because every one of them is genuinely absent on some real row; * this describes what is STORED, not what is required. */ interface ParsedDocFile { path?: string; fileName?: string; documentId?: string; fileSize?: number | string; fileType?: string; createdAt?: string; month?: number; year?: number; /** * SYS-2873: the uploader's per-file document-language choice (e.g. "vi", * "en"), present only for slots whose catalog entry carries * document_language_options. Selects the extraction endpoint upstream; * absent on every Malaysia upload. */ documentLanguage?: string; /** * SYS-3174: who uploaded this file — the acting principal, not the subject * the document is about. * * The one genuine gap in this shape. It had no declaration anywhere in this * package, while one upload route wrote it ad hoc into a single column's * entries — so for every other document the answer was unrecoverable rather * than merely missing. Declared here so the next writer finds the spelling * instead of inventing a second one. * * Absent on every entry written before that route existed; a consumer must * treat "no uploader recorded" as a real and common state, never as a * defect. */ uploadedBy?: string; } /** * Parse one IHS doc field into file entries. Handles the shapes the field takes: * a JSON-array string (bank/financial/…, possibly inline-enriched like * supplementaryDoc), an already-parsed array, or a bare URL string (ssm/form9/ic). */ declare function parseFileField(value: unknown): ParsedDocFile[]; /** * Build the presentation-agnostic document rows for an IHS. Reads the doc fields * named in DOC_DISPLAY_NAMES + the `documentMetadata` sibling map (SYS-2765) and * emits a flat DocumentRow[] (all sections, in DOC_DISPLAY_NAMES order). Metadata * is taken inline off the entry first (supplementaryDoc is enriched inline), then * from `documentMetadata[path]`. */ declare function buildDocumentRows(ihsData: Record): DocumentRow[]; /** Type column: the resolved type label, or em-dash. */ declare function formatDocumentType(row: Pick): string; /** Size column: human bytes (B / KB / MB), or em-dash when unknown. */ declare function formatDocumentSize(bytes: number | null | undefined): string; /** Uploaded column: the upload date, else the period label, else em-dash. */ declare function formatDocumentUploaded(row: Pick): string; /** * SYS-3259: money-ness is DERIVED from the registry, not listed here. A legacy * name resolves through the migration map to a canonical field, and the * registry says whether that field's `kind` is `money`. That is 471 v1 keys; * the hand-written set this replaces named four, so `monthlyNetIncome`, * `purchasePriceOTR` and every other money field rendered as a plain string. * * THE RESIDUAL, named for what it is. The ticket's premise — "Phase 2.6 makes * this a deletion" — held for ONE of the four names. `totalFinancing` is * `relocated` (the application record) and `approvedAmount` / * `monthlyInstallment` are not adapter fields at all; none has a registry * entry to derive from. They stay, as the application record's currency * fields, until that record carries its own field spec (SYS-3379 / SYS-3412). * A test pins that this set contains ONLY names the registry cannot answer. */ declare const APPLICATION_RECORD_CURRENCY_FIELDS: ReadonlySet; /** Every v1 key whose canonical destination the registry declares as money. */ declare function registryMoneyLegacyNames(): ReadonlySet; declare function processIhsDetails(ihsData: Record): IhsFieldDetail[]; declare function groupDetailsByCategory(details: IhsFieldDetail[]): IhsDetailCategory[]; declare function processIhsDetailsFromView(view: CanonicalView): IhsFieldDetail[]; /** * The adapter categories that hold DOCUMENT data — every document type's * extraction category (via the migration map: the type's legacy T-slot columns * → the category they map into) plus `document-intake` (the pointers). Derived, * not listed. A type whose columns map into two categories throws. A type whose * columns map NOWHERE (the map is frozen at the v1 surface, so any document * type added after it) resolves to null and would NOT be excluded here — that * case is caught by the test that pins the seven types by name, not by this * function; adding a document type means deciding its extraction category * there. */ declare function documentCategoryIds(): ReadonlySet; /** * Which adapter category carries the EXTRACTED values for a document type — * `bankStatements` → `finxtract-bank-statement`, `ssm` → `company-profile`. * Read off the migration map: the type's legacy extraction columns (the form * spec's `ihs_column_names`) and where they went. * * INTERSECTION, NOT UNION, across the columns. A fan-out key is attested by * more than one category — `incorporatedDate` (SYS-2722) by company-profile * from the SSM document AND by company-registration from Form 9 — so a single * column can name two categories and still be right. The category a document * type EXTRACTS INTO is the one every one of its mapped columns names: for * Form 9 that is company-registration ({cp,cr} ∩ {cr} ∩ {cr}); for SSM it is * company-profile. Unmapped columns do not vote. Null when nothing is mapped. * Throws when the intersection is empty or has two members, because then * "the" extraction category does not exist and every caller would pick one * silently. */ declare function extractionCategoryOf(documentType: string): AdapterCategory | null; /** * `buildDocumentRows` for a v2 `CanonicalView`. Same `DocumentRow[]`, same * grouping and order (DOC_DISPLAY_NAMES), same capabilities — read from the * `document-intake` instances instead of the wide pointer columns. This is * the Phase 6 blocker SYS-3378 names: until this exists, dropping a pointer * column blanks the documents table in both products. * * WHICH DOCUMENTS. Every `document-intake` instance whose `documentType` is a * type the table shows, in intake order — UNIONED with any document the * extraction categories know that intake does not (an upload predating the * intake writer; measured 1 of 1323 on the sim). The same union, in the same * order, that `resolveExtractionStatusFromView` walks, so a consumer aligning * status to rows by (docType, index) still can; and both now carry the * document hash as `documentId`, so it can join by identity instead. * * WHAT MOVED. `uploadedAt` comes from the intake instance's own `uploadedAt` * field, else its `observedAt` — the attestation IS the upload — with the * consumer-supplied `metadata` map (the `documentMetadata` sibling, keyed by * path) consulted first, exactly as v1 did, for fileName / fileType / * fileSize when the consumer has it. `uploadedBy` is read off the instance. * * WHAT DID NOT SURVIVE, said plainly. v1's `periodLabel` came from `month` / * `year` on the pointer entry, and in practice was the slot ordinal * ("Year 1", "Year 2") for financial statements and null elsewhere. Intake * instances carry no period, so `timePeriod` is 'ALL' and `periodLabel` is * null; the display name falls back to "