diff --git a/middleware/packages/harness-plugin-privacy-guard/src/service.ts b/middleware/packages/harness-plugin-privacy-guard/src/service.ts index b6b69851..c9d3f103 100644 --- a/middleware/packages/harness-plugin-privacy-guard/src/service.ts +++ b/middleware/packages/harness-plugin-privacy-guard/src/service.ts @@ -404,7 +404,11 @@ export function createPrivacyGuardService(deps?: { ); return { rowCount: dataset.rows.length, - columns: dataset.schema.fields.map((f) => ({ path: f.path, type: f.type })), + columns: dataset.schema.fields.map((f) => ({ + path: f.path, + type: f.type, + classification: f.classification, + })), rows: dataset.rows as ReadonlyArray>, }; }, diff --git a/middleware/packages/omadia-ui-orchestrator/src/patchComposition.ts b/middleware/packages/omadia-ui-orchestrator/src/patchComposition.ts index ab3d35dc..0e98ae7c 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/patchComposition.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/patchComposition.ts @@ -43,11 +43,17 @@ export interface ComposedPatch { interface TableNode { type: 'table'; loading?: string; - columns?: Array<{ fieldKey: string; label: string }>; + columns?: Array<{ fieldKey: string; label: string; privacy?: 'guard-protected' }>; rows: Array<{ rowKey: string; cells: Record }>; [key: string]: unknown; } +interface PublishedTableColumn { + fieldKey: string; + label: string; + privacy?: 'guard-protected'; +} + function escapePointerSegment(seg: string): string { return seg.replace(/~/g, '~0').replace(/\//g, '~1'); } @@ -82,6 +88,17 @@ function isTableNode(node: Record): node is TableNode { return node['type'] === 'table' && Array.isArray(node['rows']); } +function isPublishedTableColumn(value: unknown): value is PublishedTableColumn { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const column = value as { fieldKey?: unknown; label?: unknown; privacy?: unknown }; + return ( + typeof column.fieldKey === 'string' && + column.fieldKey.length > 0 && + typeof column.label === 'string' && + (column.privacy === undefined || column.privacy === 'guard-protected') + ); +} + interface ChartNode { type: 'chart'; loading?: string; @@ -452,9 +469,28 @@ export function composeStructuredPayloadPatch(opts: { if (!isTableNode(hit.node)) return null; const table: { node: TableNode; path: string } = { node: hit.node, path: hit.path }; - // Cells are mapped against the SKELETON's own columns — the contract the - // [canvas-context] handoff asked Tier 3 to fill. - const fieldKeys = (table.node.columns ?? []).map((c) => c.fieldKey); + const existingSkeletonColumns = table.node.columns ?? []; + const dataCols = (data as { columns?: unknown }).columns; + const publishedColumns = + Array.isArray(dataCols) && + dataCols.length > 0 && + dataCols.every((column): column is PublishedTableColumn => isPublishedTableColumn(column)) + ? dataCols + : undefined; + // Agent-authored rows still map against the skeleton columns exactly as + // before. Dataset publishes swap in the dataset's OWN paths and reconcile + // only the human labels by position. + const tableColumns = publishedColumns + ? publishedColumns.map((column, i) => { + const privacy = column.privacy; + return { + fieldKey: column.fieldKey, + label: existingSkeletonColumns[i]?.label ?? column.label, + ...(privacy ? { privacy } : {}), + }; + }) + : existingSkeletonColumns; + const fieldKeys = tableColumns.map((c) => c.fieldKey); if (fieldKeys.length === 0) { opts.log?.(`[patch-composition] skip: table ${String(table.node['id'])} has no columns`); return null; @@ -468,17 +504,35 @@ export function composeStructuredPayloadPatch(opts: { const cell = v === undefined || v === null ? '' - : typeof v === 'object' - ? JSON.stringify(v) - : typeof v === 'string' - ? stripInlineMarkdown(v) - : (v as number | boolean); + : Array.isArray(v) + ? // Odoo many2one fields arrive as a [id, "Display Name"] pair — + // render the label, not the raw [198,"…"] tuple. A remaining + // array of scalars (tags/codes) joins; anything else falls back. + v.length === 2 && typeof v[0] === 'number' && typeof v[1] === 'string' + ? stripInlineMarkdown(v[1]) + : v.every( + (e) => + e === null || + typeof e === 'string' || + typeof e === 'number' || + typeof e === 'boolean', + ) + ? stripInlineMarkdown(v.map((e) => (e == null ? '' : String(e))).join(', ')) + : JSON.stringify(v) + : typeof v === 'object' + ? JSON.stringify(v) + : typeof v === 'string' + ? stripInlineMarkdown(v) + : (v as number | boolean); return [k, cell]; }), ), })); const patches: TreePatchOp[] = []; + if (publishedColumns) { + patches.push({ op: 'replace', path: `${table.path}/columns`, value: tableColumns }); + } if (table.node.loading === 'skeleton') { patches.push({ op: 'replace', path: `${table.path}/loading`, value: 'none' }); } @@ -530,6 +584,7 @@ export function composeStructuredPayloadPatch(opts: { const nextTree = structuredClone(opts.baseTree); const cloneHit = findNodeById(nextTree, table.node['id'] as string, ''); if (!cloneHit || !isTableNode(cloneHit.node)) return null; + if (publishedColumns) cloneHit.node.columns = tableColumns; if (cloneHit.node.loading === 'skeleton') cloneHit.node['loading'] = 'none'; if (replaceRows) cloneHit.node.rows.splice(0, cloneHit.node.rows.length, ...mapped); else cloneHit.node.rows.push(...mapped); diff --git a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts index 04b2e3d4..b1857d28 100644 --- a/middleware/packages/omadia-ui-orchestrator/src/plugin.ts +++ b/middleware/packages/omadia-ui-orchestrator/src/plugin.ts @@ -107,6 +107,12 @@ export type CanvasDatasetResolver = ( datasetId: string, ) => PrivacyResolvedDataset | 'unavailable' | undefined; +interface DatasetPublishColumn { + fieldKey: string; + label: string; + privacy?: 'guard-protected'; +} + /** Privacy Shield v4: a dataset reference the LLM mistakenly wrapped INTO the * rows array instead of passing `datasetId` top-level. Detecting it turns a * silent one-garbage-row publish into a self-correcting tool error. */ @@ -117,6 +123,17 @@ const looksLikeDatasetRef = (rows: ReadonlyArray>): bool (typeof rows[0]['datasetId'] === 'string' || Object.values(rows[0]).some((v) => typeof v === 'string' && /^ds_[0-9a-f-]{8,}$/i.test(v))); +/** Dataset paths are authoritative row keys; humanise them for a fallback + * label when the skeleton has no column at the same position. */ +function humanizePath(path: string): string { + const collapsed = path.replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (collapsed.length === 0) return ''; + return collapsed + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + /** Hard cap for dataset publishes — the canvas viewState budget is finite; * bigger sets belong in a file (create_xlsx) or a filtered view. */ const MAX_DATASET_PUBLISH_ROWS = 500; @@ -191,6 +208,7 @@ export async function handleCanvasPublishRows( (r): r is Record => typeof r === 'object' && r !== null && !Array.isArray(r), ) : []; + let datasetColumns: DatasetPublishColumn[] | undefined; if (datasetId === undefined && hasRows && looksLikeDatasetRef(rows)) { return ( 'Error: that rows array contains a dataset REFERENCE, not data. Pass the id as the ' + @@ -214,6 +232,15 @@ export async function handleCanvasPublishRows( ); } rows = resolved.rows.map((r) => ({ ...r })); + datasetColumns = resolved.columns.map((column) => { + const privacy = + column.classification === 'sensitive-masked' ? 'guard-protected' : undefined; + return { + fieldKey: column.path, + label: humanizePath(column.path), + ...(privacy ? { privacy } : {}), + }; + }); if (rows.length > MAX_DATASET_PUBLISH_ROWS) { truncatedFrom = rows.length; rows = rows.slice(0, MAX_DATASET_PUBLISH_ROWS); @@ -276,6 +303,7 @@ export async function handleCanvasPublishRows( // A fields publish targets a scalar/KPI container; omit `rows` so the // synthesis layer routes it through the fields branch, not the table one. ...(hasMappableFields ? { fields } : { rows }), + ...(datasetColumns ? { columns: datasetColumns } : {}), ...(source ? { source } : {}), ...(actions.length > 0 ? { actions } : {}), ...(chartType ? { chartType } : {}), diff --git a/middleware/packages/plugin-api/src/privacyReceipt.ts b/middleware/packages/plugin-api/src/privacyReceipt.ts index 5e45985f..af035c84 100644 --- a/middleware/packages/plugin-api/src/privacyReceipt.ts +++ b/middleware/packages/plugin-api/src/privacyReceipt.ts @@ -193,8 +193,12 @@ export interface PrivacyBypassedToolRequest { export interface PrivacyResolvedDataset { /** Number of rows the dataset holds (the postcondition target). */ readonly rowCount: number; - /** Column schema — `path` is the row-object key, `type` the field type. */ - readonly columns: ReadonlyArray<{ readonly path: string; readonly type: string }>; + /** Column schema — `path` is the row-object key, `type` the field type, `classification` (when present) marks masked vs cleartext fields. */ + readonly columns: ReadonlyArray<{ + readonly path: string; + readonly type: string; + readonly classification?: 'sensitive-masked' | 'safe-cleartext'; + }>; /** The full real rows, keyed by column `path`. */ readonly rows: ReadonlyArray>; } diff --git a/middleware/test/canvasPublishMaskedColumns.test.ts b/middleware/test/canvasPublishMaskedColumns.test.ts new file mode 100644 index 00000000..a9d5c669 --- /dev/null +++ b/middleware/test/canvasPublishMaskedColumns.test.ts @@ -0,0 +1,122 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { parseToolEmittedStructuredPayload } from '../packages/harness-orchestrator/src/canvasSentinels.js'; +import { composeStructuredPayloadPatch } from '../packages/omadia-ui-orchestrator/src/patchComposition.js'; +import { handleCanvasPublishRows } from '../packages/omadia-ui-orchestrator/src/plugin.js'; + +describe('canvas_publish_rows datasetId publishes with masked columns', () => { + it('replaces skeleton field keys with dataset paths, preserves labels by position, and carries privacy badges', async () => { + const out = await handleCanvasPublishRows( + { + containerId: 'invoices', + datasetId: 'ds_invoices', + }, + (datasetId) => + datasetId === 'ds_invoices' + ? { + rowCount: 1, + columns: [ + { path: 'name', type: 'string', classification: 'safe-cleartext' as const }, + { + path: 'partner_id', + type: 'string', + classification: 'sensitive-masked' as const, + }, + { + path: 'invoice_date_due', + type: 'date', + classification: 'safe-cleartext' as const, + }, + { path: 'a.b.c', type: 'string', classification: 'safe-cleartext' as const }, + ], + rows: [ + { + name: 'INV-001', + partner_id: 'Marvin Vomberg', + invoice_date_due: '2026-06-17', + 'a.b.c': 'Extra column', + }, + ], + } + : undefined, + ); + const payload = parseToolEmittedStructuredPayload(out); + assert.ok(payload, 'dataset publish emits a structured payload sentinel'); + assert.deepEqual((payload.data as { columns?: unknown }).columns, [ + { fieldKey: 'name', label: 'Name' }, + { fieldKey: 'partner_id', label: 'Partner Id', privacy: 'guard-protected' }, + { fieldKey: 'invoice_date_due', label: 'Invoice Date Due' }, + { fieldKey: 'a.b.c', label: 'A B C' }, + ]); + + const baseTree = { + type: 'container', + id: 'root', + layout: 'stack', + children: [ + { + type: 'table', + id: 'invoices', + loading: 'skeleton', + columns: [ + { fieldKey: 'invoice_number', label: 'Invoice Number' }, + { fieldKey: 'customer_name', label: 'Customer' }, + { fieldKey: 'due_date', label: 'Due Date' }, + ], + rows: [], + }, + ], + }; + + const composed = composeStructuredPayloadPatch({ + baseTree, + payload, + dataRequirements: [{ containerId: 'invoices', description: 'Invoices', fields: [] }], + }); + assert.ok(composed, 'dataset payload composes onto the skeleton table'); + assert.equal(composed.patches[0]?.op, 'replace'); + assert.equal(composed.patches[0]?.path, '/children/0/columns'); + assert.deepEqual(composed.patches[0]?.value, [ + { fieldKey: 'name', label: 'Invoice Number' }, + { fieldKey: 'partner_id', label: 'Customer', privacy: 'guard-protected' }, + { fieldKey: 'invoice_date_due', label: 'Due Date' }, + { fieldKey: 'a.b.c', label: 'A B C' }, + ]); + assert.deepEqual(composed.patches[1], { + op: 'replace', + path: '/children/0/loading', + value: 'none', + }); + const rowPatch = composed.patches[2] as { + op: string; + path: string; + value: { rowKey: string; cells: Record }; + }; + assert.equal(rowPatch.op, 'add'); + assert.equal(rowPatch.path, '/children/0/rows/-'); + assert.match(rowPatch.value.rowKey, /^[0-9a-f-]+-0$/i); + assert.deepEqual(rowPatch.value.cells, { + name: 'INV-001', + partner_id: 'Marvin Vomberg', + invoice_date_due: '2026-06-17', + 'a.b.c': 'Extra column', + }); + + const table = (composed.nextTree as { + children: Array<{ + loading?: string; + columns: Array<{ fieldKey: string; label: string; privacy?: 'guard-protected' }>; + rows: Array<{ cells: Record }>; + }>; + }).children[0]; + assert.equal(table?.loading, 'none'); + assert.deepEqual(table?.columns, [ + { fieldKey: 'name', label: 'Invoice Number' }, + { fieldKey: 'partner_id', label: 'Customer', privacy: 'guard-protected' }, + { fieldKey: 'invoice_date_due', label: 'Due Date' }, + { fieldKey: 'a.b.c', label: 'A B C' }, + ]); + assert.equal(table?.rows[0]?.cells['partner_id'], 'Marvin Vomberg'); + }); +});