Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>,
};
},
Expand Down
73 changes: 64 additions & 9 deletions middleware/packages/omadia-ui-orchestrator/src/patchComposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>;
[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');
}
Expand Down Expand Up @@ -82,6 +88,17 @@ function isTableNode(node: Record<string, unknown>): 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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' });
}
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions middleware/packages/omadia-ui-orchestrator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -117,6 +123,17 @@ const looksLikeDatasetRef = (rows: ReadonlyArray<Record<string, unknown>>): 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;
Expand Down Expand Up @@ -191,6 +208,7 @@ export async function handleCanvasPublishRows(
(r): r is Record<string, unknown> => 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 ' +
Expand All @@ -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);
Expand Down Expand Up @@ -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 } : {}),
Expand Down
8 changes: 6 additions & 2 deletions middleware/packages/plugin-api/src/privacyReceipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
}
Expand Down
122 changes: 122 additions & 0 deletions middleware/test/canvasPublishMaskedColumns.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
};
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<string, unknown> }>;
}>;
}).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');
});
});
Loading