diff --git a/ARCHAI_v10_8.html b/ARCHAI_v10_8.html index e9cce03a7..dd3b5b929 100644 --- a/ARCHAI_v10_8.html +++ b/ARCHAI_v10_8.html @@ -424,6 +424,7 @@ .od-comment-flag.safe{background:rgba(74,170,119,0.15);color:#4a7;} .od-comment-flag.suspicious{background:rgba(200,136,68,0.15);color:#c84;} .od-comment-flag.harmful{background:rgba(200,68,68,0.15);color:#c44;} +.od-comment-flag.verified{background:rgba(74,170,119,0.15);color:#4a7;border:1px solid rgba(74,170,119,0.35);} .od-comment-actions-inline{display:flex;gap:6px;margin-left:auto;} .od-comment-action-btn{background:none;border:1px solid var(--border2);color:var(--text3);font-family:var(--mono);font-size:0.42rem;padding:2px 8px;cursor:pointer;letter-spacing:1px;text-transform:uppercase;} .od-comment-action-btn:hover{border-color:var(--accent2);color:var(--accent2);} @@ -4470,15 +4471,19 @@ const hidden = c.status === 'hidden'; const replies = (c.replies || []).map(r => renderODComment(r, depth + 1)).join(''); + const verified = !!c.verified; + return `
${hidden ? '⚠ ' : ''}${escHTML(c.text)}
${c.author_type || 'visitor'} ${c.created_at ? new Date(c.created_at).toLocaleDateString() : ''} ${c.ai_flag ? `${c.ai_flag}` : ''} + ${verified ? '✓ verified' : ''} ${c.ai_reason ? `${escHTML(c.ai_reason)}` : ''} ${hidden ? `` : ''} + ${!hidden ? `` : ''} @@ -4497,6 +4502,16 @@ .then(() => { if (_odCurrentObjectId) odLoadComments(_odCurrentObjectId); }); } +function odVerifyComment(commentId, verified) { + fetch(`${COMMENTS_API}/${commentId}/verify`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ verified, moderator: 'curator' }), + }) + .then(r => r.json()) + .then(() => { if (_odCurrentObjectId) odLoadComments(_odCurrentObjectId); }); +} + function odReplyTo(parentId) { const input = document.getElementById('od-comment-input'); input.focus(); @@ -5208,7 +5223,7 @@ list.innerHTML = comments.slice(0, 10).map(c => `
${escHTML(c.text)}
-
${c.author_type} · ${new Date(c.created_at).toLocaleDateString()}
+
${c.author_type} · ${new Date(c.created_at).toLocaleDateString()}${c.verified ? ' ✓ verified' : ''}
`).join(''); }) .catch(() => { list.innerHTML = ''; }); diff --git a/REPO_MAP.md b/REPO_MAP.md index 32ad22cb7..51ff800b2 100644 --- a/REPO_MAP.md +++ b/REPO_MAP.md @@ -13,8 +13,11 @@ GitHub `main` branch notes and operations guide. ### 2. Backend API and proxy - `backend-archai/src/server.js` - Express entry point - `backend-archai/src/routes/proxy.js` - safe Qdrant/Ollama/curator proxy -- `backend-archai/src/routes/comments.js` - visitor comment API +- `backend-archai/src/routes/comments.js` - visitor comment API (+ oral-history verification) - `backend-archai/src/services/moderation.js` - AI moderation for comments +- `backend-archai/src/services/verificationService.js` + `routes/verification.js` - verification tiers (institutional record / verified oral history / community response) +- `backend-archai/src/services/accessionService.js` + `routes/accession.js` - accession CMS path (verified + approved → CollectiveAccess) +- `docs/VERIFICATION_AND_ACCESSION.md` - how verification and the accession path work - `backend-archai/src/services/curator-vectors.js` - curator vector collection build/search - `backend-archai/src/data/db.js` - SQLite persistence layer diff --git a/backend-archai/package.json b/backend-archai/package.json index 83a9ccf05..c8d9a750d 100644 --- a/backend-archai/package.json +++ b/backend-archai/package.json @@ -11,6 +11,7 @@ "seed:mock": "node src/pipeline/seedMockData.js", "harvest:legal": "node scripts/legal-harvest-bot.js --report", "test:smoke": "node scripts/smoke-test-runtime.js", + "test:verification": "node scripts/test-verification-accession.js", "lint:quick": "node -e \"console.log('No linter configured yet')\"" }, "keywords": [ diff --git a/backend-archai/scripts/test-verification-accession.js b/backend-archai/scripts/test-verification-accession.js new file mode 100644 index 000000000..f78864f66 --- /dev/null +++ b/backend-archai/scripts/test-verification-accession.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Standalone checks for the verification layer and accession CMS path. +// Runs without the database/native deps — it exercises the repo-backed services +// directly. Point AUX_WORKBENCH_FILE at a throwaway path so the real workbench +// is never touched. +// +// AUX_WORKBENCH_FILE=$(mktemp) node scripts/test-verification-accession.js + +import { repo } from '../src/services/objectRepository.js'; +import { deriveVerification, setVerification, verificationSummary } from '../src/services/verificationService.js'; +import { accessionReadiness, listAccessionQueue, commitAccession } from '../src/services/accessionService.js'; + +let failed = 0; +function check(label, cond) { + if (cond) { + console.log(`OK ${label}`); + } else { + failed += 1; + console.log(`FAIL ${label}`); + } +} + +// ── Verification summary reflects the seeded tiers ─────────────────── +const summary = verificationSummary(); +check('summary counts every object', summary.total === repo.state.objects.length); +check('summary reports both verified and unverified records', summary.verified > 0 && summary.unverified > 0); +check('Echo Circuit (in review) reads as unverified', + deriveVerification(repo.getObject('FAMTEC_1997_0234')).verified === false); +check('Synthetic Garden reads as a verified institutional record', + deriveVerification(repo.getObject('FAMTEC_2022_0087')).verified === true); + +// ── Accession gate: unverified records cannot enter the CMS ────────── +const echoReadiness = accessionReadiness(repo.getObject('FAMTEC_1997_0234')); +check('unverified record is blocked from accession', + !echoReadiness.ready && echoReadiness.blockers.includes('not_verified')); +const echoCommit = commitAccession({ objectId: 'FAMTEC_1997_0234', actorEmail: 'curator@famtec.au' }); +check('commitAccession refuses an unverified record (409 + blocker)', + echoCommit.error && echoCommit.status === 409 && echoCommit.blockers.includes('not_verified')); + +// ── Accession queue holds verified + approved, not-yet-accessioned ─── +const queue = listAccessionQueue(); +check('born-digital ARCHAI demonstrator is queued for accession', + queue.some((r) => r.objectId === 'FAMTEC_2026_ARCHAI_WEB')); +check('already-accessioned record is not queued', + !queue.some((r) => r.objectId === 'FAMTEC_2022_0087')); + +// ── Committing a queued record assigns an accession number + CMS id ── +const before = repo.getObject('FAMTEC_2026_ARCHAI_WEB'); +check('queued record starts with no CMS id', !before.source?.collectiveAccessId); +const commit = commitAccession({ objectId: 'FAMTEC_2026_ARCHAI_WEB', actorEmail: 'curator@famtec.au' }); +check('commitAccession succeeds for a ready record', Boolean(commit.accession) && !commit.error); +check('accession number matches FAMTEC.YYYY.NNNN', + /^FAMTEC\.\d{4}\.\d{4}$/.test(commit.accession?.accessionNumber || '')); +check('CMS record id is written back onto source.collectiveAccessId', + repo.getObject('FAMTEC_2026_ARCHAI_WEB').source.collectiveAccessId === commit.accession.cmsRecordId); +check('re-committing an accessioned record is blocked', + commitAccession({ objectId: 'FAMTEC_2026_ARCHAI_WEB' }).blockers?.includes('already_accessioned')); + +// ── Full path: verify → approve → accession a previously blocked record +setVerification({ objectId: 'FAMTEC_1997_0234', tier: 'institutional_record', verified: true, actorEmail: 'curator@famtec.au' }); +check('setVerification flips Echo Circuit to verified', + deriveVerification(repo.getObject('FAMTEC_1997_0234')).verified === true); +check('still blocked while workflow is not approved/published', + accessionReadiness(repo.getObject('FAMTEC_1997_0234')).blockers.includes('workflow_review')); +repo.updateObject('FAMTEC_1997_0234', { workflow: { state: 'approved', updatedBy: 'curator@famtec.au', updatedAt: new Date().toISOString() } }); +// Echo Circuit already carries a legacy CMS id, so it is treated as accessioned; +// clearing it lets us prove the verify → approve → commit path end to end. +repo.updateObject('FAMTEC_1997_0234', { + source: { ...repo.getObject('FAMTEC_1997_0234').source, collectiveAccessId: null }, + accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: '' }, +}); +const echoCommit2 = commitAccession({ objectId: 'FAMTEC_1997_0234', actorEmail: 'curator@famtec.au' }); +check('verified + approved record now accessions successfully', + Boolean(echoCommit2.accession) && !echoCommit2.error); + +if (failed) { + console.error(`\n${failed} verification/accession check(s) failed.`); + process.exit(1); +} +console.log('\nAll verification/accession checks passed.'); diff --git a/backend-archai/src/data/db.js b/backend-archai/src/data/db.js index 3e502540c..1248a07b3 100644 --- a/backend-archai/src/data/db.js +++ b/backend-archai/src/data/db.js @@ -29,16 +29,29 @@ if (!tableExists) { status TEXT NOT NULL DEFAULT 'visible', ai_flag TEXT DEFAULT NULL, ai_reason TEXT DEFAULT NULL, + verified INTEGER NOT NULL DEFAULT 0, + verified_by TEXT DEFAULT NULL, + verified_at TEXT DEFAULT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), moderated_at TEXT DEFAULT NULL, moderated_by TEXT DEFAULT NULL ); `); -} else { - // Migrate: add parent_id if missing - const cols = db.prepare("PRAGMA table_info(comments)").all().map(c => c.name); - if (!cols.includes('parent_id')) { - db.exec('ALTER TABLE comments ADD COLUMN parent_id TEXT DEFAULT NULL'); +} + +// Bring pre-existing databases up to the current schema. A community memory can +// be confirmed by a curator into a verified oral history, so comments carry the +// same verification line that object records do. +{ + const cols = db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name); + const migrations = [ + ['parent_id', "ALTER TABLE comments ADD COLUMN parent_id TEXT DEFAULT NULL"], + ['verified', "ALTER TABLE comments ADD COLUMN verified INTEGER NOT NULL DEFAULT 0"], + ['verified_by', "ALTER TABLE comments ADD COLUMN verified_by TEXT DEFAULT NULL"], + ['verified_at', "ALTER TABLE comments ADD COLUMN verified_at TEXT DEFAULT NULL"], + ]; + for (const [name, ddl] of migrations) { + if (!cols.includes(name)) db.exec(ddl); } } diff --git a/backend-archai/src/data/mockObjects.js b/backend-archai/src/data/mockObjects.js index 50d21adaa..b698cd545 100644 --- a/backend-archai/src/data/mockObjects.js +++ b/backend-archai/src/data/mockObjects.js @@ -24,6 +24,8 @@ export const mockObjects = [ prohibitedStatements: ['Invented artist biography', 'Unsupported claims about emotions of visitors'], restrictions: [], workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' }, + accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2022.0087', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1008', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' }, updatedAt, }, { @@ -47,6 +49,8 @@ export const mockObjects = [ prohibitedStatements: ['Claims of consciousness', 'Unsupported personal memories'], restrictions: [], workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' }, + accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2018.0001', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1001', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' }, updatedAt, }, { @@ -70,6 +74,8 @@ export const mockObjects = [ prohibitedStatements: ['Claims of exact hardware equivalence without testing'], restrictions: [], workflow: { state: 'review', updatedBy: 'collections@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: false, verifiedBy: null, verifiedAt: null, method: null, notes: 'Provenance still under curatorial review.' }, + accession: { status: 'accessioned', accessionNumber: 'FAMTEC.1997.0234', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1051', accessionedBy: 'collections@famtec.au', accessionedAt: updatedAt, notes: 'Legacy accession; verification pending.' }, updatedAt, }, { @@ -93,6 +99,8 @@ export const mockObjects = [ prohibitedStatements: ['Attribution to historical broadcasts not documented'], restrictions: [], workflow: { state: 'approved', updatedBy: 'curator@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: '' }, + accession: { status: 'accessioned', accessionNumber: 'FAMTEC.2019.0051', cmsTarget: 'collectiveaccess', cmsRecordId: 'ca_1019', accessionedBy: 'curator@famtec.au', accessionedAt: updatedAt, notes: '' }, updatedAt, }, { @@ -116,6 +124,8 @@ export const mockObjects = [ prohibitedStatements: ['Claims that the archived capture is the current production system'], restrictions: [], workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: 'Institution-owned capture approved.' }, + accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: 'Born-digital capture verified and awaiting accession into CollectiveAccess.' }, updatedAt, }, { @@ -139,6 +149,8 @@ export const mockObjects = [ prohibitedStatements: ['Claims that the archived capture is a museum-owned collection record'], restrictions: [], workflow: { state: 'published', updatedBy: 'curator@famtec.au', updatedAt }, + verification: { tier: 'institutional_record', verified: true, verifiedBy: 'curator@famtec.au', verifiedAt: updatedAt, method: 'institutional_source', notes: 'Institution-owned capture approved.' }, + accession: { status: 'not_accessioned', accessionNumber: null, cmsTarget: 'collectiveaccess', cmsRecordId: null, accessionedBy: null, accessionedAt: null, notes: 'Born-digital capture verified and awaiting accession into CollectiveAccess.' }, updatedAt, }, ]; diff --git a/backend-archai/src/policies/roleCapabilities.js b/backend-archai/src/policies/roleCapabilities.js index ceb7e4ea1..96f7a46b0 100644 --- a/backend-archai/src/policies/roleCapabilities.js +++ b/backend-archai/src/policies/roleCapabilities.js @@ -1,7 +1,7 @@ export const roleCapabilities = { admin: ['*'], - curator: ['objects.read', 'objects.write', 'workflow.transition', 'nfc.publish', 'media.publish', 'vocab.read', 'search', 'export'], - collections: ['objects.read', 'objects.write-limited', 'upload.queue', 'workflow.submit-review', 'vocab.read', 'search'], + curator: ['objects.read', 'objects.write', 'workflow.transition', 'verification.set', 'accession.commit', 'nfc.publish', 'media.publish', 'vocab.read', 'search', 'export'], + collections: ['objects.read', 'objects.write-limited', 'upload.queue', 'workflow.submit-review', 'accession.read', 'vocab.read', 'search'], technician: ['nodel.read', 'runtime.read', 'media.validate', 'objects.read-tech'], volunteer: ['objects.read', 'notes.create', 'visitor.preview'], visitor: ['nfc.public', 'chat.object', 'comments.create'], diff --git a/backend-archai/src/routes/accession.js b/backend-archai/src/routes/accession.js new file mode 100644 index 000000000..3cf1b2b70 --- /dev/null +++ b/backend-archai/src/routes/accession.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { requireRole } from '../middleware/requireRole.js'; +import { repo } from '../services/objectRepository.js'; +import { listAccessionQueue, accessionReadiness, commitAccession } from '../services/accessionService.js'; + +export const accessionRouter = Router(); + +const commitSchema = z.object({ + objectId: z.string().min(1), + notes: z.string().optional().default(''), +}); + +// Records that are verified + approved and waiting to enter the archival CMS. +accessionRouter.get('/queue', requireRole('collections'), (_req, res) => { + res.json({ ok: true, queue: listAccessionQueue() }); +}); + +// Accession readiness (and blockers) for a single record. +accessionRouter.get('/:id', requireRole('collections'), (req, res) => { + const record = repo.getObject(req.params.id); + if (!record) return res.status(404).json({ ok: false, error: 'Object not found' }); + res.json({ ok: true, objectId: record.id, ...accessionReadiness(record) }); +}); + +// Commit a record into the archival CMS. Gated: verified + approved only. +accessionRouter.post('/commit', requireRole('curator'), (req, res) => { + const input = commitSchema.parse(req.body || {}); + const result = commitAccession({ ...input, actorEmail: req.archai.user.email }); + if (result.error) { + return res.status(result.status || 400).json({ ok: false, error: result.error, blockers: result.blockers }); + } + res.json({ ok: true, ...result }); +}); diff --git a/backend-archai/src/routes/comments.js b/backend-archai/src/routes/comments.js index 71bad8742..5d89f7da6 100644 --- a/backend-archai/src/routes/comments.js +++ b/backend-archai/src/routes/comments.js @@ -48,6 +48,12 @@ const updateStatus = db.prepare(` UPDATE comments SET status = ?, moderated_at = datetime('now'), moderated_by = ? WHERE id = ? `); +const verifyComment = db.prepare(` + UPDATE comments + SET verified = ?, verified_by = ?, verified_at = CASE WHEN ? = 1 THEN datetime('now') ELSE NULL END + WHERE id = ? +`); + const getStats = db.prepare(` SELECT status, ai_flag, COUNT(*) as count FROM comments GROUP BY status, ai_flag `); @@ -142,6 +148,18 @@ commentsRouter.patch('/:id', (req, res) => { res.json({ ok: true, updated: req.params.id, status }); }); +// ── Curator: confirm a community memory as a verified oral history ─ +// This is the comment-side of the verification line: a visible community +// response becomes a verified oral history, and carries a verified marker on +// the public "love" pages so the two tiers stay visibly distinct. +commentsRouter.patch('/:id/verify', (req, res) => { + const verified = req.body?.verified === false ? 0 : 1; + const moderator = req.body?.moderator || 'curator'; + const result = verifyComment.run(verified, verified ? moderator : null, verified, req.params.id); + if (result.changes === 0) return res.status(404).json({ ok: false, error: 'Comment not found' }); + res.json({ ok: true, updated: req.params.id, verified: Boolean(verified) }); +}); + // ── Curator: which objects have comments ───────────────────────── commentsRouter.get('/objects', (_req, res) => { const rows = getObjectsWithComments.all(); diff --git a/backend-archai/src/routes/index.js b/backend-archai/src/routes/index.js index 7977168eb..99861dee9 100644 --- a/backend-archai/src/routes/index.js +++ b/backend-archai/src/routes/index.js @@ -14,6 +14,8 @@ import { integrationsRouter } from './integrations.js'; import { commentsRouter } from './comments.js'; import { mediaRouter } from './media.js'; import { workflowsRouter } from './workflows.js'; +import { verificationRouter } from './verification.js'; +import { accessionRouter } from './accession.js'; import { webhooksRouter } from './webhooks/index.js'; import { proxyRouter } from './proxy.js'; @@ -34,5 +36,7 @@ apiRouter.use('/integrations', integrationsRouter); apiRouter.use('/comments', commentsRouter); apiRouter.use('/media', mediaRouter); apiRouter.use('/workflows', workflowsRouter); +apiRouter.use('/verification', verificationRouter); +apiRouter.use('/accession', accessionRouter); apiRouter.use('/webhooks', webhooksRouter); apiRouter.use('/proxy', proxyRouter); diff --git a/backend-archai/src/routes/verification.js b/backend-archai/src/routes/verification.js new file mode 100644 index 000000000..ca717e1f2 --- /dev/null +++ b/backend-archai/src/routes/verification.js @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { requireRole } from '../middleware/requireRole.js'; +import { setVerification, verificationSummary } from '../services/verificationService.js'; + +export const verificationRouter = Router(); + +const setSchema = z.object({ + objectId: z.string().min(1), + tier: z.enum(['institutional_record', 'verified_oral_history', 'community_response']).optional(), + verified: z.boolean().optional(), + method: z.string().optional(), + notes: z.string().optional().default(''), +}); + +// Public read: how the collection is distributed across the verification tiers. +verificationRouter.get('/summary', (_req, res) => { + res.json({ ok: true, summary: verificationSummary() }); +}); + +// Curatorial action: move a record across the verification line. +verificationRouter.post('/set', requireRole('curator'), (req, res) => { + const input = setSchema.parse(req.body || {}); + const result = setVerification({ ...input, actorEmail: req.archai.user.email }); + if (result.error) return res.status(result.status || 400).json({ ok: false, error: result.error }); + res.json({ ok: true, ...result }); +}); diff --git a/backend-archai/src/schemas/objectSchemas.js b/backend-archai/src/schemas/objectSchemas.js index f5fd2eeb3..7e6c3290a 100644 --- a/backend-archai/src/schemas/objectSchemas.js +++ b/backend-archai/src/schemas/objectSchemas.js @@ -82,3 +82,33 @@ export const objectWorkflowSchema = z.object({ updatedBy: z.string().default('system'), updatedAt: z.string(), }); + +// Verification tier — the layer that lets a record, an oral history and an open +// community response coexist in one interface without being flattened together. +// institutional_record — the museum's own object record +// verified_oral_history — a community memory the curatorial team has confirmed +// community_response — living, unverified community interpretation +// `verified` is the single line visitors read from: institutional records and +// confirmed oral histories sit above it, community responses below it. +export const objectVerificationSchema = z.object({ + tier: z.enum(['institutional_record', 'verified_oral_history', 'community_response']).default('institutional_record'), + verified: z.boolean().default(false), + verifiedBy: z.string().optional().nullable(), + verifiedAt: z.string().optional().nullable(), + method: z.string().optional().nullable(), + notes: z.string().default(''), +}); + +// Accession — the path a verified record travels to enter the archival CMS +// (CollectiveAccess). A record is never accessioned until it is both verified +// and curatorially approved; that gate is what keeps "what we've heard" separate +// from "what we've confirmed". +export const accessionSchema = z.object({ + status: z.enum(['not_accessioned', 'ready', 'accessioned', 'returned']).default('not_accessioned'), + accessionNumber: z.string().optional().nullable(), + cmsTarget: z.enum(['collectiveaccess']).default('collectiveaccess'), + cmsRecordId: z.string().optional().nullable(), + accessionedBy: z.string().optional().nullable(), + accessionedAt: z.string().optional().nullable(), + notes: z.string().default(''), +}); diff --git a/backend-archai/src/services/accessionService.js b/backend-archai/src/services/accessionService.js new file mode 100644 index 000000000..caba37864 --- /dev/null +++ b/backend-archai/src/services/accessionService.js @@ -0,0 +1,114 @@ +// Accession path for ARCHAI object records. +// +// This is the explicit, enforced path by which a record enters the archival CMS +// (CollectiveAccess). The rule that makes the path legible is a single gate: +// a record is only ever accessioned once it is BOTH verified AND curatorially +// approved. That is what keeps "what the archive has heard" separate from +// "what the archive has confirmed and committed as a permanent record". +// +// Like verificationService, this module avoids native/database dependencies so +// it can be exercised directly in tests. +import { repo } from './objectRepository.js'; +import { deriveVerification } from './verificationService.js'; + +export const ACCESSION_STATES = ['not_accessioned', 'ready', 'accessioned', 'returned']; + +// Workflow states from which a record may be committed to the CMS. +const ACCESSIONABLE_WORKFLOW = new Set(['approved', 'published']); + +const INSTITUTION_PREFIX = process.env.ARCHAI_ACCESSION_PREFIX || 'FAMTEC'; + +export function deriveAccession(record) { + const a = record?.accession && typeof record.accession === 'object' ? record.accession : {}; + const cmsRecordId = a.cmsRecordId || record?.source?.collectiveAccessId || null; + const status = ACCESSION_STATES.includes(a.status) + ? a.status + : (cmsRecordId ? 'accessioned' : 'not_accessioned'); + return { + status, + accessionNumber: a.accessionNumber || null, + cmsTarget: a.cmsTarget || 'collectiveaccess', + cmsRecordId, + accessionedBy: a.accessionedBy || null, + accessionedAt: a.accessionedAt || null, + notes: a.notes || '', + }; +} + +// Whether a record may enter the archival CMS, and if not, why not. The +// blockers array is the human-readable expression of the accession gate. +export function accessionReadiness(record) { + const verification = deriveVerification(record); + const accession = deriveAccession(record); + const workflowState = record?.workflow?.state || 'draft'; + + const blockers = []; + if (!verification.verified) blockers.push('not_verified'); + if (!ACCESSIONABLE_WORKFLOW.has(workflowState)) blockers.push(`workflow_${workflowState}`); + if (accession.status === 'accessioned') blockers.push('already_accessioned'); + + return { ready: blockers.length === 0, blockers, verification, accession, workflowState }; +} + +function nextAccessionNumber() { + const year = new Date().getFullYear(); + const prefix = `${INSTITUTION_PREFIX}.${year}.`; + let max = 0; + for (const o of repo.state.objects) { + const n = o.accession?.accessionNumber; + if (typeof n === 'string' && n.startsWith(prefix)) { + const seq = parseInt(n.slice(prefix.length), 10); + if (Number.isFinite(seq) && seq > max) max = seq; + } + } + return `${prefix}${String(max + 1).padStart(4, '0')}`; +} + +// Objects that are verified + approved and waiting to be committed to the CMS. +export function listAccessionQueue() { + return repo.state.objects + .map((o) => ({ objectId: o.id, title: o.title, ...accessionReadiness(o) })) + .filter((r) => r.ready); +} + +export function commitAccession({ objectId, actorEmail = 'system', notes = '' }) { + const record = repo.getObject(objectId); + if (!record) return { error: 'Object not found', status: 404 }; + + const readiness = accessionReadiness(record); + if (!readiness.ready) { + return { + error: `Object is not ready to accession: ${readiness.blockers.join(', ')}`, + status: 409, + blockers: readiness.blockers, + }; + } + + const existing = deriveAccession(record); + const accessionNumber = existing.accessionNumber || nextAccessionNumber(); + const cmsRecordId = existing.cmsRecordId + || record.source?.collectiveAccessId + || `ca_${accessionNumber.replace(/\W+/g, '_').toLowerCase()}`; + + const accession = { + status: 'accessioned', + accessionNumber, + cmsTarget: 'collectiveaccess', + cmsRecordId, + accessionedBy: actorEmail, + accessionedAt: new Date().toISOString(), + notes, + }; + + // Provenance travels with the record: the CMS id is written back onto the + // canonical source so downstream sync and chat resolve to the same object. + const source = { ...(record.source || {}), collectiveAccessId: cmsRecordId }; + + const updated = repo.updateObject(objectId, { accession, source }); + repo.audit({ + type: 'accession.commit', + actor: actorEmail, + summary: `${objectId} accessioned as ${accessionNumber} → collectiveaccess:${cmsRecordId}`, + }); + return { object: updated, accession }; +} diff --git a/backend-archai/src/services/verificationService.js b/backend-archai/src/services/verificationService.js new file mode 100644 index 000000000..196f5d3d3 --- /dev/null +++ b/backend-archai/src/services/verificationService.js @@ -0,0 +1,90 @@ +// Verification layer for ARCHAI object records. +// +// Verification is treated as a *layer*, not a gate on participation: anyone can +// offer a memory, but a record only crosses the verification line once the +// curatorial team has confirmed it. Visitors always see where a record sits on +// that spectrum via `verified`. +// +// This service is intentionally free of native/database dependencies so it can +// be exercised directly (see scripts/test-verification-accession.js). +import { repo } from './objectRepository.js'; + +export const VERIFICATION_TIERS = [ + 'institutional_record', + 'verified_oral_history', + 'community_response', +]; + +// Tiers that count as having crossed the verification line. +const VERIFIED_TIERS = new Set(['institutional_record', 'verified_oral_history']); + +// Read a record's verification, filling sensible defaults for legacy records +// that predate the verification field. +export function deriveVerification(record) { + const v = record?.verification; + if (v && typeof v === 'object') { + const tier = VERIFICATION_TIERS.includes(v.tier) ? v.tier : 'institutional_record'; + return { + tier, + verified: typeof v.verified === 'boolean' ? v.verified : VERIFIED_TIERS.has(tier), + verifiedBy: v.verifiedBy || null, + verifiedAt: v.verifiedAt || null, + method: v.method || null, + notes: v.notes || '', + }; + } + // Legacy fallback: a published record is treated as a confirmed institutional + // record; anything still moving through draft/review is not yet verified. + const published = record?.workflow?.state === 'published'; + return { + tier: 'institutional_record', + verified: Boolean(published), + verifiedBy: null, + verifiedAt: null, + method: published ? 'legacy_published' : null, + notes: '', + }; +} + +export function setVerification({ objectId, tier, verified, method = null, notes = '', actorEmail = 'system' }) { + const record = repo.getObject(objectId); + if (!record) return { error: 'Object not found', status: 404 }; + if (tier && !VERIFICATION_TIERS.includes(tier)) { + return { error: `Unknown verification tier: ${tier}`, status: 400 }; + } + + const current = deriveVerification(record); + const nextTier = tier || current.tier; + const isVerified = typeof verified === 'boolean' ? verified : VERIFIED_TIERS.has(nextTier); + + const verification = { + tier: nextTier, + verified: isVerified, + verifiedBy: isVerified ? actorEmail : null, + verifiedAt: isVerified ? new Date().toISOString() : null, + method: method || (isVerified ? 'curatorial_review' : null), + notes: notes || current.notes || '', + }; + + const updated = repo.updateObject(objectId, { verification }); + repo.audit({ + type: 'verification.set', + actor: actorEmail, + summary: `${objectId} → ${nextTier} (${isVerified ? 'verified' : 'unverified'})`, + }); + return { object: updated, verification }; +} + +export function verificationSummary() { + const objects = repo.state.objects; + const byTier = {}; + let verified = 0; + let unverified = 0; + for (const o of objects) { + const v = deriveVerification(o); + byTier[v.tier] = (byTier[v.tier] || 0) + 1; + if (v.verified) verified += 1; + else unverified += 1; + } + return { total: objects.length, verified, unverified, byTier }; +} diff --git a/docs/VERIFICATION_AND_ACCESSION.md b/docs/VERIFICATION_AND_ACCESSION.md new file mode 100644 index 000000000..15cc0c318 --- /dev/null +++ b/docs/VERIFICATION_AND_ACCESSION.md @@ -0,0 +1,97 @@ +# Verification layer & accession CMS path + +This document describes how ARCHAI distinguishes **what it has heard** from +**what it has confirmed**, and the path by which a confirmed record enters the +archival CMS (CollectiveAccess). + +The design principle: *verification is a layer, not a gate on participation.* +Anyone can offer a memory. Nothing enters the archival CMS until the curatorial +and exhibitions team has reviewed it. Visitors always see where a record sits on +that spectrum. + +## The three tiers + +Object records and community memories share a single verification vocabulary so +the tiers can coexist in one interface without being flattened together: + +| Tier | Meaning | `verified` | +|------|---------|-----------| +| `institutional_record` | The museum's own object record | above the line | +| `verified_oral_history` | A community memory the curatorial team has confirmed | above the line | +| `community_response` | Living, unverified community interpretation | below the line | + +`verified` is the single boolean visitors read from. A `✓ verified` marker +distinguishes confirmed records and confirmed oral histories from open community +responses on the public "love" pages. + +## Data model + +Object records (`backend-archai/src/data/mockObjects.js`, +schemas in `src/schemas/objectSchemas.js`) carry two new fields: + +```js +verification: { + tier: 'institutional_record' | 'verified_oral_history' | 'community_response', + verified: boolean, + verifiedBy, verifiedAt, method, notes +} +accession: { + status: 'not_accessioned' | 'ready' | 'accessioned' | 'returned', + accessionNumber, // e.g. FAMTEC.2026.0001 + cmsTarget: 'collectiveaccess', + cmsRecordId, // mirrors source.collectiveAccessId + accessionedBy, accessionedAt, notes +} +``` + +Community comments (`comments` table, `src/data/db.js`) gain `verified`, +`verified_by` and `verified_at` columns. Legacy databases are migrated in place. + +## Verification API + +`src/services/verificationService.js` · `src/routes/verification.js` + +- `GET /api/verification/summary` — tier/verified counts across the collection. +- `POST /api/verification/set` *(curator)* — move a record across the line: + `{ objectId, tier?, verified?, method?, notes? }`. +- `PATCH /api/comments/:id/verify` *(curator)* — confirm a visible community + memory into a verified oral history: `{ verified: true|false }`. + +Records without a `verification` field fall back to a safe default: +a published record is treated as a confirmed institutional record; anything +still in draft/review is unverified. + +## Accession CMS path + +`src/services/accessionService.js` · `src/routes/accession.js` + +Accession is the explicit, enforced path a record travels to become a permanent +record in the archival CMS. A single gate keeps the path legible: + +> **A record is only accessioned once it is BOTH verified AND curatorially +> approved (`workflow.state` in `approved` or `published`).** + +- `GET /api/accession/queue` *(collections)* — records that are verified + + approved and waiting to be committed. +- `GET /api/accession/:id` *(collections)* — readiness and blockers for one + record (`not_verified`, `workflow_`, `already_accessioned`). +- `POST /api/accession/commit` *(curator)* — commit a ready record: + - assigns an accession number (`FAMTEC..`), + - assigns / reuses the CollectiveAccess record id and writes it back onto + `source.collectiveAccessId` so provenance travels with the record, + - sets `accession.status = 'accessioned'` and writes an audit entry. + +Attempting to commit an unverified or unapproved record returns `409` with the +list of blockers — the path fails loudly rather than silently admitting +unconfirmed data into the CMS. + +## Tests + +```bash +cd backend-archai +npm run test:verification # standalone, no server/db required +``` + +`scripts/test-verification-accession.js` exercises the summary, the accession +gate (unverified → blocked), the queue, a successful commit (accession number + +CMS id write-back), and the full verify → approve → accession path. diff --git a/nfc-pages/nfc-visitor-template.html b/nfc-pages/nfc-visitor-template.html index a738dddb6..907e679f4 100644 --- a/nfc-pages/nfc-visitor-template.html +++ b/nfc-pages/nfc-visitor-template.html @@ -1137,6 +1137,19 @@ margin-top: 3px; letter-spacing: 0.5px; } +.v-prev-verified { + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.38rem; + letter-spacing: 1px; + text-transform: uppercase; + color: #4a7; + background: rgba(74, 170, 119, 0.15); + border: 1px solid rgba(74, 170, 119, 0.35); +} /* ── SHARE ── */ .v-share { @@ -2902,7 +2915,7 @@ comments.slice(0, 10).map(c => `
${escHTML(c.text)}
-
${c.author_type || 'visitor'} · ${c.created_at ? new Date(c.created_at).toLocaleDateString() : (c.time || '')}
+
${c.author_type || 'visitor'} · ${c.created_at ? new Date(c.created_at).toLocaleDateString() : (c.time || '')}${c.verified ? '✓ Verified' : ''}
` ).join(''); }