From ac3dd03bda2a7ed2e1a78b10b534a1acaf972704 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:29:02 -0400 Subject: [PATCH] feat(release-tracks): support virtual schedule updates Persist validated schedule replacements without cloning snapshots and expose active schedules to configuration clients. --- AGENTS.md | 3 + .../definitions/components/release-tracks.yml | 7 ++ app/api/definitions/openapi.yml | 3 + .../paths/release-tracks-paths.yml | 42 ++++++++++++ app/controllers/release-tracks-controller.js | 23 +++++++ .../release-tracks/release-track-schemas.js | 1 + .../release-track-registry-model.js | 3 +- .../release-track-registry.repository.js | 19 ++++++ app/routes/release-tracks-routes.js | 8 +++ .../release-tracks/release-tracks-service.js | 19 +++++- .../release-tracks/snapshot-service.js | 7 +- .../release-tracks/virtual-track-service.js | 28 ++++++++ ...rtual-snapshot-schedule-validation.spec.js | 68 +++++++++++++++++++ docs/admin/virtual-track-schedules.md | 5 ++ docs/developer/TODO.md | 48 +++++++++++++ docs/developer/task-scheduler.md | 18 +++-- docs/user/release-tracks/api-reference.md | 18 +++++ docs/user/release-tracks/virtual-tracks.md | 5 ++ 18 files changed, 315 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba9f8cdc..5f75a714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,9 @@ parameter semantics in the `docs { }` block. deletable "graph cache" to release-track exports; drafts inherit their predecessor's manifest and only member-changing writes seal a new one. The `x-mitre-collection` object is a projection, not a stored object. +- A virtual track's `snapshot_schedule` is live registry configuration, not + historical snapshot state. Schedule changes must update the registry without + cloning a draft; Workbench snapshot responses project the current schedule. - Historic full-suite flake (fixed 2026-07-10): per-spec-file mongod restarts hit "Port already in use", failing a random file's `before` hook (visible as `loginAnonymous` 404s). `database-in-memory.js` now reuses one diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index ceb2bc22..c1d364b6 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -106,6 +106,13 @@ components: one component, while conflicts include only IDs with genuinely different revisions. Each surviving member is attributed to exactly one component in objects_contributed. + snapshot_schedule: + readOnly: true + description: | + Current registry-backed materialization schedule for virtual tracks + in Workbench-format responses. It is not historical snapshot data. + allOf: + - $ref: '#/components/schemas/snapshot-schedule' scheduled_materialization: nullable: true description: | diff --git a/app/api/definitions/openapi.yml b/app/api/definitions/openapi.yml index 1fd73ccb..bb88629f 100644 --- a/app/api/definitions/openapi.yml +++ b/app/api/definitions/openapi.yml @@ -385,6 +385,9 @@ paths: /api/release-tracks/{id}/virtual/composition: $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1composition' + /api/release-tracks/{id}/virtual/schedule: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1schedule' + /api/release-tracks/{id}/virtual/snapshots/create: $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1snapshots~1create' diff --git a/app/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml index 6c127661..df5335d9 100644 --- a/app/api/definitions/paths/release-tracks-paths.yml +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -861,6 +861,48 @@ paths: '400': description: 'Track is not virtual or composition is invalid' + /api/release-tracks/{id}/virtual/schedule: + put: + summary: 'Update a virtual track snapshot schedule' + operationId: 'release-tracks-schedule-update' + description: | + Replace the registry-backed materialization schedule for a virtual + track without creating or mutating a content snapshot. Request bodies + are strictly validated via Zod: manual accepts only mode, cron requires + one five-field UTC expression, and dates requires at least one ISO UTC + timestamp. Scheduler reconciliation observes the replacement on its + next configured pass. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: 'Snapshot schedule updated successfully' + content: + application/json: + schema: + type: object + required: + - snapshot_schedule + properties: + snapshot_schedule: + $ref: '../components/release-tracks.yml#/components/schemas/snapshot-schedule' + '400': + description: 'Track is not virtual or schedule is invalid' + '404': + description: 'Release track not found' + /api/release-tracks/{id}/virtual/snapshots/create: post: summary: 'Create a virtual track snapshot' diff --git a/app/controllers/release-tracks-controller.js b/app/controllers/release-tracks-controller.js index 62842693..1ec25853 100644 --- a/app/controllers/release-tracks-controller.js +++ b/app/controllers/release-tracks-controller.js @@ -50,6 +50,7 @@ const { updateCandidateVersionBodySchema, updateConfigBodySchema, updateCompositionBodySchema, + updateScheduleBodySchema, createVirtualSnapshotBodySchema, promoteQuarantinedObjectBodySchema, reconstructSnapshotGraphBodySchema, @@ -1014,6 +1015,28 @@ exports.updateComposition = async function updateComposition(req, res, next) { } }; +/** PUT /api/release-tracks/:id/virtual/schedule */ +exports.updateSchedule = async function updateSchedule(req, res, next) { + try { + const bodyResult = updateScheduleBodySchema.safeParse(req.body); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid snapshot schedule update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.updateSchedule(req.params.id, bodyResult.data); + logger.debug(`Success: Updated snapshot schedule for track ${req.params.id}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to update snapshot schedule: ' + err); + return next(err); + } +}; + /** POST /api/release-tracks/:id/virtual/snapshots/create */ exports.createVirtualSnapshot = async function createVirtualSnapshot(req, res, next) { try { diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js index ef897365..559e3a68 100644 --- a/app/lib/release-tracks/release-track-schemas.js +++ b/app/lib/release-tracks/release-track-schemas.js @@ -685,6 +685,7 @@ module.exports = { updateCandidateVersionBodySchema, updateConfigBodySchema, updateCompositionBodySchema, + updateScheduleBodySchema: snapshotScheduleSchema, createVirtualSnapshotBodySchema, promoteQuarantinedObjectBodySchema, reconstructSnapshotGraphBodySchema, diff --git a/app/models/release-tracks/release-track-registry-model.js b/app/models/release-tracks/release-track-registry-model.js index fd74b71d..219b95ea 100644 --- a/app/models/release-tracks/release-track-registry-model.js +++ b/app/models/release-tracks/release-track-registry-model.js @@ -87,9 +87,10 @@ const releaseTrackRegistryDefinition = { default: undefined, validate: { validator: function validateRegistrySnapshotSchedule(value) { + const trackType = typeof this.getQuery === 'function' ? this.getQuery().type : this.type; return ( value === undefined || - (this.type === 'virtual' && validateSnapshotSchedule.validator(value)) + (trackType === 'virtual' && validateSnapshotSchedule.validator(value)) ); }, message: diff --git a/app/repository/release-tracks/release-track-registry.repository.js b/app/repository/release-tracks/release-track-registry.repository.js index 80e2bc72..0d3f4135 100644 --- a/app/repository/release-tracks/release-track-registry.repository.js +++ b/app/repository/release-tracks/release-track-registry.repository.js @@ -162,6 +162,25 @@ class ReleaseTrackRegistryRepository { } } + async setSnapshotSchedule(trackId, snapshotSchedule) { + try { + return await this.model + .findOneAndUpdate( + { track_id: trackId, type: 'virtual' }, + { + $set: { + snapshot_schedule: snapshotSchedule, + updated_at: new Date(), + }, + }, + { new: true, runValidators: true, lean: true }, + ) + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async replaceTaggedReleases(trackId, taggedReleases, latestTaggedVersion) { try { return await this.model diff --git a/app/routes/release-tracks-routes.js b/app/routes/release-tracks-routes.js index 832bcd41..3febe1d3 100644 --- a/app/routes/release-tracks-routes.js +++ b/app/routes/release-tracks-routes.js @@ -309,6 +309,14 @@ router releaseTracksController.updateComposition, ); +router + .route('/release-tracks/:id/virtual/schedule') + .put( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.updateSchedule, + ); + // ============================================================================= // Delete release track (must be last -- :id is a catch-all param) // ============================================================================= diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 6b8e531d..85f12210 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -212,8 +212,12 @@ async function formatWorkbenchSnapshot(snapshot, options) { selectedTiers.flatMap((tierName) => snapshot[tierName] || []), ); const enriched = await addObjectInfoToSnapshot(snapshot); - // Registry-derived, read-only: lets clients build alias URLs for the track. - enriched.alias = await snapshotService.getTrackAlias(snapshot.id); + // Registry-derived, read-only metadata used alongside snapshot content. + const metadata = await snapshotService.getTrackMetadata(snapshot.id); + enriched.alias = metadata.alias; + if (snapshot.type === 'virtual') { + enriched.snapshot_schedule = metadata.snapshot_schedule || { mode: 'manual' }; + } return filterSnapshotTiers(enriched, options?.include); } @@ -539,6 +543,17 @@ exports.updateComposition = function updateComposition(trackId, composition, use }); }; +exports.updateSchedule = function updateSchedule(trackId, schedule) { + const scheduleResult = snapshotScheduleSchema.safeParse(schedule); + if (!scheduleResult.success) { + throw new BadRequestError({ + message: 'Invalid snapshot schedule', + details: scheduleResult.error.errors, + }); + } + return virtualTrackService.updateSchedule(trackId, scheduleResult.data); +}; + exports.createVirtualSnapshot = function createVirtualSnapshot(trackId, options) { let validatedOptions = options; if (options?.scheduledMaterialization !== undefined) { diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index 102abdda..c617d83f 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -247,9 +247,12 @@ exports.resolveTrackAlias = async function resolveTrackAlias(alias) { /** * The alias registered for a track, or null. */ -exports.getTrackAlias = async function getTrackAlias(trackId) { +exports.getTrackMetadata = async function getTrackMetadata(trackId) { const entry = await registryRepo.findByTrackId(trackId); - return entry?.alias ?? null; + return { + alias: entry?.alias ?? null, + snapshot_schedule: entry?.snapshot_schedule, + }; }; // ============================================================================= diff --git a/app/services/release-tracks/virtual-track-service.js b/app/services/release-tracks/virtual-track-service.js index d7487215..a7da8d05 100644 --- a/app/services/release-tracks/virtual-track-service.js +++ b/app/services/release-tracks/virtual-track-service.js @@ -458,6 +458,34 @@ exports.updateComposition = async function updateComposition( return snapshot; }; +/** + * Replace the persisted materialization schedule for a virtual track. + * The registry is authoritative so schedule changes do not create or mutate a + * content snapshot. The scheduler reconciliation task observes the new value. + * + * @param {string} trackId + * @param {Object} schedule + * @returns {Promise<{snapshot_schedule: Object}>} + */ +exports.updateSchedule = async function updateSchedule(trackId, schedule) { + const registry = await registryRepo.findByTrackId(trackId); + if (!registry) { + throw new TrackNotFoundError(trackId); + } + if (registry.type !== 'virtual') { + throw new BadRequestError({ + message: 'This operation is only available for virtual release tracks', + details: `Track ${trackId} is a ${registry.type} track`, + }); + } + + const updated = await registryRepo.setSnapshotSchedule(trackId, schedule); + logger.verbose( + `VirtualTrackService: Updated snapshot schedule for track "${trackId}" to ${schedule.mode}`, + ); + return { snapshot_schedule: updated.snapshot_schedule }; +}; + /** * Create a new virtual snapshot by resolving the composition rules. * diff --git a/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js b/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js index 4f6b532e..9fdc8324 100644 --- a/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js +++ b/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js @@ -52,6 +52,15 @@ describe('Virtual release-track snapshot schedule validation API', function () { return response.body.data[0]; } + async function updateSchedule(trackId, snapshotSchedule, status = 200) { + return request(app) + .put(`/api/release-tracks/${trackId}/virtual/schedule`) + .send(snapshotSchedule) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(status); + } + it('accepts and persists the fields defined by each schedule mode', async function () { const schedules = [ { mode: 'manual' }, @@ -69,6 +78,65 @@ describe('Virtual release-track snapshot schedule validation API', function () { } }); + it('updates a virtual track schedule without creating a snapshot', async function () { + const created = await createTrack({ mode: 'manual' }); + const trackId = created.body.id; + const schedule = { mode: 'cron', cron: '15 9 * * 1,3' }; + + const response = await updateSchedule(trackId, schedule); + + expect(response.body.snapshot_schedule).toEqual(schedule); + const registryTrack = await getRegistryTrack(created.name); + expect(registryTrack.snapshot_schedule).toEqual(schedule); + expect(registryTrack.snapshot_count).toBe(1); + }); + + it('returns the current registry schedule with workbench snapshots', async function () { + const created = await createTrack({ mode: 'manual' }); + const schedule = { + mode: 'dates', + dates: ['2027-01-15T09:30:00.000Z', '2027-07-15T09:30:00.000Z'], + }; + await updateSchedule(created.body.id, schedule); + + const response = await request(app) + .get(`/api/release-tracks/${created.body.id}/snapshots/latest`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200); + + expect(response.body.snapshot_schedule).toEqual(schedule); + }); + + it('replaces schedule mode fields instead of retaining stale selectors', async function () { + const created = await createTrack({ mode: 'cron', cron: '0 0 * * *' }); + + const response = await updateSchedule(created.body.id, { mode: 'manual' }); + + expect(response.body.snapshot_schedule).toEqual({ mode: 'manual' }); + expect(await getRegistryTrack(created.name)).toEqual( + expect.objectContaining({ snapshot_schedule: { mode: 'manual' } }), + ); + }); + + it('rejects invalid updates and schedule updates on standard tracks', async function () { + const virtual = await createTrack({ mode: 'manual' }); + const standard = await createTrack(undefined, 201, 'standard'); + + await updateSchedule(virtual.body.id, { mode: 'cron' }, 400); + await updateSchedule(virtual.body.id, { mode: 'manual', cron: '0 0 * * *' }, 400); + await updateSchedule(standard.body.id, { mode: 'manual' }, 400); + await updateSchedule( + 'release-track--11111111-1111-4111-8111-111111111111', + { + mode: 'manual', + }, + 404, + ); + + expect(() => releaseTracksService.updateSchedule(virtual.body.id, { mode: 'cron' })).toThrow(); + }); + it('rejects fields that do not apply to manual schedules', async function () { const invalidSchedules = [ { mode: 'manual', cron: '0 0 1 1,7 *' }, diff --git a/docs/admin/virtual-track-schedules.md b/docs/admin/virtual-track-schedules.md index 7aad65cd..a184c35a 100644 --- a/docs/admin/virtual-track-schedules.md +++ b/docs/admin/virtual-track-schedules.md @@ -21,6 +21,11 @@ processed after startup. `manual` schedules register no executable work. Operators must call `POST /api/release-tracks/:id/virtual/snapshots/create`. +Editors can replace the active schedule through +`PUT /api/release-tracks/:id/virtual/schedule`. The change is visible +immediately in track and Workbench-format snapshot responses; executable jobs +are refreshed on the next `VIRTUAL_TRACK_SCHEDULES_CRON` reconciliation pass. + ## Idempotency and multiple instances The `virtualTrackScheduleOccurrences` collection stores one durable occurrence diff --git a/docs/developer/TODO.md b/docs/developer/TODO.md index e6ff635f..d0f86186 100644 --- a/docs/developer/TODO.md +++ b/docs/developer/TODO.md @@ -1,5 +1,53 @@ # Release Track TODOs +## Virtual release-track schedule configuration + +- [x] Fix schedule saves submitting unsupported deduplication fields: remove + preferred tier/status controls and compare composition to its initial + editable state, including normalized priorities and defaults. +- [x] Verify server-shaped schedule-only save and strategy-change regressions, + full frontend tests, and production build. + Focused: 94 passing; full: 167 files / 410 tests passing; production build + and changed-file lint pass. Backend schema rejects precisely + `tier_resolution`/`status_resolution` and accepts the corrected payload. + Proposed commit: `fix(release-tracks): avoid invalid composition updates`. + Body: Remove unsupported deduplication controls and compare the edited + composition to its initial form state so schedule-only saves skip cloning. + +- [x] Add controlled natural-language schedule autocomplete with hourly and + 15/30-minute presets and guided customization. +- [x] Verify autocomplete regressions, full frontend suite, and production build. + Focused tests: 94 passing; full frontend: 167 files / 410 tests passing; + production build, changed-file lint, formatting, and diff checks pass. + Proposed commit: `feat(release-tracks): autocomplete schedule presets`. + Body: Map selected schedule phrases to deterministic UTC cron expressions + and support hourly and 15/30-minute guided customization. + +- [x] Review persisted schedule validation, storage, scheduler execution, and + existing regression coverage. +- [x] Add an authenticated virtual-track schedule update endpoint with strict + validation and persistence. +- [x] Add backend regression tests, OpenAPI documentation, user/developer + documentation, and Bruno coverage. +- [x] Add a controlled frontend schedule editor for manual, recurring cron, + and explicit-date schedules, without free-text cron entry. +- [x] Add frontend connector/component regressions and usage documentation. +- [x] Run focused checks, then the complete backend and frontend suites. +- [x] Propose conventional commit messages without committing. + +Verification (2026-09-09): + +- Backend focused schedule/API and scheduler specs: 19 passing; OpenAPI: 2 + passing; changed-file ESLint clean. +- Backend complete `npm test`: OpenAPI 2, config 22, API 1024, middleware 29, + and scheduler 10 passing. +- Frontend focused component/connector specs: 92 passing; complete suite: 167 + files and 408 tests passing; application TypeScript and production build + pass; changed-file ESLint has no errors. +- Proposed commits: `feat(release-tracks): add virtual schedule updates`, + `feat(release-tracks): add guided snapshot scheduling`, and + `docs(release-tracks): add virtual schedule request`. + ## Sealed snapshot content manifests (Problem 1) Design: [release-tracks/sealed-content-manifests.md](release-tracks/sealed-content-manifests.md). diff --git a/docs/developer/task-scheduler.md b/docs/developer/task-scheduler.md index 70824e73..f6877ea1 100644 --- a/docs/developer/task-scheduler.md +++ b/docs/developer/task-scheduler.md @@ -7,6 +7,7 @@ - All the scheduler does is load the task module. It is up to the module defining the task to (1) implement the task, (2) load the task with the `node-schedule` library, and (3) execute the loader in the global scope Example: + ```javascript /** * Initialize and schedule this task @@ -27,14 +28,16 @@ function initializeTask() { logger.info(`[here-is-my-task-name] Task scheduled successfully`); } -if (config.scheduler.enableScheduler) { // <-- make sure to condition the task to only load if globally enabled! +if (config.scheduler.enableScheduler) { + // <-- make sure to condition the task to only load if globally enabled! initializeTask(); } ``` + - The old task scheduler (formerly known as the "collection manager") is now defined in `app/scheduler/sync-collection-indexes-task.js` - Adds a new global runtime configuration setting for toggling on/off all scheduled tasks. The environment variable is `ENABLE_SCHEDULER` and it maps to `config.scheduler.enableScheduler`. - Adds a new CRON pattern for configuring when tasks are scheduled. - - The `SYNC_COLLECTION_INDEXES_CRON` environment variable is read at runtime to determine the periodicity that the scheduler should use for the former collection manager (now the `sync-collection-indexes-tasks`). It maps to `config.scheduler.syncCollectionIndexesCron`. + - The `SYNC_COLLECTION_INDEXES_CRON` environment variable is read at runtime to determine the periodicity that the scheduler should use for the former collection manager (now the `sync-collection-indexes-tasks`). It maps to `config.scheduler.syncCollectionIndexesCron`. - Future tasks must follow a similar pattern: - Add the task file @@ -64,11 +67,16 @@ after the scheduled snapshot was already created. Do not put release-track composition logic in the scheduler task. It delegates to `virtual-track-service`, which is also used by the explicit HTTP operation. +Schedule changes use `PUT /api/release-tracks/:id/virtual/schedule`. The write +replaces `releaseTrackRegistry.snapshot_schedule` atomically and does not clone +the latest snapshot. The next reconciliation pass refreshes or cancels the +track-local cron job and registers any due explicit dates. + ## TODO - [ ] Add robust documentation to `USAGE.md` explaining how task scheduling works and how to create new tasks - [ ] In the future we should add the ability to dynamically load tasks without having to clone the repository and modify the `app/` source code. This new design pattern makes it possible to define them elsewhere and mount them via Docker volume. - [ ] There is another task called `check-wip-attack-ids-task.js` that should probably be deleted - - It was created with the goal of restricting ATT&CK IDs to only exist on non-WIP objects - - That conversation is sort of out of scope - - I think we're going to move away from this approach and that the task will probably be moot + - It was created with the goal of restricting ATT&CK IDs to only exist on non-WIP objects + - That conversation is sort of out of scope + - I think we're going to move away from this approach and that the task will probably be moot diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index bc4ad84e..b6078442 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -105,6 +105,7 @@ GET /api/release-tracks/:id/objects/:objectRef/versions ``` PUT /api/release-tracks/:id/virtual/composition +PUT /api/release-tracks/:id/virtual/schedule POST /api/release-tracks/:id/virtual/snapshots/create POST /api/release-tracks/:id/virtual/quarantine/promote ``` @@ -1409,6 +1410,23 @@ each referenced track must already exist and must be a standard track. Virtual tracks cannot reference other virtual tracks, and unsupported top-level properties such as `native_members` return `400 Bad Request`. +### Update Virtual Track Schedule + +``` +PUT /api/release-tracks/:id/virtual/schedule +``` + +Replaces a virtual track's persisted `snapshot_schedule` without creating or +modifying a content snapshot. The body is one of the same strict `manual`, +`cron`, or `dates` shapes accepted during track creation. The response contains +the normalized value under `snapshot_schedule`. Standard tracks return +`400 Bad Request`; unknown tracks return `404 Not Found`. + +Workbench-format snapshot responses expose the current registry-backed +`snapshot_schedule` so configuration clients do not mistake a historical +snapshot for the active schedule. Scheduler reconciliation applies a saved +change on its next configured pass. + ### Update Virtual Track Composition ``` diff --git a/docs/user/release-tracks/virtual-tracks.md b/docs/user/release-tracks/virtual-tracks.md index 34127c42..b0c49a57 100644 --- a/docs/user/release-tracks/virtual-tracks.md +++ b/docs/user/release-tracks/virtual-tracks.md @@ -12,6 +12,11 @@ Virtual release tracks are computed aggregations of standard release tracks. The - Create snapshots **manually or on schedule** (never event-driven) - All snapshots start as **drafts** and must be explicitly tagged +The active schedule is registry metadata rather than historical snapshot +content. Replace it with `PUT /api/release-tracks/:id/virtual/schedule`; this +does not create a draft. Workbench-format snapshot responses project the +current schedule for configuration interfaces. + ## Use Cases ### Scenario 1: Different Cadences for Different Object Types