@kontourai/survey 0.4.2 → 0.4.4

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.
Files changed (34) hide show
  1. package/README.md +247 -35
  2. package/dist/examples/review-workbench/downstream-public-directory-adapter.d.ts +3 -0
  3. package/dist/examples/review-workbench/downstream-public-directory-adapter.js +262 -0
  4. package/dist/examples/review-workbench/review-queue-session.d.ts +54 -0
  5. package/dist/examples/review-workbench/review-queue-session.js +122 -0
  6. package/dist/examples/review-workbench/review-surface-preview.d.ts +55 -0
  7. package/dist/examples/review-workbench/review-surface-preview.js +104 -0
  8. package/dist/examples/review-workbench/review-workbench-data.d.ts +261 -0
  9. package/dist/examples/review-workbench/review-workbench-data.js +199 -0
  10. package/dist/examples/review-workbench/review-workbench.d.ts +7 -0
  11. package/dist/examples/review-workbench/review-workbench.js +488 -0
  12. package/dist/fixtures/downstream-public-directory-proposal.d.ts +100 -0
  13. package/dist/fixtures/downstream-public-directory-proposal.js +59 -0
  14. package/dist/fixtures/public-directory-review-resource.d.ts +156 -0
  15. package/dist/fixtures/public-directory-review-resource.js +157 -0
  16. package/dist/fixtures/regulated-document-review-resource.d.ts +121 -0
  17. package/dist/fixtures/regulated-document-review-resource.js +131 -0
  18. package/dist/src/builder.d.ts +4 -1
  19. package/dist/src/builder.js +7 -0
  20. package/dist/src/index.d.ts +10 -6
  21. package/dist/src/index.js +5 -3
  22. package/dist/src/learning-projections.d.ts +19 -0
  23. package/dist/src/learning-projections.js +77 -0
  24. package/dist/src/raw-source.d.ts +15 -0
  25. package/dist/src/raw-source.js +22 -0
  26. package/dist/src/review-proof.d.ts +28 -0
  27. package/dist/src/review-proof.js +58 -1
  28. package/dist/src/review-resource.d.ts +111 -0
  29. package/dist/src/review-resource.js +1 -0
  30. package/dist/src/source-of-authority-observation.d.ts +20 -0
  31. package/dist/src/source-of-authority-observation.js +68 -0
  32. package/dist/src/to-surface.js +226 -13
  33. package/dist/src/types.d.ts +17 -1
  34. package/package.json +10 -4
package/README.md CHANGED
@@ -11,7 +11,7 @@ ingestion platform:
11
11
  - `buildSurveyTrustInput` projects those records into `@kontourai/surface`
12
12
  `TrustInput`;
13
13
  - Surface owns Claim, Subject, Claim Type, Evidence, Status, Claim Dependency,
14
- TrustInput, trust reporting, console projections, and downstream transparency.
14
+ TrustInput, trust reporting, and public reporting surfaces.
15
15
 
16
16
  The first success criterion is that generic corrected-document and public-field
17
17
  fixtures can pass through Survey and produce valid Surface reports without
@@ -64,6 +64,45 @@ const trustInput = validateTrustInput(buildSurveyTrustInput(surveyInput));
64
64
  const report = buildTrustReport(trustInput);
65
65
  ```
66
66
 
67
+ ## Contributor checks
68
+
69
+ Install the repo-owned Git hooks once per clone:
70
+
71
+ ```bash
72
+ npm run setup:repo-hooks
73
+ ```
74
+
75
+ The setup command is idempotent. It sets this repo's local `core.hooksPath` to
76
+ `.githooks` and does not require global Git configuration.
77
+
78
+ Use the same checks directly when you want to validate hook drift or package
79
+ health before pushing:
80
+
81
+ ```bash
82
+ npm run validate:repo-hooks
83
+ npm run verify
84
+ ```
85
+
86
+ The committed pre-push hook runs both commands from the repo root.
87
+
88
+ ## Producer validation path
89
+
90
+ Survey producers validate through public `@kontourai/survey` and
91
+ `@kontourai/surface` contracts:
92
+
93
+ 1. Build Survey observations with source, extraction, candidate, review, and
94
+ claim records.
95
+ 2. Call `buildSurveyTrustInput` to project the Survey records into Surface
96
+ `TrustInput`.
97
+ 3. Call Surface `validateTrustInput` on the projected input.
98
+ 4. Optionally call public Surface report APIs such as `buildTrustReport` to
99
+ inspect claims, evidence, status, gaps, and metadata.
100
+
101
+ Keep producer operational state outside Survey. Queue status, reviewer form
102
+ state, retries, source caches, and product policy decisions belong in the
103
+ producer's own data model. Survey carries only the portable source,
104
+ extraction, candidate, review, and claim projection records needed by Surface.
105
+
67
106
  ## Raw sources
68
107
 
69
108
  Use raw-source helpers when a producer wants Survey to shape source identity
@@ -113,10 +152,103 @@ const surveyInput = new SurveyInputBuilder({ source: "example-producer:run-1" })
113
152
  ```
114
153
 
115
154
  Survey exports `uploadedDocumentSource`, `apiRecordSource`, `webPageSource`,
116
- and `manualEntrySource`. Producer-provided `id` values are preserved; otherwise
117
- Survey derives a stable id from source kind and `sourceRef`. Bare checksum
118
- values are normalized to `sha256:<value>`, while already-prefixed checksum
119
- values are preserved. Producer metadata is copied through to Surface evidence.
155
+ `manualEntrySource`, and `policyStandardSource`. Producer-provided `id` values
156
+ are preserved; otherwise Survey derives a stable id from source kind and
157
+ `sourceRef`. Bare checksum values are normalized to `sha256:<value>`, while
158
+ already-prefixed checksum values are preserved. Producer metadata is copied
159
+ through to Surface evidence.
160
+
161
+ Use `policyStandardSource` when the observed material is the applied standard
162
+ itself. It records `inlineText`, `standardVersion`, and optional `paragraphRef`
163
+ on the `RawSource` and projects to Surface `policy_rule` evidence by default.
164
+ Survey only preserves the producer-applied standard text/version; it does not
165
+ decide whether that standard is correct for the producer's domain.
166
+
167
+ ## Interpretation records
168
+
169
+ Use `addInterpretation` when a producer records how an actor read a
170
+ `policy-standard` paragraph for one claim. Interpretations are flat provenance
171
+ records with an `appliesTo` edge to a claim and an `anchorsTo` edge to a
172
+ policy-standard raw source; they are not nested claim derivations or rejection
173
+ reasons.
174
+
175
+ ```ts
176
+ import {
177
+ apiRecordSource,
178
+ buildSurveyTrustInput,
179
+ fieldObservation,
180
+ policyStandardSource,
181
+ SurveyInputBuilder,
182
+ } from "@kontourai/survey";
183
+
184
+ const observedAt = new Date().toISOString();
185
+ const standard = policyStandardSource({
186
+ id: "source.example.policy-standard.rule-1",
187
+ sourceRef: "policy-standard://example/rules/2026#rule-1",
188
+ observedAt,
189
+ inlineText: "A producer reading must cite the applied rule paragraph.",
190
+ standardVersion: "2026.1",
191
+ paragraphRef: "rule-1",
192
+ });
193
+
194
+ const surveyInput = new SurveyInputBuilder({ source: "example-producer:run-1" })
195
+ .addRawSource(standard)
196
+ .addObservation(fieldObservation({
197
+ id: "observation.example.policy-application",
198
+ field: "policyApplication.status",
199
+ value: "DOCUMENTED",
200
+ rawSource: apiRecordSource({
201
+ id: "source.example.application-record",
202
+ sourceRef: "example-records://application/application-1",
203
+ observedAt,
204
+ checksum: "application-1",
205
+ }),
206
+ extraction: {
207
+ target: "policyApplication.status",
208
+ locator: "json:$.policyApplication.status",
209
+ extractor: "example-extractor",
210
+ extractedAt: observedAt,
211
+ },
212
+ claim: {
213
+ id: "claim.example.policy-application",
214
+ subjectType: "example.application",
215
+ subjectId: "application-1",
216
+ surface: "example.review",
217
+ claimType: "policy-application.status",
218
+ impactLevel: "medium",
219
+ collectedBy: "example-extractor",
220
+ },
221
+ }))
222
+ .addInterpretation({
223
+ id: "interpretation.example.rule-1",
224
+ appliesToClaimId: "claim.example.policy-application",
225
+ anchorsToSourceId: standard.id,
226
+ ruleLocator: "text:paragraph=rule-1",
227
+ reading: "The producer read rule 1 as applying to the claim.",
228
+ actor: "producer-operator",
229
+ recordedAt: observedAt,
230
+ })
231
+ .build();
232
+
233
+ const trustInput = buildSurveyTrustInput(surveyInput);
234
+ ```
235
+
236
+ Projection emits a normal Surface verification event with
237
+ `method: "survey-interpretation"`, the existing `claimId`, and anchor
238
+ `evidenceIds`. Because current Surface verification events reject unsupported
239
+ keys, typed edge details are preserved on the projected claim at
240
+ `metadata.survey.interpretations[]`. The anchor evidence uses
241
+ `evidenceType: "policy_rule"`, `method: "anchoring"`, the interpretation
242
+ `ruleLocator` as `sourceLocator`, and the policy-standard text/version metadata.
243
+
244
+ ## Review resources
245
+
246
+ Survey also exports producer-neutral `ReviewItem`, `ReviewCandidate`, and
247
+ `ReviewDecision` TypeScript resource shapes for review UI and adapter fixtures.
248
+ They use `apiVersion`, `kind`, `metadata`, `spec`, and `status` fields while
249
+ mapping back to the existing Survey record layer. See
250
+ [`docs/review-resource-contract.md`](docs/review-resource-contract.md) for
251
+ field ownership, mapping hints, and the `ReviewSession` non-goal.
120
252
 
121
253
  ## Field observations
122
254
 
@@ -185,11 +317,13 @@ assumed.
185
317
 
186
318
  ## Source-of-authority observations
187
319
 
188
- Use `sourceOfAuthorityObservation` when a producer treats the raw source as
189
- authoritative for the extracted target: an official publication, registration
190
- platform page, policy document, contract record, or system-of-record response.
191
- The helper does not decide whether the source is truly authoritative. It
192
- enforces record discipline around the producer's declared source posture.
320
+ Use `sourceOfAuthorityObservationBuilder` when a producer treats the raw source
321
+ as authoritative for the extracted target: an official publication,
322
+ registration platform page, policy document, contract record, or
323
+ system-of-record response. The builder does not decide whether the source is
324
+ truly authoritative. It guides producers through the source, extraction,
325
+ source-authority posture, review outcome, and claim fields that make the
326
+ declared source posture auditable.
193
327
 
194
328
  Verified or assumed source-of-authority observations require:
195
329
 
@@ -207,14 +341,14 @@ reserved for actor, credential, role, organization, policy, or system authority.
207
341
  ```ts
208
342
  import {
209
343
  buildSurveyTrustInput,
210
- sourceOfAuthorityObservation,
344
+ sourceOfAuthorityObservationBuilder,
211
345
  SurveyInputBuilder,
212
346
  uploadedDocumentSource,
213
347
  } from "@kontourai/survey";
214
348
 
215
349
  const observedAt = new Date().toISOString();
216
350
  const rawSource = uploadedDocumentSource({
217
- sourceRef: "https://rules.example.test/standard-deduction.pdf",
351
+ sourceRef: "https://rules.example.test/thresholds.pdf",
218
352
  observedAt,
219
353
  checksum: "abc123",
220
354
  locatorScheme: "pdf",
@@ -223,35 +357,36 @@ const rawSource = uploadedDocumentSource({
223
357
  const surveyInput = new SurveyInputBuilder({
224
358
  source: "rule-producer:run-1",
225
359
  })
226
- .addObservation(sourceOfAuthorityObservation({
227
- id: "rule.standard-deduction.mfj.2026",
228
- field: "federal.standardDeduction.mfj.2026",
229
- value: 30000,
230
- sourceAuthority: {
360
+ .addObservation(sourceOfAuthorityObservationBuilder({
361
+ id: "rule.threshold.primary.2026",
362
+ field: "regulatedRule.threshold.primary.2026",
363
+ value: 1200,
364
+ })
365
+ .withSourceAuthority({
231
366
  authorityClass: "official_publication",
232
367
  scope: {
233
- jurisdiction: "federal",
368
+ jurisdiction: "example",
234
369
  productArea: "regulated-rule",
235
- taxYear: 2026,
370
+ effectiveYear: 2026,
236
371
  },
237
372
  sourceVersion: "2026",
238
373
  declaredBy: "rule-producer",
239
- },
240
- rawSource,
241
- extraction: {
374
+ })
375
+ .fromSource(rawSource)
376
+ .withExtraction({
242
377
  confidence: 0.94,
243
- locator: "pdf:page=12;table=standard-deduction;row=mfj",
378
+ locator: "pdf:page=12;table=thresholds;row=primary",
244
379
  extractor: "rule-producer",
245
380
  extractedAt: observedAt,
246
- },
247
- reviewOutcome: {
381
+ })
382
+ .withReviewOutcome({
248
383
  status: "verified",
249
384
  actor: "rule-reviewer",
250
385
  reviewedAt: new Date().toISOString(),
251
- },
252
- claim: {
386
+ })
387
+ .forClaim({
253
388
  subjectType: "regulated-rule",
254
- subjectId: "federal:standard-deduction:mfj:2026",
389
+ subjectId: "example:threshold:primary:2026",
255
390
  surface: "regulated.rules",
256
391
  claimType: "regulated.rule-value",
257
392
  status: "verified",
@@ -259,17 +394,24 @@ const surveyInput = new SurveyInputBuilder({
259
394
  evidenceType: "policy_rule",
260
395
  evidenceMethod: "extraction",
261
396
  collectedBy: "rule-producer",
262
- },
263
- }))
397
+ })
398
+ .build())
264
399
  .build();
265
400
 
266
401
  const trustInput = buildSurveyTrustInput(surveyInput);
267
402
  ```
268
403
 
269
- Contextual claims such as "this return position is compliant" or "this camp is
270
- eligible for an 8-year-old in June" are not source-of-authority observations.
404
+ `sourceOfAuthorityObservation` remains available as the lower-level object
405
+ factory when a producer already has the full observation input assembled.
406
+
407
+ Contextual claims such as "this submission is compliant" or "this record is
408
+ eligible for a specific requester" are not source-of-authority observations.
271
409
  They are Surface claims with Claim Dependencies on source-of-authority claims
272
- and other product facts. The vertical product owns that domain logic.
410
+ and other producer facts. The producer owns that domain logic.
411
+
412
+ For the reusable producer workflow, including manual confirmation state,
413
+ source references, Survey review outcomes, and Surface report boundaries, see
414
+ [Source-Authority Review Pattern](docs/source-authority-review-pattern.md).
273
415
 
274
416
  ## Reviewed candidate resolutions
275
417
 
@@ -285,6 +427,13 @@ This is useful for corrected documents, source-of-truth choices, and review
285
427
  queues where losing candidates should remain visible for transparency rather
286
428
  than disappearing from the trust trail.
287
429
 
430
+ Candidates may include an optional `rejectionReason` when a producer wants to
431
+ record why a non-selected alternative was superseded or rejected. Survey
432
+ preserves that producer-provided rationale on the candidate and projects it to
433
+ Surface claim `metadata.survey.candidate.rejectionReason` for that candidate
434
+ while preserving producer-provided `metadata.survey` keys. Survey does not rank
435
+ candidates, choose winners, or define rejection policy.
436
+
288
437
  ## Reviewed current/proposed resolutions
289
438
 
290
439
  Use `reviewedCurrentProposedResolution` when a producer has exactly two
@@ -467,6 +616,12 @@ shared candidate set while rejecting conflicting duplicate ids. Duplicate
467
616
  conflict checks assume Survey records are JSON-shaped data, which is the same
468
617
  shape expected by Surface validation and reports.
469
618
 
619
+ If an observation candidate includes `rejectionReason`, `candidateReviewRecord`
620
+ preserves it in the shared candidate set. Use this only for producer-authored
621
+ rationale about a candidate that the producer already treats as non-selected,
622
+ superseded, or rejected; it does not affect selected candidate behavior or
623
+ status projection.
624
+
470
625
  A candidate set with status `"conflict"` represents a Survey-side Candidate
471
626
  Conflict before review has resolved which candidate should win. When no review
472
627
  outcome overrides it, `buildSurveyTrustInput` projects the claim to Surface
@@ -507,6 +662,30 @@ so producers can store or recompute the exact canonical proof material used for
507
662
  the anchor. Producer metadata is not part of the canonical payload; any
508
663
  non-portable context belongs outside the hash, such as anchor metadata.
509
664
 
665
+ The canonical payload is the portable review proof contract. It contains:
666
+
667
+ | Field | Purpose |
668
+ | --- | --- |
669
+ | `schemaVersion` / `proof.schema` / `proof.schemaVersion` | Stable Survey review proof schema identity. |
670
+ | `proof.packageName` / `proof.packageVersion` | Review proof contract identity. `proof.packageVersion` is the proof contract version, not the npm package release version. Package releases do not change canonical proof hashes unless this explicit proof contract version or another canonical field changes. |
671
+ | `proof.issuer` | Survey producer identity, derived from the claim collector. |
672
+ | `proof.producer` | Extraction producer identity, derived from the extractor id. |
673
+ | `proof.issuedAt` | Proof envelope time, derived from review time, then claim update time, then extraction time. |
674
+ | `proof.subject` | Claim identity: claim id, candidate set id, reviewed candidate id, subject, surface, claim type, and field/behavior. If the claim also names a candidate id, it must match the reviewed candidate id. |
675
+ | `proof.sourcePayload` / `rawSource.checksum` | Source payload identity, ref, and producer-supplied checksum when present. |
676
+ | `extraction` | Extracted target, value, locator, excerpt, extractor, confidence, and extraction time. |
677
+ | `candidate` / `candidateSet` | Candidate identity/value plus the ordered candidate set, selected candidate, status, and rationale. |
678
+ | `reviewOutcome` | Review decision/status, actor, review time, rationale, and evidence ids. |
679
+ | `claim` | Projected claim identity, status/value, impact, evidence method, derivation links, collector, actor, and event method. |
680
+
681
+ To recompute the anchor value, rebuild the same canonical payload from the
682
+ reviewed Survey records, call `canonicalReviewProofJson(payload)`, and compute
683
+ SHA-256 over that JSON. The result should equal
684
+ `claim.currentIntegrityAnchor.value`. The Surface anchor remains generic:
685
+ `kind: "hash"`, `algorithm: "sha256"`, `verificationStatus: "unverified"`, no
686
+ Survey-specific anchor metadata, and a source/time pointer for display. Claims
687
+ without a selected review outcome are not anchored by `{ reviewProofs: true }`.
688
+
510
689
  When the Surface projection proof option is enabled, `buildSurveyTrustInput`
511
690
  will attach the same kind of anchor to the projected reviewed claim:
512
691
 
@@ -519,6 +698,12 @@ trail in the canonical payload. It does not authenticate an actor, sign the
519
698
  payload, or prove the real-world truth of the claim. Non-goals include JWT/JWS
520
699
  signing, key management, a transparency log, and any veracity guarantee.
521
700
 
701
+ JWT-adjacent words in the payload are process-envelope vocabulary, not a v0 JWT
702
+ implementation. `issuer` identifies the Survey producer for recomputation,
703
+ `subject` identifies the claim being reviewed, and `issuedAt` records the review
704
+ proof time. Audience restrictions, expiry, cryptographic signing, key discovery,
705
+ and legal non-repudiation are deferred concepts for a future signed envelope.
706
+
522
707
  ## Computed values
523
708
 
524
709
  Computed values are normal `ClaimTarget` entries in `claims`. Producers should
@@ -656,8 +841,9 @@ in Surface with a `candidate-escalation` event.
656
841
  Use `withinComfortZone: false` on a `ReviewOutcome` when the reviewer is
657
842
  recording a decision outside their domain expertise or is flagging that the
658
843
  conclusion requires a different authority to confirm. The flag and optional
659
- `comfortZoneNote` are carried forward to the Surface verification event `notes`
660
- so the reviewer chain sees the signal without having to read into the rationale.
844
+ `comfortZoneNote` are carried forward as structured Survey metadata on the
845
+ projected Surface claim at `metadata.survey.comfortZone`. Verification event
846
+ `notes` carry the normal review or candidate-set rationale only.
661
847
 
662
848
  ```ts
663
849
  reviewOutcome: {
@@ -670,6 +856,32 @@ reviewOutcome: {
670
856
  },
671
857
  ```
672
858
 
859
+ ## Learning projections
860
+
861
+ Use `buildSurveyLearningProjections(input)` when producer or review tooling needs
862
+ workflow/evaluation signals without changing Surface `TrustInput`.
863
+
864
+ ```ts
865
+ import {
866
+ buildSurveyLearningProjections,
867
+ buildSurveyTrustInput,
868
+ } from "@kontourai/survey";
869
+
870
+ const learning = buildSurveyLearningProjections(surveyInput);
871
+ const trustInput = buildSurveyTrustInput(surveyInput);
872
+ ```
873
+
874
+ Learning projections are product-neutral `learning.*` records. Survey currently
875
+ emits `learning.comfort-zone` from structured `ReviewOutcome.withinComfortZone
876
+ === false` data and `learning.escalation` from unresolved `EscalationRecord`s,
877
+ including unattached records that producer tooling can route but Surface cannot
878
+ attach to a claim event.
879
+
880
+ These projections are producer/review workflow and evaluation signals. They are
881
+ not claims about truth or veracity, not Surface claim status, not evidence, and
882
+ not verification events. Calling `buildSurveyLearningProjections` does not alter
883
+ `buildSurveyTrustInput`, trust status derivation, or escalation event projection.
884
+
673
885
  ## Product Boundary
674
886
 
675
887
  Survey does not crawl pages, parse PDFs, rank candidates, decide review policy,
@@ -0,0 +1,3 @@
1
+ import { type ReviewItem } from "../../src/review-resource.js";
2
+ import type { DownstreamPublicDirectoryProposal } from "../../fixtures/downstream-public-directory-proposal.js";
3
+ export declare function downstreamPublicDirectoryProposalToReviewItem(proposal: DownstreamPublicDirectoryProposal, field?: string): ReviewItem;
@@ -0,0 +1,262 @@
1
+ import { reviewResourceApiVersion } from "../../src/review-resource.js";
2
+ export function downstreamPublicDirectoryProposalToReviewItem(proposal, field = firstProposedField(proposal)) {
3
+ const diff = proposal.proposedChanges[field];
4
+ if (!diff) {
5
+ throw new Error(`Proposal ${proposal.id} does not include proposed field ${field}.`);
6
+ }
7
+ const reviewState = reviewStateForProposal(proposal.status, field);
8
+ const currentCandidate = currentReviewCandidate(proposal, field, diff, reviewState.selectedRole);
9
+ const proposedCandidate = proposedReviewCandidate(proposal, field, diff, reviewState.selectedRole);
10
+ return {
11
+ apiVersion: reviewResourceApiVersion,
12
+ kind: "ReviewItem",
13
+ metadata: reviewItemMetadata(proposal, field),
14
+ spec: {
15
+ target: field,
16
+ candidateSetStatus: reviewState.candidateSetStatus,
17
+ ...optionalSelectedCandidateId(reviewState.selectedCandidateId),
18
+ rationale: proposal.reviewerNotes ?? downstreamDecisionRationale(proposal.status),
19
+ candidates: [currentCandidate, proposedCandidate],
20
+ producerPolicy: {
21
+ owner: "downstream-adapter",
22
+ sourceShape: "public-directory-current-proposed-proposal",
23
+ proposalStatus: proposal.status,
24
+ ...rejectionPolicyForProposal(proposal.status),
25
+ approvedFields: proposal.appliedFields,
26
+ feedbackTags: proposal.feedbackTags,
27
+ opaquePolicyMetadata: {
28
+ priority: proposal.priority,
29
+ crawlTrigger: proposal.crawlTrigger,
30
+ communityScope: proposal.communitySlug,
31
+ },
32
+ },
33
+ projection: selectedProjection(reviewState.selectedRole, currentCandidate, proposedCandidate),
34
+ },
35
+ status: {
36
+ observedCandidateCount: 2,
37
+ ...optionalSelectedCandidateId(reviewState.selectedCandidateId),
38
+ reviewDecisionName: proposal.reviewedAt
39
+ ? `public-directory-${proposal.publicRecordId}-${field}-${proposal.id}-decision`
40
+ : undefined,
41
+ },
42
+ };
43
+ }
44
+ function reviewItemMetadata(proposal, field) {
45
+ return {
46
+ name: `public-directory-${proposal.publicRecordId}-${field}-${proposal.id}`,
47
+ uid: proposal.id,
48
+ labels: {
49
+ domain: "public-directory",
50
+ field,
51
+ },
52
+ annotations: {
53
+ "survey.kontourai.io/adapter": "downstream-public-directory",
54
+ "survey.kontourai.io/source-shape": "sanitized-copied-proposal",
55
+ },
56
+ producer: {
57
+ displayName: proposal.recordName ?? "Example Public Record",
58
+ slug: proposal.recordSlug ?? proposal.publicRecordId,
59
+ },
60
+ };
61
+ }
62
+ function firstProposedField(proposal) {
63
+ const [field] = Object.keys(proposal.proposedChanges);
64
+ if (!field) {
65
+ throw new Error(`Proposal ${proposal.id} does not include proposed changes.`);
66
+ }
67
+ return field;
68
+ }
69
+ function currentFieldSource(proposal, field) {
70
+ const fieldSources = proposal.recordData?.fieldSources;
71
+ if (!isRecord(fieldSources)) {
72
+ return {};
73
+ }
74
+ const source = fieldSources[field];
75
+ return isRecord(source)
76
+ ? {
77
+ excerpt: typeof source.excerpt === "string" ? source.excerpt : undefined,
78
+ sourceUrl: typeof source.sourceUrl === "string" ? source.sourceUrl : undefined,
79
+ approvedAt: typeof source.approvedAt === "string" ? source.approvedAt : undefined,
80
+ }
81
+ : {};
82
+ }
83
+ function currentReviewCandidate(proposal, field, diff, selectedRole) {
84
+ const currentSource = currentFieldSource(proposal, field);
85
+ const observedAt = currentSource.approvedAt ?? proposal.reviewedAt ?? proposal.createdAt;
86
+ return reviewCandidate({
87
+ proposal,
88
+ field,
89
+ diff,
90
+ role: "current",
91
+ value: diff.old,
92
+ sourceId: `public-directory:source:${proposal.publicRecordId}:${field}:current`,
93
+ sourceRef: currentSource.sourceUrl ?? `current-record:${proposal.publicRecordId}:${field}`,
94
+ observedAt,
95
+ excerpt: currentSource.excerpt ?? `Current ${field} value from the reviewed public record.`,
96
+ extractor: "downstream-current-record",
97
+ extractedAt: observedAt,
98
+ confidence: 1,
99
+ sourceRank: selectedRole === "current" ? 1 : 2,
100
+ });
101
+ }
102
+ function proposedReviewCandidate(proposal, field, diff, selectedRole) {
103
+ return reviewCandidate({
104
+ proposal,
105
+ field,
106
+ diff,
107
+ role: "proposed",
108
+ value: diff.new,
109
+ sourceId: `public-directory:source:${proposal.publicRecordId}:${field}:${proposal.id}`,
110
+ sourceRef: diff.sourceUrl ?? proposal.sourceUrl,
111
+ observedAt: proposal.createdAt,
112
+ excerpt: diff.excerpt ?? `Proposed ${field} value from downstream extraction.`,
113
+ extractor: proposal.extractionModel,
114
+ extractedAt: proposal.createdAt,
115
+ confidence: diff.confidence,
116
+ sourceRank: selectedRole === "proposed" ? 1 : 2,
117
+ });
118
+ }
119
+ function reviewCandidate(args) {
120
+ const id = candidateId(args.field, args.role);
121
+ const extractionId = `public-directory:extraction:${args.proposal.publicRecordId}:${args.field}:${args.proposal.id}:${args.role}`;
122
+ const claimId = candidateClaimId(args);
123
+ return {
124
+ id,
125
+ role: args.role,
126
+ value: args.value,
127
+ confidence: args.confidence,
128
+ sourceRank: args.sourceRank,
129
+ source: candidateSource(args),
130
+ locator: candidateLocator(args),
131
+ extraction: candidateExtraction(args, extractionId),
132
+ claimTarget: candidateClaimTarget(args, claimId),
133
+ projection: candidateProjection(args, id, extractionId, claimId),
134
+ producer: candidateProducerMetadata(args),
135
+ };
136
+ }
137
+ function reviewStateForProposal(status, field) {
138
+ if (status === "APPROVED") {
139
+ return {
140
+ candidateSetStatus: "resolved",
141
+ selectedRole: "proposed",
142
+ selectedCandidateId: candidateId(field, "proposed"),
143
+ };
144
+ }
145
+ if (status === "REJECTED") {
146
+ return {
147
+ candidateSetStatus: "resolved",
148
+ selectedRole: "current",
149
+ selectedCandidateId: candidateId(field, "current"),
150
+ };
151
+ }
152
+ return {
153
+ candidateSetStatus: "needs-review",
154
+ };
155
+ }
156
+ function optionalSelectedCandidateId(selectedCandidateId) {
157
+ return selectedCandidateId ? { selectedCandidateId } : {};
158
+ }
159
+ function rejectionPolicyForProposal(status) {
160
+ return status === "REJECTED"
161
+ ? { rejectionSemantics: "selected-current-keeps-existing-value-and-rejects-proposed-candidate" }
162
+ : {};
163
+ }
164
+ function selectedProjection(selectedRole, currentCandidate, proposedCandidate) {
165
+ if (selectedRole === "current") {
166
+ return currentCandidate.projection;
167
+ }
168
+ if (selectedRole === "proposed") {
169
+ return proposedCandidate.projection;
170
+ }
171
+ return undefined;
172
+ }
173
+ function candidateSource(args) {
174
+ return {
175
+ sourceId: args.sourceId,
176
+ sourceRef: args.sourceRef,
177
+ kind: args.sourceRef.startsWith("http") ? "web-page" : "manual-entry",
178
+ observedAt: args.observedAt,
179
+ fetchedAt: args.role === "proposed" ? args.proposal.crawlCompletedAt ?? args.observedAt : args.observedAt,
180
+ locatorScheme: args.sourceRef.startsWith("http") ? "html" : "structured-field",
181
+ };
182
+ }
183
+ function candidateLocator(args) {
184
+ return {
185
+ scheme: args.sourceRef.startsWith("http") ? "html" : "structured-field",
186
+ locator: `field:${args.field}`,
187
+ excerpt: args.excerpt,
188
+ };
189
+ }
190
+ function candidateExtraction(args, extractionId) {
191
+ return {
192
+ extractionId,
193
+ target: args.field,
194
+ confidence: args.confidence,
195
+ extractor: args.extractor,
196
+ extractedAt: args.extractedAt,
197
+ };
198
+ }
199
+ function candidateClaimTarget(args, claimId) {
200
+ return {
201
+ claimId,
202
+ subjectType: "public-record.entity",
203
+ subjectId: args.proposal.publicRecordId,
204
+ surface: "public-directory.profile",
205
+ claimType: args.role === "current" ? "public-data.field" : "public-data.field-candidate",
206
+ fieldOrBehavior: args.field,
207
+ impactLevel: "medium",
208
+ evidenceType: "source_excerpt",
209
+ evidenceMethod: args.role === "current" ? "attestation" : "extraction",
210
+ collectedBy: args.extractor,
211
+ };
212
+ }
213
+ function candidateProjection(args, candidateIdValue, extractionId, claimId) {
214
+ return {
215
+ rawSourceId: args.sourceId,
216
+ extractionId,
217
+ candidateSetId: `public-directory:candidates:${args.proposal.publicRecordId}:${args.field}:${args.proposal.id}`,
218
+ candidateId: candidateIdValue,
219
+ reviewOutcomeId: rejectedCurrentReviewOutcomeId(args),
220
+ claimId,
221
+ };
222
+ }
223
+ function candidateProducerMetadata(args) {
224
+ const selectedWhenRejected = rejectedCurrentReviewOutcomeId(args) ? { selectedWhenRejected: true } : {};
225
+ return {
226
+ proposalId: args.proposal.id,
227
+ proposalStatus: args.proposal.status,
228
+ previousValue: args.diff.old,
229
+ diffMode: args.diff.mode,
230
+ ...selectedWhenRejected,
231
+ sourceAuthority: {
232
+ authorityClass: "public-directory-listing",
233
+ declaredBy: args.role === "current" ? "Downstream reviewed record" : "Downstream extraction pipeline",
234
+ scope: `${args.field} field on ${args.proposal.publicRecordId}`,
235
+ },
236
+ };
237
+ }
238
+ function candidateClaimId(args) {
239
+ return args.role === "current"
240
+ ? `public-directory.${args.proposal.publicRecordId}.${args.field}.current`
241
+ : `public-directory.${args.proposal.publicRecordId}.${args.field}.${args.proposal.id}.proposed`;
242
+ }
243
+ function rejectedCurrentReviewOutcomeId(args) {
244
+ return args.role === "current" && args.proposal.status === "REJECTED"
245
+ ? `public-directory:review:${args.proposal.publicRecordId}:${args.field}:${args.proposal.id}:keep-current`
246
+ : undefined;
247
+ }
248
+ function candidateId(field, role) {
249
+ return `public-directory:candidate:${field}:${role}`;
250
+ }
251
+ function downstreamDecisionRationale(status) {
252
+ if (status === "APPROVED") {
253
+ return "Downstream reviewer accepted the proposed value.";
254
+ }
255
+ if (status === "REJECTED") {
256
+ return "Downstream reviewer rejected the proposed value and kept the current value.";
257
+ }
258
+ return "Downstream proposal is awaiting review.";
259
+ }
260
+ function isRecord(value) {
261
+ return typeof value === "object" && value !== null;
262
+ }