From df3fcac3ce85f22d934cb747c940a998038f68b5 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Fri, 14 Aug 2026 14:37:45 -0400 Subject: [PATCH 1/9] feat(provenance): add intellectual origin contract --- .../provenance/src/content-origin-contract.js | 193 ++++++++++++++++++ .../provenance/src/content-origin-evidence.js | 84 ++++++++ .../src/intent/capture-contract.js | 41 +++- modules/jarvos-secondbrain/src/index.js | 6 + .../tests/content-origin-contract.test.js | 186 +++++++++++++++++ 5 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js create mode 100644 modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js create mode 100644 modules/jarvos-secondbrain/tests/content-origin-contract.test.js diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js new file mode 100644 index 00000000..a8de7b1d --- /dev/null +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js @@ -0,0 +1,193 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const CONTENT_ORIGIN_SCHEMA_VERSION = 'jarvos-content-origin/v1'; +const CONTENT_ORIGINS = Object.freeze(['human', 'assistant', 'mixed', 'unknown']); +const CONTENT_ORIGIN_BASES = Object.freeze([ + 'verbatim_user', + 'user_derived', + 'assistant_generated', + 'mixed_composition', + 'unknown', + 'legacy_author', +]); + +const BASIS_ORIGIN = Object.freeze({ + verbatim_user: 'human', + user_derived: 'human', + assistant_generated: 'assistant', + mixed_composition: 'mixed', + unknown: 'unknown', +}); + +const LEGACY_AUTHOR_ORIGINS = Object.freeze({ + andrew: 'human', + jarvis: 'assistant', + both: 'mixed', +}); + +const SHA256_RE = /^[a-f0-9]{64}$/; + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function cleanText(value) { + return String(value ?? '').replace(/\r\n/g, '\n').trim(); +} + +function digestText(value) { + return crypto.createHash('sha256').update(cleanText(value)).digest('hex'); +} + +function normalizedActorType(actor) { + if (typeof actor === 'string') return actor; + if (isPlainObject(actor)) return actor.type || actor.role || null; + return null; +} + +function normalizedCaptureEventId(event) { + if (!isPlainObject(event)) return null; + return event.capture_event_id || event.captureEventId || event.id || null; +} + +function sourceReceipt(input) { + const receipt = input?.user_source || input?.userSource || input?.source_reference || input?.sourceReference; + return isPlainObject(receipt) ? receipt : null; +} + +function invalidReceipt(reason) { + return { ok: false, reason }; +} + +/** + * Validate a user-source receipt against a resolver-owned capture record. + * The resolver is deliberately injected so the public contract does not know + * whether a harness stores source turns in a transcript, event store, or API. + */ +function validateUserSourceReceipt(receipt, options = {}) { + if (!isPlainObject(receipt)) return invalidReceipt('missing'); + + const required = ['capture_event_id', 'actor', 'source_digest', 'content_digest']; + if (required.some((field) => typeof receipt[field] !== 'string' || !receipt[field].trim())) { + return invalidReceipt('malformed'); + } + if (receipt.actor !== 'user') return invalidReceipt('non_user_actor'); + if (!SHA256_RE.test(receipt.source_digest) || !SHA256_RE.test(receipt.content_digest)) { + return invalidReceipt('malformed_digest'); + } + + const content = cleanText(options.content); + if (!content || digestText(content) !== receipt.content_digest) { + return invalidReceipt('content_mismatch'); + } + + if (typeof options.resolveUserSource !== 'function') { + return invalidReceipt('unresolved'); + } + + let source; + try { + source = options.resolveUserSource(receipt.capture_event_id); + } catch (_error) { + return invalidReceipt('unresolved'); + } + if (!isPlainObject(source)) return invalidReceipt('unresolved'); + + const sourceId = normalizedCaptureEventId(source); + const sourceActor = normalizedActorType(source.actor ?? source); + const sourceText = source.text ?? source.content ?? source.body; + if (sourceId !== receipt.capture_event_id || sourceActor !== 'user' || typeof sourceText !== 'string') { + return invalidReceipt('source_mismatch'); + } + if (digestText(sourceText) !== receipt.source_digest) return invalidReceipt('source_digest_mismatch'); + + return { ok: true, reason: null, source }; +} + +function unknownRecord(reason = 'unknown') { + return { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + ...(reason ? { normalization_reason: reason } : {}), + }; +} + +function normalizeContentOrigin(input = {}, options = {}) { + const source = isPlainObject(input) ? input : {}; + const origin = String(source.content_origin ?? source.contentOrigin ?? '').trim().toLowerCase(); + const basis = String(source.content_origin_basis ?? source.contentOriginBasis ?? '').trim().toLowerCase(); + + if (!origin && !basis) return unknownRecord('missing_declaration'); + if (!CONTENT_ORIGINS.includes(origin) || !CONTENT_ORIGIN_BASES.includes(basis)) { + return unknownRecord('invalid_enum'); + } + if (basis === 'legacy_author') return unknownRecord('legacy_basis_requires_read_time_resolution'); + if (BASIS_ORIGIN[basis] !== origin) return unknownRecord('origin_basis_mismatch'); + if (origin === 'human') { + const validation = validateUserSourceReceipt(sourceReceipt(source), options); + if (!validation.ok) return unknownRecord(`invalid_user_source:${validation.reason}`); + } + + const result = { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin, + content_origin_basis: basis, + human_evidence_eligible: origin === 'human', + }; + const receipt = sourceReceipt(source); + if (receipt && origin === 'human') result.user_source = { ...receipt }; + return result; +} + +function resolveLegacyOrigin(input = {}) { + const author = String(input.author || '').trim().toLowerCase(); + const sourceAgent = String(input.source_agent || input.sourceAgent || '').trim().toLowerCase(); + const sourceActor = normalizedActorType(input.source_actor || input.sourceActor || input.actor); + const agentEvidence = sourceActor === 'assistant' + || Boolean(sourceAgent && !['andrew', 'human', 'manual'].includes(sourceAgent)); + + if (!LEGACY_AUTHOR_ORIGINS[author] || (author === 'andrew' && agentEvidence)) { + return { content_origin: 'unknown', content_origin_basis: 'unknown' }; + } + return { + content_origin: LEGACY_AUTHOR_ORIGINS[author], + content_origin_basis: 'legacy_author', + }; +} + +function humanEvidenceEligible(record = {}, options = {}) { + if (!isPlainObject(record) || record.content_origin !== 'human') return false; + if (record.human_evidence_eligible === true) return true; + if (record.content_origin_basis === 'legacy_author') return options.allowLegacyFallback === true; + if (options.manualEntry === true) return true; + if (!record.user_source) return false; + const validation = validateUserSourceReceipt(record.user_source, options); + return validation.ok; +} + +function normalizeContentOriginWithLegacy(input = {}, options = {}) { + if (input.content_origin || input.contentOrigin || input.content_origin_basis || input.contentOriginBasis) { + return normalizeContentOrigin(input, options); + } + if (input.author) return resolveLegacyOrigin(input); + return unknownRecord('missing_declaration'); +} + +module.exports = { + CONTENT_ORIGIN_SCHEMA_VERSION, + CONTENT_ORIGINS, + CONTENT_ORIGIN_BASES, + BASIS_ORIGIN, + LEGACY_AUTHOR_ORIGINS, + cleanText, + digestText, + validateUserSourceReceipt, + normalizeContentOrigin, + normalizeContentOriginWithLegacy, + resolveLegacyOrigin, + humanEvidenceEligible, +}; diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js new file mode 100644 index 00000000..b7304f34 --- /dev/null +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js @@ -0,0 +1,84 @@ +'use strict'; + +const { + CONTENT_ORIGIN_SCHEMA_VERSION, + CONTENT_ORIGINS, + CONTENT_ORIGIN_BASES, + cleanText, + humanEvidenceEligible, +} = require('./content-origin-contract'); + +const EVIDENCE_PROJECTION_VERSION = 'jarvos-content-origin-evidence/v1'; + +function projectionUnknown(reason = 'unknown') { + return { + projection_version: EVIDENCE_PROJECTION_VERSION, + content_origin_schema: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + ...(reason ? { projection_reason: reason } : {}), + }; +} + +function projectEvidenceRecord(record = {}, options = {}) { + const clean_text = cleanText(record.clean_text ?? record.cleanText ?? options.cleanText ?? record.text); + const origin = record.content_origin; + const basis = record.content_origin_basis; + if (!clean_text || !CONTENT_ORIGINS.includes(origin) || !CONTENT_ORIGIN_BASES.includes(basis)) { + return projectionUnknown('invalid_record'); + } + if (basis === 'legacy_author' && origin === 'unknown') return projectionUnknown('invalid_legacy_record'); + + return { + projection_version: EVIDENCE_PROJECTION_VERSION, + content_origin_schema: CONTENT_ORIGIN_SCHEMA_VERSION, + clean_text, + content_origin: origin, + content_origin_basis: basis, + human_evidence_eligible: origin === 'human' && humanEvidenceEligible(record, options), + }; +} + +function projectEvidenceBatch(records = [], options = {}) { + if (!Array.isArray(records)) return []; + return records.map((record) => projectEvidenceRecord(record, options)); +} + +function readEvidenceProjection(input = {}) { + if (!input || input.projection_version !== EVIDENCE_PROJECTION_VERSION) { + return { ok: false, reason: 'unknown_projection_version', record: projectionUnknown('unknown_projection_version') }; + } + if (typeof input.clean_text !== 'string' || !input.clean_text.trim()) { + return { ok: false, reason: 'missing_clean_text', record: projectionUnknown('missing_clean_text') }; + } + if (!CONTENT_ORIGINS.includes(input.content_origin) || !CONTENT_ORIGIN_BASES.includes(input.content_origin_basis)) { + return { ok: false, reason: 'invalid_origin', record: projectionUnknown('invalid_origin') }; + } + if (typeof input.human_evidence_eligible !== 'boolean') { + return { ok: false, reason: 'missing_eligibility', record: projectionUnknown('missing_eligibility') }; + } + if (input.content_origin !== 'human' && input.human_evidence_eligible) { + return { ok: false, reason: 'ineligible_origin_marked_eligible', record: projectionUnknown('ineligible_origin') }; + } + return { + ok: true, + reason: null, + record: { + projection_version: EVIDENCE_PROJECTION_VERSION, + content_origin_schema: CONTENT_ORIGIN_SCHEMA_VERSION, + clean_text: cleanText(input.clean_text), + content_origin: input.content_origin, + content_origin_basis: input.content_origin_basis, + human_evidence_eligible: input.human_evidence_eligible, + }, + }; +} + +module.exports = { + EVIDENCE_PROJECTION_VERSION, + projectEvidenceRecord, + projectEvidenceBatch, + readEvidenceProjection, +}; + diff --git a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/intent/capture-contract.js b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/intent/capture-contract.js index 28c3b8a6..543cba68 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/intent/capture-contract.js +++ b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/intent/capture-contract.js @@ -1,5 +1,11 @@ 'use strict'; +const { + CONTENT_ORIGINS, + CONTENT_ORIGIN_BASES, + validateUserSourceReceipt, +} = require('../../../../bridge/provenance/src/content-origin-contract'); + /** * Canonical CaptureEvent schema for the ambient intent layer. * @@ -26,6 +32,10 @@ * @property {string} [privacyTier] - Public/private handling tier. * @property {object[]} [evidence] - Source-backed evidence spans. * @property {string|object} [origin] - Origin pointer for the capture. + * @property {string} [captureEventId] - Stable ID used by provenance receipts. + * @property {string} [content_origin] - Intellectual origin declaration. + * @property {string} [content_origin_basis] - Intellectual origin basis. + * @property {object} [user_source] - Receipt binding generated content to user input. */ const CAPTURE_EVENT_SCHEMA_VERSION = '2.0'; @@ -255,7 +265,34 @@ function validateEvidence(event, errors) { event.evidence.forEach((entry, index) => validateEvidenceEntry(entry, index, errors)); } -function validateCaptureEvent(event = {}) { +function validateContentOriginDeclaration(event = {}, errors, options = {}) { + const origin = event.content_origin ?? event.contentOrigin; + const basis = event.content_origin_basis ?? event.contentOriginBasis; + const hasDeclaration = origin != null || basis != null || event.user_source != null || event.userSource != null; + + if (!hasDeclaration) { + if (options.requireDeclaration === true) errors.push('content_origin declaration is required at the canonical writer boundary'); + return; + } + + if (typeof origin !== 'string' || !CONTENT_ORIGINS.includes(origin)) { + errors.push(`Unknown content_origin: "${origin}". Expected one of: ${CONTENT_ORIGINS.join(', ')}`); + } + if (typeof basis !== 'string' || !CONTENT_ORIGIN_BASES.includes(basis)) { + errors.push(`Unknown content_origin_basis: "${basis}". Expected one of: ${CONTENT_ORIGIN_BASES.join(', ')}`); + } + if (basis === 'legacy_author') errors.push('content_origin_basis legacy_author is read-time-only'); + if (origin === 'human' && basis !== 'legacy_author') { + const receipt = event.user_source || event.userSource; + const shape = validateUserSourceReceipt(receipt, { + content: event.content || event.text, + }); + if (shape.reason !== 'unresolved') errors.push(`Invalid user-source receipt: ${shape.reason}`); + } + if (event.content_adoption != null) errors.push('content_adoption is reserved and cannot be supplied during v1'); +} + +function validateCaptureEvent(event = {}, options = {}) { const errors = []; if (event.schemaVersion != null && !SUPPORTED_CAPTURE_EVENT_SCHEMA_VERSIONS.includes(String(event.schemaVersion))) { @@ -312,6 +349,7 @@ function validateCaptureEvent(event = {}) { } validateEvidence(event, errors); + validateContentOriginDeclaration(event, errors, options); return errors; } @@ -329,5 +367,6 @@ module.exports = { EVIDENCE_TYPES, ORIGIN_KINDS, EVIDENCE_REQUIRED_CAPTURE_MODES, + validateContentOriginDeclaration, validateCaptureEvent, }; diff --git a/modules/jarvos-secondbrain/src/index.js b/modules/jarvos-secondbrain/src/index.js index f3cc4c77..8131aa15 100644 --- a/modules/jarvos-secondbrain/src/index.js +++ b/modules/jarvos-secondbrain/src/index.js @@ -21,6 +21,8 @@ const adapters = require('../adapters'); const ambient = require('../packages/jarvos-ambient/src'); const capture = require('../bridge/capture/src/universal-capture.js'); const synthesis = require('../bridge/synthesis'); +const contentOrigin = require('../bridge/provenance/src/content-origin-contract.js'); +const contentOriginEvidence = require('../bridge/provenance/src/content-origin-evidence.js'); const wiki = require('../packages/jarvos-secondbrain-wiki/src'); const artifactReceipt = require('./artifact-receipt'); const artifactLink = require('./obsidian-artifact-link'); @@ -147,10 +149,14 @@ module.exports = { adapters, ambient, capture, + contentOrigin, + contentOriginEvidence, synthesis, wiki, ...adapters, ...capture, + ...contentOrigin, + ...contentOriginEvidence, ...synthesis, ...wiki, ...routing, diff --git a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js new file mode 100644 index 00000000..677909ab --- /dev/null +++ b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js @@ -0,0 +1,186 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); + +const { + CONTENT_ORIGIN_SCHEMA_VERSION, + CONTENT_ORIGINS, + CONTENT_ORIGIN_BASES, + normalizeContentOrigin, + validateUserSourceReceipt, + humanEvidenceEligible, + resolveLegacyOrigin, +} = require('../bridge/provenance/src/content-origin-contract'); +const { + EVIDENCE_PROJECTION_VERSION, + projectEvidenceRecord, + projectEvidenceBatch, + readEvidenceProjection, +} = require('../bridge/provenance/src/content-origin-evidence'); + +function digest(value) { + return crypto.createHash('sha256').update(String(value).trim().replace(/\r\n/g, '\n')).digest('hex'); +} + +function receipt(sourceText, contentText, captureEventId = 'capture-1') { + return { + capture_event_id: captureEventId, + actor: 'user', + source_digest: digest(sourceText), + content_digest: digest(contentText), + }; +} + +function resolveSource(sourceText, captureEventId = 'capture-1') { + return () => ({ + capture_event_id: captureEventId, + actor: 'user', + text: sourceText, + }); +} + +test('publishes the closed origin and basis vocabulary', () => { + assert.equal(CONTENT_ORIGIN_SCHEMA_VERSION, 'jarvos-content-origin/v1'); + assert.deepEqual(CONTENT_ORIGINS, ['human', 'assistant', 'mixed', 'unknown']); + assert.deepEqual(CONTENT_ORIGIN_BASES, [ + 'verbatim_user', + 'user_derived', + 'assistant_generated', + 'mixed_composition', + 'unknown', + 'legacy_author', + ]); +}); + +test('round-trips valid explicit origin and basis pairs', () => { + const cases = [ + ['assistant', 'assistant_generated'], + ['mixed', 'mixed_composition'], + ['unknown', 'unknown'], + ]; + + for (const [content_origin, content_origin_basis] of cases) { + const result = normalizeContentOrigin({ content_origin, content_origin_basis }); + assert.equal(result.content_origin, content_origin); + assert.equal(result.content_origin_basis, content_origin_basis); + assert.equal(result.schema_version, CONTENT_ORIGIN_SCHEMA_VERSION); + } +}); + +test('accepts verbatim and faithful user-derived content with a receipt-bound source', () => { + const sourceText = 'I want to understand how matrix multiplication shapes complex systems.'; + const verbatim = normalizeContentOrigin({ + content_origin: 'human', + content_origin_basis: 'verbatim_user', + user_source: receipt(sourceText, sourceText), + }, { + content: sourceText, + resolveUserSource: resolveSource(sourceText), + }); + const derivedText = 'Andrew wants to understand how matrix multiplication shapes complex systems.'; + const derived = normalizeContentOrigin({ + content_origin: 'human', + content_origin_basis: 'user_derived', + user_source: receipt(sourceText, derivedText), + }, { + content: derivedText, + resolveUserSource: resolveSource(sourceText), + }); + + assert.equal(verbatim.content_origin, 'human'); + assert.equal(derived.content_origin, 'human'); + assert.equal(humanEvidenceEligible(verbatim), true); + assert.equal(humanEvidenceEligible(derived), true); +}); + +test('downgrades absent, unresolved, non-user, and digest-mismatched receipts to unknown', () => { + const content = 'A generated idea should not seed ripeness.'; + const base = { content_origin: 'human', content_origin_basis: 'user_derived' }; + const cases = [ + base, + { ...base, user_source: receipt('user text', content) }, + { ...base, user_source: { ...receipt('user text', content), actor: 'assistant' } }, + { ...base, user_source: { ...receipt('user text', content), content_digest: digest('different') } }, + ]; + + for (const input of cases) { + const result = normalizeContentOrigin(input, { + content, + resolveUserSource: resolveSource('user text', 'different-capture'), + }); + assert.equal(result.content_origin, 'unknown'); + assert.equal(result.content_origin_basis, 'unknown'); + assert.equal(humanEvidenceEligible(result), false); + } +}); + +test('runtime actor identity cannot turn assistant copy into human evidence', () => { + const result = normalizeContentOrigin({ + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + source_agent: 'jarvis', + actor: { type: 'assistant', name: 'jarvis' }, + }); + + assert.equal(result.content_origin, 'assistant'); + assert.equal(humanEvidenceEligible(result), false); +}); + +test('resolves constrained legacy author fallback without rewriting the note', () => { + assert.deepEqual(resolveLegacyOrigin({ author: 'andrew' }), { + content_origin: 'human', + content_origin_basis: 'legacy_author', + }); + assert.deepEqual(resolveLegacyOrigin({ author: 'jarvis' }), { + content_origin: 'assistant', + content_origin_basis: 'legacy_author', + }); + assert.deepEqual(resolveLegacyOrigin({ author: 'both' }), { + content_origin: 'mixed', + content_origin_basis: 'legacy_author', + }); + assert.deepEqual(resolveLegacyOrigin({ author: 'andrew', source_agent: 'codex' }), { + content_origin: 'unknown', + content_origin_basis: 'unknown', + }); +}); + +test('strips deferred adoption state and preserves explicit unknown', () => { + const result = normalizeContentOrigin({ + content_origin: 'unknown', + content_origin_basis: 'unknown', + content_adoption: { state: 'accepted' }, + }); + + assert.equal(result.content_origin, 'unknown'); + assert.equal(result.content_origin_basis, 'unknown'); + assert.equal('content_adoption' in result, false); +}); + +test('projects clean evidence without marker or source-receipt text', () => { + const content = 'assistant context about the user idea'; + const record = normalizeContentOrigin({ + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + user_source: receipt('private user text', content), + }, { content }); + const projection = projectEvidenceRecord({ + ...record, + clean_text: content, + marker_text: '', + }); + const batch = projectEvidenceBatch([{ ...record, clean_text: content }]); + const read = readEvidenceProjection(projection); + + assert.equal(projection.projection_version, EVIDENCE_PROJECTION_VERSION); + assert.equal(projection.clean_text, content); + assert.equal(projection.human_evidence_eligible, false); + assert.equal('user_source' in projection, false); + assert.equal(JSON.stringify(projection).includes('jarvos-content-origin secret'), false); + assert.equal(batch.length, 1); + assert.equal(read.ok, true); + assert.equal(read.record.clean_text, content); +}); + From a15496799896c39f6cf9aa3f6bf27242b1406a00 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Fri, 14 Aug 2026 14:51:38 -0400 Subject: [PATCH 2/9] feat(provenance): propagate origin through note capture --- .../session-source/session-source-adapter.js | 107 +++++++++++++++++- .../bridge/capture/src/universal-capture.js | 51 ++++++++- .../provenance/src/content-origin-contract.js | 15 +++ .../provenance/src/content-origin-evidence.js | 1 - .../provenance/src/note-journal-contract.js | 19 +++- .../bridge/skills/contracts/note-creation.js | 7 ++ .../jarvos-ambient/src/routing/index.js | 86 +++++++++++--- .../src/lib/note-schema.js | 76 ++++++++++++- .../src/write-to-vault.js | 6 +- .../tests/content-origin-contract.test.js | 1 - .../tests/keyword-capture-router.test.js | 24 ++++ .../personality-note-journal-contract.test.js | 30 +++++ .../tests/session-source-adapters.test.js | 23 ++++ .../tests/universal-capture.test.js | 70 ++++++++++++ .../tests/write-to-vault-mutation.test.js | 65 +++++++++++ 15 files changed, 554 insertions(+), 27 deletions(-) diff --git a/modules/jarvos-secondbrain/adapters/session-source/session-source-adapter.js b/modules/jarvos-secondbrain/adapters/session-source/session-source-adapter.js index 82c863ed..8ffed239 100644 --- a/modules/jarvos-secondbrain/adapters/session-source/session-source-adapter.js +++ b/modules/jarvos-secondbrain/adapters/session-source/session-source-adapter.js @@ -5,6 +5,10 @@ const { CAPTURE_EVENT_SCHEMA_VERSION, validateCaptureEvent, } = require('../../packages/jarvos-ambient/src/intent/capture-contract'); +const { + digestText, + normalizeContentOrigin, +} = require('../../bridge/provenance/src/content-origin-contract'); const TOOL_SOURCE = { openclaw: 'openclaw', @@ -78,6 +82,91 @@ function actorForMessage(message = {}) { return ROLE_TO_ACTOR[role] || 'unknown'; } +function originForMessage({ actorType, text, captureEventId, message = {}, session = {}, options = {} }) { + const suppliedOrigin = message.content_origin + ?? message.contentOrigin + ?? session.content_origin + ?? session.contentOrigin + ?? options.content_origin + ?? options.contentOrigin; + const suppliedBasis = message.content_origin_basis + ?? message.contentOriginBasis + ?? session.content_origin_basis + ?? session.contentOriginBasis + ?? options.content_origin_basis + ?? options.contentOriginBasis; + const suppliedSource = message.user_source + ?? message.userSource + ?? session.user_source + ?? session.userSource + ?? options.user_source + ?? options.userSource; + + let userSourceRecord = message.user_source_record + ?? message.userSourceRecord + ?? session.user_source_record + ?? session.userSourceRecord + ?? options.user_source_record + ?? options.userSourceRecord; + + if (suppliedOrigin || suppliedBasis || suppliedSource) { + const candidate = { + content_origin: suppliedOrigin || 'unknown', + content_origin_basis: suppliedBasis || 'unknown', + ...(suppliedSource ? { user_source: suppliedSource } : {}), + }; + const normalized = normalizeContentOrigin(candidate, { + content: text, + resolveUserSource: options.resolveUserSource || (userSourceRecord + ? (captureEventIdToResolve) => { + const id = userSourceRecord.capture_event_id || userSourceRecord.captureEventId || userSourceRecord.id; + return id === captureEventIdToResolve ? userSourceRecord : null; + } + : null), + captureEventId, + }); + return { + ...normalized, + ...(userSourceRecord ? { user_source_record: userSourceRecord } : {}), + }; + } + + if (actorType === 'human') { + const userSource = { + capture_event_id: captureEventId, + actor: 'user', + source_digest: digestText(text), + content_digest: digestText(text), + }; + userSourceRecord = { + capture_event_id: captureEventId, + actor: 'user', + text, + }; + const normalized = normalizeContentOrigin({ + content_origin: 'human', + content_origin_basis: 'verbatim_user', + user_source: userSource, + }, { + content: text, + resolveUserSource: () => userSourceRecord, + captureEventId, + }); + return { + ...normalized, + user_source_record: userSourceRecord, + }; + } + + if (actorType === 'assistant') { + return { content_origin: 'assistant', content_origin_basis: 'assistant_generated' }; + } + if (actorType === 'mixed') { + return { content_origin: 'mixed', content_origin_basis: 'mixed_composition' }; + } + return { content_origin: 'unknown', content_origin_basis: 'unknown' }; +} + function sessionMessages(session = {}) { return [ ...asArray(session.messages), @@ -120,6 +209,15 @@ function buildCaptureEvent({ tool, session, message, index, options }) { const sourceMessageId = messageId(message, index); const path = sourcePath(session, options); const actorType = actorForMessage(message); + const captureEventId = `capture:${tool}:${sourceId}:${sourceMessageId}`; + const contentOrigin = originForMessage({ + actorType, + text, + captureEventId, + message, + session, + options, + }); const timestamp = firstString( message.timestamp, message.createdAt, @@ -130,7 +228,8 @@ function buildCaptureEvent({ tool, session, message, index, options }) { const actorModel = firstString(message.model, session.model); return { - id: `capture:${tool}:${sourceId}:${sourceMessageId}`, + id: captureEventId, + captureEventId, schemaVersion: CAPTURE_EVENT_SCHEMA_VERSION, text, date: isoDate(timestamp), @@ -162,6 +261,12 @@ function buildCaptureEvent({ tool, session, message, index, options }) { ref: sourceId, ...(path ? { path } : {}), }, + content_origin_schema: contentOrigin.schema_version || 'jarvos-content-origin/v1', + content_origin: contentOrigin.content_origin, + content_origin_basis: contentOrigin.content_origin_basis, + ...(contentOrigin.user_source ? { user_source: contentOrigin.user_source } : {}), + ...(contentOrigin.user_source_record ? { user_source_record: contentOrigin.user_source_record } : {}), + human_evidence_eligible: contentOrigin.human_evidence_eligible === true, }; } diff --git a/modules/jarvos-secondbrain/bridge/capture/src/universal-capture.js b/modules/jarvos-secondbrain/bridge/capture/src/universal-capture.js index 63c8ddd4..19833065 100644 --- a/modules/jarvos-secondbrain/bridge/capture/src/universal-capture.js +++ b/modules/jarvos-secondbrain/bridge/capture/src/universal-capture.js @@ -4,6 +4,9 @@ const { CAPTURE_EVENT_SCHEMA_VERSION, validateCaptureEvent, } = require('../../../packages/jarvos-ambient/src/intent/capture-contract'); +const { + normalizeContentOrigin, +} = require('../../provenance/src/content-origin-contract'); const { applyRoutingPlan, detectTrigger, @@ -75,7 +78,7 @@ function normalizeEvidence(raw = {}, text) { }].map(compact); } -function normalizeCaptureEvent(rawInput = {}) { +function normalizeCaptureEvent(rawInput = {}, options = {}) { const raw = rawInput.captureEvent && typeof rawInput.captureEvent === 'object' ? { ...rawInput.captureEvent, ...rawInput } : { ...rawInput }; @@ -83,8 +86,37 @@ function normalizeCaptureEvent(rawInput = {}) { const text = String(raw.text ?? raw.content ?? raw.body ?? '').trim(); const source = normalizeSource(raw); + const hasOriginDeclaration = raw.content_origin != null + || raw.contentOrigin != null + || raw.content_origin_basis != null + || raw.contentOriginBasis != null + || raw.user_source != null + || raw.userSource != null; + const sourceRecord = raw.user_source_record || raw.userSourceRecord; + const resolveUserSource = options.resolveUserSource || raw.resolveUserSource || (sourceRecord + ? (captureEventId) => { + const id = sourceRecord.capture_event_id || sourceRecord.captureEventId || sourceRecord.id; + return id === captureEventId ? sourceRecord : null; + } + : null); + const captureEventId = raw.captureEventId || raw.capture_event_id || raw.eventId; + const contentOrigin = normalizeContentOrigin({ + content_origin: raw.content_origin ?? raw.contentOrigin, + content_origin_basis: raw.content_origin_basis ?? raw.contentOriginBasis, + user_source: raw.user_source ?? raw.userSource, + }, { + content: text, + resolveUserSource, + captureEventId, + }); + if (options.requireDeclaration === true && !hasOriginDeclaration) { + const error = new Error('invalid CaptureEvent v2: content_origin declaration is required at the canonical writer boundary'); + error.errors = ['content_origin declaration is required at the canonical writer boundary']; + throw error; + } const event = { schemaVersion: String(raw.schemaVersion || CAPTURE_EVENT_SCHEMA_VERSION), + captureEventId, trigger: raw.trigger || raw.keyword || raw.mode || raw.type || raw.route, salienceClass: raw.salienceClass, confidence: raw.confidence, @@ -100,6 +132,11 @@ function normalizeCaptureEvent(rawInput = {}) { privacyTier: raw.privacyTier || 'local-private', origin: normalizeOrigin(raw, source), evidence: normalizeEvidence(raw, text), + content_origin_schema: contentOrigin.schema_version, + content_origin: contentOrigin.content_origin, + content_origin_basis: contentOrigin.content_origin_basis, + user_source: contentOrigin.user_source, + human_evidence_eligible: contentOrigin.human_evidence_eligible, substantive: raw.substantive, createNote: raw.createNote, createDurableNote: raw.createDurableNote, @@ -109,7 +146,7 @@ function normalizeCaptureEvent(rawInput = {}) { }; const normalized = compact(event); - const errors = validateCaptureEvent(normalized); + const errors = validateCaptureEvent(normalized, { requireDeclaration: false }); if (errors.length) { const error = new Error(`invalid CaptureEvent v2: ${errors.join('; ')}`); error.errors = errors; @@ -128,10 +165,16 @@ function frontmatterForCaptureEvent(event) { source_actor: typeof event.actor === 'string' ? event.actor : event.actor.type, source_agent: typeof event.actor === 'string' ? event.actor : event.actor.name, capture_event_schema: event.schemaVersion, + capture_event_id: event.captureEventId, + content_origin_schema: event.content_origin_schema, capture_mode: event.captureMode, privacy_tier: event.privacyTier, origin_ref: origin, evidence_count: Array.isArray(event.evidence) ? event.evidence.length : 0, + content_origin: event.content_origin, + content_origin_basis: event.content_origin_basis, + content_origin_source: event.user_source, + human_evidence_eligible: event.human_evidence_eligible, }); } @@ -152,11 +195,11 @@ function ignoredCaptureMessage() { } function captureWithJarvos(rawInput = {}, options = {}) { - const captureEvent = normalizeCaptureEvent(rawInput); + const captureEvent = normalizeCaptureEvent(rawInput, options); const adapter = options.adapter || createStorageAdapter(options); const frontmatter = { - ...frontmatterForCaptureEvent(captureEvent), ...(captureEvent.frontmatter || {}), + ...frontmatterForCaptureEvent(captureEvent), }; const routingInput = { ...captureEvent, diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js index a8de7b1d..ce4c4481 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js @@ -77,6 +77,9 @@ function validateUserSourceReceipt(receipt, options = {}) { if (!SHA256_RE.test(receipt.source_digest) || !SHA256_RE.test(receipt.content_digest)) { return invalidReceipt('malformed_digest'); } + if (options.captureEventId && receipt.capture_event_id !== options.captureEventId) { + return invalidReceipt('capture_event_mismatch'); + } const content = cleanText(options.content); if (!content || digestText(content) !== receipt.content_digest) { @@ -143,6 +146,17 @@ function normalizeContentOrigin(input = {}, options = {}) { return result; } +function frontmatterForContentOrigin(input = {}, options = {}) { + const normalized = normalizeContentOrigin(input, options); + return { + content_origin_schema: normalized.schema_version, + content_origin: normalized.content_origin, + content_origin_basis: normalized.content_origin_basis, + ...(normalized.user_source ? { content_origin_source: { ...normalized.user_source } } : {}), + human_evidence_eligible: normalized.human_evidence_eligible, + }; +} + function resolveLegacyOrigin(input = {}) { const author = String(input.author || '').trim().toLowerCase(); const sourceAgent = String(input.source_agent || input.sourceAgent || '').trim().toLowerCase(); @@ -187,6 +201,7 @@ module.exports = { digestText, validateUserSourceReceipt, normalizeContentOrigin, + frontmatterForContentOrigin, normalizeContentOriginWithLegacy, resolveLegacyOrigin, humanEvidenceEligible, diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js index b7304f34..8b7fd467 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js @@ -81,4 +81,3 @@ module.exports = { projectEvidenceBatch, readEvidenceProjection, }; - diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js index 7983b25e..5a2af2be 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js @@ -56,14 +56,31 @@ function parseInput(input) { ) { throw new Error('lightweight Idea: captures must use node scripts/jarvos-capture.js; pass substantive:true or createDurableNote:true only for intentional durable idea notes'); } + const suppliedFrontmatter = input.frontmatter || {}; + const contentOrigin = input.content_origin ?? input.contentOrigin ?? suppliedFrontmatter.content_origin ?? 'unknown'; + const contentOriginBasis = input.content_origin_basis + ?? input.contentOriginBasis + ?? suppliedFrontmatter.content_origin_basis + ?? 'unknown'; + const contentOriginSource = input.user_source + ?? input.userSource + ?? suppliedFrontmatter.content_origin_source; + const humanEvidenceEligible = input.human_evidence_eligible + ?? suppliedFrontmatter.human_evidence_eligible + ?? false; + return { personality, title: input.title, content: String(input.content), frontmatter: { - ...(input.frontmatter || {}), + ...suppliedFrontmatter, source_personality: personality, contract: 'obsidian-note-journal-v1', + content_origin: contentOrigin, + content_origin_basis: contentOriginBasis, + ...(contentOriginSource !== undefined ? { content_origin_source: contentOriginSource } : {}), + human_evidence_eligible: humanEvidenceEligible, }, }; } diff --git a/modules/jarvos-secondbrain/bridge/skills/contracts/note-creation.js b/modules/jarvos-secondbrain/bridge/skills/contracts/note-creation.js index 049acf3e..cfd39c99 100644 --- a/modules/jarvos-secondbrain/bridge/skills/contracts/note-creation.js +++ b/modules/jarvos-secondbrain/bridge/skills/contracts/note-creation.js @@ -50,6 +50,13 @@ module.exports = { confidence: { type: 'number', minimum: 0, maximum: 1 }, date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, frontmatter: { type: 'object' }, + content_origin: { type: 'string', enum: ['human', 'assistant', 'mixed', 'unknown'] }, + content_origin_basis: { + type: 'string', + enum: ['verbatim_user', 'user_derived', 'assistant_generated', 'mixed_composition', 'unknown'], + }, + user_source: { type: 'object' }, + human_evidence_eligible: { type: 'boolean' }, }, }, output: { diff --git a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js index 5a9aa2e2..28ce4044 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js +++ b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js @@ -8,6 +8,10 @@ const { primaryText, stripLeadingKeyword, } = require('../intent/keyword-capture-router'); +const { + CONTENT_ORIGIN_SCHEMA_VERSION, + normalizeContentOrigin, +} = require('../../../../bridge/provenance/src/content-origin-contract'); const MEMORY = 'memory'; const WORK_INTAKE = 'work-intake'; @@ -31,6 +35,54 @@ const NOTES_HEADING = '## 📝 Notes'; const DECISIONS_HEADING = '## ✅ Decisions'; const REMEMBERED_HEADING = '## 🧠 Remembered'; +function captureContentForProvenance(capture = {}) { + return String(capture.content ?? capture.text ?? capture.body ?? '').trim(); +} + +function normalizeCaptureProvenance(capture = {}, options = {}) { + const hasValidatedHumanRecord = capture.content_origin_schema === CONTENT_ORIGIN_SCHEMA_VERSION + && capture.content_origin === 'human' + && capture.human_evidence_eligible === true + && capture.user_source + && typeof capture.user_source === 'object'; + const normalized = hasValidatedHumanRecord + ? { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'human', + content_origin_basis: capture.content_origin_basis, + user_source: { ...capture.user_source }, + human_evidence_eligible: true, + } + : normalizeContentOrigin({ + content_origin: capture.content_origin ?? capture.contentOrigin, + content_origin_basis: capture.content_origin_basis ?? capture.contentOriginBasis, + user_source: capture.user_source ?? capture.userSource, + }, { + content: captureContentForProvenance(capture), + resolveUserSource: options.resolveUserSource || capture.resolveUserSource, + }); + + return { + ...capture, + content_origin_schema: normalized.schema_version, + content_origin: normalized.content_origin, + content_origin_basis: normalized.content_origin_basis, + ...(normalized.user_source ? { user_source: { ...normalized.user_source } } : {}), + human_evidence_eligible: normalized.human_evidence_eligible, + }; +} + +function contentOriginFrontmatter(capture = {}, options = {}) { + const normalized = normalizeCaptureProvenance(capture, options); + return { + content_origin_schema: normalized.content_origin_schema, + content_origin: normalized.content_origin, + content_origin_basis: normalized.content_origin_basis, + ...(normalized.user_source ? { content_origin_source: { ...normalized.user_source } } : {}), + human_evidence_eligible: normalized.human_evidence_eligible, + }; +} + function inferTitle(capture = {}, fallbackPrefix = 'Captured Note', options = {}) { const explicit = String(capture.title || '').trim(); if (explicit) return stripLeadingKeyword(explicit); @@ -93,6 +145,7 @@ function isSubstantiveIdea(capture = {}) { } function buildKeywordRoutingPlan(capture = {}, options = {}) { + capture = normalizeCaptureProvenance(capture, options); const detectedTrigger = detectTrigger(capture); const captureIntent = hasCaptureIntent(capture); const date = String(capture.date || '').trim() || undefined; @@ -134,6 +187,7 @@ function buildKeywordRoutingPlan(capture = {}, options = {}) { source: 'idea-capture', trigger: IDEA, created_from: date ? `journal/${date}` : 'journal', + ...contentOriginFrontmatter(capture, options), } : null, }; } @@ -155,6 +209,7 @@ function buildKeywordRoutingPlan(capture = {}, options = {}) { source: detectedTrigger ? 'note-capture' : 'default-note-bias', trigger: detectedTrigger || NOTE, created_from: date ? `journal/${date}` : 'journal', + ...contentOriginFrontmatter(capture, options), }, }; } @@ -190,6 +245,7 @@ function buildNoteAction(plan, capture = {}) { frontmatter: { ...(capture.frontmatter || {}), ...(plan.noteFrontmatter || {}), + ...contentOriginFrontmatter(capture), }, }, }; @@ -300,10 +356,11 @@ function buildSkillInvocations(plan) { } function buildThreePackagePlan(capture = {}, options = {}) { - const keywordPlan = buildKeywordRoutingPlan(capture, options); + const normalizedCapture = normalizeCaptureProvenance(capture, options); + const keywordPlan = buildKeywordRoutingPlan(normalizedCapture, options); - const salienceClass = capture.salienceClass || null; - const confidence = typeof capture.confidence === 'number' ? capture.confidence : null; + const salienceClass = normalizedCapture.salienceClass || null; + const confidence = typeof normalizedCapture.confidence === 'number' ? normalizedCapture.confidence : null; const memoryClass = salienceClass ? SALIENCE_TO_MEMORY_CLASS[salienceClass] : null; const salienceOverridesIgnored = Boolean( @@ -314,8 +371,8 @@ function buildThreePackagePlan(capture = {}, options = {}) { ); if (keywordPlan.ignored && salienceOverridesIgnored) { - const text = primaryText(capture); - const title = String(capture.title || text.split(/\r?\n/)[0] || '').slice(0, 80).trim(); + const text = primaryText(normalizedCapture); + const title = String(normalizedCapture.title || text.split(/\r?\n/)[0] || '').slice(0, 80).trim(); keywordPlan.ignored = false; keywordPlan.defaultedToNoteBias = true; @@ -332,19 +389,20 @@ function buildThreePackagePlan(capture = {}, options = {}) { keywordPlan.journalSection = salienceClass === 'decision' ? DECISIONS_HEADING : NOTES_HEADING; keywordPlan.journalLine = title ? `- [[${title}]]` : `- ${text.slice(0, 120)}`; keywordPlan.createNote = true; - keywordPlan.noteTitle = title || inferTitle(capture, `Captured ${salienceClass}`, options); + keywordPlan.noteTitle = title || inferTitle(normalizedCapture, `Captured ${salienceClass}`, options); keywordPlan.noteContent = text; keywordPlan.noteFrontmatter = { type: 'draft', source: 'salience-capture', salience_class: salienceClass, confidence, - created_from: capture.date ? `journal/${capture.date}` : 'journal', + created_from: normalizedCapture.date ? `journal/${normalizedCapture.date}` : 'journal', + ...contentOriginFrontmatter(normalizedCapture, options), }; } } - if (keywordPlan.ignored && (capture.workIntake || capture.routeToWork || capture.createIssue)) { + if (keywordPlan.ignored && (normalizedCapture.workIntake || normalizedCapture.routeToWork || normalizedCapture.createIssue)) { keywordPlan.ignored = false; keywordPlan.route = WORK_INTAKE; keywordPlan.defaultedToNoteBias = false; @@ -365,16 +423,16 @@ function buildThreePackagePlan(capture = {}, options = {}) { const memoryParams = shouldRouteToMemory ? { class: memoryClass, - content: capture.title || primaryText(capture).slice(0, 200), - rationale: capture.rationale || undefined, - source: capture.date ? `journal/${capture.date}` : 'journal', + content: normalizedCapture.title || primaryText(normalizedCapture).slice(0, 200), + rationale: normalizedCapture.rationale || undefined, + source: normalizedCapture.date ? `journal/${normalizedCapture.date}` : 'journal', confidence, } : null; const plan = { version: 'ambient-routing-plan/v1', ...keywordPlan, - capture: { ...capture }, + capture: { ...normalizedCapture }, routeToMemory: shouldRouteToMemory, memoryClass, memoryParams, @@ -392,11 +450,11 @@ function buildThreePackagePlan(capture = {}, options = {}) { const actions = [ buildJournalAction(plan), - buildNoteAction(plan, capture), + buildNoteAction(plan, normalizedCapture), buildMemoryAction(memoryParams), ].filter(Boolean); - const workIntake = buildWorkIntakePlan(capture, { salienceClass, confidence }); + const workIntake = buildWorkIntakePlan(normalizedCapture, { salienceClass, confidence }); if (workIntake && !plan.ignored) { plan.workIntake = workIntake; actions.push(workIntake); diff --git a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/lib/note-schema.js b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/lib/note-schema.js index a93ac30d..0e868d33 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/lib/note-schema.js +++ b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/lib/note-schema.js @@ -1,7 +1,22 @@ 'use strict'; +const { + BASIS_ORIGIN, + CONTENT_ORIGIN_BASES, + CONTENT_ORIGIN_SCHEMA_VERSION, + CONTENT_ORIGINS, +} = require('../../../../bridge/provenance/src/content-origin-contract'); + const REQUIRED_FIELDS = ['status', 'type', 'project', 'created', 'updated', 'author']; const WRITER_OWNED_FIELDS = ['jarvos_note_id']; +const RESERVED_V1_FIELDS = ['content_adoption']; +const CONTENT_ORIGIN_FIELDS = [ + 'content_origin_schema', + 'content_origin', + 'content_origin_basis', + 'content_origin_source', + 'human_evidence_eligible', +]; const ALLOWED_STATUS = new Set(['active', 'draft', 'archived', 'abandoned']); const ALLOWED_TYPE = new Set(['project-note', 'draft', 'research', 'decision', 'reference', 'article', 'chapter']); @@ -415,12 +430,59 @@ function splitIncomingFrontmatter(frontmatter) { if (REQUIRED_FIELDS.includes(key)) required[key] = value; // A note id is assigned by the canonical writer. Ignore caller-provided // values so writes cannot forge an id or replace an existing one. - else if (WRITER_OWNED_FIELDS.includes(key)) continue; + else if (WRITER_OWNED_FIELDS.includes(key) || RESERVED_V1_FIELDS.includes(key)) continue; else optional[key] = value; } return { required, optional }; } +function normalizeContentOriginFrontmatter(frontmatter = {}) { + const normalized = { ...frontmatter }; + const hasDeclaration = CONTENT_ORIGIN_FIELDS.some((field) => normalized[field] !== undefined); + if (!hasDeclaration) { + return { + fields: { + content_origin_schema: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + }, + errors: [], + }; + } + + const origin = String(normalized.content_origin || '').trim().toLowerCase(); + const basis = String(normalized.content_origin_basis || '').trim().toLowerCase(); + const errors = []; + if (!CONTENT_ORIGINS.includes(origin)) { + errors.push(`content_origin must be one of: ${CONTENT_ORIGINS.join(', ')}`); + } + if (!CONTENT_ORIGIN_BASES.includes(basis)) { + errors.push(`content_origin_basis must be one of: ${CONTENT_ORIGIN_BASES.join(', ')}`); + } + if (basis === 'legacy_author') { + errors.push('content_origin_basis legacy_author is read-time-only'); + } else if (BASIS_ORIGIN[basis] && BASIS_ORIGIN[basis] !== origin) { + errors.push(`content_origin ${origin || '(missing)'} does not match basis ${basis}`); + } + if (normalized.human_evidence_eligible !== undefined && typeof normalized.human_evidence_eligible !== 'boolean') { + errors.push('human_evidence_eligible must be a boolean when provided'); + } + + return { + fields: { + content_origin_schema: normalized.content_origin_schema || CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin || 'unknown', + content_origin_basis: basis || 'unknown', + ...(normalized.content_origin_source !== undefined + ? { content_origin_source: normalized.content_origin_source } + : {}), + human_evidence_eligible: normalized.human_evidence_eligible === true && origin === 'human', + }, + errors, + }; +} + function canonicalizeFrontmatter({ incomingFrontmatter = {}, existingFrontmatter = {}, today }) { const split = splitIncomingFrontmatter(incomingFrontmatter); if (split.error) return { errors: [split.error] }; @@ -431,6 +493,7 @@ function canonicalizeFrontmatter({ incomingFrontmatter = {}, existingFrontmatter for (const [key, value] of Object.entries(existingFrontmatter || {})) { if (REQUIRED_FIELDS.includes(key)) existingRequired[key] = value; else if (WRITER_OWNED_FIELDS.includes(key)) existingWriterOwned[key] = value; + else if (RESERVED_V1_FIELDS.includes(key)) continue; else existingOptional[key] = value; } @@ -443,11 +506,13 @@ function canonicalizeFrontmatter({ incomingFrontmatter = {}, existingFrontmatter const optional = { ...existingOptional, ...split.optional, ...existingWriterOwned }; for (const key of REQUIRED_FIELDS) delete optional[key]; + const provenance = normalizeContentOriginFrontmatter({ ...normalized, ...optional }); + return { - errors, + errors: [...errors, ...provenance.errors], required: normalized, - optional, - frontmatter: { ...normalized, ...optional }, + optional: { ...optional, ...provenance.fields }, + frontmatter: { ...normalized, ...optional, ...provenance.fields }, }; } @@ -466,6 +531,8 @@ function renderFrontmatter(frontmatter) { module.exports = { REQUIRED_FIELDS, WRITER_OWNED_FIELDS, + RESERVED_V1_FIELDS, + CONTENT_ORIGIN_FIELDS, ALLOWED_STATUS, ALLOWED_TYPE, ALLOWED_AUTHOR, @@ -484,6 +551,7 @@ module.exports = { frontmatterToObject, defaultRequiredFields, splitIncomingFrontmatter, + normalizeContentOriginFrontmatter, canonicalizeFrontmatter, renderFrontmatter, }; diff --git a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js index dbd94d61..42b49ed6 100755 --- a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js +++ b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js @@ -63,10 +63,14 @@ function normalizeFrontmatter({ incoming = {}, existing = {} } = {}) { // jarvos_note_id is deliberately writer-owned: callers cannot choose it, // while an existing canonical note retains its stable identity. - return { + const normalizedFrontmatter = { ...canonical.frontmatter, jarvos_note_id: canonical.frontmatter.jarvos_note_id || randomUUID(), }; + // Reserved v1 fields are intentionally not persisted, even if a caller + // supplied them through a lower-level adapter. + delete normalizedFrontmatter.content_adoption; + return normalizedFrontmatter; } function buildFrontmatter({ incomingFrontmatter = {}, existingFrontmatter = {} } = {}) { diff --git a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js index 677909ab..311478d9 100644 --- a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js +++ b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js @@ -183,4 +183,3 @@ test('projects clean evidence without marker or source-receipt text', () => { assert.equal(read.ok, true); assert.equal(read.record.clean_text, content); }); - diff --git a/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js b/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js index cca382e8..e177b343 100644 --- a/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js +++ b/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js @@ -160,3 +160,27 @@ test('adapter abstraction works with a mock storage adapter', () => { assert.equal(calls[1][1], '## 📝 Notes'); assert.match(JSON.stringify(calls[0][3]), /journal\/2026-01-02/); }); + +test('routing carries explicit origin metadata and writes compatibility captures as unknown', () => { + const calls = []; + const adapter = { + ensureJournal() { return { existed: true }; }, + appendLineToJournalSection(input) { return input; }, + writeNote(input) { calls.push(input); return { written: true, title: input.title, path: `/tmp/${input.title}.md` }; }, + }; + + applyRoutingPlan({ trigger: 'note', text: 'Generated copy', date: TEST_DATE }, { adapter }); + assert.equal(calls[0].frontmatter.content_origin, 'unknown'); + assert.equal(calls[0].frontmatter.content_origin_basis, 'unknown'); + assert.equal(calls[0].frontmatter.human_evidence_eligible, false); + + applyRoutingPlan({ + trigger: 'note', + text: 'Generated copy with an explicit declaration', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + date: TEST_DATE, + }, { adapter }); + assert.equal(calls[1].frontmatter.content_origin, 'assistant'); + assert.equal(calls[1].frontmatter.content_origin_basis, 'assistant_generated'); +}); diff --git a/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js b/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js index 4dcf203f..0640b3ff 100644 --- a/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js +++ b/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js @@ -117,6 +117,9 @@ test('supported AI personalities can execute the Obsidian note/journal contract' assert.equal(first.created, true); assert.equal(first.qmdStatus, 'pending-refresh'); assert.equal(first.verification.ok, true); + assert.equal(first.verification.frontmatter.content_origin, 'unknown'); + assert.equal(first.verification.frontmatter.content_origin_basis, 'unknown'); + assert.equal(first.verification.frontmatter.human_evidence_eligible, false); assert.ok(first.notePath.startsWith(path.join(root, 'Notes'))); assert.equal(first.journalPath, path.join(root, 'Journal', `${new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' })}.md`)); @@ -134,6 +137,33 @@ test('supported AI personalities can execute the Obsidian note/journal contract' } }); +test('personality contract carries an explicit assistant origin without adopting deferred state', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sup-provenance-contract-')); + withEnv({ + VAULT_NOTES_DIR: path.join(root, 'Notes'), + JOURNAL_DIR: path.join(root, 'Journal'), + JARVOS_KNOWLEDGE_DIR: path.join(root, '.jarvos', 'knowledge'), + JARVOS_ALLOW_UNSAFE_TEST_JOURNAL_WRITE: '1', + }, () => { + const mutationService = makeTestMutationService(root); + const result = writeNoteThroughContract({ + personality: 'codex', + title: 'Assistant provenance contract', + content: 'Generated explanation for later retrieval.', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + content_adoption: { state: 'accepted' }, + frontmatter: { status: 'draft', type: 'reference', project: 'SUP-2229', author: 'jarvis' }, + }, { mutationService }); + + assert.equal(result.verification.frontmatter.content_origin, 'assistant'); + assert.equal(result.verification.frontmatter.content_origin_basis, 'assistant_generated'); + assert.equal(result.verification.frontmatter.human_evidence_eligible, false); + assert.doesNotMatch(fs.readFileSync(result.notePath, 'utf8'), /content_adoption/); + }); + fs.rmSync(root, { recursive: true, force: true }); +}); + test('canonical writer owns jarvos_note_id and preserves it across caller-supplied rewrites', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sup-note-id-')); withEnv({ diff --git a/modules/jarvos-secondbrain/tests/session-source-adapters.test.js b/modules/jarvos-secondbrain/tests/session-source-adapters.test.js index d38c3014..a9d32d88 100644 --- a/modules/jarvos-secondbrain/tests/session-source-adapters.test.js +++ b/modules/jarvos-secondbrain/tests/session-source-adapters.test.js @@ -47,6 +47,9 @@ test('OpenClaw session adapter emits source-backed CaptureEvent v2 events', () = assertSourceBackedEvent(result.events[0], 'openclaw'); assert.equal(result.events[0].actor.type, 'assistant'); assert.equal(result.events[0].actor.model, 'test-model'); + assert.equal(result.events[0].content_origin, 'assistant'); + assert.equal(result.events[0].content_origin_basis, 'assistant_generated'); + assert.equal(result.events[0].human_evidence_eligible, false); assert.equal(result.events[0].source.label, 'Architecture decision'); assert.equal(result.events[0].date, '2026-06-21'); }); @@ -70,6 +73,10 @@ test('Codex session adapter handles content arrays and stable source IDs', () => assertSourceBackedEvent(result.events[0], 'codex'); assert.equal(result.events[0].id, 'capture:codex:codex-session-1:turn-1'); assert.equal(result.events[0].actor.type, 'human'); + assert.equal(result.events[0].content_origin, 'human'); + assert.equal(result.events[0].content_origin_basis, 'verbatim_user'); + assert.equal(result.events[0].human_evidence_eligible, true); + assert.equal(result.events[0].user_source.capture_event_id, result.events[0].captureEventId); assert.match(result.events[0].text, /source notes remain authoritative/); }); @@ -89,6 +96,22 @@ test('Claude Code session adapter accepts entries and caller privacy overrides', assertSourceBackedEvent(result.events[0], 'claude-code', 'private'); assert.equal(result.events[0].actor.type, 'tool'); assert.equal(result.events[0].privacyTier, 'private'); + assert.equal(result.events[0].content_origin, 'unknown'); +}); + +test('session adapter preserves an explicit mixed declaration only when it is a valid origin pair', () => { + const result = createOpenClawSessionAdapter({ + content_origin: 'mixed', + content_origin_basis: 'mixed_composition', + }).normalizeSession({ + sessionId: 'mixed-session', + messages: [{ role: 'assistant', content: 'A jointly edited conclusion.' }], + }); + + assert.equal(result.events.length, 1); + assert.equal(result.events[0].content_origin, 'mixed'); + assert.equal(result.events[0].content_origin_basis, 'mixed_composition'); + assert.equal(result.events[0].human_evidence_eligible, false); }); test('session adapters skip secret sessions and unsupported tools explicitly', () => { diff --git a/modules/jarvos-secondbrain/tests/universal-capture.test.js b/modules/jarvos-secondbrain/tests/universal-capture.test.js index af15c7db..679a0cb5 100644 --- a/modules/jarvos-secondbrain/tests/universal-capture.test.js +++ b/modules/jarvos-secondbrain/tests/universal-capture.test.js @@ -9,9 +9,11 @@ const path = require('node:path'); const { captureWithJarvos, + frontmatterForCaptureEvent, normalizeCaptureEvent, } = require('../bridge/capture/src/universal-capture'); const { createAcknowledgedVaultMutationService } = require('./helpers/acknowledged-vault-mutation-service'); +const { digestText } = require('../bridge/provenance/src/content-origin-contract'); const TEST_DATE = '2026-06-22'; @@ -63,6 +65,74 @@ function baseCapture(source, overrides = {}) { }; } +test('compatibility captures carry explicit unknown provenance and enforcement rejects omissions', () => { + const compatibility = normalizeCaptureEvent(baseCapture('codex', { + text: 'note: compatibility provenance', + })); + assert.equal(compatibility.content_origin, 'unknown'); + assert.equal(compatibility.content_origin_basis, 'unknown'); + assert.equal(compatibility.human_evidence_eligible, false); + + assert.throws( + () => normalizeCaptureEvent(baseCapture('codex', { text: 'note: enforced provenance' }), { requireDeclaration: true }), + /content_origin declaration is required/, + ); +}); + +test('declared provenance reaches note frontmatter while deferred adoption is stripped', () => { + const event = normalizeCaptureEvent(baseCapture('codex', { + title: 'Assistant-origin note', + content: 'Generated copy that remains searchable context.', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + content_adoption: { state: 'accepted' }, + })); + const frontmatter = frontmatterForCaptureEvent(event); + + assert.equal(frontmatter.content_origin, 'assistant'); + assert.equal(frontmatter.content_origin_basis, 'assistant_generated'); + assert.equal(frontmatter.human_evidence_eligible, false); + assert.equal('content_adoption' in frontmatter, false); +}); + +test('receipt-bound human provenance survives the universal capture-to-note route', () => { + const content = 'The user supplied this durable thought.'; + const captureEventId = 'capture-codex-human-1'; + const calls = []; + const adapter = { + ensureJournal() { return { existed: true }; }, + appendLineToJournalSection(input) { return input; }, + writeNote(input) { + calls.push(input); + return { written: true, title: input.title, path: `/tmp/${input.title}.md` }; + }, + }; + const result = captureWithJarvos({ + ...baseCapture('codex', { + captureEventId, + title: 'User thought', + text: content, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + user_source: { + capture_event_id: captureEventId, + actor: 'user', + source_digest: digestText(content), + content_digest: digestText(content), + }, + }), + }, { + adapter, + resolveUserSource: (id) => id === captureEventId ? { capture_event_id: id, actor: 'user', text: content } : null, + }); + + assert.equal(result.ok, true); + assert.equal(calls[0].frontmatter.content_origin, 'human'); + assert.equal(calls[0].frontmatter.content_origin_basis, 'verbatim_user'); + assert.equal(calls[0].frontmatter.human_evidence_eligible, true); + assert.equal(calls[0].frontmatter.content_origin_source.capture_event_id, captureEventId); +}); + test('normalizes supported and custom agents into CaptureEvent v2', () => { for (const source of ['codex', 'claude-code', 'openclaw', 'chatgpt', 'custom:future-agent']) { const event = normalizeCaptureEvent(baseCapture(source, { diff --git a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js index 9fcf93ab..50857704 100644 --- a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js +++ b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js @@ -71,6 +71,71 @@ test('injected writer reports the identity carried by the submitted operation', }); }); +test('canonical note writes persist origin metadata, default missing declarations to unknown, and strip adoption state', () => { + withVault(({ root }) => { + const result = writeNoteFile({ + title: 'Provenance note', + content: 'Generated context stays searchable but is not user evidence.', + frontmatter: { + status: 'draft', + type: 'reference', + project: 'PROVENANCE', + author: 'jarvis', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + content_adoption: { state: 'accepted' }, + }, + operationId: 'note-provenance-0001', + vaultId: 'vault-provenance', + vaultRoot: root, + mutationExecutor(operation) { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, operation.content, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }, + }); + + const content = fs.readFileSync(result.path, 'utf8'); + assert.match(content, /content_origin_schema: jarvos-content-origin\/v1/); + assert.match(content, /content_origin: assistant/); + assert.match(content, /content_origin_basis: assistant_generated/); + assert.doesNotMatch(content, /content_adoption/); + }); +}); + +test('note updates preserve an existing explicit origin when the update has no new declaration', () => { + withVault(({ root }) => { + const execute = (operation) => { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (operation.operationKind === 'create') fs.writeFileSync(target, operation.content, 'utf8'); + else fs.writeFileSync(target, `${fs.readFileSync(target, 'utf8').trimEnd()}\n\n${operation.replayPayload.body}\n`, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }; + const context = (operationId) => ({ + operationId, + vaultId: 'vault-provenance-update', + vaultRoot: root, + mutationExecutor: execute, + }); + const first = writeNoteFile({ + title: 'Stable provenance', + content: 'Assistant draft.', + frontmatter: { status: 'draft', type: 'reference', project: 'PROVENANCE', author: 'jarvis', content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + ...context('note-provenance-0002'), + }); + writeNoteFile({ + title: 'Stable provenance', + content: 'A later maintenance update.', + ...context('note-provenance-0003'), + }); + const content = fs.readFileSync(first.path, 'utf8'); + assert.match(content, /content_origin: assistant/); + assert.match(content, /content_origin_basis: assistant_generated/); + }); +}); + function settled(value) { return { then(fn) { try { fn(value); return this; } catch (error) { this.error = error; return this; } }, From b9c6b71b13de9019389231437a0a66de2c76c1cc Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Fri, 14 Aug 2026 15:18:41 -0400 Subject: [PATCH 3/9] feat(provenance): mark journal entries without visible signatures --- .../obsidian/src/vault-mutation-adapter.js | 24 +++- .../obsidian/src/vault-storage-adapter.js | 19 ++- .../provenance/src/content-origin-contract.js | 130 ++++++++++++++++- .../routing/src/keyword-capture-router.js | 1 + .../routing/src/three-package-router.js | 1 + .../bridge/skills/contracts/journal-entry.js | 18 ++- .../jarvos-ambient/src/routing/index.js | 21 +++ .../src/journal-maintenance.js | 11 +- .../src/vault-transform-registry.js | 134 +++++++++++++++++- .../tests/content-origin-contract.test.js | 59 ++++++++ .../tests/journal-maintenance.test.js | 41 +++++- .../tests/keyword-capture-router.test.js | 15 ++ .../tests/vault-mutation-adapter.test.js | 27 ++++ .../vault-storage-adapter-journal.test.js | 77 ++++++++++ 14 files changed, 562 insertions(+), 16 deletions(-) diff --git a/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js b/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js index cf94fe8a..f8a38ba2 100644 --- a/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js +++ b/modules/jarvos-secondbrain/adapters/obsidian/src/vault-mutation-adapter.js @@ -30,6 +30,18 @@ function rawInvariantTransformProgram() { return `const p = input.replayPayload || {}; const hasNoteIdentity = (current, id) => { const match = current.match(/^---\\r?\\n[\\s\\S]*?^jarvos_note_id:\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s#]+))[\\s\\S]*?^---/m); return (match?.[1] || match?.[2] || match?.[3] || '') === id; }; const heading = raw => { const name = String(raw || '').trim().replace(/^##\\s*/, '').trim(); return name ? '## ' + (name === '🗂️ Notes Created' ? '📝 Notes' : name) : null; }; const range = (lines, h) => { const start = lines.findIndex(line => line.trim() === h); let end = lines.length; for (let i = start + 1; start !== -1 && i < lines.length; i += 1) { if (/^##\\s/.test(lines[i])) { end = i; break; } } return { start, end }; }; const escape = value => String(value).replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&'); const linkRe = target => new RegExp('^\\\\s*-\\\\s*\\\\[\\\\[' + escape(target) + '(?:\\\\|[^\\\\]]+)?\\\\]\\\\]\\\\s*$'); const transform = (() => { if (input.transformName === 'append-line' && input.transformVersion === 1 && typeof p.line === 'string') { const line = p.line.trim(); return current => current.includes(line); } if (input.transformName === 'note-append-body' && input.transformVersion === 1 && typeof p.noteId === 'string' && typeof p.body === 'string') { const id = p.noteId.trim(); const body = p.body.trim(); return current => hasNoteIdentity(current, id) && current.includes(body); } if (input.transformName === 'session-thread-append' && input.transformVersion === 1 && typeof p.noteId === 'string' && typeof p.entry === 'string') { const id = p.noteId.trim(); const entry = p.entry.trim(); return current => hasNoteIdentity(current, id) && current.includes(entry); } if (input.transformName === 'journal-section-line' && input.transformVersion === 1 && typeof p.heading === 'string' && typeof p.line === 'string') { const h = heading(p.heading); const line = p.line.trim(); return current => { const lines = String(current).split('\\n'); const r = range(lines, h); return Boolean(h && r.start !== -1 && lines.slice(r.start + 1, r.end).some(entry => entry.trim() === line)); }; } if (input.transformName === 'journal-backlink' && input.transformVersion === 1 && typeof p.linkTarget === 'string') { const h = heading(p.section || '📝 Notes'); const re = linkRe(p.linkTarget.trim()); return current => { const lines = String(current).split('\\n'); const r = range(lines, h); return Boolean(h && r.start !== -1 && lines.slice(r.start + 1, r.end).filter(entry => re.test(entry)).length === 1 && lines.filter(entry => re.test(entry)).length === 1); }; } return null; })();`; } +function journalOriginTransformHelpersProgram() { + return "const markerPayload = origin => { const o = origin || {}; const json = JSON.stringify({ schema_version: 'jarvos-content-origin/v1', content_origin: o.content_origin, content_origin_basis: o.content_origin_basis, clean_text_digest: o.clean_text_digest, human_evidence_eligible: o.human_evidence_eligible === true && o.content_origin === 'human', ...(o.source_ref ? { source_ref: String(o.source_ref) } : {}) }); return ''; }; const cleanMarker = value => String(value || '').replace(/\\s*/gi, '').trim(); const cleanLine = value => { const clean = cleanMarker(value); return clean.startsWith('- ') ? clean : '- ' + clean.replace(/^[-\\s]+/, ''); }; "; +} + +function journalOriginMutationBranchProgram() { + return "if (input.transformName === 'journal-section-line' && input.transformVersion === 2 && typeof p.heading === 'string' && typeof p.line === 'string' && p.contentOrigin) { const h = heading(p.heading); const line = cleanLine(p.line); const marker = markerPayload(p.contentOrigin); return { apply: current => { if (!h || !line.startsWith('- ')) return current; const lines = String(current).split('\\n'); const r = range(lines, h); if (r.start === -1) { const trimmed = String(current).trimEnd(); return trimmed + (trimmed ? '\\n\\n' : '') + h + '\\n' + line + '\\n' + marker + '\\n'; } let match = -1; for (let i = r.start + 1; i < r.end; i += 1) { if (lines[i].trim().startsWith('- ') && cleanMarker(lines[i]) === line) { match = i; break; } } if (match !== -1) { if (!String(lines[match + 1] || '').trim().startsWith('$/; function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -53,7 +55,11 @@ function normalizedCaptureEventId(event) { } function sourceReceipt(input) { - const receipt = input?.user_source || input?.userSource || input?.source_reference || input?.sourceReference; + const receipt = input?.user_source + || input?.userSource + || input?.content_origin_source + || input?.source_reference + || input?.sourceReference; return isPlainObject(receipt) ? receipt : null; } @@ -157,6 +163,122 @@ function frontmatterForContentOrigin(input = {}, options = {}) { }; } +function contentOriginPairIsValid(contentOrigin, contentOriginBasis) { + return CONTENT_ORIGINS.includes(contentOrigin) + && CONTENT_ORIGIN_BASES.includes(contentOriginBasis) + && contentOriginBasis !== 'legacy_author' + && BASIS_ORIGIN[contentOriginBasis] === contentOrigin; +} + +function encodeMarkerPayload(payload) { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +function decodeMarkerPayload(encoded) { + try { + const raw = String(encoded); + const decoded = raw.startsWith('%7B') || raw.startsWith('%7b') + ? JSON.parse(decodeURIComponent(raw)) + : JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); + return isPlainObject(decoded) ? decoded : null; + } catch { + return null; + } +} + +/** + * Render an invisible, line-adjacent journal marker. The marker contains no + * source text; it binds only the clean bullet digest to the bounded origin + * declaration and an opaque source reference. + */ +function renderJournalOriginMarker({ cleanText, clean_text_digest, content_origin, content_origin_basis, source_ref, human_evidence_eligible = false } = {}) { + const origin = String(content_origin || '').trim().toLowerCase(); + const basis = String(content_origin_basis || '').trim().toLowerCase(); + if (!contentOriginPairIsValid(origin, basis)) throw new Error('Invalid journal content-origin declaration'); + const payload = { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin, + content_origin_basis: basis, + clean_text_digest: clean_text_digest || digestText(cleanText), + human_evidence_eligible: origin === 'human' && human_evidence_eligible === true, + ...(source_ref ? { source_ref: String(source_ref).trim() } : {}), + }; + return `${JOURNAL_MARKER_PREFIX}${encodeMarkerPayload(payload)} -->`; +} + +function unknownJournalOrigin(reason = 'unknown') { + return { + content_origin: 'unknown', + content_origin_basis: 'unknown', + human_evidence_eligible: false, + ...(reason ? { normalization_reason: reason } : {}), + }; +} + +function parseJournalOriginMarker(marker, cleanText) { + const match = String(marker || '').trim().match(JOURNAL_MARKER_RE); + if (!match) return unknownJournalOrigin('missing_or_malformed_marker'); + const payload = decodeMarkerPayload(match[1]); + if (!payload || payload.schema_version !== CONTENT_ORIGIN_SCHEMA_VERSION) return unknownJournalOrigin('invalid_marker_payload'); + if (!contentOriginPairIsValid(payload.content_origin, payload.content_origin_basis)) return unknownJournalOrigin('invalid_marker_origin'); + if (!SHA256_RE.test(String(payload.clean_text_digest || '')) || digestText(cleanText) !== payload.clean_text_digest) { + return unknownJournalOrigin('marker_digest_mismatch'); + } + if (payload.source_ref !== undefined && (typeof payload.source_ref !== 'string' || !payload.source_ref.trim())) { + return unknownJournalOrigin('invalid_marker_source_ref'); + } + return { + schema_version: payload.schema_version, + content_origin: payload.content_origin, + content_origin_basis: payload.content_origin_basis, + clean_text_digest: payload.clean_text_digest, + human_evidence_eligible: payload.human_evidence_eligible === true && payload.content_origin === 'human', + ...(payload.source_ref ? { source_ref: payload.source_ref } : {}), + }; +} + +function stripJournalOriginMarkers(text) { + return String(text || '') + .replace(/\s*/gi, '') + .replace(/\n{3,}/g, '\n\n'); +} + +function cleanJournalEntryText(line) { + return stripJournalOriginMarkers(String(line || '')).trim(); +} + +function parseJournalEntry(lines, index) { + const source = Array.isArray(lines) ? lines : String(lines || '').split(/\r?\n/); + const line = String(source[index] || '').trim(); + if (!line.startsWith('- ')) return null; + const cleanText = cleanJournalEntryText(line); + const markerLines = []; + for (let markerIndex = index + 1; markerIndex < source.length; markerIndex += 1) { + const candidate = String(source[markerIndex] || '').trim(); + if (!candidate.startsWith(''], 0); + assert.equal(malformed.origin.content_origin, 'unknown'); + assert.equal(malformed.clean_text, 'Agent thought'); + + const duplicate = parseJournalEntry([ + '- Duplicate markers', + malformed.marker_line, + malformed.marker_line, + ], 0); + assert.equal(duplicate.origin.content_origin, 'unknown'); + assert.equal(duplicate.origin.normalization_reason, 'duplicate_marker'); +}); diff --git a/modules/jarvos-secondbrain/tests/journal-maintenance.test.js b/modules/jarvos-secondbrain/tests/journal-maintenance.test.js index 86909680..fe0634a4 100644 --- a/modules/jarvos-secondbrain/tests/journal-maintenance.test.js +++ b/modules/jarvos-secondbrain/tests/journal-maintenance.test.js @@ -15,9 +15,44 @@ const { stripLeadingRecoveryScaffold, syncOneDate: rawSyncOneDate, } = require('../packages/jarvos-secondbrain-journal/src/journal-maintenance.js'); +const { renderJournalOriginMarker } = require('../bridge/provenance/src/content-origin-contract'); const TEST_DATE = '2026-01-02'; +test('ordinary maintenance removes legacy signatures without adding a replacement and preserves hidden markers', () => { + const marker = renderJournalOriginMarker({ + cleanText: 'assistant thought', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + source_ref: 'capture:codex:maintenance', + }); + const original = [ + '---', + 'journal: Journal', + `journal-date: ${TEST_DATE}`, + '---', + '', + '## 📝 Notes', + '-', + '', + '## 💡 Ideas', + '- assistant thought', + marker, + '', + '## 📓 Journal Entry', + '-', + '', + '— Edited by Jarvis', + '', + ].join('\n'); + + const config = loadConfig(); + const normalized = normalizeSections(original, TEST_DATE, config, { fetchers: { projects: () => '-' } }); + const output = renderJournal(TEST_DATE, config, normalized); + assert.doesNotMatch(output, /Written by Jarvis|Edited by Jarvis/); + assert.match(output, /- assistant thought\n', + contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + }, + }; + const manual = '## 💡 Ideas\n- Same thought\n'; + const result = transforms.applyNode(manual, operation); + assert.equal(result, manual); + assert.doesNotMatch(result, /jarvos-content-origin/); +}); + +test('journal transform replaces malformed or duplicate markers instead of treating them as human evidence', () => { + const transforms = createJarvosVaultTransforms(); + const operation = { + transformName: 'journal-section-line', + transformVersion: 2, + replayPayload: { + heading: '## 💡 Ideas', + line: '- Recover marker', + contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' }, + }, + }; + const malformed = '## 💡 Ideas\n- Recover marker\n\n\n'; + const result = transforms.applyNode(malformed, operation); + assert.equal(transforms.isSatisfied(result, operation), true); + assert.equal(result.split('jarvos-content-origin/v1').length - 1, 1); +}); From 6cb37acd899ac5a67338cb062724a87bed4be720 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Fri, 14 Aug 2026 15:27:51 -0400 Subject: [PATCH 4/9] feat(provenance): gate human evidence downstream --- .../docs/MEMORY_PROMOTION_RULES.md | 8 + modules/jarvos-memory/lib/memory-config.js | 8 + modules/jarvos-memory/lib/memory-record.js | 7 + .../src/lib/hindsight-adapter.js | 59 ++++++ .../jarvos-memory/src/lib/memory-promotion.js | 47 ++++- .../test/memory-promotion.test.js | 31 +++ .../provenance/src/content-origin-contract.js | 24 +++ .../src/ripeness-artifact-contract.js | 181 ++++++++++++++++++ .../src/knowledge-optimizer.js | 45 ++++- modules/jarvos-secondbrain/src/index.js | 3 + .../tests/content-origin-contract.test.js | 4 +- .../tests/knowledge-units.test.js | 70 +++++++ tests/ripeness-artifact-contract-test.js | 119 ++++++++++++ 13 files changed, 596 insertions(+), 10 deletions(-) create mode 100644 modules/jarvos-memory/src/lib/hindsight-adapter.js create mode 100644 modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js create mode 100644 tests/ripeness-artifact-contract-test.js diff --git a/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md b/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md index fcf3814b..87c4e464 100644 --- a/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md +++ b/modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md @@ -56,6 +56,14 @@ raw transcripts. A knowledge unit must have source evidence, a non-sensitive privacy decision, and `downstreamEligibility.memoryPromotion !== false`. Sensitive/private or uncited units stay in the secondbrain sidecar layer. +Intellectual origin is a separate gate from privacy and search eligibility. A +knowledge unit marked `human` must also carry `human_evidence_eligible: true` +before it can establish a user fact, preference, decision, belief, or lesson in +durable Memory. `assistant`, `mixed`, and `unknown` units remain searchable and +usable as context, but are rejected by the human-memory promotion gate. Legacy +notes may use the constrained `legacy_author` fallback during the compatibility +period; new programmatic human records require a validated user-source receipt. + ### Transcript evidence -> reviewed memory Transcript evidence is source material, not durable memory. A bounded diff --git a/modules/jarvos-memory/lib/memory-config.js b/modules/jarvos-memory/lib/memory-config.js index 1509a0f0..2e5d8336 100644 --- a/modules/jarvos-memory/lib/memory-config.js +++ b/modules/jarvos-memory/lib/memory-config.js @@ -21,8 +21,16 @@ function getMemoryPaths() { }; } +function getHindsightConfig() { + return { + apiUrl: process.env.HINDSIGHT_API_URL || 'http://127.0.0.1:8888', + timeoutMs: Number(process.env.HINDSIGHT_TIMEOUT_MS || 1500), + }; +} + module.exports = { DEFAULT_CLAWD_ROOT, getClawdRoot, getMemoryPaths, + getHindsightConfig, }; diff --git a/modules/jarvos-memory/lib/memory-record.js b/modules/jarvos-memory/lib/memory-record.js index ca3cd92d..14646af7 100644 --- a/modules/jarvos-memory/lib/memory-record.js +++ b/modules/jarvos-memory/lib/memory-record.js @@ -16,6 +16,7 @@ const { getClawdRoot: getWorkspaceRoot } = require('./memory-config'); * @param {string} [params.noteRef] - Link to full note if one was created * @param {number} [params.confidence] - 0.0-1.0 * @param {string} [params.supersedes] - ID of prior memory this replaces + * @param {object} [params.provenance] - Intellectual-origin declaration from the source unit * @returns {{ record: object, written: boolean, path: string|null, error: string|null }} */ function createMemoryRecord(params = {}) { @@ -54,6 +55,9 @@ function createMemoryRecord(params = {}) { created: now.toISOString(), confidence: typeof params.confidence === 'number' ? params.confidence : undefined, supersedes: params.supersedes || undefined, + content_origin: params.provenance?.content_origin || undefined, + content_origin_basis: params.provenance?.content_origin_basis || undefined, + human_evidence_eligible: params.provenance?.human_evidence_eligible === true ? true : undefined, status: 'active', }; @@ -94,6 +98,9 @@ function createMemoryRecord(params = {}) { record.noteRef ? `note_ref: "${record.noteRef}"` : null, record.supersedes ? `supersedes: "${record.supersedes}"` : null, record.confidence != null ? `confidence: ${record.confidence}` : null, + record.content_origin ? `content_origin: ${record.content_origin}` : null, + record.content_origin_basis ? `content_origin_basis: ${record.content_origin_basis}` : null, + record.human_evidence_eligible === true ? 'human_evidence_eligible: true' : null, '---', ].filter(Boolean).join('\n'); diff --git a/modules/jarvos-memory/src/lib/hindsight-adapter.js b/modules/jarvos-memory/src/lib/hindsight-adapter.js new file mode 100644 index 00000000..fb790d91 --- /dev/null +++ b/modules/jarvos-memory/src/lib/hindsight-adapter.js @@ -0,0 +1,59 @@ +'use strict'; + +class HindsightAdapter { + constructor({ apiUrl = 'http://127.0.0.1:8888', timeoutMs = 1500 } = {}) { + this.apiUrl = String(apiUrl).replace(/\/$/, ''); + this.timeoutMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : 1500; + } + + async request(path, { method = 'GET', body } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(`${this.apiUrl}${path}`, { + method, + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Hindsight returned HTTP ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timer); + } + } + + async ping() { + try { + const response = await this.request('/health'); + return response?.status === 'ok' || response?.ok === true; + } catch { + return false; + } + } + + async recall(query) { + try { + const response = await this.request('/recall', { method: 'POST', body: { query: String(query || '') } }); + return { + results: Array.isArray(response?.results) + ? response.results.map((item) => typeof item === 'string' ? item : item?.text).filter(Boolean) + : [], + error: null, + }; + } catch (error) { + return { results: [], error: error.message }; + } + } + + async reflect(query) { + try { + const response = await this.request('/reflect', { method: 'POST', body: { query: String(query || '') } }); + return { text: typeof response?.text === 'string' ? response.text : null, error: null }; + } catch (error) { + return { text: null, error: error.message }; + } + } +} + +module.exports = { HindsightAdapter }; diff --git a/modules/jarvos-memory/src/lib/memory-promotion.js b/modules/jarvos-memory/src/lib/memory-promotion.js index 1cda9259..f24a51fe 100644 --- a/modules/jarvos-memory/src/lib/memory-promotion.js +++ b/modules/jarvos-memory/src/lib/memory-promotion.js @@ -31,9 +31,9 @@ const { CORE_MEMORY_CLASSES, } = require('./memory-schema'); -const { createMemoryRecord } = require('./memory-record'); +const { createMemoryRecord } = require('../../lib/memory-record'); const { HindsightAdapter } = require('./hindsight-adapter'); -const { getHindsightConfig } = require('./memory-config'); +const { getHindsightConfig } = require('../../lib/memory-config'); function promotionContent(event = {}) { if (event.knowledgeUnit) return String(event.knowledgeUnit.text || '').trim(); @@ -45,6 +45,38 @@ function hasKnowledgeUnitEvidence(unit = {}) { && unit.evidence.some((entry) => entry?.sourcePath || entry?.quote || entry?.bodySha256 || entry?.ref); } +function contentOriginRecord(unit = {}, event = {}) { + const candidate = unit.provenance || unit; + const eventOrigin = event.content_origin ?? event.contentOrigin; + const eventBasis = event.content_origin_basis ?? event.contentOriginBasis; + const origin = candidate.content_origin ?? candidate.contentOrigin ?? eventOrigin; + const basis = candidate.content_origin_basis ?? candidate.contentOriginBasis ?? eventBasis; + const hasDeclaration = origin !== undefined || basis !== undefined + || candidate.human_evidence_eligible !== undefined + || candidate.humanEvidenceEligible !== undefined; + if (!hasDeclaration) return null; + return { + content_origin: String(origin || 'unknown').trim().toLowerCase(), + content_origin_basis: String(basis || 'unknown').trim().toLowerCase(), + human_evidence_eligible: candidate.human_evidence_eligible === true + || candidate.humanEvidenceEligible === true + || event.human_evidence_eligible === true + || event.humanEvidenceEligible === true, + }; +} + +function humanEvidenceGate(unit, event) { + const provenance = contentOriginRecord(unit, event); + if (!provenance) return null; + if (provenance.content_origin !== 'human' || provenance.human_evidence_eligible !== true) { + return { + provenance, + reason: `content origin '${provenance.content_origin}' is context-only for human-memory promotion`, + }; + } + return { provenance, reason: null }; +} + function isRawTranscriptSource(source = {}) { if (typeof source === 'string') return source === 'transcript' || source === 'raw-transcript'; return source.type === 'transcript' || source.kind === 'transcript' || source.raw === true; @@ -72,6 +104,10 @@ function reviewKnowledgeUnitCandidate(event = {}) { if (!hasKnowledgeUnitEvidence(unit)) { return { shouldPromote: false, memoryClass: null, reason: 'knowledgeUnit promotion requires source evidence' }; } + const evidenceGate = humanEvidenceGate(unit, event); + if (evidenceGate?.reason) { + return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason }; + } if (unit.privacyDecision?.excludedFromPromotion || unit.privacyDecision?.tier === 'secret' || unit.privacyDecision?.tier === 'sensitive') { return { shouldPromote: false, memoryClass: null, reason: `knowledgeUnit privacy tier '${unit.privacyDecision?.tier || 'unknown'}' is not eligible for promotion` }; } @@ -124,6 +160,11 @@ function reviewCandidate(event = {}) { return { shouldPromote: false, memoryClass: null, reason: 'raw source-backed captures must promote through cited knowledgeUnit references' }; } + const evidenceGate = humanEvidenceGate({}, event); + if (evidenceGate?.reason) { + return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason }; + } + const text = promotionContent(event); if (!text) { return { shouldPromote: false, memoryClass: null, reason: 'no content to promote' }; @@ -208,6 +249,7 @@ function promoteCandidate(event = {}) { const content = promotionContent(event); const unit = event.knowledgeUnit || null; + const provenance = contentOriginRecord(unit || {}, event); const result = createMemoryRecord({ class: review.memoryClass, content, @@ -216,6 +258,7 @@ function promoteCandidate(event = {}) { noteRef: event.noteRef || unit?.source?.path, confidence: event.confidence || unit?.confidence, supersedes: event.supersedes, + provenance, }); if (result.error) { diff --git a/modules/jarvos-memory/test/memory-promotion.test.js b/modules/jarvos-memory/test/memory-promotion.test.js index d21f3b92..01773497 100644 --- a/modules/jarvos-memory/test/memory-promotion.test.js +++ b/modules/jarvos-memory/test/memory-promotion.test.js @@ -274,6 +274,9 @@ describe('knowledgeUnit promotion gates', () => { downstreamEligibility: { memoryPromotion: true, }, + content_origin: 'human', + content_origin_basis: 'legacy_author', + human_evidence_eligible: true, ...overrides, }; } @@ -362,6 +365,34 @@ describe('knowledgeUnit promotion gates', () => { assert.match(review.reason, /memoryPromotion is false/); }); + it('rejects assistant-generated knowledge units from human memory', () => { + const review = reviewCandidate({ + knowledgeUnit: knowledgeUnit({ + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }), + }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /context-only/); + }); + + it('rejects mixed and unknown knowledge units from human memory', () => { + for (const provenance of [ + { content_origin: 'mixed', content_origin_basis: 'mixed_composition', human_evidence_eligible: false }, + { content_origin: 'unknown', content_origin_basis: 'unknown', human_evidence_eligible: false }, + ]) { + const review = reviewCandidate({ knowledgeUnit: knowledgeUnit(provenance) }); + assert.equal(review.shouldPromote, false); + assert.match(review.reason, /context-only/); + } + }); + + it('rejects an explicitly non-human direct event while preserving legacy undeclared events during compatibility', () => { + assert.equal(reviewCandidate({ text: 'Assistant preference', salienceClass: 'preference', content_origin: 'assistant', content_origin_basis: 'assistant_generated' }).shouldPromote, false); + assert.equal(reviewCandidate({ text: 'Legacy preference', salienceClass: 'preference' }).shouldPromote, true); + }); + it('promotes cited knowledge units through the local file path', () => { const result = promoteCandidate({ knowledgeUnit: knowledgeUnit({ diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js index 8c9ffc2d..78383eca 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-contract.js @@ -307,12 +307,35 @@ function humanEvidenceEligible(record = {}, options = {}) { function normalizeContentOriginWithLegacy(input = {}, options = {}) { if (input.content_origin || input.contentOrigin || input.content_origin_basis || input.contentOriginBasis) { + if (options.allowUnresolvedReceipt === true) return normalizeContentOriginForRead(input); return normalizeContentOrigin(input, options); } if (input.author) return resolveLegacyOrigin(input); return unknownRecord('missing_declaration'); } +function normalizeContentOriginForRead(input = {}) { + const source = isPlainObject(input) ? input : {}; + const origin = String(source.content_origin ?? source.contentOrigin ?? '').trim().toLowerCase(); + const basis = String(source.content_origin_basis ?? source.contentOriginBasis ?? '').trim().toLowerCase(); + if (!CONTENT_ORIGINS.includes(origin) || !CONTENT_ORIGIN_BASES.includes(basis) || basis === 'legacy_author' || BASIS_ORIGIN[basis] !== origin) { + return unknownRecord('invalid_read_declaration'); + } + const receipt = sourceReceipt(source); + if (origin === 'human') { + if (!receipt || receipt.actor !== 'user' || !SHA256_RE.test(String(receipt.source_digest || '')) || !SHA256_RE.test(String(receipt.content_digest || ''))) { + return unknownRecord('invalid_read_receipt'); + } + } + return { + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: origin, + content_origin_basis: basis, + ...(receipt ? { user_source: { ...receipt } } : {}), + human_evidence_eligible: origin === 'human' && source.human_evidence_eligible === true, + }; +} + module.exports = { CONTENT_ORIGIN_SCHEMA_VERSION, CONTENT_ORIGINS, @@ -331,6 +354,7 @@ module.exports = { cleanJournalEntryText, parseJournalEntry, normalizeContentOriginWithLegacy, + normalizeContentOriginForRead, resolveLegacyOrigin, humanEvidenceEligible, }; diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js new file mode 100644 index 00000000..b40836df --- /dev/null +++ b/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js @@ -0,0 +1,181 @@ +'use strict'; + +// Public, data-free contract for the private Ripeness producer. It deliberately +// knows nothing about vault locations, source adapters, calibration, or content +// policy; consumers pass only a parsed artifact and the effective instant. + +const crypto = require('crypto'); + +const RIPENESS_ARTIFACT_SCHEMA_VERSION = 'jarvos-ripeness-artifact/v2'; +const LEGACY_RIPENESS_ARTIFACT_SCHEMA_VERSION = 'jarvos-ripeness-artifact/v1'; +const RIPENESS_TIME_ZONE = 'America/New_York'; +const SHA256_RE = /^[a-f0-9]{64}$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const MAX_THEMES = 3; +const MAX_FRAGMENTS_PER_THEME = 4; +const MAX_SUPPORT_PER_THEME = 4; +const MAX_FRAGMENT_CHARS = 320; +const CONTENT_ORIGINS = Object.freeze(['human', 'assistant', 'mixed', 'unknown']); +const HUMAN_ORIGIN_BASES = Object.freeze(['verbatim_user', 'user_derived', 'legacy_author']); +const CONTEXT_ORIGIN_BASES = Object.freeze(['assistant_generated', 'mixed_composition', 'unknown', 'legacy_author']); +const CONTEXT_BASIS_BY_ORIGIN = Object.freeze({ + assistant: 'assistant_generated', + mixed: 'mixed_composition', + unknown: 'unknown', +}); + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function artifactWithoutDigest(artifact) { + const copy = { ...artifact }; + delete copy.outputDigest; + return copy; +} + +function computeRipenessArtifactDigest(artifact) { + return crypto.createHash('sha256').update(canonicalJson(artifactWithoutDigest(artifact))).digest('hex'); +} + +function localDateFor(now = new Date(), timeZone = RIPENESS_TIME_ZONE) { + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isIsoDate(value) { + return typeof value === 'string' && DATE_RE.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00.000Z`)); +} + +function invalid(status, artifact = null) { + return { ok: false, status, artifact, ...(status === 'legacy_non_qualifying' ? { legacy: true } : {}) }; +} + +function validOriginCounts(counts) { + if (!isPlainObject(counts)) return false; + return CONTENT_ORIGINS.every((origin) => Number.isInteger(counts[origin]) && counts[origin] >= 0); +} + +function validHumanSupport(support) { + return isPlainObject(support) + && typeof support.id === 'string' && support.id.length > 0 && support.id.length <= 200 + && isIsoDate(support.date) + && support.content_origin === 'human' + && HUMAN_ORIGIN_BASES.includes(support.content_origin_basis) + && support.human_evidence_eligible === true + && (support.text === undefined || (typeof support.text === 'string' + && support.text.length > 0 + && support.text.length <= MAX_FRAGMENT_CHARS + && !/jarvos-content-origin\/v\d+/i.test(support.text))); +} + +function validContextSupport(support) { + return isPlainObject(support) + && typeof support.id === 'string' && support.id.length > 0 && support.id.length <= 200 + && isIsoDate(support.date) + && ['assistant', 'mixed', 'unknown'].includes(support.content_origin) + && CONTEXT_ORIGIN_BASES.includes(support.content_origin_basis) + && (support.content_origin_basis === 'legacy_author' + || CONTEXT_BASIS_BY_ORIGIN[support.content_origin] === support.content_origin_basis) + && support.human_evidence_eligible === false + && (support.text === undefined || (typeof support.text === 'string' + && support.text.length > 0 + && support.text.length <= MAX_FRAGMENT_CHARS + && !/jarvos-content-origin\/v\d+/i.test(support.text))); +} + +function validateFragment(fragment) { + return isPlainObject(fragment) + && isIsoDate(fragment.date) + && typeof fragment.text === 'string' + && fragment.text.length > 0 + && fragment.text.length <= MAX_FRAGMENT_CHARS + && !/jarvos-content-origin\/v\d+/i.test(fragment.text) + && fragment.content_origin === 'human' + && HUMAN_ORIGIN_BASES.includes(fragment.content_origin_basis) + && fragment.human_evidence_eligible === true; +} + +function validateTheme(theme) { + if (!isPlainObject(theme) + || !Number.isInteger(theme.days) || theme.days < 1 + || !Number.isInteger(theme.spanDays) || theme.spanDays < 1 + || !isIsoDate(theme.firstSeen) || !isIsoDate(theme.lastSeen) + || theme.firstSeen > theme.lastSeen + || !Number.isInteger(theme.qualifyingHumanDays) || theme.qualifyingHumanDays < 1 + || theme.qualifyingHumanDays > theme.days + || !Array.isArray(theme.fragments) || theme.fragments.length > MAX_FRAGMENTS_PER_THEME + || !Array.isArray(theme.qualifyingHumanSupport) || theme.qualifyingHumanSupport.length < 1 || theme.qualifyingHumanSupport.length > MAX_SUPPORT_PER_THEME + || !Array.isArray(theme.contextSupport) || theme.contextSupport.length > MAX_SUPPORT_PER_THEME + || !Array.isArray(theme.support) || theme.support.length > MAX_SUPPORT_PER_THEME + || !validOriginCounts(theme.originCounts) + || theme.originCounts.human < 1) return false; + + return theme.fragments.every(validateFragment) + && theme.qualifyingHumanSupport.every(validHumanSupport) + && theme.contextSupport.every(validContextSupport) + && theme.support.every((support) => typeof support === 'string' && support.length > 0 && support.length <= 200); +} + +function validateOmission(omission) { + if (!isPlainObject(omission)) return false; + if (omission.id !== undefined && (typeof omission.id !== 'string' || omission.id.length > 200)) return false; + if (omission.count !== undefined && (!Number.isInteger(omission.count) || omission.count < 1)) return false; + if (omission.content_origin !== undefined && !CONTENT_ORIGINS.includes(omission.content_origin)) return false; + if (omission.content_origin_basis !== undefined && typeof omission.content_origin_basis !== 'string') return false; + return true; +} + +function validateRipenessArtifact(artifact, { + now = new Date(), + timeZone = RIPENESS_TIME_ZONE, + requireCurrentDate = true, +} = {}) { + if (!isPlainObject(artifact)) return invalid('malformed'); + if (artifact.schemaVersion === LEGACY_RIPENESS_ARTIFACT_SCHEMA_VERSION) return invalid('legacy_non_qualifying'); + if (artifact.schemaVersion !== RIPENESS_ARTIFACT_SCHEMA_VERSION) return invalid('unknown_schema'); + if (!isIsoDate(artifact.asOf)) return invalid('malformed'); + if (artifact.timeZone !== timeZone || typeof artifact.effectiveAt !== 'string' || Number.isNaN(Date.parse(artifact.effectiveAt))) return invalid('provenance_incomplete'); + if (!isPlainObject(artifact.producer) + || typeof artifact.producer.engine !== 'string' || !artifact.producer.engine + || typeof artifact.producer.version !== 'string' || !artifact.producer.version + || typeof artifact.producer.runId !== 'string' || !artifact.producer.runId + || !SHA256_RE.test(String(artifact.producer.configDigest || ''))) return invalid('provenance_incomplete'); + if (!isPlainObject(artifact.publication) || artifact.publication.state !== 'published') return invalid('provenance_incomplete'); + if (artifact.omissions !== undefined && (!Array.isArray(artifact.omissions) || !artifact.omissions.every(validateOmission))) return invalid('malformed'); + if (!SHA256_RE.test(String(artifact.outputDigest || '')) || artifact.outputDigest !== computeRipenessArtifactDigest(artifact)) return invalid('digest_mismatch'); + if (!Array.isArray(artifact.themes) || artifact.themes.length > MAX_THEMES || !artifact.themes.every(validateTheme)) return invalid('non_qualifying_theme'); + + const expectedDate = localDateFor(now, timeZone); + if (requireCurrentDate && artifact.asOf !== expectedDate) return invalid(artifact.asOf > expectedDate ? 'future' : 'stale'); + return { ok: true, status: artifact.themes.length ? 'fresh' : 'fresh_empty', artifact }; +} + +module.exports = { + RIPENESS_ARTIFACT_SCHEMA_VERSION, + LEGACY_RIPENESS_ARTIFACT_SCHEMA_VERSION, + RIPENESS_TIME_ZONE, + MAX_THEMES, + MAX_FRAGMENTS_PER_THEME, + MAX_SUPPORT_PER_THEME, + MAX_FRAGMENT_CHARS, + CONTENT_ORIGINS, + HUMAN_ORIGIN_BASES, + CONTEXT_ORIGIN_BASES, + canonicalJson, + computeRipenessArtifactDigest, + localDateFor, + validateRipenessArtifact, +}; diff --git a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js index 780df3ba..19413342 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js +++ b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js @@ -16,6 +16,11 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { + CONTENT_ORIGIN_SCHEMA_VERSION, + humanEvidenceEligible, + normalizeContentOriginWithLegacy, +} = require('../../../bridge/provenance/src/content-origin-contract'); const SECRET_TERMS = [ 'password', @@ -116,7 +121,26 @@ function knowledgeUnitId({ sourcePath, bodyHash, kind, text }) { return `ku_${sha256(`${sourcePath}:${bodyHash}:${kind}:${text}`).slice(0, 16)}`; } -function buildKnowledgeUnits({ sourcePath, title, bodyHash, frontmatter, claims, summary, sensitivity }) { +function noteProvenance(frontmatter = {}) { + const normalized = normalizeContentOriginWithLegacy(frontmatter, { + allowLegacyFallback: true, + allowUnresolvedReceipt: true, + }); + const eligible = normalized.content_origin === 'human' + && (normalized.human_evidence_eligible === true + || humanEvidenceEligible(normalized, { allowLegacyFallback: true })); + return { + content_origin_schema: normalized.schema_version || CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: normalized.content_origin || 'unknown', + content_origin_basis: normalized.content_origin_basis || 'unknown', + human_evidence_eligible: eligible, + humanEvidenceEligible: eligible, + ...(normalized.user_source ? { content_origin_source: { ...normalized.user_source } } : {}), + ...(normalized.normalization_reason ? { normalization_reason: normalized.normalization_reason } : {}), + }; +} + +function buildKnowledgeUnits({ sourcePath, title, bodyHash, frontmatter, claims, summary, sensitivity, provenance }) { const author = String(frontmatter.author || 'unknown').trim() || 'unknown'; const source = { type: 'note', @@ -138,12 +162,15 @@ function buildKnowledgeUnits({ sourcePath, title, bodyHash, frontmatter, claims, ontologyPromotion: false, }; + const unitProvenance = { ...provenance }; const claimUnits = claims.map((claim, index) => ({ id: knowledgeUnitId({ sourcePath, bodyHash, kind: 'claim', text: claim.text }), kind: 'claim', text: claim.text, title, author, + ...unitProvenance, + provenance: { ...unitProvenance }, source, confidence: 0.72, evidence: [{ @@ -166,6 +193,8 @@ function buildKnowledgeUnits({ sourcePath, title, bodyHash, frontmatter, claims, text: summary, title, author, + ...unitProvenance, + provenance: { ...unitProvenance }, source, confidence: 0.58, evidence: [{ @@ -251,6 +280,7 @@ function buildArtifact({ filePath, notesDir, title, body, frontmatter, created, const now = new Date().toISOString(); const claims = extractClaims(body); const noteSummary = summarize(body); + const provenance = noteProvenance(frontmatter); const gbrainStatus = sensitivity.excluded ? 'skipped' : 'queued'; const memoryWikiStatus = sensitivity.excluded ? 'skipped' : 'queued'; @@ -262,6 +292,7 @@ function buildArtifact({ filePath, notesDir, title, body, frontmatter, created, claims, summary: noteSummary, sensitivity, + provenance, }); return { @@ -278,12 +309,9 @@ function buildArtifact({ filePath, notesDir, title, body, frontmatter, created, relationships: wikilinks.map((link) => ({ type: 'wikilink', target: link, targetSlug: slugify(link) })), claims, privacyTier: sensitivity.privacyTier, - knowledgeUnits, - sensitivity: { - excluded: sensitivity.excluded, - reasons: sensitivity.reasons, - }, + ...provenance, provenance: { + ...provenance, sourcePath, absolutePath: filePath, bodySha256: bodyHash, @@ -291,6 +319,11 @@ function buildArtifact({ filePath, notesDir, title, body, frontmatter, created, citation: `[[${title}]]`, journalBacklink: journal || null, }, + knowledgeUnits, + sensitivity: { + excluded: sensitivity.excluded, + reasons: sensitivity.reasons, + }, summary: noteSummary, gbrain: { status: gbrainStatus, slug: sensitivity.excluded ? null : slugify(title), skippedReasons: sensitivity.reasons }, memoryWiki: { status: memoryWikiStatus, skippedReasons: sensitivity.reasons }, diff --git a/modules/jarvos-secondbrain/src/index.js b/modules/jarvos-secondbrain/src/index.js index 8131aa15..1bb3ff90 100644 --- a/modules/jarvos-secondbrain/src/index.js +++ b/modules/jarvos-secondbrain/src/index.js @@ -23,6 +23,7 @@ const capture = require('../bridge/capture/src/universal-capture.js'); const synthesis = require('../bridge/synthesis'); const contentOrigin = require('../bridge/provenance/src/content-origin-contract.js'); const contentOriginEvidence = require('../bridge/provenance/src/content-origin-evidence.js'); +const ripenessArtifact = require('../bridge/provenance/src/ripeness-artifact-contract.js'); const wiki = require('../packages/jarvos-secondbrain-wiki/src'); const artifactReceipt = require('./artifact-receipt'); const artifactLink = require('./obsidian-artifact-link'); @@ -151,12 +152,14 @@ module.exports = { capture, contentOrigin, contentOriginEvidence, + ripenessArtifact, synthesis, wiki, ...adapters, ...capture, ...contentOrigin, ...contentOriginEvidence, + ...ripenessArtifact, ...synthesis, ...wiki, ...routing, diff --git a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js index 88bc16ba..9298f9a4 100644 --- a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js +++ b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js @@ -105,8 +105,8 @@ test('accepts the canonical note frontmatter source-receipt field', () => { const receipt = { capture_event_id: 'capture-frontmatter-1', actor: 'user', - source_digest: digestText(source), - content_digest: digestText(content), + source_digest: digest(source), + content_digest: digest(content), }; const normalized = normalizeContentOrigin({ content_origin: 'human', diff --git a/modules/jarvos-secondbrain/tests/knowledge-units.test.js b/modules/jarvos-secondbrain/tests/knowledge-units.test.js index f5afca72..990fa82b 100644 --- a/modules/jarvos-secondbrain/tests/knowledge-units.test.js +++ b/modules/jarvos-secondbrain/tests/knowledge-units.test.js @@ -63,6 +63,10 @@ test('buildArtifact emits generalized source-backed knowledge units for safe not const unit = first.knowledgeUnits[0]; assert.equal(unit.kind, 'claim'); assert.equal(unit.author, 'andrew'); + assert.equal(unit.content_origin, 'human'); + assert.equal(unit.content_origin_basis, 'legacy_author'); + assert.equal(unit.human_evidence_eligible, true); + assert.equal(unit.provenance.humanEvidenceEligible, true); assert.equal(unit.source.type, 'note'); assert.equal(unit.source.path, 'Notes/Secondbrain Architecture.md'); assert.equal(unit.privacyDecision.tier, 'local-private'); @@ -99,6 +103,8 @@ test('optimizeNoteKnowledge writes knowledge units into artifacts and queues', ( assert.equal(sourceEntry.knowledgeUnits.length, 2); assert.equal(memoryWikiQueue.entries['Notes/Secondbrain Architecture.md'].knowledgeUnits.length, 2); assert.equal(sourceEntry.knowledgeUnits[0].author, 'jarvis'); + assert.equal(sourceEntry.knowledgeUnits[0].content_origin, 'assistant'); + assert.equal(sourceEntry.knowledgeUnits[0].human_evidence_eligible, false); assert.equal(sourceEntry.knowledgeUnits[0].downstreamEligibility.gbrain, true); }); @@ -125,3 +131,67 @@ test('sensitive notes keep local knowledge units but block downstream promotion' assert.equal(artifact.knowledgeUnits[0].downstreamEligibility.gbrain, false); assert.equal(artifact.knowledgeUnits[0].downstreamEligibility.memoryPromotion, false); }); + +test('knowledge units preserve explicit origin while keeping searchable derivatives available', () => { + const { notesDir, filePath } = noteFixture(); + const artifact = buildArtifact({ + filePath, + notesDir, + title: 'Generated Research', + body: 'The assistant generated this summary for retrieval, but it is not Andrew\'s evidence.', + frontmatter: { + author: 'jarvis', + type: 'reference', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }, + created: true, + }); + + assert.equal(artifact.content_origin, 'assistant'); + assert.equal(artifact.knowledgeUnits[0].content_origin, 'assistant'); + assert.equal(artifact.knowledgeUnits[0].human_evidence_eligible, false); + assert.equal(artifact.knowledgeUnits[0].downstreamEligibility.qmd, true); + assert.equal(artifact.knowledgeUnits[0].downstreamEligibility.memoryPromotion, true); +}); + +test('explicit human note provenance requires a syntactically valid source receipt', () => { + const { notesDir, filePath } = noteFixture(); + const body = 'A faithful user-derived note with a source receipt.'; + const valid = buildArtifact({ + filePath, + notesDir, + title: 'User-derived note', + body, + frontmatter: { + content_origin: 'human', + content_origin_basis: 'user_derived', + human_evidence_eligible: true, + content_origin_source: { + capture_event_id: 'capture-1', + actor: 'user', + source_digest: 'a'.repeat(64), + content_digest: 'b'.repeat(64), + }, + }, + created: true, + }); + assert.equal(valid.knowledgeUnits[0].content_origin, 'human'); + assert.equal(valid.knowledgeUnits[0].human_evidence_eligible, true); + + const invalid = buildArtifact({ + filePath, + notesDir, + title: 'Unproven note', + body, + frontmatter: { + content_origin: 'human', + content_origin_basis: 'user_derived', + human_evidence_eligible: true, + }, + created: true, + }); + assert.equal(invalid.knowledgeUnits[0].content_origin, 'unknown'); + assert.equal(invalid.knowledgeUnits[0].human_evidence_eligible, false); +}); diff --git a/tests/ripeness-artifact-contract-test.js b/tests/ripeness-artifact-contract-test.js new file mode 100644 index 00000000..a567adce --- /dev/null +++ b/tests/ripeness-artifact-contract-test.js @@ -0,0 +1,119 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const test = require('node:test'); + +const { + RIPENESS_ARTIFACT_SCHEMA_VERSION, + computeRipenessArtifactDigest, + localDateFor, + validateRipenessArtifact, +} = require('../modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract'); + +function artifact(overrides = {}) { + const value = { + schemaVersion: RIPENESS_ARTIFACT_SCHEMA_VERSION, + asOf: '2026-08-10', + effectiveAt: '2026-08-10T05:00:00.000Z', + timeZone: 'America/New_York', + producer: { + engine: 'ripeness-nudge', + version: 'test-engine-v1', + runId: 'run_test_123', + configDigest: crypto.createHash('sha256').update('config').digest('hex'), + }, + publication: { state: 'published' }, + themes: [{ + days: 3, + spanDays: 14, + firstSeen: '2026-07-27', + lastSeen: '2026-08-09', + qualifyingHumanDays: 2, + originCounts: { human: 2, assistant: 1, mixed: 0, unknown: 0 }, + fragments: [{ + date: '2026-08-09', + text: 'Synthetic recurring thought.', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + human_evidence_eligible: true, + }], + qualifyingHumanSupport: [{ + id: 'human-support-1', + date: '2026-08-09', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + human_evidence_eligible: true, + }], + contextSupport: [{ + id: 'assistant-support-1', + date: '2026-08-08', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }], + support: ['Synthetic support'], + }], + ...overrides, + }; + value.outputDigest = computeRipenessArtifactDigest(value); + return value; +} + +const now = new Date('2026-08-10T12:00:00.000Z'); + +test('validates a fresh, published artifact and accepts fresh empty output', () => { + assert.equal(localDateFor(now, 'America/New_York'), '2026-08-10'); + assert.deepEqual(validateRipenessArtifact(artifact(), { now }), { ok: true, status: 'fresh', artifact: artifact() }); + assert.equal(validateRipenessArtifact(artifact({ themes: [] }), { now }).status, 'fresh_empty'); +}); + +test('fails closed on future, stale, unknown schema, digest, provenance, and bounded-row defects', () => { + assert.equal(validateRipenessArtifact(artifact({ asOf: '2026-08-11' }), { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ asOf: '2026-08-09' }), { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ schemaVersion: 'unknown/v9' }), { now }).ok, false); + const digestMismatch = artifact(); digestMismatch.outputDigest = '0'.repeat(64); + assert.equal(validateRipenessArtifact(digestMismatch, { now }).ok, false); + const noRun = artifact(); delete noRun.producer.runId; noRun.outputDigest = computeRipenessArtifactDigest(noRun); + assert.equal(validateRipenessArtifact(noRun, { now }).ok, false); + assert.equal(validateRipenessArtifact(artifact({ themes: Array.from({ length: 4 }, () => artifact().themes[0]) }), { now }).ok, false); +}); + +test('assistant-only themes cannot validate and legacy artifacts are explicitly non-qualifying', () => { + const assistantOnly = artifact({ + themes: [{ + ...artifact().themes[0], + originCounts: { human: 0, assistant: 3, mixed: 0, unknown: 0 }, + qualifyingHumanDays: 0, + qualifyingHumanSupport: [], + fragments: [], + contextSupport: [{ + id: 'assistant-only', + date: '2026-08-09', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }], + }], + }); + assert.equal(validateRipenessArtifact(assistantOnly, { now }).ok, false); + + const legacy = artifact({ schemaVersion: 'jarvos-ripeness-artifact/v1' }); + assert.deepEqual(validateRipenessArtifact(legacy, { now }), { + ok: false, + status: 'legacy_non_qualifying', + artifact: null, + legacy: true, + }); +}); + +test('artifact digest covers origin composition and eligibility fields', () => { + const changed = artifact(); + changed.themes[0].contextSupport[0].content_origin = 'mixed'; + changed.themes[0].contextSupport[0].content_origin_basis = 'mixed_composition'; + changed.outputDigest = computeRipenessArtifactDigest(changed); + assert.equal(validateRipenessArtifact(changed, { now }).ok, true); + + changed.themes[0].qualifyingHumanSupport[0].human_evidence_eligible = false; + assert.equal(validateRipenessArtifact(changed, { now }).ok, false); +}); From 38f1b0b3d5e5ea532b5bc3aded7166564902de60 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Fri, 14 Aug 2026 15:35:34 -0400 Subject: [PATCH 5/9] feat(provenance): publish clean evidence projections --- .../provenance/src/content-origin-evidence.js | 67 ++++++++++++++++++- .../src/ripeness-artifact-contract.js | 2 +- .../tests/content-origin-contract.test.js | 23 +++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js index 8b7fd467..0a0f2677 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js @@ -6,7 +6,10 @@ const { CONTENT_ORIGIN_BASES, cleanText, humanEvidenceEligible, + normalizeContentOriginWithLegacy, + parseJournalEntry, } = require('./content-origin-contract'); +const { frontmatterToObject, parseFrontmatter } = require('../../../packages/jarvos-secondbrain-notes/src/lib/note-schema'); const EVIDENCE_PROJECTION_VERSION = 'jarvos-content-origin-evidence/v1'; @@ -36,7 +39,67 @@ function projectEvidenceRecord(record = {}, options = {}) { clean_text, content_origin: origin, content_origin_basis: basis, - human_evidence_eligible: origin === 'human' && humanEvidenceEligible(record, options), + human_evidence_eligible: origin === 'human' && (options.prevalidated === true + ? record.human_evidence_eligible === true + : humanEvidenceEligible(record, options)), + }; +} + +function projectJournalEntriesFromMarkdown(markdown, { date = null, section = 'ideas' } = {}) { + const lines = String(markdown || '').split(/\r?\n/); + const entries = []; + let inSection = false; + for (let index = 0; index < lines.length; index += 1) { + const current = lines[index]; + if (/^##\s/.test(current)) { + inSection = section === 'ideas' ? /💡|ideas/i.test(current) : section === 'notes' ? /📝|notes/i.test(current) : true; + continue; + } + if (!inSection || !current.trim().startsWith('- ')) continue; + const entry = parseJournalEntry(lines, index); + if (!entry) continue; + const projected = projectEvidenceRecord({ + clean_text: entry.clean_text, + content_origin: entry.origin.content_origin, + content_origin_basis: entry.origin.content_origin_basis, + human_evidence_eligible: entry.origin.human_evidence_eligible === true, + }, { + prevalidated: Boolean(entry.marker && !entry.marker.normalization_reason), + manualEntry: !entry.marker_line, + allowLegacyFallback: true, + }); + entries.push({ + ...projected, + date: date || null, + source_id: `journal:${date || 'unknown'}:${index}`, + }); + index += entry.marker_lines?.length || (entry.marker_line ? 1 : 0); + } + return entries; +} + +function projectNoteMarkdown(markdown, { sourcePath = null, title = null } = {}) { + const parsed = parseFrontmatter(String(markdown || '')); + const frontmatter = parsed ? frontmatterToObject(parsed) : {}; + const normalized = normalizeContentOriginWithLegacy(frontmatter, { + allowLegacyFallback: true, + allowUnresolvedReceipt: true, + }); + const clean_text = cleanText(parsed?.remainder || markdown); + const eligible = normalized.content_origin === 'human' + && (normalized.human_evidence_eligible === true + || humanEvidenceEligible(normalized, { allowLegacyFallback: true })); + const projected = projectEvidenceRecord({ + clean_text, + content_origin: normalized.content_origin, + content_origin_basis: normalized.content_origin_basis, + human_evidence_eligible: eligible, + }, { prevalidated: true, allowLegacyFallback: true }); + return { + ...projected, + source_id: `note:${sourcePath || title || 'unknown'}`, + source_path: sourcePath || null, + title: title || null, }; } @@ -79,5 +142,7 @@ module.exports = { EVIDENCE_PROJECTION_VERSION, projectEvidenceRecord, projectEvidenceBatch, + projectJournalEntriesFromMarkdown, + projectNoteMarkdown, readEvidenceProjection, }; diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js index b40836df..0dceebf8 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/ripeness-artifact-contract.js @@ -16,7 +16,7 @@ const MAX_FRAGMENTS_PER_THEME = 4; const MAX_SUPPORT_PER_THEME = 4; const MAX_FRAGMENT_CHARS = 320; const CONTENT_ORIGINS = Object.freeze(['human', 'assistant', 'mixed', 'unknown']); -const HUMAN_ORIGIN_BASES = Object.freeze(['verbatim_user', 'user_derived', 'legacy_author']); +const HUMAN_ORIGIN_BASES = Object.freeze(['verbatim_user', 'user_derived', 'legacy_author', 'unknown']); const CONTEXT_ORIGIN_BASES = Object.freeze(['assistant_generated', 'mixed_composition', 'unknown', 'legacy_author']); const CONTEXT_BASIS_BY_ORIGIN = Object.freeze({ assistant: 'assistant_generated', diff --git a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js index 9298f9a4..b1410038 100644 --- a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js +++ b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js @@ -21,6 +21,8 @@ const { EVIDENCE_PROJECTION_VERSION, projectEvidenceRecord, projectEvidenceBatch, + projectJournalEntriesFromMarkdown, + projectNoteMarkdown, readEvidenceProjection, } = require('../bridge/provenance/src/content-origin-evidence'); @@ -242,3 +244,24 @@ test('journal entry parsing treats unmarked bullets as manual human evidence and assert.equal(duplicate.origin.content_origin, 'unknown'); assert.equal(duplicate.origin.normalization_reason, 'duplicate_marker'); }); + +test('public projections expose clean journal and note evidence without marker parsing in consumers', () => { + const marker = renderJournalOriginMarker({ + cleanText: 'Assistant-generated context', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + }); + const journal = projectJournalEntriesFromMarkdown(`## 💡 Ideas\n- User thought\n- Assistant-generated context\n${marker}\n`, { date: '2026-08-14' }); + assert.equal(journal.length, 2); + assert.equal(journal[0].content_origin, 'human'); + assert.equal(journal[0].human_evidence_eligible, true); + assert.equal(journal[1].content_origin, 'assistant'); + assert.equal(journal[1].human_evidence_eligible, false); + assert.equal(journal[1].clean_text, 'Assistant-generated context'); + assert.doesNotMatch(JSON.stringify(journal), /'; }; const cleanMarker = value => String(value || '').replace(/\\s*/gi, '').trim(); const cleanLine = value => { const clean = cleanMarker(value); return clean.startsWith('- ') ? clean : '- ' + clean.replace(/^[-\\s]+/, ''); }; "; + return "const markerPayload = origin => { const o = origin || {}; const json = JSON.stringify({ schema_version: 'jarvos-content-origin/v1', content_origin: o.content_origin, content_origin_basis: o.content_origin_basis, clean_text_digest: o.clean_text_digest, human_evidence_eligible: o.human_evidence_eligible === true && o.content_origin === 'human', ...(o.source_ref ? { source_ref: String(o.source_ref) } : {}), ...(o.user_source && typeof o.user_source === 'object' ? { user_source: { capture_event_id: o.user_source.capture_event_id, actor: o.user_source.actor, source_digest: o.user_source.source_digest, content_digest: o.clean_text_digest || o.user_source.content_digest } } : {}) }); const encoded = btoa(unescape(encodeURIComponent(json))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, ''); return ''; }; const cleanMarker = value => String(value || '').replace(/\\s*/gi, '').trim(); const markerObject = value => { const match = String(value || '').match(/^$/); if (!match) return null; try { const token = match[1]; const padded = token.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((token.length + 3) % 4); const decoded = token.startsWith('%7B') || token.startsWith('%7b') ? decodeURIComponent(token) : new TextDecoder().decode(Uint8Array.from(atob(padded), c => c.charCodeAt(0))); return JSON.parse(decoded); } catch (_) { return null; } }; const sha256 = value => /^[a-f0-9]{64}$/.test(String(value || '')); const verifiedHumanMarker = value => { const marker = markerObject(value); const receipt = marker && marker.user_source; return Boolean(marker && marker.schema_version === 'jarvos-content-origin/v1' && marker.content_origin === 'human' && (marker.content_origin_basis === 'verbatim_user' || marker.content_origin_basis === 'user_derived') && marker.human_evidence_eligible === true && sha256(marker.clean_text_digest) && receipt && receipt.actor === 'user' && typeof receipt.capture_event_id === 'string' && receipt.capture_event_id.trim() && sha256(receipt.source_digest) && receipt.content_digest === marker.clean_text_digest && marker.source_ref === receipt.capture_event_id); }; const sameVerifiedHumanOrigin = (left, right) => { const a = markerObject(left); const b = markerObject(right); return Boolean(verifiedHumanMarker(left) && verifiedHumanMarker(right) && a.content_origin === b.content_origin && a.content_origin_basis === b.content_origin_basis && a.human_evidence_eligible === b.human_evidence_eligible); }; const cleanLine = value => { const clean = cleanMarker(value); return clean.startsWith('- ') ? clean : '- ' + clean.replace(/^[-\\s]+/, ''); }; "; } function journalOriginMutationBranchProgram() { - return "if (input.transformName === 'journal-section-line' && input.transformVersion === 2 && typeof p.heading === 'string' && typeof p.line === 'string' && p.contentOrigin) { const h = heading(p.heading); const line = cleanLine(p.line); const marker = markerPayload(p.contentOrigin); return { apply: current => { if (!h || !line.startsWith('- ')) return current; const lines = String(current).split('\\n'); const r = range(lines, h); if (r.start === -1) { const trimmed = String(current).trimEnd(); return trimmed + (trimmed ? '\\n\\n' : '') + h + '\\n' + line + '\\n' + marker + '\\n'; } let match = -1; for (let i = r.start + 1; i < r.end; i += 1) { if (lines[i].trim().startsWith('- ') && cleanMarker(lines[i]) === line) { match = i; break; } } if (match !== -1) { if (!String(lines[match + 1] || '').trim().startsWith('`; } @@ -231,6 +257,11 @@ function parseJournalOriginMarker(marker, cleanText) { if (payload.source_ref !== undefined && (typeof payload.source_ref !== 'string' || !payload.source_ref.trim())) { return unknownJournalOrigin('invalid_marker_source_ref'); } + if (payload.content_origin === 'human') { + const validation = validateUserSourceReceipt(payload.user_source, { content: cleanText }); + if (validation.reason !== 'unresolved') return unknownJournalOrigin(`invalid_marker_user_source:${validation.reason}`); + if (payload.source_ref !== payload.user_source.capture_event_id) return unknownJournalOrigin('marker_source_ref_mismatch'); + } return { schema_version: payload.schema_version, content_origin: payload.content_origin, @@ -238,6 +269,7 @@ function parseJournalOriginMarker(marker, cleanText) { clean_text_digest: payload.clean_text_digest, human_evidence_eligible: payload.human_evidence_eligible === true && payload.content_origin === 'human', ...(payload.source_ref ? { source_ref: payload.source_ref } : {}), + ...(payload.user_source ? { user_source: { ...payload.user_source } } : {}), }; } @@ -311,14 +343,14 @@ function humanEvidenceEligible(record = {}, options = {}) { function normalizeContentOriginWithLegacy(input = {}, options = {}) { if (input.content_origin || input.contentOrigin || input.content_origin_basis || input.contentOriginBasis) { - if (options.allowUnresolvedReceipt === true) return normalizeContentOriginForRead(input); + if (options.allowUnresolvedReceipt === true) return normalizeContentOriginForRead(input, options); return normalizeContentOrigin(input, options); } if (input.author) return resolveLegacyOrigin(input); return unknownRecord('missing_declaration'); } -function normalizeContentOriginForRead(input = {}) { +function normalizeContentOriginForRead(input = {}, options = {}) { const source = isPlainObject(input) ? input : {}; const origin = String(source.content_origin ?? source.contentOrigin ?? '').trim().toLowerCase(); const basis = String(source.content_origin_basis ?? source.contentOriginBasis ?? '').trim().toLowerCase(); @@ -330,6 +362,9 @@ function normalizeContentOriginForRead(input = {}) { if (!receipt || receipt.actor !== 'user' || !SHA256_RE.test(String(receipt.source_digest || '')) || !SHA256_RE.test(String(receipt.content_digest || ''))) { return unknownRecord('invalid_read_receipt'); } + if (options.content !== undefined && digestText(options.content) !== receipt.content_digest) { + return unknownRecord('read_content_digest_mismatch'); + } } return { schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, @@ -348,9 +383,11 @@ module.exports = { BASIS_ORIGIN, LEGACY_AUTHOR_ORIGINS, cleanText, + cleanNoteContent, digestText, validateUserSourceReceipt, normalizeContentOrigin, + contentOriginPairIsValid, frontmatterForContentOrigin, JOURNAL_MARKER_PREFIX, renderJournalOriginMarker, diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js index 0a0f2677..b09bd2eb 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/content-origin-evidence.js @@ -5,6 +5,7 @@ const { CONTENT_ORIGINS, CONTENT_ORIGIN_BASES, cleanText, + cleanNoteContent, humanEvidenceEligible, normalizeContentOriginWithLegacy, parseJournalEntry, @@ -62,9 +63,12 @@ function projectJournalEntriesFromMarkdown(markdown, { date = null, section = 'i clean_text: entry.clean_text, content_origin: entry.origin.content_origin, content_origin_basis: entry.origin.content_origin_basis, + user_source: entry.origin.user_source, human_evidence_eligible: entry.origin.human_evidence_eligible === true, }, { - prevalidated: Boolean(entry.marker && !entry.marker.normalization_reason), + prevalidated: Boolean(entry.marker + && !entry.marker.normalization_reason + && (entry.origin.content_origin !== 'human' || entry.origin.user_source)), manualEntry: !entry.marker_line, allowLegacyFallback: true, }); @@ -81,11 +85,12 @@ function projectJournalEntriesFromMarkdown(markdown, { date = null, section = 'i function projectNoteMarkdown(markdown, { sourcePath = null, title = null } = {}) { const parsed = parseFrontmatter(String(markdown || '')); const frontmatter = parsed ? frontmatterToObject(parsed) : {}; + const clean_text = cleanText(parsed?.remainder || markdown); const normalized = normalizeContentOriginWithLegacy(frontmatter, { allowLegacyFallback: true, allowUnresolvedReceipt: true, + content: cleanNoteContent(clean_text, title), }); - const clean_text = cleanText(parsed?.remainder || markdown); const eligible = normalized.content_origin === 'human' && (normalized.human_evidence_eligible === true || humanEvidenceEligible(normalized, { allowLegacyFallback: true })); diff --git a/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js b/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js index 5a2af2be..2003b80f 100644 --- a/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js +++ b/modules/jarvos-secondbrain/bridge/provenance/src/note-journal-contract.js @@ -12,6 +12,10 @@ const { frontmatterToObject, parseFrontmatter } = require('../../../packages/jar const { createObsidianOwnedMutationService } = require('./obsidian-mutation'); const { linkNoteToJournal } = require('./link-to-journal'); const { createArtifactReceipt } = require('../../../src/artifact-receipt'); +const { + cleanNoteContent, + frontmatterForContentOrigin, +} = require('./content-origin-contract'); const SUPPORTED_PERSONALITIES = new Set(['michael', 'claude-code', 'hermes', 'codex']); const LIGHTWEIGHT_IDEA_RE = /^\s*idea\s*[:\-]/i; @@ -35,7 +39,7 @@ function countJournalBacklinks(journalMd, title) { return matches ? matches.length : 0; } -function parseInput(input) { +function parseInput(input, { resolveUserSource } = {}) { if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('input must be a JSON object'); } @@ -68,6 +72,16 @@ function parseInput(input) { const humanEvidenceEligible = input.human_evidence_eligible ?? suppliedFrontmatter.human_evidence_eligible ?? false; + const normalizedOrigin = frontmatterForContentOrigin({ + content_origin: contentOrigin, + content_origin_basis: contentOriginBasis, + content_origin_source: contentOriginSource, + human_evidence_eligible: humanEvidenceEligible, + }, { + content: cleanNoteContent(String(input.content), input.title), + resolveUserSource, + captureEventId: input.captureEventId ?? input.capture_event_id, + }); return { personality, @@ -77,10 +91,7 @@ function parseInput(input) { ...suppliedFrontmatter, source_personality: personality, contract: 'obsidian-note-journal-v1', - content_origin: contentOrigin, - content_origin_basis: contentOriginBasis, - ...(contentOriginSource !== undefined ? { content_origin_source: contentOriginSource } : {}), - human_evidence_eligible: humanEvidenceEligible, + ...normalizedOrigin, }, }; } @@ -228,8 +239,8 @@ function dispatchBacklink({ result, section = '📝 Notes', createIfMissing = tr } } -function writeNoteThroughContract(rawInput, { mutationService, link } = {}) { - const input = parseInput(rawInput); +function writeNoteThroughContract(rawInput, { mutationService, link, resolveUserSource } = {}) { + const input = parseInput(rawInput, { resolveUserSource }); const service = mutationService || createObsidianOwnedMutationService({ source: 'bridge.note-journal-contract' }); const filePath = path.join(getVaultNotesDir(), `${String(input.title).trim().replace(/[/\\:*?"<>|]/g, '-')}.md`); const vaultRelativePath = path.relative(service.vaultRoot, filePath).split(path.sep).join('/'); diff --git a/modules/jarvos-secondbrain/bridge/routing/src/keyword-capture-router.js b/modules/jarvos-secondbrain/bridge/routing/src/keyword-capture-router.js index d2ed9965..9bb18afb 100644 --- a/modules/jarvos-secondbrain/bridge/routing/src/keyword-capture-router.js +++ b/modules/jarvos-secondbrain/bridge/routing/src/keyword-capture-router.js @@ -50,7 +50,7 @@ const { const { createArtifactReceipt } = require('../../../src/artifact-receipt'); function applyRoutingPlan(capture = {}, options = {}) { - const plan = buildRoutingPlan(capture); + const plan = buildRoutingPlan(capture, options); const date = plan.date; const result = { plan, diff --git a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js index c4f14046..a8eef97d 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js +++ b/modules/jarvos-secondbrain/packages/jarvos-ambient/src/routing/index.js @@ -9,7 +9,6 @@ const { stripLeadingKeyword, } = require('../intent/keyword-capture-router'); const { - CONTENT_ORIGIN_SCHEMA_VERSION, normalizeContentOrigin, } = require('../../../../bridge/provenance/src/content-origin-contract'); @@ -40,27 +39,16 @@ function captureContentForProvenance(capture = {}) { } function normalizeCaptureProvenance(capture = {}, options = {}) { - const hasValidatedHumanRecord = capture.content_origin_schema === CONTENT_ORIGIN_SCHEMA_VERSION - && capture.content_origin === 'human' - && capture.human_evidence_eligible === true - && capture.user_source - && typeof capture.user_source === 'object'; - const normalized = hasValidatedHumanRecord - ? { - schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, - content_origin: 'human', - content_origin_basis: capture.content_origin_basis, - user_source: { ...capture.user_source }, - human_evidence_eligible: true, - } - : normalizeContentOrigin({ - content_origin: capture.content_origin ?? capture.contentOrigin, - content_origin_basis: capture.content_origin_basis ?? capture.contentOriginBasis, - user_source: capture.user_source ?? capture.userSource, - }, { - content: captureContentForProvenance(capture), - resolveUserSource: options.resolveUserSource || capture.resolveUserSource, - }); + const captureEventId = capture.captureEventId ?? capture.capture_event_id ?? capture.id; + const normalized = normalizeContentOrigin({ + content_origin: capture.content_origin ?? capture.contentOrigin, + content_origin_basis: capture.content_origin_basis ?? capture.contentOriginBasis, + user_source: capture.user_source ?? capture.userSource, + }, { + content: captureContentForProvenance(capture), + resolveUserSource: options.resolveUserSource || capture.resolveUserSource, + captureEventId, + }); return { ...capture, @@ -94,6 +82,7 @@ function journalOriginForCapture(capture = {}, options = {}) { content_origin: normalized.content_origin, content_origin_basis: normalized.content_origin_basis, human_evidence_eligible: normalized.human_evidence_eligible === true, + ...(normalized.user_source ? { user_source: { ...normalized.user_source } } : {}), ...(sourceRef ? { source_ref: String(sourceRef) } : {}), }; } @@ -251,7 +240,7 @@ function buildJournalAction(plan) { }; } -function buildNoteAction(plan, capture = {}) { +function buildNoteAction(plan, capture = {}, options = {}) { if (plan.ignored || !plan.createNote) return null; return { kind: 'note', @@ -263,7 +252,7 @@ function buildNoteAction(plan, capture = {}) { frontmatter: { ...(capture.frontmatter || {}), ...(plan.noteFrontmatter || {}), - ...contentOriginFrontmatter(capture), + ...contentOriginFrontmatter(capture, options), }, }, }; @@ -470,7 +459,7 @@ function buildThreePackagePlan(capture = {}, options = {}) { const actions = [ buildJournalAction(plan), - buildNoteAction(plan, normalizedCapture), + buildNoteAction(plan, normalizedCapture, options), buildMemoryAction(memoryParams), ].filter(Boolean); diff --git a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js index 9931d412..b56fa826 100644 --- a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js +++ b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/knowledge-optimizer.js @@ -18,6 +18,7 @@ const path = require('path'); const crypto = require('crypto'); const { CONTENT_ORIGIN_SCHEMA_VERSION, + cleanNoteContent, humanEvidenceEligible, normalizeContentOriginWithLegacy, } = require('../../../bridge/provenance/src/content-origin-contract'); @@ -121,10 +122,11 @@ function knowledgeUnitId({ sourcePath, bodyHash, kind, text }) { return `ku_${sha256(`${sourcePath}:${bodyHash}:${kind}:${text}`).slice(0, 16)}`; } -function noteProvenance(frontmatter = {}) { +function noteProvenance(frontmatter = {}, body = '', title = '') { const normalized = normalizeContentOriginWithLegacy(frontmatter, { allowLegacyFallback: true, allowUnresolvedReceipt: true, + content: cleanNoteContent(body, title), }); const eligible = normalized.content_origin === 'human' && (normalized.human_evidence_eligible === true @@ -279,7 +281,7 @@ function buildArtifact({ filePath, notesDir, title, body, frontmatter, created, const now = new Date().toISOString(); const claims = extractClaims(body); const noteSummary = summarize(body); - const provenance = noteProvenance(frontmatter); + const provenance = noteProvenance(frontmatter, body, title); const gbrainStatus = sensitivity.excluded ? 'skipped' : 'queued'; const memoryWikiStatus = sensitivity.excluded ? 'skipped' : 'queued'; diff --git a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js index 42b49ed6..a646a771 100755 --- a/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js +++ b/modules/jarvos-secondbrain/packages/jarvos-secondbrain-notes/src/write-to-vault.js @@ -8,7 +8,7 @@ 'use strict'; const { existsSync, readFileSync } = require('fs'); -const { randomUUID } = require('crypto'); +const { createHash, randomUUID } = require('crypto'); const { join, relative, sep } = require('path'); const { artifactFromMutationResult, @@ -18,6 +18,7 @@ const { getVaultNotesDir, loadConfig } = require('./lib/notes-config'); const { optimizeNoteKnowledge } = require('./knowledge-optimizer'); const { canonicalizeFrontmatter, + CONTENT_ORIGIN_FIELDS, frontmatterToObject, parseFrontmatter, renderFrontmatter, @@ -44,16 +45,41 @@ function buildNoteBody(title, content) { return String(content || '').startsWith('# ') ? String(content || '') : `# ${title}\n\n${content}`; } +function hasContentOriginDeclaration(frontmatter = {}) { + return CONTENT_ORIGIN_FIELDS.some((field) => frontmatter[field] !== undefined); +} + +function hasExactBlock(content, block) { + const expected = String(block || '').trim(); + return Boolean(expected && (`\n\n${String(content || '').trim()}\n\n`).includes(`\n\n${expected}\n\n`)); +} + +function appendBlock(content, block) { + const source = String(content || ''); + const expected = String(block || '').trim(); + return hasExactBlock(source, expected) + ? source + : `${source.trimEnd()}\n\n${expected}\n`; +} + +function provenanceDeclarationsDiffer(existing = {}, next = {}) { + return CONTENT_ORIGIN_FIELDS.some((field) => JSON.stringify(existing[field]) !== JSON.stringify(next[field])); +} + function readExistingFrontmatter(filePath) { if (!existsSync(filePath)) return {}; const existing = readFileSync(filePath, 'utf8'); return frontmatterToObject(parseFrontmatter(existing)); } -function normalizeFrontmatter({ incoming = {}, existing = {} } = {}) { +function normalizeFrontmatter({ incoming = {}, existing = {}, preserveExistingProvenance = true } = {}) { + const existingForNormalization = { ...existing }; + if (!preserveExistingProvenance) { + for (const field of CONTENT_ORIGIN_FIELDS) delete existingForNormalization[field]; + } const canonical = canonicalizeFrontmatter({ incomingFrontmatter: incoming, - existingFrontmatter: existing, + existingFrontmatter: existingForNormalization, today: todayDate(), }); @@ -73,10 +99,11 @@ function normalizeFrontmatter({ incoming = {}, existing = {} } = {}) { return normalizedFrontmatter; } -function buildFrontmatter({ incomingFrontmatter = {}, existingFrontmatter = {} } = {}) { +function buildFrontmatter({ incomingFrontmatter = {}, existingFrontmatter = {}, preserveExistingProvenance = true } = {}) { return renderFrontmatter(normalizeFrontmatter({ incoming: incomingFrontmatter, existing: existingFrontmatter, + preserveExistingProvenance, })); } @@ -85,10 +112,38 @@ function buildFrontmatter({ incomingFrontmatter = {}, existingFrontmatter = {} } function createNoteMutationOperation({ operationId, vaultId, vaultRelativePath, title, content, frontmatter = {}, existingContent = '', existingFrontmatter = {}, appendEntry, sequence = 1, source } = {}) { if (typeof operationId !== 'string' || !operationId.trim()) throw new Error('operationId is required for a note mutation'); if (!vaultId || !vaultRelativePath) throw new Error('vaultId and vaultRelativePath are required for a note mutation'); - const normalizedFrontmatter = normalizeFrontmatter({ incoming: frontmatter, existing: existingFrontmatter }); const body = buildNoteBody(title, content); + const existingBody = parseFrontmatter(existingContent)?.remainder || existingContent; + const appendBody = appendEntry ? String(appendEntry).trim() : body; + const materialBodyChange = Boolean(existingContent) && !hasExactBlock(existingBody, appendBody); + const preserveExistingProvenance = !(materialBodyChange && !hasContentOriginDeclaration(frontmatter)); + const normalizedFrontmatter = normalizeFrontmatter({ + incoming: frontmatter, + existing: existingFrontmatter, + preserveExistingProvenance, + }); const rendered = renderFrontmatter(normalizedFrontmatter) + body; const created = !existingContent; + const provenanceRewrite = Boolean(existingContent) + && (hasContentOriginDeclaration(frontmatter) || hasContentOriginDeclaration(existingFrontmatter)) + && (materialBodyChange || provenanceDeclarationsDiffer(existingFrontmatter, normalizedFrontmatter)); + if (provenanceRewrite) { + const nextBody = appendBlock(existingBody, appendBody).trimEnd(); + const nextContent = `${renderFrontmatter(normalizedFrontmatter)}${nextBody}\n`; + return { + schemaVersion: 1, + operationId: operationId.trim(), + vaultId, + vaultRelativePath, + sequence, + operationKind: 'replace', + content: nextContent, + expectedContent: String(existingContent), + expectedHash: createHash('sha256').update(String(existingContent), 'utf8').digest('hex'), + noteId: normalizedFrontmatter.jarvos_note_id, + ...(source ? { source } : {}), + }; + } const replayPayload = created ? null : appendEntry @@ -127,9 +182,15 @@ function writeNoteFile({ title, content, frontmatter = {}, appendEntry, mutation const created = !existsSync(filePath); const existingFrontmatter = readExistingFrontmatter(filePath); const body = buildNoteBody(title, content); + const existingContent = created ? '' : readFileSync(filePath, 'utf8'); + const existingBody = parseFrontmatter(existingContent)?.remainder || existingContent; + const appendBody = appendEntry ? String(appendEntry).trim() : buildNoteBody(title, content); + const materialBodyChange = Boolean(existingContent) && !hasExactBlock(existingBody, appendBody); + const preserveExistingProvenance = !(materialBodyChange && !hasContentOriginDeclaration(frontmatter)); const normalizedFrontmatter = normalizeFrontmatter({ incoming: frontmatter, existing: existingFrontmatter, + preserveExistingProvenance, }); if (typeof mutationExecutor !== 'function' || !vaultId || !vaultRoot || !operationId) { throw new Error('Canonical vault mutation composition is required; package note writes cannot modify Markdown directly'); @@ -142,7 +203,7 @@ function writeNoteFile({ title, content, frontmatter = {}, appendEntry, mutation title, content, frontmatter, - existingContent: created ? '' : readFileSync(filePath, 'utf8'), + existingContent, existingFrontmatter, appendEntry, sequence, diff --git a/modules/jarvos-secondbrain/src/vault-transform-registry.js b/modules/jarvos-secondbrain/src/vault-transform-registry.js index 04e0d893..bf96894c 100644 --- a/modules/jarvos-secondbrain/src/vault-transform-registry.js +++ b/modules/jarvos-secondbrain/src/vault-transform-registry.js @@ -85,6 +85,9 @@ function normalizedJournalOrigin(contentOrigin = {}) { human_evidence_eligible: contentOrigin.human_evidence_eligible === true && origin === 'human', ...(contentOrigin.clean_text_digest ? { clean_text_digest: String(contentOrigin.clean_text_digest) } : {}), ...(contentOrigin.source_ref ? { source_ref: String(contentOrigin.source_ref).trim() } : {}), + ...(contentOrigin.user_source && typeof contentOrigin.user_source === 'object' + ? { user_source: { ...contentOrigin.user_source } } + : {}), }; } @@ -125,6 +128,9 @@ function journalSectionLineOriginTransform(content, { heading, line, contentOrig const matchingIndex = matchingJournalBullet(lines, range, canonicalLine); if (matchingIndex !== -1) { const entry = parseJournalEntry(lines, matchingIndex); + // An existing unmarked bullet is the legacy/manual-human convention. It + // already represents stronger evidence than a new non-human echo, so keep + // it untouched and let the invariant treat the safe no-op as satisfied. if (!entry?.marker && !entry?.marker_line) return source; const existing = entry.origin; @@ -162,6 +168,7 @@ function journalSectionLineOriginSatisfied(content, { heading, line, contentOrig const index = matchingJournalBullet(lines, range, canonicalLine); if (index === -1) return false; const entry = parseJournalEntry(lines, index); + if (entry && !entry.marker && !entry.marker_line) return true; const actual = entry?.marker ? parseJournalOriginMarker(entry.marker_line, entry.clean_text) : null; return Boolean(actual && !actual.normalization_reason diff --git a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js index b1410038..ae2989eb 100644 --- a/modules/jarvos-secondbrain/tests/content-origin-contract.test.js +++ b/modules/jarvos-secondbrain/tests/content-origin-contract.test.js @@ -227,6 +227,33 @@ test('journal origin markers are invisible, digest-bound, and round-trip clean t assert.equal(stripJournalOriginMarkers(`- ${cleanText}\n${marker}`), `- ${cleanText}\n`); }); +test('human journal markers require and carry a clean-content-bound receipt', () => { + const cleanText = 'A user-supplied journal idea'; + const captureEventId = 'capture-human-journal-1'; + const marker = renderJournalOriginMarker({ + cleanText, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + source_ref: captureEventId, + user_source: receipt('The user supplied this idea.', cleanText, captureEventId), + human_evidence_eligible: true, + }); + const parsed = parseJournalOriginMarker(marker, cleanText); + assert.equal(parsed.content_origin, 'human'); + assert.equal(parsed.human_evidence_eligible, true); + assert.equal(parsed.user_source.content_digest, digest(cleanText)); + assert.throws( + () => renderJournalOriginMarker({ + cleanText, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + source_ref: captureEventId, + human_evidence_eligible: true, + }), + /user-source receipt/, + ); +}); + test('journal entry parsing treats unmarked bullets as manual human evidence and malformed markers as unknown', () => { const manual = parseJournalEntry(['- A manually typed thought'], 0); assert.equal(manual.origin.content_origin, 'human'); @@ -265,3 +292,52 @@ test('public projections expose clean journal and note evidence without marker p assert.equal(note.human_evidence_eligible, false); assert.equal(note.clean_text, 'Generated note body.'); }); + +test('a human marker without a receipt cannot become projected evidence', () => { + const cleanText = 'Forged human-labelled copy'; + const payload = Buffer.from(JSON.stringify({ + schema_version: CONTENT_ORIGIN_SCHEMA_VERSION, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + clean_text_digest: digest(cleanText), + human_evidence_eligible: true, + source_ref: 'capture-forged', + }), 'utf8').toString('base64url'); + const journal = projectJournalEntriesFromMarkdown( + `## 💡 Ideas\n- ${cleanText}\n\n`, + { date: '2026-08-14' }, + ); + assert.equal(journal[0].content_origin, 'unknown'); + assert.equal(journal[0].human_evidence_eligible, false); +}); + +test('note projections downgrade a receipt when the clean body digest is stale', () => { + const original = 'The user-supplied note body.'; + const markdown = `---\ncontent_origin: human\ncontent_origin_basis: verbatim_user\nhuman_evidence_eligible: true\ncontent_origin_source: ${JSON.stringify(receipt(original, original))}\n---\n\n# Digest note\n\nA later assistant rewrite.`; + const projected = projectNoteMarkdown(markdown, { title: 'Digest note', sourcePath: 'Notes/Digest note.md' }); + assert.equal(projected.content_origin, 'unknown'); + assert.equal(projected.human_evidence_eligible, false); +}); + +test('evidence projection rejects malformed records before private consumers see them', () => { + const valid = { + projection_version: EVIDENCE_PROJECTION_VERSION, + clean_text: 'Valid context', + content_origin: 'assistant', + content_origin_basis: 'assistant_generated', + human_evidence_eligible: false, + }; + const cases = [ + ['unknown_projection_version', { ...valid, projection_version: 'jarvos-content-origin-evidence/v0' }], + ['missing_clean_text', { ...valid, clean_text: '' }], + ['invalid_origin', { ...valid, content_origin: 'not-an-origin' }], + ['missing_eligibility', { ...valid, human_evidence_eligible: undefined }], + ['ineligible_origin_marked_eligible', { ...valid, human_evidence_eligible: true }], + ]; + for (const [reason, input] of cases) { + const result = readEvidenceProjection(input); + assert.equal(result.ok, false, reason); + assert.equal(result.reason, reason); + assert.equal(result.record.human_evidence_eligible, false); + } +}); diff --git a/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js b/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js index 7ff6c4ed..e79fb88f 100644 --- a/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js +++ b/modules/jarvos-secondbrain/tests/keyword-capture-router.test.js @@ -10,6 +10,7 @@ const { detectTrigger, hasCaptureIntent, } = require('../bridge/routing/src/keyword-capture-router.js'); +const { digestText } = require('../bridge/provenance/src/content-origin-contract.js'); const { createAcknowledgedVaultMutationService } = require('./helpers/acknowledged-vault-mutation-service'); const TEST_DATE = '2026-01-02'; @@ -183,6 +184,52 @@ test('routing carries explicit origin metadata and writes compatibility captures }, { adapter }); assert.equal(calls[1].frontmatter.content_origin, 'assistant'); assert.equal(calls[1].frontmatter.content_origin_basis, 'assistant_generated'); + + applyRoutingPlan({ + trigger: 'note', + text: 'Forged human-shaped copy', + content_origin_schema: 'jarvos-content-origin/v1', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + human_evidence_eligible: true, + user_source: {}, + date: TEST_DATE, + }, { adapter }); + assert.equal(calls[2].frontmatter.content_origin, 'unknown'); + assert.equal(calls[2].frontmatter.human_evidence_eligible, false); +}); + +test('routing binds a human receipt to the current capture event', () => { + const calls = []; + const adapter = { + ensureJournal() { return { existed: true }; }, + appendLineToJournalSection(input) { return input; }, + writeNote(input) { calls.push(input); return { written: true, title: input.title, path: `/tmp/${input.title}.md` }; }, + }; + const text = 'User supplied words for a durable note.'; + const sourceReceipt = { + capture_event_id: 'capture-original', + actor: 'user', + source_digest: digestText(text), + content_digest: digestText(text), + }; + + applyRoutingPlan({ + trigger: 'note', + text, + captureEventId: 'capture-replacement', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + user_source: sourceReceipt, + human_evidence_eligible: true, + date: TEST_DATE, + }, { + adapter, + resolveUserSource: () => ({ capture_event_id: 'capture-original', actor: 'user', text }), + }); + + assert.equal(calls[0].frontmatter.content_origin, 'unknown'); + assert.equal(calls[0].frontmatter.human_evidence_eligible, false); }); test('idea journal appends carry origin payloads while note backlinks rely on note frontmatter', () => { diff --git a/modules/jarvos-secondbrain/tests/knowledge-units.test.js b/modules/jarvos-secondbrain/tests/knowledge-units.test.js index 93de9da1..d48a265b 100644 --- a/modules/jarvos-secondbrain/tests/knowledge-units.test.js +++ b/modules/jarvos-secondbrain/tests/knowledge-units.test.js @@ -10,6 +10,7 @@ const { buildArtifact, optimizeNoteKnowledge, } = require('../packages/jarvos-secondbrain-notes/src/knowledge-optimizer'); +const { digestText } = require('../bridge/provenance/src/content-origin-contract'); function noteFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-ku-')); @@ -172,7 +173,7 @@ test('explicit human note provenance requires a syntactically valid source recei capture_event_id: 'capture-1', actor: 'user', source_digest: 'a'.repeat(64), - content_digest: 'b'.repeat(64), + content_digest: digestText(body), }, }, created: true, diff --git a/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js b/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js index 0640b3ff..08012171 100644 --- a/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js +++ b/modules/jarvos-secondbrain/tests/personality-note-journal-contract.test.js @@ -10,6 +10,7 @@ const { spawnSync } = require('child_process'); const REPO_ROOT = path.resolve(__dirname, '..'); const CONTRACT_CLI = path.join(REPO_ROOT, 'scripts', 'obsidian-note-journal-contract.js'); const { verifyContract, writeNoteThroughContract } = require('../bridge/provenance/src/note-journal-contract.js'); +const { digestText } = require('../bridge/provenance/src/content-origin-contract.js'); const { createConfiguredVaultMutationService } = require('../src/vault-mutation-service.js'); function runContract({ root, personality, frontmatter = {} }) { @@ -164,6 +165,50 @@ test('personality contract carries an explicit assistant origin without adopting fs.rmSync(root, { recursive: true, force: true }); }); +test('personality contract cannot self-certify human provenance without the capture resolver', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sup-provenance-contract-human-')); + withEnv({ + VAULT_NOTES_DIR: path.join(root, 'Notes'), + JOURNAL_DIR: path.join(root, 'Journal'), + JARVOS_KNOWLEDGE_DIR: path.join(root, '.jarvos', 'knowledge'), + JARVOS_ALLOW_UNSAFE_TEST_JOURNAL_WRITE: '1', + }, () => { + const mutationService = makeTestMutationService(root); + const content = 'User supplied words preserved verbatim.'; + const captureEventId = 'capture-contract-human-1'; + const receipt = { + capture_event_id: captureEventId, + actor: 'user', + source_digest: digestText(content), + content_digest: digestText(content), + }; + const input = { + personality: 'codex', + title: 'Resolver-backed human contract', + content, + captureEventId, + content_origin: 'human', + content_origin_basis: 'verbatim_user', + user_source: receipt, + human_evidence_eligible: true, + frontmatter: { status: 'draft', type: 'reference', project: 'SUP-2229', author: 'jarvis' }, + }; + + const unresolved = writeNoteThroughContract(input, { mutationService }); + assert.equal(unresolved.verification.frontmatter.content_origin, 'unknown'); + assert.equal(unresolved.verification.frontmatter.human_evidence_eligible, false); + + const resolved = writeNoteThroughContract({ ...input, title: 'Resolver-backed human contract (valid)' }, { + mutationService: makeTestMutationService(root), + resolveUserSource: (id) => id === captureEventId ? { capture_event_id: id, actor: 'user', text: content } : null, + }); + assert.equal(resolved.verification.frontmatter.content_origin, 'human'); + assert.equal(resolved.verification.frontmatter.content_origin_basis, 'verbatim_user'); + assert.equal(resolved.verification.frontmatter.human_evidence_eligible, true); + }); + fs.rmSync(root, { recursive: true, force: true }); +}); + test('canonical writer owns jarvos_note_id and preserves it across caller-supplied rewrites', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sup-note-id-')); withEnv({ diff --git a/modules/jarvos-secondbrain/tests/session-source-adapters.test.js b/modules/jarvos-secondbrain/tests/session-source-adapters.test.js index a9d32d88..bda0c816 100644 --- a/modules/jarvos-secondbrain/tests/session-source-adapters.test.js +++ b/modules/jarvos-secondbrain/tests/session-source-adapters.test.js @@ -55,7 +55,12 @@ test('OpenClaw session adapter emits source-backed CaptureEvent v2 events', () = }); test('Codex session adapter handles content arrays and stable source IDs', () => { - const adapter = createCodexSessionAdapter(); + const userText = 'Save this quote about source-backed notes.\n\nThe source notes remain authoritative.'; + const adapter = createCodexSessionAdapter({ + resolveUserSource: (captureEventId) => captureEventId === 'capture:codex:codex-session-1:turn-1' + ? { capture_event_id: captureEventId, actor: 'user', text: userText } + : null, + }); const result = adapter.normalizeSession({ sessionId: 'codex-session-1', turns: [{ @@ -80,6 +85,17 @@ test('Codex session adapter handles content arrays and stable source IDs', () => assert.match(result.events[0].text, /source notes remain authoritative/); }); +test('a session role alone cannot mint a human-source receipt', () => { + const result = createCodexSessionAdapter().normalizeSession({ + sessionId: 'codex-unverified-user', + turns: [{ messageId: 'turn-1', role: 'user', content: 'A role label is not a source receipt.' }], + }); + + assert.equal(result.events.length, 1); + assert.equal(result.events[0].content_origin, 'unknown'); + assert.equal(result.events[0].human_evidence_eligible, false); +}); + test('Claude Code session adapter accepts entries and caller privacy overrides', () => { const adapter = createClaudeCodeSessionAdapter({ privacyTier: 'private' }); const result = adapter.normalizeSession({ diff --git a/modules/jarvos-secondbrain/tests/universal-capture.test.js b/modules/jarvos-secondbrain/tests/universal-capture.test.js index 679a0cb5..0179c720 100644 --- a/modules/jarvos-secondbrain/tests/universal-capture.test.js +++ b/modules/jarvos-secondbrain/tests/universal-capture.test.js @@ -77,6 +77,24 @@ test('compatibility captures carry explicit unknown provenance and enforcement r () => normalizeCaptureEvent(baseCapture('codex', { text: 'note: enforced provenance' }), { requireDeclaration: true }), /content_origin declaration is required/, ); + for (const partial of [ + { content_origin: 'assistant' }, + { content_origin_basis: 'assistant_generated' }, + { user_source: { capture_event_id: 'capture-only' } }, + ]) { + assert.throws( + () => normalizeCaptureEvent(baseCapture('codex', { text: 'note: partial provenance', ...partial }), { requireDeclaration: true }), + /content_origin declaration is required/, + ); + } + assert.throws( + () => normalizeCaptureEvent(baseCapture('codex', { + text: 'note: human without receipt', + content_origin: 'human', + content_origin_basis: 'verbatim_user', + }), { requireDeclaration: true }), + /valid user-source receipt/, + ); }); test('declared provenance reaches note frontmatter while deferred adoption is stripped', () => { diff --git a/modules/jarvos-secondbrain/tests/vault-mutation-adapter.test.js b/modules/jarvos-secondbrain/tests/vault-mutation-adapter.test.js index 36151fd1..4e3797b0 100644 --- a/modules/jarvos-secondbrain/tests/vault-mutation-adapter.test.js +++ b/modules/jarvos-secondbrain/tests/vault-mutation-adapter.test.js @@ -187,7 +187,15 @@ function settled(value) { return { then(fn) { try { fn(value); return this; } ca function runInFakeObsidian(operation, { initial, readback } = {}) { const files = initial === undefined ? new Map() : new Map([[operation.vaultRelativePath, { path: operation.vaultRelativePath, content: initial }]]); const vault = { getFileByPath: (target) => files.get(target) || null, create: (target, content) => { const file = { path: target, content }; files.set(target, file); return settled(file); }, process: (file, transform) => { file.content = transform(file.content); return settled(file); }, delete: (file) => { files.delete(file.path); return settled(); }, read: (file) => settled(readback === undefined ? file.content : readback) }; - const context = { app: { vault }, TextDecoder, Uint8Array, atob: (value) => Buffer.from(value, 'base64').toString('binary'), JSON }; + const context = { + app: { vault }, + TextDecoder, + Uint8Array, + atob: (value) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value) => Buffer.from(value, 'binary').toString('base64'), + unescape, + JSON, + }; context.globalThis = context; vm.runInNewContext(buildObsidianMutationProgram(operation), context); return { result: context.__jarvosVaultMutationResults[operation.operationId], content: files.get(operation.vaultRelativePath)?.content }; } @@ -221,6 +229,54 @@ test('every registered transform matches the production Obsidian evaluator', () assert.equal(actual.content, expected, transformName); assert.equal(transforms.isSatisfied(actual.content, input), true, transformName); } + + const humanLine = '- User evidence'; + const humanSource = 'User evidence'; + const humanOrigin = { + content_origin: 'human', + content_origin_basis: 'verbatim_user', + clean_text_digest: digestText(humanSource), + source_ref: 'capture-human-parity', + user_source: { + capture_event_id: 'capture-human-parity', + actor: 'user', + source_digest: digestText(humanSource), + content_digest: digestText(humanSource), + }, + human_evidence_eligible: true, + }; + const humanInitial = transforms.applyNode('## 💡 Ideas\n-\n', { + transformName: 'journal-section-line', + transformVersion: 2, + replayPayload: { heading: '## 💡 Ideas', line: humanLine, contentOrigin: humanOrigin }, + }); + const humanOriginSameClassification = { + ...humanOrigin, + source_ref: 'capture-human-parity-2', + user_source: { + ...humanOrigin.user_source, + capture_event_id: 'capture-human-parity-2', + source_digest: digestText('A second user source receipt'), + }, + }; + const malformedHumanMarker = ``; + const v2Cases = [ + ['## 💡 Ideas\n- Existing manual thought\n', '- Existing manual thought', { heading: '## 💡 Ideas', line: '- Existing manual thought', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated' } }], + ['## 💡 Ideas\n- Assistant evidence\n\n', '- Assistant evidence', { heading: '## 💡 Ideas', line: '- Assistant evidence', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Assistant evidence') } }], + [humanInitial, humanLine, { heading: '## 💡 Ideas', line: humanLine, contentOrigin: humanOriginSameClassification }], + [humanInitial, humanLine, { heading: '## 💡 Ideas', line: humanLine, contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText(humanSource) } }], + [`## 💡 Ideas\n- Malformed human\n${malformedHumanMarker}\n`, '- Malformed human', { heading: '## 💡 Ideas', line: '- Malformed human', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Malformed human') } }], + ['## 💡 Ideas\n- Duplicate marker\n\n\n', '- Duplicate marker', { heading: '## 💡 Ideas', line: '- Duplicate marker', contentOrigin: { content_origin: 'assistant', content_origin_basis: 'assistant_generated', clean_text_digest: digestText('Duplicate marker') } }], + ]; + for (const [initial, line, contentOrigin] of v2Cases) { + const input = { ...operation(), operationKind: 'transform', transformName: 'journal-section-line', transformVersion: 2, replayPayload: { ...contentOrigin } }; + const expected = transforms.applyNode(initial, input); + const actual = runInFakeObsidian(input, { initial }); + const expectedSatisfied = transforms.isSatisfied(expected, input); + assert.equal(actual.result.status, expectedSatisfied ? 'done' : 'error', `v2 ${line}`); + assert.equal(actual.content, expected, `v2 ${line}`); + assert.equal(transforms.isSatisfied(actual.content, input), expectedSatisfied, `v2 ${line}`); + } }); test('fixed program preserves quoted note identity for identity-safe note transforms', () => { diff --git a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js index 50857704..d2506b0a 100644 --- a/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js +++ b/modules/jarvos-secondbrain/tests/write-to-vault-mutation.test.js @@ -104,12 +104,37 @@ test('canonical note writes persist origin metadata, default missing declaration }); }); -test('note updates preserve an existing explicit origin when the update has no new declaration', () => { +test('canonical note writes make an omitted origin explicit unknown', () => { + withVault(({ root }) => { + const result = writeNoteFile({ + title: 'Undeclared note', + content: 'A note without a provenance declaration remains context-only.', + operationId: 'note-provenance-unknown-0001', + vaultId: 'vault-provenance', + vaultRoot: root, + mutationExecutor(operation) { + const target = path.join(root, operation.vaultRelativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, operation.content, 'utf8'); + return { status: 'committed', obsidian: 'acknowledged' }; + }, + }); + + const content = fs.readFileSync(result.path, 'utf8'); + assert.match(content, /content_origin_schema: jarvos-content-origin\/v1/); + assert.match(content, /content_origin: unknown/); + assert.match(content, /content_origin_basis: unknown/); + assert.match(content, /human_evidence_eligible: false/); + }); +}); + +test('material note updates without a declaration downgrade inherited provenance to unknown', () => { withVault(({ root }) => { const execute = (operation) => { const target = path.join(root, operation.vaultRelativePath); fs.mkdirSync(path.dirname(target), { recursive: true }); if (operation.operationKind === 'create') fs.writeFileSync(target, operation.content, 'utf8'); + else if (operation.operationKind === 'replace') fs.writeFileSync(target, operation.content, 'utf8'); else fs.writeFileSync(target, `${fs.readFileSync(target, 'utf8').trimEnd()}\n\n${operation.replayPayload.body}\n`, 'utf8'); return { status: 'committed', obsidian: 'acknowledged' }; }; @@ -131,8 +156,9 @@ test('note updates preserve an existing explicit origin when the update has no n ...context('note-provenance-0003'), }); const content = fs.readFileSync(first.path, 'utf8'); - assert.match(content, /content_origin: assistant/); - assert.match(content, /content_origin_basis: assistant_generated/); + assert.match(content, /content_origin: unknown/); + assert.match(content, /content_origin_basis: unknown/); + assert.doesNotMatch(content, /content_origin: assistant/); }); }); From 6a1aba856bd771ba03ba636d49bb28694d6c4090 Mon Sep 17 00:00:00 2001 From: Andrew Levine Date: Sat, 15 Aug 2026 22:10:54 -0400 Subject: [PATCH 9/9] test(provenance): acknowledge capture receipts in integration test --- .../tests/universal-capture.test.js | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/modules/jarvos-secondbrain/tests/universal-capture.test.js b/modules/jarvos-secondbrain/tests/universal-capture.test.js index 0179c720..ac652cb0 100644 --- a/modules/jarvos-secondbrain/tests/universal-capture.test.js +++ b/modules/jarvos-secondbrain/tests/universal-capture.test.js @@ -118,11 +118,26 @@ test('receipt-bound human provenance survives the universal capture-to-note rout const captureEventId = 'capture-codex-human-1'; const calls = []; const adapter = { - ensureJournal() { return { existed: true }; }, - appendLineToJournalSection(input) { return input; }, + ensureJournal() { + return { + existed: true, + artifactReceipt: { artifacts: [{ kind: 'journal', vaultRelativePath: 'Journal/2026-06-22.md', outcome: 'committed' }] }, + }; + }, + appendLineToJournalSection(input) { + return { + ...input, + artifactReceipt: { artifacts: [{ kind: 'journal', vaultRelativePath: 'Journal/2026-06-22.md', outcome: 'committed' }] }, + }; + }, writeNote(input) { calls.push(input); - return { written: true, title: input.title, path: `/tmp/${input.title}.md` }; + return { + written: true, + title: input.title, + path: `/tmp/${input.title}.md`, + artifactReceipt: { artifacts: [{ kind: 'note', vaultRelativePath: 'Notes/User thought.md', outcome: 'committed' }] }, + }; }, }; const result = captureWithJarvos({