instar 1.3.433 → 1.3.434

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,55 @@
1
+ /**
2
+ * paritySubmit.ts — Phase-3 dual-forward emitter.
3
+ *
4
+ * Builds the parity-submit request payload Echo POSTs to the Portal's
5
+ * `POST /api/instar/feedback-factory/parity-submit` endpoint during the
6
+ * feedback-process migration's Phase-3 dual-forward. Echo computes cluster
7
+ * decisions locally (processUnprocessed → ClusterResult[]); Portal runs its own
8
+ * processor on the same batch and returns matched/diverged.
9
+ *
10
+ * Payload shape is LOCKED with Dawn (Portal owner), 2026-06-08:
11
+ * { batchId, items: [{ feedbackId, action, clusterId, fingerprint, similarity, clusterTitle?, note? }] }
12
+ *
13
+ * Per-item `fingerprint` is INCLUDED on the wire (Dawn's call): Portal should not
14
+ * re-implement clusterFingerprint('type|component|normalized_title') — the canonical
15
+ * value is computed here (single source of truth) and Portal may re-derive to validate.
16
+ * For both 'merge' and 'create', the fingerprint is that of the cluster named by
17
+ * `clusterId` (merged cluster's fp / new cluster's fp respectively).
18
+ *
19
+ * This module is the deterministic emitter only — the HTTP POST + matched/diverged
20
+ * response handling is the dual-forward client (added when Portal's endpoint is live).
21
+ */
22
+ import type { ClusterResult, Cluster } from './types.js';
23
+ /** One item in the parity-submit payload (Dawn-locked wire shape). */
24
+ export interface ParitySubmitItem {
25
+ feedbackId: string;
26
+ action: 'merge' | 'create';
27
+ clusterId: string;
28
+ /** Canonical clusterFingerprint of the cluster at `clusterId`. Computed here, not Portal-side. */
29
+ fingerprint: string;
30
+ similarity: number;
31
+ clusterTitle?: string;
32
+ note?: string;
33
+ }
34
+ /** The full parity-submit request body. */
35
+ export interface ParitySubmitRequest {
36
+ batchId: string;
37
+ items: ParitySubmitItem[];
38
+ }
39
+ /** Minimal cluster identity the emitter needs to compute a fingerprint. */
40
+ export type ClusterIdentity = Pick<Cluster, 'type' | 'title'>;
41
+ export interface BuildParitySubmitOptions {
42
+ batchId: string;
43
+ }
44
+ /**
45
+ * Map a batch of local clustering decisions to the Dawn-locked parity-submit payload.
46
+ *
47
+ * @param results the ClusterResult[] from processUnprocessed for this batch
48
+ * @param clusters resolver clusterId → cluster identity ({type,title}); a Map, or a
49
+ * function. Must resolve EVERY clusterId referenced by `results`.
50
+ * @param opts { batchId }
51
+ * @throws if any result references a clusterId the resolver can't resolve (a missing
52
+ * cluster is a real integrity error — never silently drop an item).
53
+ */
54
+ export declare function buildParitySubmitPayload(results: ClusterResult[], clusters: Map<string, ClusterIdentity> | ((clusterId: string) => ClusterIdentity | undefined), opts: BuildParitySubmitOptions): ParitySubmitRequest;
55
+ //# sourceMappingURL=paritySubmit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paritySubmit.d.ts","sourceRoot":"","sources":["../../../src/feedback-factory/processor/paritySubmit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAEzD,sEAAsE;AACtE,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,kGAAkG;IAClG,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,2EAA2E;AAC3E,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC;AAE9D,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,aAAa,EAAE,EACxB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,MAAM,KAAK,eAAe,GAAG,SAAS,CAAC,EAC7F,IAAI,EAAE,wBAAwB,GAC7B,mBAAmB,CA8BrB"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * paritySubmit.ts — Phase-3 dual-forward emitter.
3
+ *
4
+ * Builds the parity-submit request payload Echo POSTs to the Portal's
5
+ * `POST /api/instar/feedback-factory/parity-submit` endpoint during the
6
+ * feedback-process migration's Phase-3 dual-forward. Echo computes cluster
7
+ * decisions locally (processUnprocessed → ClusterResult[]); Portal runs its own
8
+ * processor on the same batch and returns matched/diverged.
9
+ *
10
+ * Payload shape is LOCKED with Dawn (Portal owner), 2026-06-08:
11
+ * { batchId, items: [{ feedbackId, action, clusterId, fingerprint, similarity, clusterTitle?, note? }] }
12
+ *
13
+ * Per-item `fingerprint` is INCLUDED on the wire (Dawn's call): Portal should not
14
+ * re-implement clusterFingerprint('type|component|normalized_title') — the canonical
15
+ * value is computed here (single source of truth) and Portal may re-derive to validate.
16
+ * For both 'merge' and 'create', the fingerprint is that of the cluster named by
17
+ * `clusterId` (merged cluster's fp / new cluster's fp respectively).
18
+ *
19
+ * This module is the deterministic emitter only — the HTTP POST + matched/diverged
20
+ * response handling is the dual-forward client (added when Portal's endpoint is live).
21
+ */
22
+ import { clusterFingerprint } from './parity.js';
23
+ /**
24
+ * Map a batch of local clustering decisions to the Dawn-locked parity-submit payload.
25
+ *
26
+ * @param results the ClusterResult[] from processUnprocessed for this batch
27
+ * @param clusters resolver clusterId → cluster identity ({type,title}); a Map, or a
28
+ * function. Must resolve EVERY clusterId referenced by `results`.
29
+ * @param opts { batchId }
30
+ * @throws if any result references a clusterId the resolver can't resolve (a missing
31
+ * cluster is a real integrity error — never silently drop an item).
32
+ */
33
+ export function buildParitySubmitPayload(results, clusters, opts) {
34
+ if (!opts || typeof opts.batchId !== 'string' || opts.batchId.length === 0) {
35
+ throw new Error('buildParitySubmitPayload: opts.batchId is required');
36
+ }
37
+ const resolve = (clusterId) => typeof clusters === 'function' ? clusters(clusterId) : clusters.get(clusterId);
38
+ const items = results.map((r) => {
39
+ const cluster = resolve(r.clusterId);
40
+ if (!cluster) {
41
+ throw new Error(`buildParitySubmitPayload: no cluster identity for clusterId=${r.clusterId} ` +
42
+ `(feedbackId=${r.feedbackId}) — cannot compute fingerprint`);
43
+ }
44
+ const fingerprint = clusterFingerprint({ type: cluster.type ?? '', title: cluster.title });
45
+ const item = {
46
+ feedbackId: r.feedbackId,
47
+ action: r.action,
48
+ clusterId: r.clusterId,
49
+ fingerprint,
50
+ similarity: r.similarity,
51
+ };
52
+ // Optional fields: include only when present (omit, never null — Portal treats absent as unset).
53
+ if (r.clusterTitle !== undefined)
54
+ item.clusterTitle = r.clusterTitle;
55
+ if (r.note !== undefined)
56
+ item.note = r.note;
57
+ return item;
58
+ });
59
+ return { batchId: opts.batchId, items };
60
+ }
61
+ //# sourceMappingURL=paritySubmit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paritySubmit.js","sourceRoot":"","sources":["../../../src/feedback-factory/processor/paritySubmit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AA4BjD;;;;;;;;;GASG;AACH,MAAM,UAAU,wBAAwB,CACtC,OAAwB,EACxB,QAA6F,EAC7F,IAA8B;IAE9B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,OAAO,KAAK,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3E,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,OAAO,GAAG,CAAC,SAAiB,EAA+B,EAAE,CACjE,OAAO,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IAEjF,MAAM,KAAK,GAAuB,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QAClD,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACrC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,+DAA+D,CAAC,CAAC,SAAS,GAAG;gBAC3E,eAAe,CAAC,CAAC,UAAU,gCAAgC,CAC9D,CAAC;QACJ,CAAC;QACD,MAAM,WAAW,GAAG,kBAAkB,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAC3F,MAAM,IAAI,GAAqB;YAC7B,UAAU,EAAE,CAAC,CAAC,UAAU;YACxB,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,SAAS,EAAE,CAAC,CAAC,SAAS;YACtB,WAAW;YACX,UAAU,EAAE,CAAC,CAAC,UAAU;SACzB,CAAC;QACF,iGAAiG;QACjG,IAAI,CAAC,CAAC,YAAY,KAAK,SAAS;YAAE,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC,YAAY,CAAC;QACrE,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS;YAAE,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC;QAC7C,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * paritySubmitClient.ts — Phase-3 dual-forward HTTP client.
3
+ *
4
+ * POSTs the emitter payload (buildParitySubmitPayload) to the Portal's
5
+ * `POST /api/instar/feedback-factory/parity-submit` and parses the LOCKED
6
+ * response shape Dawn deployed 2026-06-08:
7
+ *
8
+ * { batchId, processed, matched, diverged, errors,
9
+ * results: [{ feedbackId, action, status, clusterId?, divergenceReason?, error? }] }
10
+ *
11
+ * `status` is the per-item branch predicate (Dawn: "the status field is the branch
12
+ * predicate") — matched | diverged | not_found | error. The verdict is derived from
13
+ * results[] (authoritative per-item), keyed by feedbackId; top-level counts are
14
+ * surfaced for reporting.
15
+ *
16
+ * FAIL-CLOSED (no-silent-degradation standard): a non-2xx response, non-JSON body,
17
+ * or a body that does not match the locked shape THROWS — the dual-forward
18
+ * orchestration must never silently treat a failed/garbled submit as "matched/done".
19
+ * Auth: Bearer <INSTAR_ECHO_READ_TOKEN> (Portal also accepts X-Internal-Key).
20
+ */
21
+ import type { ParitySubmitRequest } from './paritySubmit.js';
22
+ export declare const DEFAULT_PARITY_SUBMIT_ENDPOINT = "https://dawn.bot-me.ai/api/instar/feedback-factory/parity-submit";
23
+ /** Per-item outcome status (Dawn-locked). */
24
+ export type ParityItemStatus = 'matched' | 'diverged' | 'not_found' | 'error';
25
+ /** One result row (Dawn-locked). */
26
+ export interface ParitySubmitResultItem {
27
+ feedbackId: string;
28
+ action: 'merge' | 'create';
29
+ status: ParityItemStatus;
30
+ clusterId?: string;
31
+ /** Present when status='diverged': "Fingerprint mismatch — Echo: …, Portal: …". */
32
+ divergenceReason?: string;
33
+ /** Present when status='error': the unexpected-failure message. */
34
+ error?: string;
35
+ }
36
+ /** The full parity-submit response body (Dawn-locked). */
37
+ export interface ParitySubmitResponse {
38
+ batchId: string;
39
+ processed: number;
40
+ matched: number;
41
+ diverged: number;
42
+ errors: number;
43
+ results: ParitySubmitResultItem[];
44
+ }
45
+ /** Derived verdict — branch on this, keyed by feedbackId. */
46
+ export interface ParitySubmitVerdict {
47
+ response: ParitySubmitResponse;
48
+ /** True iff EVERY result is status='matched' (the clean done-state). */
49
+ allMatched: boolean;
50
+ /** Items needing attention: status 'diverged' or 'not_found' (carry divergenceReason). */
51
+ diverged: ParitySubmitResultItem[];
52
+ /** Items that hit an unexpected Portal-side failure (status='error'). */
53
+ errored: ParitySubmitResultItem[];
54
+ /** Every result keyed by feedbackId for O(1) lookup. */
55
+ byFeedbackId: Map<string, ParitySubmitResultItem>;
56
+ }
57
+ export interface SubmitParityOptions {
58
+ /** INSTAR_ECHO_READ_TOKEN — sent as `Authorization: Bearer <token>`. Required. */
59
+ token: string;
60
+ /** Override the endpoint (default = the Portal production path). */
61
+ endpoint?: string;
62
+ /** Inject a fetch implementation (for tests / non-global-fetch runtimes). */
63
+ fetchImpl?: typeof fetch;
64
+ /** Abort the request after this many ms (default 30s). */
65
+ timeoutMs?: number;
66
+ }
67
+ /** Thrown on any fail-closed condition (HTTP error, non-JSON, shape drift). */
68
+ export declare class ParitySubmitError extends Error {
69
+ readonly kind: 'http' | 'network' | 'parse' | 'shape';
70
+ readonly detail?: unknown | undefined;
71
+ constructor(message: string, kind: 'http' | 'network' | 'parse' | 'shape', detail?: unknown | undefined);
72
+ }
73
+ /** Validate a parsed body against the locked shape; throws ParitySubmitError('shape') on drift. */
74
+ export declare function parseParitySubmitResponse(data: unknown): ParitySubmitResponse;
75
+ /** Build the derived verdict from a validated response (per-item status is authoritative). */
76
+ export declare function verdictFromResponse(response: ParitySubmitResponse): ParitySubmitVerdict;
77
+ /**
78
+ * POST a parity-submit batch and return the derived verdict.
79
+ *
80
+ * @throws ParitySubmitError — fail-closed on HTTP error / network failure / non-JSON /
81
+ * shape drift. The caller decides retry/escalate; a failed submit is NEVER
82
+ * silently treated as success.
83
+ */
84
+ export declare function submitParityBatch(payload: ParitySubmitRequest, opts: SubmitParityOptions): Promise<ParitySubmitVerdict>;
85
+ //# sourceMappingURL=paritySubmitClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paritySubmitClient.d.ts","sourceRoot":"","sources":["../../../src/feedback-factory/processor/paritySubmitClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE7D,eAAO,MAAM,8BAA8B,qEACyB,CAAC;AAErE,6CAA6C;AAC7C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAC;AAS9E,oCAAoC;AACpC,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,0DAA0D;AAC1D,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAED,6DAA6D;AAC7D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,wEAAwE;IACxE,UAAU,EAAE,OAAO,CAAC;IACpB,0FAA0F;IAC1F,QAAQ,EAAE,sBAAsB,EAAE,CAAC;IACnC,yEAAyE;IACzE,OAAO,EAAE,sBAAsB,EAAE,CAAC;IAClC,wDAAwD;IACxD,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;CACnD;AAED,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,+EAA+E;AAC/E,qBAAa,iBAAkB,SAAQ,KAAK;IAGxC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO;IACrD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO;gBAFzB,OAAO,EAAE,MAAM,EACN,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,EAC5C,MAAM,CAAC,EAAE,OAAO,YAAA;CAK5B;AAMD,mGAAmG;AACnG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,OAAO,GAAG,oBAAoB,CAgC7E;AAED,8FAA8F;AAC9F,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,oBAAoB,GAAG,mBAAmB,CAYvF;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CAqC9B"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * paritySubmitClient.ts — Phase-3 dual-forward HTTP client.
3
+ *
4
+ * POSTs the emitter payload (buildParitySubmitPayload) to the Portal's
5
+ * `POST /api/instar/feedback-factory/parity-submit` and parses the LOCKED
6
+ * response shape Dawn deployed 2026-06-08:
7
+ *
8
+ * { batchId, processed, matched, diverged, errors,
9
+ * results: [{ feedbackId, action, status, clusterId?, divergenceReason?, error? }] }
10
+ *
11
+ * `status` is the per-item branch predicate (Dawn: "the status field is the branch
12
+ * predicate") — matched | diverged | not_found | error. The verdict is derived from
13
+ * results[] (authoritative per-item), keyed by feedbackId; top-level counts are
14
+ * surfaced for reporting.
15
+ *
16
+ * FAIL-CLOSED (no-silent-degradation standard): a non-2xx response, non-JSON body,
17
+ * or a body that does not match the locked shape THROWS — the dual-forward
18
+ * orchestration must never silently treat a failed/garbled submit as "matched/done".
19
+ * Auth: Bearer <INSTAR_ECHO_READ_TOKEN> (Portal also accepts X-Internal-Key).
20
+ */
21
+ export const DEFAULT_PARITY_SUBMIT_ENDPOINT = 'https://dawn.bot-me.ai/api/instar/feedback-factory/parity-submit';
22
+ const VALID_STATUSES = new Set([
23
+ 'matched',
24
+ 'diverged',
25
+ 'not_found',
26
+ 'error',
27
+ ]);
28
+ /** Thrown on any fail-closed condition (HTTP error, non-JSON, shape drift). */
29
+ export class ParitySubmitError extends Error {
30
+ kind;
31
+ detail;
32
+ constructor(message, kind, detail) {
33
+ super(message);
34
+ this.kind = kind;
35
+ this.detail = detail;
36
+ this.name = 'ParitySubmitError';
37
+ }
38
+ }
39
+ function assert(cond, kind, message, detail) {
40
+ if (!cond)
41
+ throw new ParitySubmitError(message, kind, detail);
42
+ }
43
+ /** Validate a parsed body against the locked shape; throws ParitySubmitError('shape') on drift. */
44
+ export function parseParitySubmitResponse(data) {
45
+ assert(data && typeof data === 'object', 'shape', 'parity-submit: response is not an object', data);
46
+ const d = data;
47
+ assert(typeof d.batchId === 'string', 'shape', 'parity-submit: missing/invalid batchId');
48
+ for (const k of ['processed', 'matched', 'diverged', 'errors']) {
49
+ assert(typeof d[k] === 'number', 'shape', `parity-submit: missing/invalid count "${k}"`);
50
+ }
51
+ assert(Array.isArray(d.results), 'shape', 'parity-submit: results is not an array');
52
+ const results = d.results.map((r, i) => {
53
+ assert(r && typeof r === 'object', 'shape', `parity-submit: results[${i}] is not an object`);
54
+ const row = r;
55
+ assert(typeof row.feedbackId === 'string', 'shape', `parity-submit: results[${i}].feedbackId missing`);
56
+ assert(row.action === 'merge' || row.action === 'create', 'shape', `parity-submit: results[${i}].action invalid (${String(row.action)})`);
57
+ assert(typeof row.status === 'string' && VALID_STATUSES.has(row.status), 'shape', `parity-submit: results[${i}].status unrecognized (${String(row.status)}) — contract drift`);
58
+ const item = {
59
+ feedbackId: row.feedbackId,
60
+ action: row.action,
61
+ status: row.status,
62
+ };
63
+ if (typeof row.clusterId === 'string')
64
+ item.clusterId = row.clusterId;
65
+ if (typeof row.divergenceReason === 'string')
66
+ item.divergenceReason = row.divergenceReason;
67
+ if (typeof row.error === 'string')
68
+ item.error = row.error;
69
+ return item;
70
+ });
71
+ return {
72
+ batchId: d.batchId,
73
+ processed: d.processed,
74
+ matched: d.matched,
75
+ diverged: d.diverged,
76
+ errors: d.errors,
77
+ results,
78
+ };
79
+ }
80
+ /** Build the derived verdict from a validated response (per-item status is authoritative). */
81
+ export function verdictFromResponse(response) {
82
+ const byFeedbackId = new Map();
83
+ const diverged = [];
84
+ const errored = [];
85
+ for (const r of response.results) {
86
+ byFeedbackId.set(r.feedbackId, r);
87
+ if (r.status === 'diverged' || r.status === 'not_found')
88
+ diverged.push(r);
89
+ else if (r.status === 'error')
90
+ errored.push(r);
91
+ }
92
+ const allMatched = response.results.length > 0 && response.results.every((r) => r.status === 'matched');
93
+ return { response, allMatched, diverged, errored, byFeedbackId };
94
+ }
95
+ /**
96
+ * POST a parity-submit batch and return the derived verdict.
97
+ *
98
+ * @throws ParitySubmitError — fail-closed on HTTP error / network failure / non-JSON /
99
+ * shape drift. The caller decides retry/escalate; a failed submit is NEVER
100
+ * silently treated as success.
101
+ */
102
+ export async function submitParityBatch(payload, opts) {
103
+ assert(opts && typeof opts.token === 'string' && opts.token.length > 0, 'http', 'submitParityBatch: opts.token (INSTAR_ECHO_READ_TOKEN) is required');
104
+ assert(payload && typeof payload.batchId === 'string' && payload.batchId.length > 0, 'shape', 'submitParityBatch: payload.batchId is required');
105
+ const endpoint = opts.endpoint ?? DEFAULT_PARITY_SUBMIT_ENDPOINT;
106
+ const f = opts.fetchImpl ?? globalThis.fetch;
107
+ assert(typeof f === 'function', 'network', 'submitParityBatch: no fetch implementation available');
108
+ const timeoutMs = opts.timeoutMs ?? 30_000;
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
111
+ let res;
112
+ try {
113
+ res = await f(endpoint, {
114
+ method: 'POST',
115
+ headers: {
116
+ Authorization: `Bearer ${opts.token}`,
117
+ 'Content-Type': 'application/json',
118
+ },
119
+ body: JSON.stringify(payload),
120
+ signal: controller.signal,
121
+ });
122
+ }
123
+ catch (err) {
124
+ throw new ParitySubmitError(`parity-submit network error: ${err.message}`, 'network', err);
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ assert(res.ok, 'http', `parity-submit returned HTTP ${res.status}`, res.status);
130
+ let data;
131
+ try {
132
+ data = await res.json();
133
+ }
134
+ catch (err) {
135
+ throw new ParitySubmitError(`parity-submit: response was not valid JSON: ${err.message}`, 'parse', err);
136
+ }
137
+ return verdictFromResponse(parseParitySubmitResponse(data));
138
+ }
139
+ //# sourceMappingURL=paritySubmitClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paritySubmitClient.js","sourceRoot":"","sources":["../../../src/feedback-factory/processor/paritySubmitClient.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.433",
3
+ "version": "1.3.434",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-08T22:16:09.710Z",
5
- "instarVersion": "1.3.433",
4
+ "generatedAt": "2026-06-08T22:46:04.841Z",
5
+ "instarVersion": "1.3.434",
6
6
  "entryCount": 199,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,26 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Phase-3 dual-forward of the feedback-process migration (Dawn Portal → Echo/Instar) gains its two deterministic client-side pieces, both built to the Portal's locked parity-submit contract:
9
+
10
+ - **Emitter** (`buildParitySubmitPayload`, `src/feedback-factory/processor/paritySubmit.ts`) — turns an array of `ClusterResult` + a cluster-resolver into the locked request payload `{ batchId, items: [{ feedbackId, action, clusterId, fingerprint, similarity, clusterTitle?, note? }] }`. The per-item `fingerprint` is computed echo-side via the canonical `clusterFingerprint({type, title})` (single source of truth — the Portal re-derives only to validate). Throws on an unresolvable `clusterId` or an empty `batchId` (no silent drop); optional fields are omitted when absent.
11
+ - **Client** (`submitParityBatch` + `parseParitySubmitResponse` + `verdictFromResponse`, `src/feedback-factory/processor/paritySubmitClient.ts`) — POSTs the emitter payload to `POST /api/instar/feedback-factory/parity-submit` (Bearer `INSTAR_ECHO_READ_TOKEN`, 30s AbortController timeout) and parses the locked response `{ batchId, processed, matched, diverged, errors, results: [{ feedbackId, action, status, clusterId?, divergenceReason?, error? }] }`. `status` (`matched|diverged|not_found|error`) is the per-item branch predicate; the derived verdict is keyed by `feedbackId`, with `allMatched` true only when every result is `matched`. **Fail-closed (no-silent-degradation standard):** a non-2xx response, network error, non-JSON body, or any shape drift THROWS `ParitySubmitError` — a failed/garbled submit is never silently treated as "matched/done".
12
+
13
+ ## What to Tell Your User
14
+
15
+ Internal migration infrastructure — nothing to configure, and no production feedback traffic flows through it yet. This is the client half of the parity dual-forward: Echo computes a feedback cluster verdict and submits it to the Portal, which re-derives the fingerprint to confirm agreement before applying. Cutover to live dual-forward remains a later, operator-gated step.
16
+
17
+ ## Summary of New Capabilities
18
+
19
+ | Capability | How to use |
20
+ |-----------|-----------|
21
+ | Build the locked parity-submit payload from cluster results | `buildParitySubmitPayload(results, clusterResolver, { batchId })` |
22
+ | Submit a parity batch + get a fail-closed verdict | `submitParityBatch(payload, { token })` → `{ allMatched, diverged, errored, byFeedbackId, response }` |
23
+
24
+ ## Evidence
25
+
26
+ Unit tests green: emitter 9/9 (`tests/unit/feedback-factory/parity-submit.test.ts` — both sides of every boundary: merge/create, fingerprint-correct-by-construction, optional-field present/absent, Map+fn resolver, throw-on-missing-cluster, throw-on-empty-batchId) and client 12/12 (`parity-submit-client.test.ts` — request shape incl. Bearer auth + default endpoint, verdict partitioning, and every FAIL-CLOSED path: non-2xx, non-JSON, shape drift, network throw, missing token). `tsc --noEmit` clean. **Live-verified:** the real `submitParityBatch` ran end-to-end against Dawn's live prod endpoint (empty-batch round-trip) → HTTP 200, locked shape parsed with no drift-throw, verdict `allMatched=false` (correct for empty results), `batchId` echoed. Tier-1 change; side-effects review at `upgrades/side-effects/feedback-parity-phase3-dual-forward.md`. Earned from topic 12476 (feedback-process migration, Dawn-coordinated; response contract locked by Dawn 2026-06-08).
@@ -0,0 +1,19 @@
1
+ # ELI16 — Feedback parity Phase-3 dual-forward (emitter + client)
2
+
3
+ ## The situation
4
+
5
+ Feedback about Instar agents currently lives in Dawn's Portal (a Python service backed by Postgres). We're migrating that whole feedback-processing pipeline over to Instar/Echo in reversible phases. Before Instar can be trusted to OWN the processing, it has to PROVE — against the Portal's live data — that it groups feedback into the same "clusters" the Portal does and reaches the same verdicts. "Phase-3 dual-forward" is the step where Echo computes a cluster verdict and forwards it to the Portal, which independently re-checks it before applying anything. This PR adds the two client-side building blocks for that forward.
6
+
7
+ ## What the two pieces do
8
+
9
+ **The emitter** (`buildParitySubmitPayload`) takes Echo's cluster decisions — an array of `ClusterResult` (each says "this feedback item should merge into cluster X" or "create a new cluster") — and packs them into the exact request shape the Portal's `parity-submit` endpoint expects: `{ batchId, items: [{ feedbackId, action, clusterId, fingerprint, similarity, clusterTitle?, note? }] }`. The `fingerprint` is the cluster's identity hash, computed ONE way (the canonical `clusterFingerprint({type, title})` already used everywhere else in the processor) so there is a single source of truth; the Portal re-derives it only to confirm agreement, never to override. If a cluster id can't be resolved to a real cluster, or the batch id is empty, the emitter THROWS instead of quietly emitting a malformed batch.
10
+
11
+ **The client** (`submitParityBatch`) sends that payload to the Portal over HTTPS with a Bearer token, then reads the reply. The Portal answers with a per-item `status`: `matched` (fingerprints agreed, applied), `diverged` (mismatch — NOT applied, with a reason), `not_found`, or `error`. The client turns that into a tidy verdict keyed by `feedbackId`, and only reports `allMatched` when every single item matched.
12
+
13
+ ## The one rule that matters most
14
+
15
+ If anything goes wrong on the wire — the server returns a non-200, the network drops, the body isn't valid JSON, or the reply's shape doesn't match the locked contract — the client THROWS a `ParitySubmitError`. It NEVER silently treats a broken or garbled submit as "matched / done." Silent success on a failed write is exactly how a data migration corrupts the canonical store, so we fail loudly and hand the decision (retry / escalate) back to the caller. This is the project's no-silent-degradation standard applied at the one network boundary.
16
+
17
+ ## Why it's safe to ship now
18
+
19
+ Nothing is wired into a live pipeline yet — no production feedback traffic flows through these, and there are no database writes. They are the deterministic building blocks the later, operator-gated cutover step will call. They're covered by 21 unit tests (both sides of every decision boundary, including every fail-closed path) and were verified end-to-end against the Portal's live endpoint with an empty-batch round-trip (HTTP 200, locked shape parsed, correct verdict). The actual cutover to live dual-forward remains a separate, deliberate, operator-approved step.
@@ -0,0 +1,76 @@
1
+ # Side-Effects Review — Feedback parity Phase-3 dual-forward (emitter + client)
2
+
3
+ **Version / slug:** `feedback-parity-phase3-dual-forward`
4
+ **Date:** `2026-06-08`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required`
7
+
8
+ ## Summary of the change
9
+
10
+ Adds the two deterministic client-side pieces of the feedback-process migration's
11
+ Phase-3 dual-forward: an **emitter** (`buildParitySubmitPayload`) that builds the
12
+ Portal's locked parity-submit request payload from `ClusterResult[]`, and an HTTP
13
+ **client** (`submitParityBatch` + `parseParitySubmitResponse` + `verdictFromResponse`)
14
+ that POSTs it to `/api/instar/feedback-factory/parity-submit` and parses the locked
15
+ response into a fail-closed verdict keyed by `feedbackId`. No call sites wire these
16
+ into a running pipeline yet — they are the building blocks the later cutover step will
17
+ invoke. No production feedback traffic flows through them.
18
+
19
+ ## Decision-point inventory
20
+
21
+ 1. **Emitter — `clusterId` resolves to a cluster?** Unresolvable → THROW (no silent drop).
22
+ 2. **Emitter — `batchId` non-empty?** Empty → THROW.
23
+ 3. **Client — HTTP status 2xx?** Non-2xx → THROW `ParitySubmitError('http')`.
24
+ 4. **Client — body is JSON?** Non-JSON → THROW `('parse')`.
25
+ 5. **Client — body matches the locked shape?** Drift (unknown `status`, missing
26
+ counts, results-not-array, bad `action`) → THROW `('shape')`.
27
+ 6. **Client — network/timeout?** fetch throws/aborts → THROW `('network')`.
28
+ 7. **Verdict — `allMatched`?** True iff `results.length > 0` AND every `status==='matched'`.
29
+
30
+ ## 1. Over-block
31
+
32
+ **What legitimate inputs does this change reject?** The emitter throws on an
33
+ unresolvable `clusterId` or empty `batchId` — both are genuine programmer errors that
34
+ must surface, not silently emit a malformed/partial batch; this is intended strictness,
35
+ not over-block. The client throws on any non-2xx / non-JSON / shape-drift / network
36
+ condition. A `diverged`, `not_found`, or `error` *item* inside a well-formed 200 is NOT
37
+ a throw — it is a normal verdict outcome surfaced in `diverged`/`errored`; only a
38
+ transport/contract failure throws. So a real divergence report is never mistaken for an
39
+ error. The 30s timeout could abort a legitimately slow Portal response — acceptable: a
40
+ fail-closed throw is the correct outcome (caller retries/escalates), never a silent
41
+ "done".
42
+
43
+ ## 2. Under-block
44
+
45
+ **What does this still miss?** The client validates structural shape, not semantic
46
+ correctness of Portal-computed verdicts — if the Portal returns a well-formed but
47
+ wrong `status`, the client trusts it (correct: the Portal is the reference authority
48
+ for apply/diverge). `allMatched=false` on an empty `results[]` is deliberate (an empty
49
+ batch proves nothing). The emitter does not dedupe `feedbackId`s within a batch (caller's
50
+ responsibility; the Portal keys results by `feedbackId`). No retry/backoff is built in —
51
+ fail-closed surfaces the error to the caller, which owns the retry policy.
52
+
53
+ ## 3. Level-of-abstraction fit
54
+
55
+ **Right layer?** Yes. The emitter lives beside the existing `processor/parity.ts`
56
+ (reusing its canonical `clusterFingerprint` — no second fingerprint implementation), and
57
+ the client is a thin, injectable (`fetchImpl`) transport wrapper over the locked contract.
58
+ Fingerprint stays single-source-of-truth (computed once, echo-side; Portal re-derives only
59
+ to validate). The fail-closed policy is enforced at the one ingress/egress boundary
60
+ (`submitParityBatch`/`parseParitySubmitResponse`), not scattered across callers.
61
+
62
+ ## 4. Blast radius
63
+
64
+ **What breaks if this is wrong?** Effectively nothing today: no call site invokes these
65
+ yet, no traffic, no DB writes, no migration to existing agents (pure new source under
66
+ `src/feedback-factory/processor/`, ships in dist). A bug would surface only when the later
67
+ cutover step wires `submitParityBatch` in — and the fail-closed design means a wrong/garbled
68
+ submit throws (caller halts) rather than silently corrupting the canonical store. Reversible
69
+ by not invoking it / reverting the two files.
70
+
71
+ ## 5. Failure mode
72
+
73
+ **Fail-open or fail-closed?** Fail-CLOSED by construction — every transport/contract
74
+ anomaly throws `ParitySubmitError`; the dual-forward orchestration can never read a failed
75
+ submit as success. Verified live: the real client ran against Dawn's prod endpoint
76
+ (empty-batch round-trip) → 200, locked shape parsed, verdict `allMatched=false`, no throw.