From 56af233d2323c0dde52724840c9f253156543b0f Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Wed, 28 Oct 2026 15:18:41 -0400 Subject: [PATCH] Move archived flag into ext:opencase so it no longer leaks from CFDocuments (CGE-189) --- .../infrastructure/caseApi/CaseApiClient.ts | 4 +- apps/editor/src/ui/home/HomeScreen.tsx | 2 +- .../case/endpoints/GetAllCFDocuments.ts | 5 +- .../case/endpoints/GetCFItemAssociations.ts | 4 +- .../case/endpoints/ListFrameworks.ts | 4 +- .../__tests__/GetAllCFDocuments.test.ts | 26 ++++++- .../__tests__/GetCFItemAssociations.test.ts | 3 +- .../__tests__/ListFrameworks.test.ts | 4 +- .../persistence/file/FileFrameworkStore.ts | 18 +++-- .../FileFrameworkStore.restart.test.ts | 73 +++++++++++++++++++ 10 files changed, 120 insertions(+), 23 deletions(-) diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 0c8111b..a895c31 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -18,6 +18,8 @@ export type OpenCaseListExtensions = { alignmentParticipants?: Array<{ identifier?: string; uri: string }> /** When true, the CASE API serves this framework without authentication. */ publicAccess?: boolean + /** Server-level archive flag — independent of CASE adoptionStatus. */ + archived?: boolean } export type AlignmentFrameworkSummary = { @@ -54,8 +56,6 @@ export type CfDocumentSummary = { adoptionStatus?: string lastChangeDateTime?: string caseVersion?: string - /** Server-level archive flag — independent of CASE adoptionStatus */ - archived?: boolean readOnly?: boolean cgeFrameworkId?: string /** OpenCASE-proprietary data (e.g. sourcePackageURI/isModifiedFromSource for the mirror/fork badge) — only present when the request sends X-CASE-EDITOR. */ diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index 30f9ed9..a43b18f 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -266,7 +266,7 @@ export default function HomeScreen({ try { const docs = await api.listCfDocuments({ caseVersion: 'v1p1' }) // Filter to only server-level archived frameworks - const archived = docs.filter((d) => d.archived === true) + const archived = docs.filter((d) => d.extensions?.['ext:opencase']?.archived === true) setArchivedFrameworks(archived) } catch (e: unknown) { setArchivedFrameworks([]) diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index 0708097..939ac11 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -1,6 +1,6 @@ import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' -import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore' import { buildCFPackageURI } from '../services/packageUri' export interface GetAllCFDocumentsQuery { @@ -46,7 +46,7 @@ export class GetAllCFDocuments { // Filter server-level archived documents unless includeArchived is true if (!query.includeArchived) { - documents = documents.filter(doc => doc.meta.archived !== true) + documents = documents.filter(doc => !getOpenCaseArchived(doc.meta)) } // Apply filtering (basic implementation - can be enhanced) @@ -118,7 +118,6 @@ export class GetAllCFDocuments { if (docMeta.subject) doc.subject = docMeta.subject if (docMeta.version) doc.version = docMeta.version if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus - if (docMeta.archived) doc.archived = true // CASE v1.1-only fields: only include when not serving via v1p0 if (!isV1p0) { diff --git a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts index e101736..c454128 100644 --- a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts +++ b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts @@ -1,7 +1,7 @@ import { type CFPackageRepository } from '../ports/CFPackageRepository' import { type CaseVersion, type SourcedId, type TenantId } from '../../../domain/case/value-objects/Identifiers' import { logger } from '../../../infrastructure/logging/Logger' -import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseAlignmentParticipants, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore' export interface GetCFItemAssociationsQuery { tenantId: TenantId @@ -49,7 +49,7 @@ export class GetCFItemAssociations { const alignmentDocs = this.store.getAllDocuments(query.tenantId, storageVersion).filter( meta => meta.frameworkType === 'Alignment' && - !meta.archived && + !getOpenCaseArchived(meta) && getOpenCaseAlignmentParticipants(meta)?.some(p => p.identifier === pkg.document.sourcedId) ) diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index 1ab6e2f..2129cba 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -1,5 +1,5 @@ import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers' -import { type FileFrameworkStore, getOpenCaseAlignmentParticipants } from '../../../infrastructure/persistence/file/FileFrameworkStore' +import { type FileFrameworkStore, getOpenCaseAlignmentParticipants, getOpenCaseArchived } from '../../../infrastructure/persistence/file/FileFrameworkStore' import { logger } from '../../../infrastructure/logging/Logger' export interface ListFrameworksQuery { @@ -39,7 +39,7 @@ export class ListFrameworks { for (const version of versions) { const documents = this.store.getAllDocuments(query.tenantId, version) for (const doc of documents) { - if (!query.includeArchived && doc.archived === true) continue + if (!query.includeArchived && getOpenCaseArchived(doc)) continue if (query.frameworkType && doc.frameworkType !== query.frameworkType) continue if (query.participantId) { const participates = getOpenCaseAlignmentParticipants(doc)?.some(p => p.identifier === query.participantId) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts index cc1f4a1..c8a5d24 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -214,7 +214,7 @@ describe('GetAllCFDocuments', () => { lastChangeDateTime: new Date('2024-01-02T00:00:00Z'), currentFile: 'file2.json', adoptionStatus: 'Implemented', - archived: true + openCaseExtensions: { archived: true } }, { sourcedId: 'doc-3', @@ -253,7 +253,7 @@ describe('GetAllCFDocuments', () => { lastChangeDateTime: new Date('2024-01-02T00:00:00Z'), currentFile: 'file2.json', adoptionStatus: 'Implemented', - archived: true + openCaseExtensions: { archived: true } } ] @@ -268,6 +268,28 @@ describe('GetAllCFDocuments', () => { expect(result.CFDocuments).toHaveLength(2) }) + it('should serve archived only inside ext:opencase, never as a top-level CFDocument field', async () => { + const documents = [ + { + sourcedId: 'doc-1', + title: 'Archived Document', + lastChangeDateTime: new Date('2024-01-02T00:00:00Z'), + currentFile: 'file1.json', + openCaseExtensions: { archived: true } + } + ] + + mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.1' ? documents as any : [])) + + const v1p1 = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.1', includeArchived: true }) + expect(v1p1.CFDocuments[0]).not.toHaveProperty('archived') + expect(v1p1.CFDocuments[0].extensions['ext:opencase'].archived).toBe(true) + + const v1p0 = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.0', includeArchived: true }) + expect(v1p0.CFDocuments[0]).not.toHaveProperty('archived') + expect(v1p0.CFDocuments[0]).not.toHaveProperty('extensions') + }) + it('should nest the complete, untransformed ext:opencase object under extensions for CASE 1.1', async () => { // Includes fields (importedAt, layout, edgeType) that are NOT modeled as their own // DocumentMetadata properties — they must still be served verbatim, unmodified. diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts index aafa9b0..1af5742 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts @@ -194,7 +194,6 @@ describe('GetCFItemAssociations', () => { lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/align-doc-1/align-doc-1_v0001.json', frameworkType: 'Alignment', - archived: false, openCaseExtensions: { alignmentParticipants: [ { identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }, @@ -251,8 +250,8 @@ describe('GetCFItemAssociations', () => { lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/align-doc-archived/align-doc-archived_v0001.json', frameworkType: 'Alignment', - archived: true, openCaseExtensions: { + archived: true, alignmentParticipants: [{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }] } } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts index af85577..49837ff 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts @@ -150,7 +150,7 @@ describe('ListFrameworks', () => { lastChangeDateTime: new Date('2024-01-02T00:00:00Z'), currentFile: 'frameworks/doc-2/doc-2_v0001.json', adoptionStatus: 'Implemented', - archived: true + openCaseExtensions: { archived: true } }, { sourcedId: 'doc-3', @@ -189,7 +189,7 @@ describe('ListFrameworks', () => { lastChangeDateTime: new Date('2024-01-02T00:00:00Z'), currentFile: 'frameworks/doc-2/doc-2_v0001.json', adoptionStatus: 'Implemented', - archived: true + openCaseExtensions: { archived: true } } ] diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index eb24107..820db9a 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -22,8 +22,6 @@ export interface DocumentMetadata { currentFile: string // relative to tenant/version root adoptionStatus?: string // CASE domain field — NOT used for server-level archive filtering licenseIdentifier?: string // UUID of the assigned CFLicense - /** Server-level archive flag — independent of CASE adoptionStatus */ - archived?: boolean /** When true, framework is a cached remote reference and cannot be mutated. */ readOnly?: boolean /** CASE Global coalition registry ID (read-only caches). */ @@ -67,6 +65,11 @@ export function getOpenCasePublicAccess (meta: DocumentMetadata | undefined | nu return meta?.openCaseExtensions?.publicAccess === true } +/** True when the framework is archived (server-level soft delete, independent of CASE adoptionStatus). */ +export function getOpenCaseArchived (meta: DocumentMetadata | undefined | null): boolean { + return meta?.openCaseExtensions?.archived === true +} + export interface DocumentVersionInfo { file: string lastChangeDateTime: Date @@ -245,12 +248,13 @@ export class FileFrameworkStore { currentFile: d.currentFile, adoptionStatus: d.adoptionStatus, licenseIdentifier: d.licenseIdentifier, - archived: d.archived, readOnly: d.readOnly === true, cgeFrameworkId: typeof d.cgeFrameworkId === 'string' ? d.cgeFrameworkId : undefined, linkedFromDocId: typeof d.linkedFromDocId === 'string' ? d.linkedFromDocId : undefined, cgeCachedAt: d.cgeCachedAt ? new Date(d.cgeCachedAt as string | number | Date) : undefined, - openCaseExtensions: d.openCaseExtensions, + // `archived` used to be a top-level index field — fold it into openCaseExtensions, + // where it now lives. The old field is dropped the next time the index is written. + openCaseExtensions: d.archived === true ? { ...d.openCaseExtensions, archived: true } : d.openCaseExtensions, }) } } catch { @@ -748,7 +752,6 @@ export class FileFrameworkStore { currentFile: meta.currentFile, adoptionStatus: meta.adoptionStatus, licenseIdentifier: meta.licenseIdentifier, - archived: meta.archived, readOnly: meta.readOnly, cgeFrameworkId: meta.cgeFrameworkId, linkedFromDocId: meta.linkedFromDocId, @@ -1127,7 +1130,8 @@ export class FileFrameworkStore { if (!meta) { throw new Error(`Document ${storageKey} not found in index for tenant ${tenantId} version ${version}`) } - meta.archived = archived || undefined // omit false to keep index clean + const { archived: _previous, ...rest } = meta.openCaseExtensions ?? {} + meta.openCaseExtensions = archived ? { ...rest, archived: true } : rest // omit false to keep index clean } /** @@ -1135,7 +1139,7 @@ export class FileFrameworkStore { */ isDocumentArchived (tenantId: TenantId, version: CaseVersion, storageKey: string): boolean { const meta = this.getDocumentMetadata(tenantId, version, storageKey) - return meta?.archived === true + return getOpenCaseArchived(meta) } removeDefinitionsFromIndexForDocument (tenantId: TenantId, version: CaseVersion, storageKey: string): void { diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts index 015ec0b..6bb445b 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts @@ -161,4 +161,77 @@ describe('FileFrameworkStore — index survives a server restart', () => { await fs.rm(baseDataDir, { recursive: true, force: true }) }) + + it('stores the archived flag in openCaseExtensions alongside the existing ext:opencase keys, across a restart', async () => { + const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-archived-')) + const storageKey = 'doc-1' + const idxDir = path.join(baseDataDir, 'tenants', tenantId, 'v1p1', 'indexes') + + const store1 = new FileFrameworkStore({ baseDataDir }) + const extOpencase = { isModifiedFromSource: false, importedAt: '2026-10-19T12:20:29.751Z' } + const bundle = { + document: { sourcedId: 'doc-1', title: 'A framework', lastChangeDateTime: '2024-01-01T00:00:00Z', extensions: { 'ext:opencase': extOpencase } }, + items: [], + associations: [], + rubrics: [], + } + const { relativePath } = await store1.writeBundleFile(tenantId, version, storageKey, bundle) + await store1.updateIndexesForBundle(tenantId, version, storageKey, bundle, relativePath) + + store1.setDocumentArchived(tenantId, version, storageKey, true) + await store1.writeIndexesToDisk(tenantId, version) + + const persisted = JSON.parse(await fs.readFile(path.join(idxDir, 'documents.json'), 'utf8')) + expect(persisted[0]).not.toHaveProperty('archived') + expect(persisted[0].openCaseExtensions).toEqual({ ...extOpencase, archived: true }) + + const store2 = new FileFrameworkStore({ baseDataDir }) + await store2.loadAll() + expect(store2.isDocumentArchived(tenantId, version, storageKey)).toBe(true) + + store2.setDocumentArchived(tenantId, version, storageKey, false) + expect(store2.isDocumentArchived(tenantId, version, storageKey)).toBe(false) + expect(store2.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase) + + await fs.rm(baseDataDir, { recursive: true, force: true }) + }) + + it('folds a legacy top-level archived flag in documents.json into openCaseExtensions on load', async () => { + const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-legacy-archived-')) + const storageKey = 'doc-1' + const idxDir = path.join(baseDataDir, 'tenants', tenantId, 'v1p1', 'indexes') + await fs.mkdir(idxDir, { recursive: true }) + + const extOpencase = { isModifiedFromSource: false } + await fs.writeFile( + path.join(idxDir, 'documents.json'), + JSON.stringify([ + { + storageKey, + sourcedId: storageKey, + title: 'A framework', + lastChangeDateTime: '2024-01-01T00:00:00Z', + currentFile: 'frameworks/doc-1/doc-1_v0001.json', + archived: true, + openCaseExtensions: extOpencase, + }, + ]), + 'utf8' + ) + + const store = new FileFrameworkStore({ baseDataDir }) + await store.loadAll() + + expect(store.isDocumentArchived(tenantId, version, storageKey)).toBe(true) + const meta = store.getDocumentMetadata(tenantId, version, storageKey) + expect(meta).not.toHaveProperty('archived') + expect(meta?.openCaseExtensions).toEqual({ ...extOpencase, archived: true }) + + await store.writeIndexesToDisk(tenantId, version) + const persisted = JSON.parse(await fs.readFile(path.join(idxDir, 'documents.json'), 'utf8')) + expect(persisted[0]).not.toHaveProperty('archived') + expect(persisted[0].openCaseExtensions).toEqual({ ...extOpencase, archived: true }) + + await fs.rm(baseDataDir, { recursive: true, force: true }) + }) })