diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index 841d80ef..06fcf205 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -392,11 +392,13 @@ components: description: 'Exact component snapshot timestamp; also identifies when that snapshot was created' resolved_version: type: string - description: 'Tagged version of the exact component snapshot' + nullable: true + description: 'Tagged version of the exact component snapshot, or null for a draft' strategy_used: type: string enum: - latest_tagged + - latest_draft - specific_version - specific_snapshot filters_applied: @@ -652,9 +654,11 @@ components: type: object description: | Reference to a component standard track. Selector fields are determined - by resolution_strategy: latest_tagged rejects version and snapshot; - specific_version requires only version; specific_snapshot requires only - snapshot. Unknown properties are rejected. + by resolution_strategy: latest_tagged and latest_draft reject version and + snapshot; specific_version requires only version; specific_snapshot requires + only snapshot and remains tagged-only. latest_draft selects the active + untagged standard snapshot, never an older retained source or a tagged + fallback. Every strategy imports members only. Unknown properties are rejected. additionalProperties: false required: - track_id @@ -673,6 +677,7 @@ components: type: string enum: - latest_tagged + - latest_draft - specific_version - specific_snapshot description: 'How to resolve which snapshot to use from this component' @@ -732,6 +737,17 @@ components: - version - required: - snapshot + - title: 'Latest active draft' + properties: + resolution_strategy: + enum: + - latest_draft + not: + anyOf: + - required: + - version + - required: + - snapshot - title: 'Specific release version' required: - version @@ -795,11 +811,12 @@ components: type: object description: | Virtual releases only. Immutable provenance keyed by component - release-track ID; each value is the tagged component version frozen - in the released draft's composition_resolution. Standard release - history entries omit this property. + release-track ID; each value is the tagged component version or null + for a draft source frozen in the released draft's composition_resolution. + Standard release history entries omit this property. additionalProperties: type: string + nullable: true pattern: '^\d+\.\d+$' example: release-track--a1b2c3d4-e5f6-7890-abcd-ef1234567890: '5.2' diff --git a/app/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml index 4f2d6a18..a8321070 100644 --- a/app/api/definitions/paths/release-tracks-paths.yml +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -836,8 +836,8 @@ paths: The new pending draft has empty members and quarantine tiers and a null composition_resolution. Materialize it before release preview or commit. Request body is strictly validated via Zod. Unknown composition, - component, filter, and deduplication keys are rejected. latest_tagged - rejects selector fields; specific_version requires version; + component, filter, and deduplication keys are rejected. latest_tagged and + latest_draft reject selector fields; specific_version requires version; specific_snapshot requires snapshot. Component IDs and required non-negative integer priorities must each be unique. An optional strict scheduled_materialization object is persisted on the diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js index 59858552..386939b4 100644 --- a/app/lib/release-tracks/release-track-schemas.js +++ b/app/lib/release-tracks/release-track-schemas.js @@ -230,7 +230,12 @@ const deduplicationStrategySchema = z.enum([ 'quarantine', ]); -const resolutionStrategySchema = z.enum(['latest_tagged', 'specific_version', 'specific_snapshot']); +const resolutionStrategySchema = z.enum([ + 'latest_tagged', + 'latest_draft', + 'specific_version', + 'specific_snapshot', +]); const conflictPolicySchema = z.enum([ 'prefer_latest', @@ -365,6 +370,12 @@ const componentTrackSchema = z.discriminatedUnion('resolution_strategy', [ resolution_strategy: z.literal('latest_tagged'), }) .strict(), + z + .object({ + ...componentTrackBaseShape, + resolution_strategy: z.literal('latest_draft'), + }) + .strict(), z .object({ ...componentTrackBaseShape, diff --git a/app/models/release-tracks/release-track-snapshot-schema.js b/app/models/release-tracks/release-track-snapshot-schema.js index 09bf7ecf..f5c4a40d 100644 --- a/app/models/release-tracks/release-track-snapshot-schema.js +++ b/app/models/release-tracks/release-track-snapshot-schema.js @@ -122,7 +122,7 @@ const componentTrackDefinition = { }, resolution_strategy: { type: String, - enum: ['latest_tagged', 'specific_version', 'specific_snapshot'], + enum: ['latest_tagged', 'latest_draft', 'specific_version', 'specific_snapshot'], required: true, }, priority: { @@ -172,7 +172,10 @@ const componentSnapshotResolutionDefinition = { resolved_snapshot_id: { type: Date, required: true }, resolved_version: { type: String, - required: true, + required: function () { + return this.strategy_used !== 'latest_draft'; + }, + default: null, validate: validateVersion, }, strategy_used: { type: String, required: true }, @@ -360,12 +363,11 @@ const versionHistoryEntryDefinition = { candidates_count: { type: Number }, quarantine_count: { type: Number }, }, - // Virtual tracks only: immutable component track ID → tagged version. + // Virtual tracks only: immutable component track ID → version (null for drafts). component_versions: { type: Map, of: { type: String, - required: true, validate: validateVersion, }, default: undefined, diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js index 0358ad00..683808fa 100644 --- a/app/repository/release-tracks/release-track-dynamic.repository.js +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -560,7 +560,7 @@ class ReleaseTrackDynamicRepository { } } - async deleteOlderDrafts(trackId, modified) { + async deleteOlderDrafts(trackId, modified, referencedDrafts = []) { try { const Model = this._getModel(trackId); const retainedDrafts = await Model.distinct('release_source_modified', { @@ -568,6 +568,7 @@ class ReleaseTrackDynamicRepository { version: { $type: 'string' }, release_source_modified: { $type: 'date' }, }).exec(); + retainedDrafts.push(...referencedDrafts); const query = { id: trackId, version: null, @@ -618,6 +619,26 @@ class ReleaseTrackDynamicRepository { } } + async findResolvedComponentSnapshotIds(trackId, componentTrackId) { + try { + const Model = this._getModel(trackId); + const snapshots = await Model.aggregate([ + { + $match: { + id: trackId, + 'composition_resolution.component_snapshots.track_id': componentTrackId, + }, + }, + { $unwind: '$composition_resolution.component_snapshots' }, + { $match: { 'composition_resolution.component_snapshots.track_id': componentTrackId } }, + { $group: { _id: '$composition_resolution.component_snapshots.resolved_snapshot_id' } }, + ]).exec(); + return snapshots.map((snapshot) => snapshot._id); + } catch (err) { + throw new DatabaseError(err); + } + } + async deleteAllSnapshots(trackId) { try { const Model = this._getModel(trackId); diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index ca66f216..68f8e80e 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -430,6 +430,16 @@ exports.cloneSnapshot = async function cloneSnapshot( overrides, options = {}, ) { + if (sourceSnapshot.type === 'standard') { + const { withReleaseLock } = require('./versioning-service'); + return withReleaseLock(trackId, () => + cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options), + ); + } + return cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options); +}; + +async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options) { const clone = deepClone(sourceSnapshot); const hasSnapshotDescriptionOverride = Object.prototype.hasOwnProperty.call( overrides || {}, @@ -479,7 +489,17 @@ exports.cloneSnapshot = async function cloneSnapshot( } if (saved.type === 'standard') { - const prunedDrafts = await dynamicRepo.deleteOlderDrafts(trackId, saved.modified); + // Materialization holds this same track's release lock until provenance + // is persisted, so this scan cannot miss a concurrently created dependent. + const virtualTracks = (await registryRepo.findAll({ type: 'virtual' })).data; + const referencedDrafts = await mapWithConcurrency(virtualTracks, 12, (track) => + dynamicRepo.findResolvedComponentSnapshotIds(track.track_id, trackId), + ); + const prunedDrafts = await dynamicRepo.deleteOlderDrafts( + trackId, + saved.modified, + referencedDrafts.flat(), + ); await contentManifestService.discardUnreferenced( trackId, prunedDrafts.map((snapshot) => snapshot.content_manifest_id), @@ -498,7 +518,7 @@ exports.cloneSnapshot = async function cloneSnapshot( } logger.verbose(`SnapshotService: Cloned snapshot for track "${trackId}"`); return saved; -}; +} // ============================================================================= // Track cloning diff --git a/app/services/release-tracks/versioning-service.js b/app/services/release-tracks/versioning-service.js index 1d67ba69..987b28b0 100644 --- a/app/services/release-tracks/versioning-service.js +++ b/app/services/release-tracks/versioning-service.js @@ -95,15 +95,15 @@ function virtualReleaseChanges(previousSnapshot, draftSnapshot) { } /** - * Capture the tagged component versions frozen into a materialized virtual - * draft. Track IDs are stable provenance keys; component names are descriptive - * metadata and may change or collide. + * Capture the component versions frozen into a materialized virtual draft, + * using null for draft sources. Track IDs are stable provenance keys; + * component names are descriptive metadata and may change or collide. */ function virtualComponentVersions(snapshot) { return Object.fromEntries( (snapshot.composition_resolution?.component_snapshots || []).map((component) => [ component.track_id, - component.resolved_version, + component.resolved_version ?? null, ]), ); } diff --git a/app/services/release-tracks/virtual-track-service.js b/app/services/release-tracks/virtual-track-service.js index 838d9957..97fab6ab 100644 --- a/app/services/release-tracks/virtual-track-service.js +++ b/app/services/release-tracks/virtual-track-service.js @@ -9,7 +9,7 @@ const CreationCause = require('../../lib/release-tracks/snapshot-creation-causes // snapshot creation via resolution of component tracks. // // Virtual tracks aggregate content from multiple standard tracks by: -// 1. Resolving each component track to a specific tagged snapshot +// 1. Resolving each component track to a tagged snapshot or its active draft // 2. Collecting members from each resolved snapshot // 3. Applying per-component filters (object_types and domains) // 4. Deduplicating across all components @@ -127,8 +127,7 @@ exports.validateComposition = async function validateComposition(composition) { }; /** - * Resolve a component track to a specific tagged snapshot based on its - * resolution strategy. + * Resolve a component track to a snapshot based on its resolution strategy. * * @param {Object} component - A component_tracks entry * @returns {Promise} The resolved snapshot document @@ -142,6 +141,22 @@ async function resolveComponentSnapshot(component) { snapshot = await dynamicRepo.getLatestTaggedSnapshot(component.track_id); break; + case 'latest_draft': + snapshot = await dynamicRepo.getLatestSnapshot(component.track_id); + // Older untagged snapshots may be retained for releases or virtual + // provenance. They are not an active rolling draft. + if ( + !snapshot || + snapshot.version != null || + (await dynamicRepo.getReleaseBySourceModified(component.track_id, snapshot.modified)) + ) { + throw new BadRequestError({ + message: `Component track '${component.track_id}' has no active draft snapshot`, + details: 'Create a standard-track draft before materializing with latest_draft', + }); + } + break; + case 'specific_version': snapshot = await dynamicRepo.getSnapshotByVersion(component.track_id, component.version); break; @@ -160,8 +175,8 @@ async function resolveComponentSnapshot(component) { throw new NoTaggedSnapshotsError(component.track_id); } - // For specific_snapshot strategy, the snapshot may be a draft — validate it's tagged - if (snapshot.version == null) { + // Explicit snapshot selection remains tagged-only. + if (component.resolution_strategy !== 'latest_draft' && snapshot.version == null) { throw new NoTaggedSnapshotsError(component.track_id); } @@ -375,7 +390,7 @@ async function resolveComposition(snapshot, registryMap) { track_name: registry.name, track_type: registry.type, resolved_snapshot_id: resolvedSnapshot.modified, - resolved_version: resolvedSnapshot.version, + resolved_version: resolvedSnapshot.version ?? null, strategy_used: component.resolution_strategy, filters_applied: component.filters || undefined, total_objects_in_source: totalObjectsInSource, @@ -497,7 +512,7 @@ exports.updateSchedule = async function updateSchedule(trackId, schedule) { * Create a new virtual snapshot by resolving the composition rules. * * For each component track: - * 1. Resolve to a tagged snapshot via the configured strategy + * 1. Resolve to a tagged snapshot or active draft via the configured strategy * 2. Extract and filter members * Then deduplicate across all components and persist a new draft snapshot. * diff --git a/app/tests/api/release-tracks/release-tracks-release.spec.js b/app/tests/api/release-tracks/release-tracks-release.spec.js index b2e9411b..17e05f1f 100644 --- a/app/tests/api/release-tracks/release-tracks-release.spec.js +++ b/app/tests/api/release-tracks/release-tracks-release.spec.js @@ -467,26 +467,6 @@ describe('Release-track release planning and commit API', function () { name: 'DatabaseError', details: expect.stringContaining('Component version keys must be valid release track IDs'), }); - - const missingValueModified = new Date(created.getTime() + 3000); - await expect( - dynamicRepo.saveSnapshot(track.id, { - ...snapshotBase(track), - modified: missingValueModified, - version: '1.2', - version_history: [ - { - ...historyEntry, - version: '1.2', - snapshot_id: missingValueModified, - component_versions: { [track.id]: null }, - }, - ], - }), - ).rejects.toMatchObject({ - name: 'DatabaseError', - details: expect.stringContaining('is required'), - }); }); it('resolves latest when the release request is handled', async function () { diff --git a/app/tests/api/release-tracks/virtual-composition-validation.spec.js b/app/tests/api/release-tracks/virtual-composition-validation.spec.js index 9b28bf28..77e66932 100644 --- a/app/tests/api/release-tracks/virtual-composition-validation.spec.js +++ b/app/tests/api/release-tracks/virtual-composition-validation.spec.js @@ -128,6 +128,8 @@ describe('Virtual release-track composition validation API', function () { const invalidComponents = [ component('latest_tagged', { version: '1.0' }), component('latest_tagged', { snapshot: timestamp }), + component('latest_draft', { version: '1.0' }), + component('latest_draft', { snapshot: timestamp }), component('specific_version'), component('specific_version', { snapshot: timestamp }), component('specific_version', { version: '1.0', snapshot: timestamp }), @@ -146,6 +148,7 @@ describe('Virtual release-track composition validation API', function () { const timestamp = '2024-02-01T10:00:00.000Z'; const validComponents = [ component('latest_tagged'), + component('latest_draft'), component('specific_version', { version: '1.0' }), component('specific_snapshot', { snapshot: timestamp }), ]; diff --git a/app/tests/api/release-tracks/virtual-determinism.spec.js b/app/tests/api/release-tracks/virtual-determinism.spec.js index d2bfd1a7..cf299090 100644 --- a/app/tests/api/release-tracks/virtual-determinism.spec.js +++ b/app/tests/api/release-tracks/virtual-determinism.spec.js @@ -2,11 +2,14 @@ const request = require('supertest'); const { expect } = require('expect'); +const sinon = require('sinon'); const config = require('../../../config/config'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const modelFactory = require('../../../models/release-tracks/model-factory'); +const snapshotService = require('../../../services/release-tracks/snapshot-service'); +const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); const login = require('../../shared/login'); const { cloneForCreate } = require('../../shared/clone-for-create'); const { releaseExactMembers } = require('./release-track-test-helpers'); @@ -88,7 +91,7 @@ describe('Virtual release-track deterministic membership API', function () { return { component, contents }; } - async function createVirtual(name, componentTrackId) { + async function createVirtual(name, componentTrackId, strategy = 'latest_tagged') { return post('/api/release-tracks/new', { name, type: 'virtual', @@ -96,7 +99,7 @@ describe('Virtual release-track deterministic membership API', function () { component_tracks: [ { track_id: componentTrackId, - resolution_strategy: 'latest_tagged', + resolution_strategy: strategy, priority: 1, }, ], @@ -186,6 +189,252 @@ describe('Virtual release-track deterministic membership API', function () { expect(materialized.members[0].object_modified).not.toBe('latest'); }); + it('materializes draft-only members and freezes null provenance through source advance and release', async function () { + const member = await createRevision('Draft Member A'); + const staged = await createRevision('Draft Staged Only'); + const candidate = await createRevision('Draft Candidate Only'); + const component = await post('/api/release-tracks/new', { + name: 'Draft Only Source', + type: 'standard', + config: { member_sync: { strategy: 'manual' } }, + }); + const source = await snapshotService.cloneSnapshot(component.id, component, { + members: [{ object_ref: member.stix.id, object_modified: member.stix.modified }], + staged: [ + { + object_ref: staged.stix.id, + object_modified: 'latest', + object_status: 'work-in-progress', + object_staged_at: new Date(), + object_staged_by: 'system', + }, + ], + candidates: [ + { + object_ref: candidate.stix.id, + object_modified: 'latest', + object_status: 'work-in-progress', + object_added_at: new Date(), + object_added_by: 'system', + }, + ], + }); + const { component: taggedComponent } = await createReleasedComponent( + 'Tagged Alongside Draft', + member, + ); + const virtual = await post('/api/release-tracks/new', { + name: 'Mixed Draft and Tagged Virtual', + type: 'virtual', + composition: { + component_tracks: [ + { track_id: component.id, priority: 0, resolution_strategy: 'latest_draft' }, + { track_id: taggedComponent.id, priority: 1, resolution_strategy: 'latest_tagged' }, + ], + }, + }); + const materialized = await post( + `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, + {}, + ); + expect(materialized.members).toEqual([ + { object_ref: member.stix.id, object_modified: member.stix.modified }, + ]); + expect(materialized.composition_resolution.component_snapshots[0]).toMatchObject({ + track_id: component.id, + resolved_snapshot_id: new Date(source.modified).toISOString(), + resolved_version: null, + strategy_used: 'latest_draft', + total_objects_in_source: 1, + }); + + const newerMember = await createRevision('Draft Member B', member); + const advanced = await snapshotService.cloneSnapshot(component.id, source, { + members: [{ object_ref: newerMember.stix.id, object_modified: newerMember.stix.modified }], + }); + const sourcePath = `/api/release-tracks/${component.id}/snapshots/${encodeURIComponent( + new Date(source.modified).toISOString(), + )}`; + expect(revisionKeys(await get(sourcePath))).toEqual(revisionKeys(materialized)); + const deletion = await request(app) + .delete(sourcePath) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(409); + expect(deletion.body.dependent_snapshots).toEqual([ + expect.objectContaining({ track_id: virtual.id, snapshot_modified: materialized.modified }), + ]); + + const released = await post( + `/api/release-tracks/${virtual.id}/snapshots/latest/release`, + { version: '1.0' }, + 200, + ); + expect(released.modified).toBe(materialized.modified); + expect(released.content_manifest_id).toBe(materialized.content_manifest_id); + expect(revisionKeys(released)).toEqual(revisionKeys(materialized)); + expect(released.composition_resolution).toEqual(materialized.composition_resolution); + expect(released.version_history.at(-1).component_versions).toEqual({ + [component.id]: null, + [taggedComponent.id]: '1.0', + }); + const bundle = await get(`/api/release-tracks/${virtual.id}/snapshots/latest?format=bundle`); + expect(bundle.objects.filter((object) => object.type === 'course-of-action')).toEqual([ + expect.objectContaining({ id: member.stix.id, modified: member.stix.modified }), + ]); + await post(`/api/release-tracks/${component.id}/meta`, { description: 'Advance again' }, 200); + expect(revisionKeys(await get(sourcePath))).toEqual(revisionKeys(materialized)); + expect(await dynamicRepo.getSnapshotByModified(component.id, advanced.modified)).toBeNull(); + + const next = await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}); + expect(next.composition_resolution.component_snapshots[0].resolved_version).toBeNull(); + expect(next.members).toEqual([ + { object_ref: newerMember.stix.id, object_modified: newerMember.stix.modified }, + ]); + expect( + revisionKeys( + await get( + `/api/release-tracks/${virtual.id}/snapshots/${encodeURIComponent(released.modified)}`, + ), + ), + ).toEqual(revisionKeys(materialized)); + }); + + it('never falls back to a tagged release or retained historical draft when no active draft exists', async function () { + const component = await post('/api/release-tracks/new', { + name: 'Active Draft Selection', + type: 'standard', + }); + const virtual = await createVirtual('Active Draft Virtual', component.id, 'latest_draft'); + const first = await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}); + const newer = await post( + `/api/release-tracks/${component.id}/meta`, + { description: 'New rolling draft' }, + 200, + ); + const release = await post( + `/api/release-tracks/${component.id}/snapshots/latest/release`, + {}, + 200, + ); + expect(release.release_source_modified).toBe(newer.modified); + expect( + await dynamicRepo.getSnapshotByModified(component.id, component.modified), + ).not.toBeNull(); + const failure = await post( + `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, + {}, + 400, + ); + expect(failure.message).toContain('no active draft'); + expect((await get(`/api/release-tracks/${virtual.id}/snapshots/latest`)).modified).toBe( + first.modified, + ); + await request(app) + .put(`/api/release-tracks/${virtual.id}/virtual/composition`) + .send({ + component_tracks: [ + { + track_id: component.id, + priority: 0, + resolution_strategy: 'specific_snapshot', + snapshot: newer.modified, + }, + ], + }) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 400); + + const active = await post( + `/api/release-tracks/${component.id}/meta`, + { description: 'Next active draft' }, + 200, + ); + const nextVirtual = await createVirtual( + 'Next Active Draft Virtual', + component.id, + 'latest_draft', + ); + const next = await post(`/api/release-tracks/${nextVirtual.id}/virtual/snapshots/create`, {}); + expect(next.composition_resolution.component_snapshots[0].resolved_snapshot_id).toBe( + active.modified, + ); + }); + + it('serializes draft pruning with virtual materialization until provenance is persisted', async function () { + const component = await post('/api/release-tracks/new', { + name: 'Draft Pruning Race', + type: 'standard', + }); + const virtual = await createVirtual('Draft Pruning Race Virtual', component.id, 'latest_draft'); + const save = dynamicRepo.saveSnapshot; + const stub = sinon.stub(dynamicRepo, 'saveSnapshot').callsFake(async (trackId, snapshot) => { + if (trackId === virtual.id) { + await post(`/api/release-tracks/${component.id}/meta`, { description: 'Racing edit' }, 409); + } + return save.call(dynamicRepo, trackId, snapshot); + }); + try { + await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}); + } finally { + stub.restore(); + } + await post(`/api/release-tracks/${component.id}/meta`, { description: 'After persist' }, 200); + expect( + await dynamicRepo.getSnapshotByModified(component.id, component.modified), + ).not.toBeNull(); + + const resolve = dynamicRepo.findResolvedComponentSnapshotIds; + const scan = sinon + .stub(dynamicRepo, 'findResolvedComponentSnapshotIds') + .callsFake(async (...args) => { + if (args[0] === virtual.id) { + await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 409); + } + return resolve.apply(dynamicRepo, args); + }); + try { + await post(`/api/release-tracks/${component.id}/meta`, { description: 'Pruning first' }, 200); + } finally { + scan.restore(); + } + expect( + await dynamicRepo.getSnapshotByModified(component.id, component.modified), + ).not.toBeNull(); + }); + + it('prunes a retained source after its last virtual dependent is deleted', async function () { + const component = await post('/api/release-tracks/new', { + name: 'Released Draft Retention', + type: 'standard', + }); + const virtual = await createVirtual('Temporary Draft Dependent', component.id, 'latest_draft'); + const materialized = await post( + `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, + {}, + ); + await post( + `/api/release-tracks/${component.id}/meta`, + { description: 'Keep dependent source' }, + 200, + ); + expect( + await dynamicRepo.getSnapshotByModified(component.id, component.modified), + ).not.toBeNull(); + await request(app) + .delete( + `/api/release-tracks/${virtual.id}/snapshots/${encodeURIComponent(materialized.modified)}`, + ) + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(204); + await post( + `/api/release-tracks/${component.id}/meta`, + { description: 'Prune unused source' }, + 200, + ); + expect(await dynamicRepo.getSnapshotByModified(component.id, component.modified)).toBeNull(); + }); + after(async function () { await database.closeConnection(); }); diff --git a/docs/developer/FRONTEND_TODO.md b/docs/developer/FRONTEND_TODO.md index d78a481c..583a80f9 100644 --- a/docs/developer/FRONTEND_TODO.md +++ b/docs/developer/FRONTEND_TODO.md @@ -729,7 +729,7 @@ frontend-only properties in the submitted composition, component, filter, or deduplication objects. Component selector fields must also follow the selected strategy: -- `latest_tagged` sends neither `version` nor `snapshot`. +- `latest_tagged` and `latest_draft` send neither `version` nor `snapshot`. - `specific_version` sends `version` and omits `snapshot`. - `specific_snapshot` sends `snapshot` and omits `version`. - Every component sends a unique, non-negative integer `priority`; lower @@ -915,18 +915,18 @@ Done when: Virtual release history entries now include: ```ts -component_versions?: Record; +component_versions?: Record; ``` Each key is an immutable component release-track ID and each value is the -tagged component version frozen in the virtual draft's +tagged component version or `null` for a draft source, frozen in the virtual draft's `composition_resolution`. The map is present only for virtual releases; standard release history entries omit it. Component display names are deliberately not used as keys because names can change or collide. -The existing `VersionHistoryEntry` interface currently types this property as -`any`. Replace that with `Record`. If the UI presents -provenance to operators, pair each track ID with the matching +The `VersionHistoryEntry` interface types this property as +`Record`. Display null values as **Draft** and pair +each track ID with the matching `composition_resolution.component_snapshots[].track_name` from the same released snapshot while retaining the ID as the authoritative identity. diff --git a/docs/developer/TODO.md b/docs/developer/TODO.md index 2be02d79..de3e3b31 100644 --- a/docs/developer/TODO.md +++ b/docs/developer/TODO.md @@ -2920,3 +2920,16 @@ database. - [x] Frontend: draft-then-tag flow (header keeps only Create Draft; tagging from draft cards), DETAILS tab renamed Board, track deletion in a CONFIG danger zone. + +## Draft components in virtual tracks (2026-09-22) + +- [x] Add strict `latest_draft` resolution of the active standard draft's + members only, without falling back to tagged or historical snapshots. +- [x] Preserve exact source timestamps and nullable draft versions in + composition resolution and virtual release history. +- [x] Retain virtual-referenced source drafts during pruning and serialize + standard clone writes with materialization using the release lock. +- [x] Expose per-component draft/tagged selection in frontend creation and + configuration; render draft provenance without inventing a version. +- [x] Verify the real frontend/API flow, focused regressions, and the full + backend test suite; update API documentation and Bruno request notes. diff --git a/docs/developer/release-tracks/entities.md b/docs/developer/release-tracks/entities.md index 44ad7218..165bce67 100644 --- a/docs/developer/release-tracks/entities.md +++ b/docs/developer/release-tracks/entities.md @@ -7,7 +7,7 @@ This document tracks new database schemas, interfaces, etc.; as well as changes | Collection | Purpose | Written by | Growth and retention | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `releaseTrackRegistry` | One document per track: name, type, denormalized counters, the tagged-release catalogue (`tagged_releases`), the release lock, and virtual schedules. The index that maps a track to its own snapshot collection. | Track create/delete, every snapshot write (counters), release commit and conversion to draft (catalogue). | One document per track. | -| `release-track--` | The track's snapshots: one active rolling draft, a preserved source draft per tagged standard release, and every tagged release; every materialized draft plus releases for a virtual track. | Snapshot service and release commit. | Standard tracks grow by two snapshots per release plus one active draft; virtual tracks by materializations. | +| `release-track--` | The track's snapshots: one active rolling draft, preserved standard release sources, source drafts referenced by virtual provenance, and tagged releases; every materialized draft plus releases for a virtual track. | Snapshot service and release commit. | Standard tracks retain releases, their source drafts, virtual-pinned drafts, and one active draft; virtual tracks grow by materializations. | | `releaseTrackContentManifests` | The sealed bill of materials each snapshot references (`content_manifest_id`). Several snapshots share one manifest when their member sets are identical. | Sealed whenever members are written; discarded when no snapshot references it. | Bounded by member-changing writes, not by snapshot count. | | `releaseTrackContentManifestEntries` | One exact-revision pointer per object a manifest emits or depends on. The `(object_ref, object_modified)` index is what protects referenced revisions from deletion. | With its manifest. | Roughly members + relationships + a few supporting objects per manifest. | | `releaseTrackReconciliations` | Outstanding backref reconciliation work only: a record is created before the `workspace.release_tracks` listeners run and deleted when they succeed, so anything present is pending or failed and needs repair. | Every snapshot write. | Normally empty. | @@ -393,7 +393,7 @@ Virtual release tracks compute their contents by aggregating objects from compon component_tracks: [ { track_id: "release-track--groups-monthly", - resolution_strategy: "latest_tagged", // "latest_tagged" | "specific_version" | "specific_snapshot" + resolution_strategy: "latest_tagged", // "latest_tagged" | "latest_draft" | "specific_version" | "specific_snapshot" priority: 1, // Always required and unique (lower number = higher priority) // Optional: filters to limit which objects are included @@ -634,15 +634,15 @@ restart recovery idempotent. Failed occurrences remain retryable. **Virtual Track Constraints:** -- Can only reference **tagged snapshots** from component tracks (not drafts) -- Can only sync from component tracks' **`members` tier** (released objects only) +- Reference tagged snapshots or active drafts selected with `latest_draft` +- Sync from component tracks' **`members` tier** only (never staged or candidates) - Can only compose from **standard release tracks** (not other virtual tracks - no nesting allowed) - Is purely compositional and has no `native_members` or second membership authority; aggregate-specific content belongs in another standard component track - Snapshots are created **manually or on schedule** (never event-driven) - All snapshots start as **drafts** and must be explicitly tagged -- Component tracks must exist and have at least one tagged release +- Component tracks must exist; selected source snapshot eligibility is checked at materialization - Each component track must have a unique **priority** value (no duplicates) - Priority is a required non-negative integer for every component, regardless of deduplication strategy @@ -668,11 +668,12 @@ restart recovery idempotent. Failed occurrences remain retryable. `version_history[].component_versions`. This is an object keyed by immutable component `track_id`, not display name. It records the frozen materialization inputs even when a component has newer releases by the time the virtual draft - is tagged. Standard release history entries omit the field + is tagged. Draft components record `null`; tagged components record their + version string. Standard release history entries omit the field - Composition request objects are strict; unknown composition, component, filter, and deduplication keys return `400 Bad Request` - Selector fields form a discriminated request contract: - - `latest_tagged` rejects `version` and `snapshot` + - `latest_tagged` and `latest_draft` reject `version` and `snapshot` - `specific_version` requires `version` and rejects `snapshot` - `specific_snapshot` requires `snapshot` and rejects `version` - Quarantine promotion selects an exact revision in a new draft and preserves diff --git a/docs/developer/release-tracks/implementation-notes.md b/docs/developer/release-tracks/implementation-notes.md index 2d153600..96e72c9a 100644 --- a/docs/developer/release-tracks/implementation-notes.md +++ b/docs/developer/release-tracks/implementation-notes.md @@ -204,7 +204,7 @@ Virtual-only operations are deliberately scoped beneath rules, empty members/quarantine tiers, and `composition_resolution: null`. Clearing all three prevents a materialized result from surviving a change to the rules that produced it. -- `POST /virtual/snapshots/create` resolves tagged component snapshots and +- `POST /virtual/snapshots/create` resolves tagged or active-draft component snapshots and persists the concrete members, quarantine, and immutable `composition_resolution`. - Every persisted member and quarantine entry uses an exact @@ -215,20 +215,33 @@ Virtual-only operations are deliberately scoped beneath moving reference. - `member_sync.strategy = track_latest` applies only to standard tracks. New object revisions may update a component's newer candidate/staged draft, but - they cannot rewrite the members of the tagged component snapshot selected - during virtual materialization or an already-persisted virtual snapshot. + they cannot rewrite the members of the component snapshot selected during + virtual materialization or an already-persisted virtual snapshot. - `POST /virtual/quarantine/promote` clones the latest virtual snapshot, selects one exact quarantined revision for members, and removes all quarantined alternatives for that object. Composition input uses strict Zod objects at the composition, component, filter, and deduplication levels. Components form a discriminated union on -`resolution_strategy`: `latest_tagged` accepts no selector, +`resolution_strategy`: `latest_tagged` and `latest_draft` accept no selector, `specific_version` requires only `version`, and `specific_snapshot` requires only `snapshot`. This prevents misspelled filters or irrelevant selectors from being silently stripped before persistence. The same schema is used for initial virtual-track creation and composition updates. +`latest_draft` resolves the newest standard snapshot only when it is untagged +and not a preserved release source. It does not search older retained drafts +or fall back to a release. An unavailable active draft returns `400 Bad Request`. +All strategies contribute members only; candidates and staged entries are +never composed. `specific_snapshot` remains tagged-only. + +Standard clone save/prune and virtual materialization share the existing +release lock. Concurrent operations fail fast with `409 Conflict`, preventing +pruning between source resolution and persisted virtual provenance. Pruning +retains every source snapshot named by persisted virtual provenance, including +historical virtual drafts. Once the last dependent disappears, the next +standard clone can prune the source if no other retention rule protects it. + Component `priority` is always required, even when the selected deduplication strategy does not inspect it. Zod rejects duplicate component IDs and priorities before service delegation. The facade also asks the virtual-track @@ -339,13 +352,13 @@ planned snapshot, and the commit path tags that snapshot in place. Virtual release planning also derives `version_history[].component_versions` directly from the selected draft's immutable `composition_resolution.component_snapshots`. The property is a -component track ID to tagged `MAJOR.MINOR` version map. It deliberately does -not query the component tracks at preview or commit time: a component can -advance after virtual materialization without changing the provenance of the -already-frozen draft. Standard release history entries omit the virtual-only -property. Mongoose validates every map value with the shared release-version -validator and requires every persisted component resolution to identify its -tagged `resolved_version`. +component track ID to tagged `MAJOR.MINOR` version or `null` map. It deliberately +does not query the component tracks at preview or commit time: a component can +advance after materialization without changing the already-frozen provenance. +Standard release history entries omit the property. Mongoose validates string +values with the shared release-version validator. Component resolutions require +a tagged `resolved_version` for tagged strategies and allow `null` for +`latest_draft`. Standard release commit assigns a fresh timestamp, stores `release_source_modified`, and inserts the tagged clone while retaining the diff --git a/docs/developer/release-tracks/member-sync-strategies.md b/docs/developer/release-tracks/member-sync-strategies.md index 720d4cf8..86998eeb 100644 --- a/docs/developer/release-tracks/member-sync-strategies.md +++ b/docs/developer/release-tracks/member-sync-strategies.md @@ -131,7 +131,7 @@ Member sync strategies integrate with several existing release track features: - **Candidacy Threshold:** When a new revision is auto-enrolled as a candidate, it may be immediately promoted to `staged` if its status meets the candidacy threshold. - **Conflict Resolution Policies:** Member sync resolves overlaps with existing `candidates`/`staged` entries through its own `supplant` config (below). *Manual* candidate adds and demotions instead go through `config.promotion_conflicts.into_candidates` (default `prefer_latest`) — see `release-workflow.md`. The two are deliberately separate: supplant expresses sync intent (replace/queue/ignore), while `into_candidates` uses the same policy vocabulary as the other tier transitions. -- **Snapshot Creation:** Any change to a release track's object lists (`candidates`, `staged`, `members`) creates a replacement draft snapshot. Standard tracks retain only the newest untagged draft after it is durably saved; tagged snapshots remain historical. Member sync follows this convention. +- **Snapshot Creation:** Any change to a release track's object lists (`candidates`, `staged`, `members`) creates a replacement draft snapshot. After it is durably saved, older untagged drafts are pruned unless they are preserved release sources or referenced by virtual provenance. Tagged snapshots remain historical. Member sync follows this convention. --- diff --git a/docs/developer/release-tracks/sealed-content-manifests.md b/docs/developer/release-tracks/sealed-content-manifests.md index eec15044..58a9def6 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -128,12 +128,16 @@ never rewritten by conversion. Legacy `delete_release` audit entries remain valid; new conversions use `convert_release_to_draft`. Virtual materialization acquires the existing database-backed release locks -for all component tracks in sorted order, before resolving any release, and +for all component tracks in sorted order, before resolving any source snapshot, and holds them through snapshot persistence. Partial acquisition and failed materialization unwind the locks. Contention fails fast with 409. The rollback dependency scan therefore cannot miss an in-flight materialization: either rollback owns the lock first, or it sees the persisted virtual dependency after materialization releases the lock. +Standard clone save/prune acquires the same lock, so a source draft cannot +disappear between resolution and persistence. Pruning retains drafts referenced +by virtual provenance; after the final dependent is removed, a later standard +clone can prune an otherwise-unprotected draft. Retag prepares both bundle serializations before writing, then atomically publishes the version, publication metadata, bundle ID, and hashes on the @@ -151,7 +155,8 @@ History's repository projection and service summary both expose and never queries relationships, and a draft replays its inherited members graph. - Manifest storage is bounded by the number of member-changing writes, not - the number of snapshots. Standard tracks already keep only one draft. + the number of snapshots. Standard tracks retain one active draft plus release + sources and drafts referenced by virtual provenance. - Editing an object creates no relationship revisions; editing a relationship creates exactly one relationship revision. - Existing databases are migrated in place: the `graph_manifest_id` field is diff --git a/docs/developer/release-tracks/snapshot-creation-causes.md b/docs/developer/release-tracks/snapshot-creation-causes.md index 32eba523..6f0fd1a6 100644 --- a/docs/developer/release-tracks/snapshot-creation-causes.md +++ b/docs/developer/release-tracks/snapshot-creation-causes.md @@ -72,8 +72,9 @@ publication configuration. That config write creates a draft labelled "Configuration updated", even if the supplied config equals its prior value. An actual scheduled materialization is labelled "Scheduled snapshot". -Standard tracks keep only their latest rolling draft, so this is provenance -for each surviving snapshot, not a complete event log. If auto-promotion +Standard tracks retain their latest rolling draft plus release sources and +drafts referenced by virtual provenance. This is provenance for each surviving +snapshot, not a complete event log. If auto-promotion immediately replaces a candidate-add/review draft, the surviving snapshot is labelled "Candidates automatically promoted". Tagged snapshots retain their creation cause throughout their lifetime. Standard release creation persists a diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index 424c93cf..6e8091bc 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -659,7 +659,7 @@ The virtual release response records the materialized component provenance in ``` Keys are immutable component track IDs and values are the tagged versions -stored in the selected draft's `composition_resolution`. The server does not +or `null` for draft components stored in the selected draft's `composition_resolution`. The server does not look up the components' current releases, so advancing a component after materialization does not rewrite the virtual release's provenance. Standard release history entries omit `component_versions`. @@ -1372,7 +1372,7 @@ multiple standard component tracks based on configurable rules. - Compute contents only from standard component tracks; virtual-track nesting is rejected - Are purely compositional and cannot own native members -- Only reference **tagged snapshots** from component tracks (never drafts) +- Reference **tagged snapshots** or explicitly selected **active drafts** - Create snapshots **manually or on schedule** (never event-driven) - All snapshots start as **drafts** and must be explicitly tagged - Support **resolution strategies** to control which component versions are included @@ -1381,7 +1381,14 @@ multiple standard component tracks based on configurable rules. 1. `latest_tagged` - Always use the most recent tagged snapshot from component 2. `specific_version` - Pin to a specific semantic version (e.g., "5.0") -3. `specific_snapshot` - Pin to a specific snapshot by timestamp +3. `specific_snapshot` - Pin to a specific tagged snapshot by timestamp +4. `latest_draft` - Use the newest standard snapshot only if it is an active untagged draft + +Every strategy contributes **members only**, never staged objects or +candidates. `latest_draft` does not fall back to a tagged release or older +retained draft; no active draft returns `400 Bad Request`. Draft component +provenance stores `resolved_version: null` and the exact source timestamp. +Later source changes do not alter the materialized virtual snapshot. See [virtual-tracks.md](./virtual-tracks.md) for complete documentation. @@ -1474,8 +1481,8 @@ snapshot, and timestamp-selected snapshot GET requests. Composition, component, filter, and deduplication objects are strict. Unknown keys, including the incorrect singular `filters.domain`, return `400 Bad Request`. Component selectors are also strategy-specific: -`latest_tagged` rejects `version` and `snapshot`; `specific_version` requires -only `version`; and `specific_snapshot` requires only `snapshot`. +`latest_tagged` and `latest_draft` reject `version` and `snapshot`; +`specific_version` requires only `version`; `specific_snapshot` requires only `snapshot`. Every component requires a unique, non-negative integer `priority`; lower numbers have higher priority. When composition is supplied during creation, each referenced track must already exist and must be a standard track. Virtual @@ -1613,7 +1620,7 @@ readiness marker for those shared release operations. Each resulting `members` and `quarantine` entry contains an exact `(object_ref, object_modified)` pair. Virtual materialization preserves exact -revisions already frozen in the selected tagged component snapshots. It also +revisions already frozen in the selected tagged or active-draft component snapshots. It also resolves any unresolved legacy component entry before persistence. The virtual snapshot never stores `"latest"` and does not inherit a standard component's `track_latest` member-sync behavior. diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index 2b0967cc..bfa779c9 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -1137,8 +1137,8 @@ July 16 (manual): ### Virtual Track Constraints -- Only references **tagged snapshots** from components (never drafts) +- References tagged snapshots or active drafts explicitly selected with `latest_draft`; members only - Snapshots created **manually or on schedule** (never event-driven) - All snapshots start as **drafts** (must explicitly tag) -- Component tracks must have at least one tagged release +- Component tracks must have a source eligible for their selected strategy at materialization - Circular dependencies not allowed diff --git a/docs/user/release-tracks/summary.md b/docs/user/release-tracks/summary.md index 4efc2b91..eb6d13eb 100644 --- a/docs/user/release-tracks/summary.md +++ b/docs/user/release-tracks/summary.md @@ -36,7 +36,7 @@ The Release Tracks API supports two types of release tracks: - No duplicate object tracking - objects managed in source tracks only - Purely compositional - virtual tracks cannot add native members of their own - Create snapshots manually or on schedule (never event-driven) -- Always compose from tagged snapshots only (never drafts) +- Compose members from tagged snapshots or active drafts explicitly selected with `latest_draft` - Examples: "EnterpriseTwiceAnnual" (aggregates Groups + Techniques + Software) **Use Case for Virtual Tracks:** @@ -102,7 +102,8 @@ We borrow heavily concepts from git. Snapshots are sort of like commits and tagg - Identified by `stix.modified` timestamp - Immutable once created - Standard tracks retain one active rolling draft plus the hidden source draft - for each tagged release; tagged releases remain historical + for each tagged release and any drafts referenced by virtual provenance; + tagged releases remain historical - May be a **draft release** (untagged) or **tagged release** (has version number) **Tagged Releases** (like Git tags) diff --git a/docs/user/release-tracks/terminology.md b/docs/user/release-tracks/terminology.md index 7d5d81e2..9403ac43 100644 --- a/docs/user/release-tracks/terminology.md +++ b/docs/user/release-tracks/terminology.md @@ -301,7 +301,7 @@ A **virtual release track** is a special type of release track that computes its **Characteristics:** - Does NOT manage objects through candidate/staged/released workflow - Aggregates content only from **standard component tracks** -- Only references **tagged snapshots** from component tracks (never drafts) +- References tagged snapshots or active drafts explicitly selected with `latest_draft` - Creates snapshots **manually** or **on schedule** (*never* event-driven; see [Types of Release Tracks](#types-of-release-tracks) for explanation) - All snapshots start as drafts and must be explicitly tagged - Is purely compositional and cannot own native objects; place additional @@ -412,12 +412,14 @@ A **resolution strategy** determines which snapshot from a component track to us **Options:** 1. **latest_tagged** - Use the most recent tagged snapshot from the component track 2. **specific_version** - Use a specific semantic version (e.g., "5.0") -3. **specific_snapshot** - Use a specific snapshot by timestamp +3. **specific_snapshot** - Use a specific tagged snapshot by timestamp +4. **latest_draft** - Use the newest standard snapshot if it is an active untagged draft; no fallback **Examples:** - `{ resolution_strategy: "latest_tagged", priority: 0 }` → Always gets latest - `{ resolution_strategy: "specific_version", version: "5.0", priority: 0 }` → Always uses v5.0 - `{ resolution_strategy: "specific_snapshot", snapshot: "2024-02-01T10:00:00Z", priority: 0 }` → Always uses that exact snapshot +- `{ resolution_strategy: "latest_draft", priority: 0 }` → Uses the active draft's members only, never staged objects or candidates --- diff --git a/docs/user/release-tracks/versioning.md b/docs/user/release-tracks/versioning.md index e1dc9d04..5f83d6bc 100644 --- a/docs/user/release-tracks/versioning.md +++ b/docs/user/release-tracks/versioning.md @@ -22,7 +22,8 @@ A **snapshot** is an immutable state of a release track at a specific point in t Every content-changing operation creates a replacement snapshot with a new `modified` timestamp. For a standard track, the replacement is saved first and -then the older untagged draft is removed. Tagged snapshots are never pruned. +then older untagged drafts are pruned, except preserved release sources and +drafts referenced by persisted virtual provenance. Tagged snapshots are never pruned. A snapshot may be either a **draft release** (untagged) or a **tagged release** (has version number). @@ -67,8 +68,9 @@ id: "release-track--123", modified: "2024-01-15T16:20:00.000Z" ] ``` -The timeline lists the first draft only to illustrate its replacement. Once the -second draft is durably stored, the first draft is no longer retrievable. +The timeline lists the first draft only to illustrate its replacement. In this +example it has no virtual dependents or tagged release preserving it, so once +the second draft is durably stored, the first draft is no longer retrievable. ## The Release Operation diff --git a/docs/user/release-tracks/virtual-tracks.md b/docs/user/release-tracks/virtual-tracks.md index 7c8d8f1b..3b90b00a 100644 --- a/docs/user/release-tracks/virtual-tracks.md +++ b/docs/user/release-tracks/virtual-tracks.md @@ -7,7 +7,7 @@ Virtual release tracks are computed aggregations of standard release tracks. The **Key Characteristics:** - Virtual tracks **compute** their contents from component standard tracks -- Only reference **tagged snapshots** from standard tracks (never drafts) +- Reference **tagged snapshots** or explicitly selected **active drafts** from standard tracks - Maintain their own **independent snapshot history and versioning** - Create snapshots **manually or on schedule** (never event-driven) - All snapshots start as **drafts** and must be explicitly tagged @@ -190,9 +190,36 @@ Resolves to a specific snapshot by its `modified` timestamp. **Use case:** "Lock to exact snapshot for reproducibility" +#### 4. `latest_draft` + +Resolves the standard track's newest snapshot only if it is an active, +untagged draft. A track with no tagged releases can be used: + +```javascript +{ + track_id: "release-track--uuid-1", + resolution_strategy: "latest_draft", + priority: 0 +} +``` + +Only the draft's **members** contribute. Staged objects and candidates are +excluded; this is not a preview of the standard track's prospective release. +Member revisions are frozen when the virtual snapshot is materialized. + +If the newest snapshot is tagged, materialization returns `400 Bad Request` +with a "no active draft snapshot" message. There is no fallback to a tagged +release or an older retained draft. Preserved release-source drafts are not +active drafts. Create a new standard-track draft before materializing. + +Mixed compositions may use `latest_draft` for some components and +`latest_tagged` for others. Draft provenance records the exact +`resolved_snapshot_id`, `strategy_used: "latest_draft"`, and +`resolved_version: null`. Tagging the virtual snapshot does not tag its sources. + Component selectors are strict and strategy-specific: -- `latest_tagged` rejects both `version` and `snapshot`. +- `latest_tagged` and `latest_draft` reject both `version` and `snapshot`. - `specific_version` requires `version` and rejects `snapshot`. - `specific_snapshot` requires `snapshot` and rejects `version`. @@ -201,15 +228,18 @@ not silently discarded. ### Component Track Sync Rules -Virtual tracks **only sync from component tracks' `members` tier** (`x_mitre_contents`). This ensures that virtual tracks only aggregate objects that have been officially released in their source tracks. +Virtual tracks **only sync from component tracks' `members` tier** (`x_mitre_contents`), whether the selected source is tagged or a draft. **Important:** -- Virtual tracks reference **tagged snapshots only** (never drafts) +- Draft components require the explicit `latest_draft` strategy; the other strategies remain tagged-only - Virtual tracks pull objects from **`members` tier only** (never staged or candidates) -- This guarantees that virtual track releases are composed of stable, released content +- Each materialization freezes exact member revisions and source provenance -**Rationale:** Since virtual tracks can only reference tagged snapshots from component tracks, it makes sense to only pull from the `members` tier, which contains the released objects from those snapshots. +Source drafts referenced by virtual snapshots are retained when the standard +track advances. Deletion remains blocked while a virtual snapshot depends on +the source. After the last dependent is removed, a later standard draft write +can prune that otherwise-unprotected source. ### Filters @@ -226,7 +256,7 @@ filters: { } ``` -Domain filters hydrate the exact revisions pinned by the component's tagged +Domain filters hydrate the exact revisions pinned by the selected component snapshot; they do not inspect the latest database revision. Matching uses inclusive **any-match** semantics, not exact-array equality: an object is included when at least one value in its canonical `x_mitre_domains` array @@ -689,11 +719,11 @@ POST /api/release-tracks/:id/snapshots/:modified/release } ``` -`component_versions` is keyed by immutable component track ID. Its values come -from the selected draft's `composition_resolution`, not from the component -tracks' current releases. If a component advances after this virtual draft was -materialized, the virtual release still records the version that actually -produced its frozen contents. Standard release history entries omit this +`component_versions` is keyed by immutable component track ID. Values are +tagged version strings or `null` for draft components, copied from the selected +virtual draft's `composition_resolution`. If a component advances or is tagged +after materialization, the virtual release still records its original source +state and exact snapshot ID. Standard release history entries omit this virtual-only property. The snapshot-history endpoint (`GET /api/release-tracks/:id/snapshots`) also @@ -703,7 +733,7 @@ clients present provenance beside the draft or release it describes rather than presenting only the virtual track's current HEAD resolution. In each component entry, `resolved_snapshot_id` is the exact component snapshot's creation timestamp and stable retrieval key; `resolved_version` names its -tagged version. The stored source, filtered, and contributed counts belong to +tagged version or is `null` for a draft. The stored source, filtered, and contributed counts belong to that materialization and are not recomputed from the component track's current state. Full snapshot retrieval additionally returns the deduplication report and resolution summary. @@ -832,37 +862,14 @@ Each virtual track snapshot stores metadata about how it was composed: ### Validation Rules -#### 1. Component tracks must have tagged snapshots +#### 1. Component snapshots must satisfy their resolution strategy -```javascript -// When creating virtual snapshot -for (const component of composition.component_tracks) { - const snapshot = await resolveSnapshot(component); - - if (snapshot.version === null) { - throw new ValidationError( - `Component track ${component.track_id} resolved to draft snapshot. ` + - `Virtual tracks can only reference tagged snapshots.`, - ); - } -} -``` - -**User experience:** - -```bash -POST /api/release-tracks/release-track--uuid-virtual/virtual/snapshots/create - -# Error response: -{ - "error": "ValidationError", - "message": "Cannot create virtual snapshot: component track 'GroupsMonthly' has no tagged releases", - "details": { - "component": "release-track--uuid-1", - "issue": "No tagged snapshots found (all snapshots are drafts)" - } -} -``` +`latest_tagged`, `specific_version`, and `specific_snapshot` require a tagged +source. `latest_draft` requires the newest standard snapshot to be an active +untagged draft. Missing eligible sources return `400 Bad Request`; the server +does not silently select another strategy. Component identity and type are +validated when composition is configured, while snapshot eligibility is +checked at materialization. #### 2. Component tracks must be standard tracks @@ -1294,15 +1301,20 @@ Virtual tracks cannot transition workflow status of composed objects. **Alternative:** If you need to change object status, do it in the source standard track. -### 3. Only Reference Tagged Snapshots +### 3. Draft Composition Is Members-Only -Virtual tracks cannot compose from draft snapshots. +`latest_draft` does not include candidates or staged content and does not +implicitly release the standard track. To include staged changes, release the +standard track first and select `latest_tagged`, or make a later active draft +after the release. -**Rationale:** Ensures stability and prevents virtual snapshots from inadvertently including WIP content. +## Error Handling -**Alternative:** Tag the standard track snapshot first, then create virtual snapshot. +### Error: Component Has No Active Draft -## Error Handling +`latest_draft` materialization returns `400 Bad Request` when the newest +standard snapshot is tagged or a preserved release source. Create a new active +standard draft, or explicitly change the component strategy to `latest_tagged`. ### Error: Component Has No Tagged Snapshots