@kontourai/survey 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Producer Profile core — ADR 0003 §4, CONTEXT.md "Producer Profile".
3
+ *
4
+ * This module carries the shared scaffolding every Producer Profile
5
+ * (inquiry-mapping, schema-mapping, and — from Slice 4 — agent-utterance)
6
+ * needs to turn its own proposals into Survey's existing Candidate/Candidate
7
+ * Set records: a generic proposal -> Candidate Set projection grouped by
8
+ * target, the Candidate Conflict rule, and one canonical `Candidate.metadata`
9
+ * key with a typed accessor, replacing each profile's hand-rolled projection,
10
+ * conflict check, and `as`-cast metadata round-trip.
11
+ *
12
+ * This is a module-internal seam: its exports are consumed directly by
13
+ * profile modules via relative import and are NOT re-exported from
14
+ * `src/index.ts`.
15
+ *
16
+ * Hard constraint (ADR 0003 §4): this module never decides a review outcome
17
+ * or a claim status. It only shapes proposal-backed Candidate/Candidate Set
18
+ * records — every profile still routes its output through Survey's existing
19
+ * review -> claim machinery unchanged.
20
+ */
21
+ // ---------------------------------------------------------------------------
22
+ // Canonical proposal-metadata key
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * The one canonical `Candidate.metadata` key every Producer Profile uses to
26
+ * carry its profile-specific proposal payload. Replaces the per-profile keys
27
+ * (`mappingProposal`, `schemaMappingProposal`) each profile used before
28
+ * adopting this core module.
29
+ */
30
+ export const PRODUCER_PROPOSAL_METADATA_KEY = "producerProposal";
31
+ // ---------------------------------------------------------------------------
32
+ // Candidate Conflict rule
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * The shared Candidate Conflict rule: a group of proposals conflicts iff it
36
+ * carries more than one distinct `equivalenceKey`. A group of 0 or 1
37
+ * proposals can never conflict.
38
+ */
39
+ export function hasCandidateConflict(proposals) {
40
+ return new Set(proposals.map((p) => p.equivalenceKey)).size > 1;
41
+ }
42
+ /**
43
+ * Build one Candidate Set (and its Candidates) from one target's proposal
44
+ * group. Grouping proposals by target itself stays a caller concern — this
45
+ * function projects exactly one group per call; it never reaches across
46
+ * multiple targets on its own. `status` is `"conflict"` when
47
+ * {@link hasCandidateConflict} is true for `proposals`, otherwise
48
+ * `"needs-review"` (an empty `proposals` array yields `"needs-review"` with
49
+ * an empty `candidates` array). `selectedCandidateId` is left unset — both
50
+ * profiles compute it themselves, or not at all, per their own review flow.
51
+ */
52
+ export function projectProposalsToCandidateSet(target, proposals, options) {
53
+ const candidates = proposals.map((proposal) => ({
54
+ id: proposal.candidateId,
55
+ extractionId: proposal.extractionId,
56
+ value: proposal.value,
57
+ confidence: proposal.confidence,
58
+ metadata: {
59
+ [PRODUCER_PROPOSAL_METADATA_KEY]: proposal.metadata,
60
+ },
61
+ }));
62
+ const status = hasCandidateConflict(proposals) ? "conflict" : "needs-review";
63
+ const candidateSet = {
64
+ id: options.candidateSetId,
65
+ target,
66
+ candidates,
67
+ status,
68
+ rationale: options.candidateSetRationale?.(status, proposals),
69
+ metadata: options.candidateSetMetadata,
70
+ };
71
+ return { candidateSet, candidates };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Typed proposal-metadata accessor
75
+ // ---------------------------------------------------------------------------
76
+ /**
77
+ * Typed read-back of the proposal payload a Candidate carries under
78
+ * {@link PRODUCER_PROPOSAL_METADATA_KEY}. Returns `undefined` if the
79
+ * Candidate, its `metadata`, or the key itself is absent — never throws.
80
+ *
81
+ * No fallback reads of any legacy per-profile metadata key are performed
82
+ * (Owner decision: no legacy support).
83
+ */
84
+ export function getProducerProposal(candidate) {
85
+ return candidate?.metadata?.[PRODUCER_PROPOSAL_METADATA_KEY];
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Shared auto-accept primitives
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * Actor identity every Producer Profile's auto-accept policy uses when it
92
+ * accepts a proposal without human review. Shared literal — see ADR 0003
93
+ * §4 (the core never decides "verified"; auto-accept only ever produces
94
+ * "assumed" + comfort-zone true).
95
+ */
96
+ export const AUTO_ACCEPT_ACTOR = "auto-accept-policy";
97
+ /**
98
+ * The comfort-zone posture every Producer Profile's auto-accept policy
99
+ * sets when it accepts a proposal: `withinComfortZone: true` always — an
100
+ * auto-accepted proposal is, by definition, one the policy's declared
101
+ * threshold covers, so there is nothing "outside comfort zone" about an
102
+ * auto-accept decision (ADR 0003 §4).
103
+ */
104
+ export const AUTO_ACCEPT_WITHIN_COMFORT_ZONE = true;
105
+ /**
106
+ * The one auto-accept threshold rule every Producer Profile applies: a
107
+ * confidence value clears an auto-accept policy iff it is at or above
108
+ * (inclusive) the policy's minimum confidence.
109
+ *
110
+ * This is a low-level primitive used by {@link evaluateAutoAccept} below,
111
+ * which is now the single place that decides the gate/rationale/`reviewedAt`
112
+ * auto-accept policy for both profiles (see
113
+ * `docs/decisions/producer-profile.md`, "Auto-accept policy unification").
114
+ * What still stays entirely per-profile: output record shapes (e.g.
115
+ * `InquiryMapping` vs. schema-mapping's inline `ReviewOutcome`), id
116
+ * templates, and each profile's own selection/iteration algorithm for which
117
+ * candidate's evidence gets passed into that decision.
118
+ */
119
+ export function meetsAutoAcceptThreshold(confidence, minConfidence) {
120
+ return confidence >= minConfidence;
121
+ }
122
+ /**
123
+ * The one core auto-accept policy decision every Producer Profile delegates
124
+ * to, per the owner-accepted semantics recorded in
125
+ * `docs/decisions/producer-profile.md` ("Auto-accept policy unification"):
126
+ *
127
+ * 1. Gate on the accepted evidence's OWN confidence (not a group's), via
128
+ * {@link meetsAutoAcceptThreshold} — and never accept when `hasConflict`.
129
+ * 2. Compose a rationale citing that same gate-clearing confidence, and
130
+ * append `evidence.rationale` when present (`!== undefined`).
131
+ * 3. Stamp `reviewedAt` from `evidence.proposedAt` when present, falling
132
+ * back to `fallbackTimestamp` (and reporting which source was used via
133
+ * `reviewedAtSource`) when a profile's evidence carries no timestamp of
134
+ * its own.
135
+ * 4. Always report `actor: AUTO_ACCEPT_ACTOR` and
136
+ * `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
137
+ * auto-accept only ever yields "assumed" with the comfort-zone posture).
138
+ *
139
+ * This function decides the policy only — it never renders a review outcome
140
+ * or claim-status record itself (ADR 0003 §4). Each profile still renders
141
+ * its own distinct record shape (`InquiryMapping` vs. schema-mapping's
142
+ * inline `ReviewOutcome`) from this decision's fields.
143
+ */
144
+ export function evaluateAutoAccept(evidence, hasConflict, policy, fallbackTimestamp) {
145
+ const accepted = !hasConflict && meetsAutoAcceptThreshold(evidence.confidence, policy.minConfidence);
146
+ const rationale = `Auto-accepted: confidence ${evidence.confidence} >= threshold ${policy.minConfidence}.` +
147
+ (evidence.rationale !== undefined ? ` ${evidence.rationale}` : "");
148
+ const reviewedAt = evidence.proposedAt ?? fallbackTimestamp;
149
+ return {
150
+ accepted,
151
+ confidence: evidence.confidence,
152
+ rationale,
153
+ reviewedAt,
154
+ reviewedAtSource: evidence.proposedAt !== undefined ? "proposedAt" : "fallback",
155
+ actor: AUTO_ACCEPT_ACTOR,
156
+ withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE,
157
+ };
158
+ }
@@ -39,6 +39,24 @@ export interface PolicyStandardSourceInput extends Omit<RawSourceInput, "locator
39
39
  locatorScheme?: LocatorScheme;
40
40
  metadata?: Record<string, unknown>;
41
41
  }
42
+ /**
43
+ * Raw Source kinds whose factories accept a caller-supplied `locatorScheme`
44
+ * override and otherwise fall back to a per-kind default. `uploaded-document`
45
+ * has no default (`undefined`) because `UploadedDocumentSourceInput.locatorScheme`
46
+ * is required, not optional — modeled here as a real, explicit table entry
47
+ * rather than an omission, so a caller-supplied value is the only source of
48
+ * truth for that kind.
49
+ *
50
+ * Module-internal seam: consumed by relative import from
51
+ * `rawSourceWithDefaultLocatorScheme` below and by
52
+ * `tests/raw-source-defaults.test.ts`, NOT re-exported from `src/index.ts`.
53
+ */
54
+ export declare const DEFAULT_LOCATOR_SCHEME: {
55
+ "uploaded-document": undefined;
56
+ "api-record": LocatorScheme;
57
+ "web-page": LocatorScheme;
58
+ "manual-entry": LocatorScheme;
59
+ };
42
60
  export declare function uploadedDocumentSource(input: UploadedDocumentSourceInput): RawSource;
43
61
  export declare function apiRecordSource(input: ApiRecordSourceInput): RawSource;
44
62
  export declare function webPageSource(input: WebPageSourceInput): RawSource;
@@ -1,23 +1,38 @@
1
+ /**
2
+ * Raw Source kinds whose factories accept a caller-supplied `locatorScheme`
3
+ * override and otherwise fall back to a per-kind default. `uploaded-document`
4
+ * has no default (`undefined`) because `UploadedDocumentSourceInput.locatorScheme`
5
+ * is required, not optional — modeled here as a real, explicit table entry
6
+ * rather than an omission, so a caller-supplied value is the only source of
7
+ * truth for that kind.
8
+ *
9
+ * Module-internal seam: consumed by relative import from
10
+ * `rawSourceWithDefaultLocatorScheme` below and by
11
+ * `tests/raw-source-defaults.test.ts`, NOT re-exported from `src/index.ts`.
12
+ */
13
+ export const DEFAULT_LOCATOR_SCHEME = {
14
+ "uploaded-document": undefined,
15
+ "api-record": "structured-field",
16
+ "web-page": "html",
17
+ "manual-entry": "structured-field",
18
+ };
19
+ function rawSourceWithDefaultLocatorScheme(kind, input) {
20
+ return rawSource(kind, {
21
+ locatorScheme: DEFAULT_LOCATOR_SCHEME[kind],
22
+ ...input,
23
+ });
24
+ }
1
25
  export function uploadedDocumentSource(input) {
2
- return rawSource("uploaded-document", input);
26
+ return rawSourceWithDefaultLocatorScheme("uploaded-document", input);
3
27
  }
4
28
  export function apiRecordSource(input) {
5
- return rawSource("api-record", {
6
- locatorScheme: "structured-field",
7
- ...input,
8
- });
29
+ return rawSourceWithDefaultLocatorScheme("api-record", input);
9
30
  }
10
31
  export function webPageSource(input) {
11
- return rawSource("web-page", {
12
- locatorScheme: "html",
13
- ...input,
14
- });
32
+ return rawSourceWithDefaultLocatorScheme("web-page", input);
15
33
  }
16
34
  export function manualEntrySource(input) {
17
- return rawSource("manual-entry", {
18
- locatorScheme: "structured-field",
19
- ...input,
20
- });
35
+ return rawSourceWithDefaultLocatorScheme("manual-entry", input);
21
36
  }
22
37
  export function policyStandardSource(input) {
23
38
  const policyStandard = {
@@ -1,20 +1,6 @@
1
1
  import type { SurveyObservationInput } from "./builder.js";
2
- export interface RepeatedObservationInput<TItem> {
3
- id: string;
4
- field: string;
5
- value: readonly TItem[];
6
- rawSource: SurveyObservationInput["rawSource"];
7
- extraction: Omit<SurveyObservationInput["extraction"], "target" | "value" | "excerpt"> & {
8
- target?: string;
9
- excerpt?: string | null;
10
- };
11
- reviewOutcome?: SurveyObservationInput["reviewOutcome"];
12
- claim: Omit<SurveyObservationInput["claim"], "fieldOrBehavior" | "value"> & {
13
- fieldOrBehavior?: string;
14
- };
15
- candidate?: SurveyObservationInput["candidate"];
16
- candidateSet?: SurveyObservationInput["candidateSet"];
2
+ import { type ObservationAuthoringInput } from "./observation-helper.js";
3
+ export interface RepeatedObservationInput<TItem> extends ObservationAuthoringInput<readonly TItem[]> {
17
4
  representation?: "aggregate-array";
18
- metadata?: Record<string, unknown>;
19
5
  }
20
6
  export declare function repeatedObservation<TItem>(input: RepeatedObservationInput<TItem>): SurveyObservationInput;
@@ -1,16 +1,4 @@
1
- import { buildObservation } from "./observation-helper.js";
1
+ import { buildRepeatedObservation } from "./observation-helper.js";
2
2
  export function repeatedObservation(input) {
3
- const representation = input.representation ?? "aggregate-array";
4
- const value = [...input.value];
5
- return buildObservation({
6
- ...input,
7
- value,
8
- surveyMetadata: {
9
- repeated: {
10
- representation,
11
- itemCount: value.length,
12
- },
13
- },
14
- defaultExcerpt: `${input.field}: ${value.length} item(s)`,
15
- });
3
+ return buildRepeatedObservation(input);
16
4
  }
@@ -2,7 +2,7 @@ import { type DeriveReviewSessionApplyResultForSnapshotResult, type MapReviewWor
2
2
  import { type ReviewDecisionModeIssue } from "./producer-decision-mode.js";
3
3
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
4
4
  import type { ReviewDecision, ReviewSessionEvent } from "../review-resource.js";
5
- import type { ReviewQueueSessionState } from "./review-queue-session.js";
5
+ import { type ReviewQueueSessionState } from "./review-queue-session.js";
6
6
  export interface ServerReviewSessionRecord {
7
7
  readonly sessionName: string;
8
8
  readonly snapshot: ReviewQueueSessionState;
@@ -59,6 +59,19 @@ export interface DeriveServerReviewSessionApplyResultOptions {
59
59
  }
60
60
  export declare function createServerReviewSessionRecord(options: CreateServerReviewSessionRecordOptions): ServerReviewSessionRecord;
61
61
  export declare function hashReviewSessionSnapshot(snapshot: ReviewQueueSessionState): string;
62
+ /**
63
+ * Project the current review session state from a snapshot plus its
64
+ * append-only event log: replay events over the snapshot when any exist,
65
+ * otherwise return the snapshot itself unchanged (same object reference —
66
+ * no eager replay, no copy). Collapses the
67
+ * `events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot`
68
+ * conditional that was duplicated across the MCP and console review
69
+ * adapters (src/mcp/review-mcp.ts, src/console/review-console-server.ts) —
70
+ * same shape of fix as canonicalJson in ./canonical.ts, which was
71
+ * extracted after two near-identical copies of a derivation desynced
72
+ * (audit 2026-06-28, ops#24).
73
+ */
74
+ export declare function currentSessionState(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
62
75
  export declare function compareServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): ServerReviewSessionFreshnessComparison;
63
76
  export declare function assertServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): void;
64
77
  export declare function validateServerReviewSessionEvents(record: ServerReviewSessionRecord, events: readonly ReviewSessionEvent[]): ServerReviewSessionEventValidationIssue[];
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { deriveReviewSessionApplyResultForSnapshot, mapReviewWorkbenchResultsToApplyActions, ReviewApplyActionMappingError, } from "./review-workbench.js";
3
3
  import { validateReviewDecisionMode, } from "./producer-decision-mode.js";
4
4
  import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
5
+ import { replayReviewSessionEvents } from "./review-queue-session.js";
5
6
  import { canonicalJson } from "./canonical.js";
6
7
  export class StaleServerReviewSessionError extends Error {
7
8
  name = "StaleServerReviewSessionError";
@@ -34,6 +35,21 @@ export function createServerReviewSessionRecord(options) {
34
35
  export function hashReviewSessionSnapshot(snapshot) {
35
36
  return sha256(canonicalJson(snapshot));
36
37
  }
38
+ /**
39
+ * Project the current review session state from a snapshot plus its
40
+ * append-only event log: replay events over the snapshot when any exist,
41
+ * otherwise return the snapshot itself unchanged (same object reference —
42
+ * no eager replay, no copy). Collapses the
43
+ * `events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot`
44
+ * conditional that was duplicated across the MCP and console review
45
+ * adapters (src/mcp/review-mcp.ts, src/console/review-console-server.ts) —
46
+ * same shape of fix as canonicalJson in ./canonical.ts, which was
47
+ * extracted after two near-identical copies of a derivation desynced
48
+ * (audit 2026-06-28, ops#24).
49
+ */
50
+ export function currentSessionState(snapshot, events) {
51
+ return events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
52
+ }
37
53
  export function compareServerReviewSessionFreshness(record, snapshot, eventCount) {
38
54
  const actualSnapshotHash = hashReviewSessionSnapshot(snapshot);
39
55
  const eventCountMatches = record.eventCount === undefined || eventCount === undefined || record.eventCount === eventCount;
@@ -20,6 +20,7 @@
20
20
  * so that resolveInquiry can resolve across systems with weakest-link
21
21
  * capping.
22
22
  */
23
+ import { evaluateAutoAccept, getProducerProposal, projectProposalsToCandidateSet, } from "./producer-profile.js";
23
24
  import { buildSurveyTrustBundle } from "./to-surface.js";
24
25
  // ---------------------------------------------------------------------------
25
26
  // Canonical pair key
@@ -84,10 +85,7 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
84
85
  const subjectId = mappingSubjectId(first.sourceField, first.targetField);
85
86
  const candidateSetId = `schema-mapping.candidate-set.${pairKey}`;
86
87
  const claimId = `schema-mapping.claim.${pairKey}`;
87
- // Detect conflict: proposals disagree on relation
88
- const relations = new Set(pairProposals.map((p) => p.relation));
89
- const status = relations.size > 1 ? "conflict" : "needs-review";
90
- const candidates = pairProposals.map((proposal) => {
88
+ const candidateSetProposals = pairProposals.map((proposal) => {
91
89
  // Use the source system's RawSource for this extraction
92
90
  const rawSource = sourceById.get(proposal.sourceField.system) ?? rawSources[0];
93
91
  const extractionId = `schema-mapping.extraction.${proposal.id}`;
@@ -120,7 +118,7 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
120
118
  };
121
119
  extractions.push(extraction);
122
120
  return {
123
- id: `schema-mapping.candidate.${proposal.id}`,
121
+ candidateId: `schema-mapping.candidate.${proposal.id}`,
124
122
  extractionId,
125
123
  value: {
126
124
  relation: proposal.relation,
@@ -128,70 +126,79 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
128
126
  conversion: proposal.conversion,
129
127
  },
130
128
  confidence: proposal.confidence,
129
+ equivalenceKey: proposal.relation,
131
130
  metadata: {
132
- schemaMappingProposal: {
133
- proposalId: proposal.id,
134
- sourceField: proposal.sourceField,
135
- targetField: proposal.targetField,
136
- relation: proposal.relation,
137
- conversion: proposal.conversion,
138
- evidence: proposal.evidence,
139
- confidence: proposal.confidence,
140
- rationale: proposal.rationale,
141
- proposedBy: proposal.proposedBy,
142
- proposedAt: proposal.proposedAt,
143
- },
131
+ proposalId: proposal.id,
132
+ sourceField: proposal.sourceField,
133
+ targetField: proposal.targetField,
134
+ relation: proposal.relation,
135
+ conversion: proposal.conversion,
136
+ evidence: proposal.evidence,
137
+ confidence: proposal.confidence,
138
+ rationale: proposal.rationale,
139
+ proposedBy: proposal.proposedBy,
140
+ proposedAt: proposal.proposedAt,
144
141
  },
145
142
  };
146
143
  });
147
- const candidateSet = {
148
- id: candidateSetId,
149
- target: `schema-mapping:${pairKey}`,
150
- candidates,
151
- selectedCandidateId: status !== "conflict" ? candidates[0]?.id : undefined,
152
- status,
153
- rationale: status === "conflict"
154
- ? `Proposals disagree on relation for pair ${pairKey}: ${[...relations].join(", ")}`
155
- : `${candidates.length} proposal(s) agree on relation "${first.relation}" for pair ${pairKey}.`,
156
- metadata: {
144
+ const { candidateSet, candidates } = projectProposalsToCandidateSet(`schema-mapping:${pairKey}`, candidateSetProposals, {
145
+ candidateSetId,
146
+ candidateSetMetadata: {
157
147
  schemaMapping: {
158
148
  pairKey,
159
149
  sourceField: first.sourceField,
160
150
  targetField: first.targetField,
161
151
  },
162
152
  },
163
- };
153
+ candidateSetRationale: (status, proposals) => status === "conflict"
154
+ ? `Proposals disagree on relation for pair ${pairKey}: ${[...new Set(proposals.map((p) => p.equivalenceKey))].join(", ")}`
155
+ : `${proposals.length} proposal(s) agree on relation "${first.relation}" for pair ${pairKey}.`,
156
+ });
157
+ candidateSet.selectedCandidateId = candidateSet.status !== "conflict" ? candidates[0]?.id : undefined;
164
158
  candidateSets.push(candidateSet);
165
- // Auto-accept policy: non-conflicting proposals above threshold → assumed
166
- let autoReviewStatus;
167
- if (status !== "conflict" && options.autoAcceptMinConfidence !== undefined) {
168
- const topConfidence = Math.max(...pairProposals.map((p) => p.confidence));
169
- if (topConfidence >= options.autoAcceptMinConfidence) {
170
- autoReviewStatus = "assumed";
171
- }
172
- }
159
+ // Auto-accept policy: delegate the gate/rationale/reviewedAt decision to
160
+ // the core (docs/decisions/producer-profile.md — gates on the SELECTED
161
+ // candidate's own confidence, not the group's; keeps the existing
162
+ // candidates[0]-based selection algorithm unchanged, out of scope here).
173
163
  const selectedCandidate = candidateSet.selectedCandidateId
174
164
  ? candidates.find((c) => c.id === candidateSet.selectedCandidateId)
175
165
  : candidates[0];
176
- if (autoReviewStatus && selectedCandidate) {
177
- const reviewId = `schema-mapping.review.${pairKey}`;
178
- reviewOutcomes.push({
179
- id: reviewId,
180
- candidateSetId,
181
- candidateId: selectedCandidate.id,
182
- status: autoReviewStatus,
183
- actor: "auto-accept-policy",
184
- reviewedAt: generatedAt,
185
- rationale: `Auto-accepted: confidence ${selectedCandidate.confidence} >= threshold ${options.autoAcceptMinConfidence}`,
186
- withinComfortZone: true,
187
- });
166
+ const hasConflict = candidateSet.status === "conflict";
167
+ if (!hasConflict && options.autoAcceptMinConfidence !== undefined && selectedCandidate) {
168
+ const selectedProposal = getProducerProposal(selectedCandidate);
169
+ const decision = evaluateAutoAccept({
170
+ // Fail-closed fallback: Candidate.confidence is `number | undefined`
171
+ // (src/types.ts), but no conforming SchemaMappingExtractor can
172
+ // produce an undefined confidence today (MappingProposalRecord.confidence
173
+ // is required and is copied straight through to Candidate.confidence
174
+ // at construction), so this is unreachable through the typed contract.
175
+ // NEGATIVE_INFINITY (not 0) guarantees rejection regardless of
176
+ // autoAcceptMinConfidence's sign, matching old code's NaN-poisoning
177
+ // behavior of never auto-accepting on a missing confidence.
178
+ confidence: selectedCandidate.confidence ?? Number.NEGATIVE_INFINITY,
179
+ rationale: selectedProposal?.rationale,
180
+ proposedAt: selectedProposal?.proposedAt,
181
+ }, hasConflict, { minConfidence: options.autoAcceptMinConfidence }, generatedAt);
182
+ if (decision.accepted) {
183
+ const reviewId = `schema-mapping.review.${pairKey}`;
184
+ reviewOutcomes.push({
185
+ id: reviewId,
186
+ candidateSetId,
187
+ candidateId: selectedCandidate.id,
188
+ status: "assumed",
189
+ actor: decision.actor,
190
+ reviewedAt: decision.reviewedAt,
191
+ rationale: decision.rationale,
192
+ withinComfortZone: decision.withinComfortZone,
193
+ });
194
+ }
188
195
  }
189
196
  // Project to ClaimTarget (subjectType "system-field", fieldOrBehavior "maps-to")
190
197
  if (selectedCandidate) {
191
198
  const review = reviewOutcomes.find((r) => r.candidateSetId === candidateSetId);
192
199
  const claimStatus = review
193
200
  ? review.status
194
- : (status === "conflict" ? "disputed" : undefined);
201
+ : (candidateSet.status === "conflict" ? "disputed" : undefined);
195
202
  const claimTarget = {
196
203
  id: claimId,
197
204
  candidateSetId,
@@ -266,7 +273,7 @@ export function mappingReviewToSurface(reviewedMappings, options = {}) {
266
273
  const rawSources = [];
267
274
  const seenSources = new Set();
268
275
  for (const rm of accepted) {
269
- const meta = rm.selectedCandidate.metadata?.schemaMappingProposal;
276
+ const meta = getProducerProposal(rm.selectedCandidate);
270
277
  const sourceField = meta?.sourceField ?? rm.proposal.sourceField;
271
278
  const sourceId = `schema-mapping.source.${sourceField.system}`;
272
279
  if (!seenSources.has(sourceId)) {
@@ -290,7 +297,7 @@ export function mappingReviewToSurface(reviewedMappings, options = {}) {
290
297
  const reviewOutcomes = [];
291
298
  const claims = [];
292
299
  for (const rm of accepted) {
293
- const meta = rm.selectedCandidate.metadata?.schemaMappingProposal;
300
+ const meta = getProducerProposal(rm.selectedCandidate);
294
301
  const sourceField = meta?.sourceField ?? rm.proposal.sourceField;
295
302
  const targetField = meta?.targetField ?? rm.proposal.targetField;
296
303
  const relation = (meta?.relation ?? rm.proposal.relation);
@@ -379,7 +386,7 @@ export function mappingReviewToSurface(reviewedMappings, options = {}) {
379
386
  // subject and back-references the mapping claim via mappingClaimId.
380
387
  const identityLinks = [];
381
388
  for (const rm of accepted) {
382
- const meta = rm.selectedCandidate.metadata?.schemaMappingProposal;
389
+ const meta = getProducerProposal(rm.selectedCandidate);
383
390
  const sourceField = meta?.sourceField ?? rm.proposal.sourceField;
384
391
  const targetField = meta?.targetField ?? rm.proposal.targetField;
385
392
  const relation = (meta?.relation ?? rm.proposal.relation);
@@ -1,4 +1,5 @@
1
1
  import { buildObservation } from "./observation-helper.js";
2
+ import { assertReviewOutcomeDiscipline } from "./producer-discipline.js";
2
3
  export class SourceOfAuthorityObservationBuilder {
3
4
  state;
4
5
  constructor(args) {
@@ -115,15 +116,11 @@ function assertVerifiedPosture(input) {
115
116
  if (!input.extraction.locator) {
116
117
  throw new Error(`Source-of-authority observation ${input.id} cannot be ${status} without a source locator`);
117
118
  }
118
- if (!input.reviewOutcome) {
119
- throw new Error(`Source-of-authority observation ${input.id} cannot be ${status} without a review outcome`);
120
- }
121
- if (!input.reviewOutcome.actor) {
122
- throw new Error(`Source-of-authority observation ${input.id} cannot be ${status} without review actor authority`);
123
- }
124
- if (!input.reviewOutcome.reviewedAt) {
125
- throw new Error(`Source-of-authority observation ${input.id} cannot be ${status} without reviewedAt`);
126
- }
119
+ assertReviewOutcomeDiscipline({
120
+ subject: `Source-of-authority observation ${input.id}`,
121
+ status,
122
+ review: input.reviewOutcome,
123
+ });
127
124
  }
128
125
  function claimStatus(claimStatusValue, reviewStatus) {
129
126
  return claimStatusValue ?? reviewStatus;
@@ -1,4 +1,5 @@
1
1
  import { buildReviewProofAnchor } from "./review-proof.js";
2
+ import { assertReviewOutcomeDiscipline } from "./producer-discipline.js";
2
3
  export function buildSurveyTrustBundle(input, options = {}) {
3
4
  const rawSources = indexById(input.rawSources, "raw source");
4
5
  const extractions = indexById(input.extractions, "extraction");
@@ -334,15 +335,11 @@ function statusFor(input) {
334
335
  return "proposed";
335
336
  }
336
337
  function assertProducerDiscipline(input) {
337
- if ((input.status === "verified" || input.status === "assumed") && !input.review) {
338
- throw new Error(`Claim ${input.projection.id} cannot be ${input.status} without a review outcome`);
339
- }
340
- if ((input.status === "verified" || input.status === "assumed") && !input.review?.actor) {
341
- throw new Error(`Claim ${input.projection.id} cannot be ${input.status} without review actor authority`);
342
- }
343
- if ((input.status === "verified" || input.status === "assumed") && !input.review?.reviewedAt) {
344
- throw new Error(`Claim ${input.projection.id} cannot be ${input.status} without reviewedAt`);
345
- }
338
+ assertReviewOutcomeDiscipline({
339
+ subject: `Claim ${input.projection.id}`,
340
+ status: input.status,
341
+ review: input.review,
342
+ });
346
343
  if (input.rawSource.kind !== "manual-entry" && !input.extraction.locator) {
347
344
  throw new Error(`Claim ${input.projection.id} needs a source locator for ${input.rawSource.kind}`);
348
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -55,8 +55,11 @@
55
55
  "typecheck": "tsc --noEmit",
56
56
  "test": "npm run build && node --test dist/tests/*.test.js",
57
57
  "test:browser": "playwright test",
58
- "verify": "node scripts/check-content-boundary.cjs && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser",
58
+ "verify": "node scripts/check-content-boundary.cjs && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser",
59
59
  "check:content-boundary": "node scripts/check-content-boundary.cjs",
60
+ "check:decisions": "node scripts/check-decisions.cjs check",
61
+ "gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
62
+ "freeze:adrs": "node scripts/freeze-adrs.mjs",
60
63
  "sync:review-workbench-assets": "node scripts/sync-review-workbench-assets.cjs",
61
64
  "check:review-workbench-assets": "node scripts/sync-review-workbench-assets.cjs --check",
62
65
  "check:review-workbench": "npm run check:review-workbench:static && npm run check:generated-css",