Hmbown/CodeWhale · error · Error

Facts must be scalar metadata, not content objects.

Error message

Facts must be scalar metadata, not content objects.

What it means

Fact values must be scalars — string, finite number, boolean, or null. Any other value (nested object, array, undefined) throws `Facts must be scalar metadata, not content objects.` This enforces that observations stay metadata-only: facts cannot smuggle unbounded content into the evidence record.

Solutions

  1. Flatten nested objects into separate scalar facts (e.g. `foo.bar` keys)
  2. Serialize arrays to delimited strings (join with ',') if a list is needed
  3. Move large/structured content out of facts into your own store and reference it by id

Example fix

// before
facts: { tags: ["a","b"] }
// after
facts: { tags: "a,b" }
Defensive patterns

Strategy: validation

Validate before calling

for (const [k, v] of Object.entries(rec.facts ?? {})) { const ok = v === null || typeof v === 'boolean' || typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)); if (!ok) throw new Error(`fact '${k}' must be scalar`); }

Type guard

const isScalarFact = (v: unknown): v is string | number | boolean | null => v === null || ['string','number','boolean'].includes(typeof v);

Try / catch

try { validateObservation(rec); } catch (e) { if (e.message.includes('scalar metadata')) flattenOrExternalizeFacts(rec); throw e; }

Prevention

When it happens

Trigger: validateObservation given `facts` entries whose values are objects (e.g. `{foo:{bar:1}}`), arrays (`tags:["a","b"]`), or nested structures produced by dumping structured data into facts.

Common situations: Developer stores a config blob or array of IDs as a fact; serializing a JS object where a field became undefined-adjacent nested data; migrating logs where facts held full payloads.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/164f62c89bc7651f. Report an issue: GitHub.

Appendix: source

Thrown at pet/src/core/evidence.ts:97

function onlyKeys(o:Record<string,unknown>,keys:string[],label:string):void{
  for(const k of Object.keys(o))if(!keys.includes(k))throw new Error(`Unknown ${label} field: ${k}.`);
}
/** All unrecognized fields are rejected, never secretly retained in metadata-only evidence. */
export function validateObservation(input:unknown):Observation {
  const o=object(input,'observation');
  onlyKeys(o,['version','id','runId','operationId','sourceId','epoch','sequence','time','receivedAt','subject','stage','surface','action','effect','status','target','actionDigest','authority','correlation','facts'],'observation');
  if(o.version!==1)throw new Error('Unsupported observation version.');
  const t=object(o.time,'time'),s=object(o.subject,'subject');
  onlyKeys(t,['wallMs','clockId','monotonicMs','taskMs','uncertaintyMs'],'time');onlyKeys(s,['agentId','sandboxId','isolationGroup'],'subject');
  const seq=num(o.sequence,'sequence',1);if(!Number.isSafeInteger(seq))throw new Error('sequence must be an integer.');
  const facts=object(o.facts??{},'facts'),safeFacts:Observation['facts']={};
  if(Object.keys(facts).length>48)throw new Error('Too many observation facts.');
  for(const [k,v] of Object.entries(facts)){
    text(k,'fact key',80);if(['__proto__','prototype','constructor'].includes(k))throw new Error('Unsafe fact key.');
    if(typeof v==='string')safeFacts[k]=text(v,'fact value',512);
    else if(typeof v==='number')safeFacts[k]=num(v,'fact value',-Number.MAX_SAFE_INTEGER);
    else if(v===null||typeof v==='boolean')safeFacts[k]=v;
    else throw new Error('Facts must be scalar metadata, not content objects.');
  }
  let target:Observation['target'];if(o.target!==undefined){const a=object(o.target,'target');onlyKeys(a,['id','kind','version','boundary'],'target');target={id:text(a.id,'target.id'),kind:text(a.kind,'target.kind',64),version:optionalText(a,'version'),boundary:a.boundary===undefined?undefined:enumValue(a.boundary,['local','external','unknown'] as const,'boundary')};}
  let authority:Observation['authority'];if(o.authority!==undefined){const a=object(o.authority,'authority');onlyKeys(a,['grantId','claimed'],'authority');authority={grantId:optionalText(a,'grantId'),claimed:a.claimed===undefined?undefined:boolean(a.claimed,'claimed')};}
  let correlation:Observation['correlation'];if(o.correlation!==undefined){const a=object(o.correlation,'correlation');onlyKeys(a,['sessionId','requestId','parentOperationId','messageId'],'correlation');correlation={sessionId:optionalText(a,'sessionId'),requestId:optionalText(a,'requestId'),parentOperationId:optionalText(a,'parentOperationId'),messageId:optionalText(a,'messageId')};}
  return {version:1,id:text(o.id,'id'),runId:text(o.runId,'runId'),operationId:optionalText(o,'operationId'),sourceId:text(o.sourceId,'sourceId'),epoch:text(o.epoch,'epoch'),sequence:seq,
    time:{wallMs:num(t.wallMs,'wallMs'),clockId:optionalText(t,'clockId'),monotonicMs:optionalNumber(t,'monotonicMs'),taskMs:optionalNumber(t,'taskMs'),uncertaintyMs:optionalNumber(t,'uncertaintyMs')},receivedAt:optionalNumber(o,'receivedAt'),
    subject:{agentId:text(s.agentId,'agentId'),sandboxId:optionalText(s,'sandboxId'),isolationGroup:optionalText(s,'isolationGroup')},stage:enumValue(o.stage,STAGES,'stage'),surface:enumValue(o.surface,SURFACES,'surface'),action:text(o.action,'action',128),effect:enumValue(o.effect,EFFECTS,'effect'),status:enumValue(o.status,['started','completed','error','unknown'],'status'),target,actionDigest:optionalText(o,'actionDigest'),authority,correlation,facts:safeFacts};
}
export function validatePolicy(input:unknown):BoundaryPolicy {
  const o=object(input,'policy');onlyKeys(o,['version','id','sources','grants','expectedSurfaces','forbiddenCrossGroup'],'policy');
  if(o.version!==1||!Array.isArray(o.sources)||o.sources.length>256||!Array.isArray(o.grants)||o.grants.length>2048)throw new Error('Invalid policy version or limits.');
  const sources=o.sources.map(x=>{const a=object(x,'source');onlyKeys(a,['id','stages','surfaces','runIds','sandboxIds','isolationGroups','heartbeatMs','description'],'source');return {id:text(a.id,'source.id'),stages:strings(a.stages,'stages').map(v=>enumValue(v,STAGES,'stage')),surfaces:strings(a.surfaces,'surfaces').map(v=>enumValue(v,SURFACES,'surface')),runIds:strings(a.runIds,'runIds'),sandboxIds:a.sandboxIds===undefined?undefined:strings(a.sandboxIds,'sandboxIds'),isolationGroups:a.isolationGroups===undefined?undefined:strings(a.isolationGroups,'isolationGroups'),heartbeatMs:num(a.heartbeatMs,'heartbeatMs',1),description:optionalText(a,'description')};});
  const grants=o.grants.map(x=>{const a=object(x,'grant');onlyKeys(a,['id','runIds','sandboxIds','targetIds','actions','effects','notBefore','expiresAt','actionDigest'],'grant');const g={id:text(a.id,'grant.id'),runIds:strings(a.runIds,'runIds'),sandboxIds:strings(a.sandboxIds,'sandboxIds'),targetIds:strings(a.targetIds,'targetIds'),actions:strings(a.actions,'actions'),effects:strings(a.effects,'effects').map(v=>enumValue(v,EFFECTS,'effect')),notBefore:num(a.notBefore,'notBefore'),expiresAt:num(a.expiresAt,'expiresAt'),actionDigest:optionalText(a,'actionDigest')};if(g.expiresAt<=g.notBefore)throw new Error('Grant expiry must follow its start.');return g;});
  if(new Set(sources.map(s=>s.id)).size!==sources.length||new Set(grants.map(g=>g.id)).size!==grants.length)throw new Error('Duplicate policy identity.');
  return {version:1,id:text(o.id,'policy.id'),sources,grants,expectedSurfaces:strings(o.expectedSurfaces,'expectedSurfaces',true).map(v=>enumValue(v,SURFACES,'surface')),forbiddenCrossGroup:boolean(o.forbiddenCrossGroup,'forbiddenCrossGroup')};
}
export function validateBundle(input:unknown,maxRecords=100_000):EvidenceBundle {
  const o=object(input,'bundle');onlyKeys(o,['format','name','records','policy','asOf'],'bundle');

View on GitHub (pinned to 433685b202)