From 7acb8115334b876943444ecc41080f5a9813bf3f Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Mon, 12 Oct 2026 12:27:41 -0500 Subject: [PATCH] Require reviewed reporting channels and complete schedule coverage before activation --- AGENTS.md | 2 +- docs/architecture.md | 6 + docs/data-model.md | 27 +- docs/upgrading-to-model-v12.md | 31 + package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/AGENTS.md | 2 + packages/create-filegrc/template/README.md | 2 + .../create-filegrc/template/data/AGENTS.md | 2 + .../template/data/obligations/AGENTS.md | 2 + .../template/data/workspace.json | 2 +- packages/create-filegrc/template/package.json | 2 +- packages/create-filegrc/test/create.test.js | 2 +- packages/filegrc/README.md | 6 +- packages/filegrc/model/index.js | 9 +- packages/filegrc/model/v12.json | 12298 ++++++++++++++++ packages/filegrc/package.json | 2 +- packages/filegrc/src/activation-readiness.js | 90 + .../src/collection-review-integrity.js | 3 +- packages/filegrc/src/collection-review.js | 26 +- packages/filegrc/src/collection-revision.js | 12 +- packages/filegrc/src/collection-scope.js | 4 + packages/filegrc/src/document-activation.js | 4 + packages/filegrc/src/historical-workspace.js | 3 +- packages/filegrc/src/model-migration.js | 26 +- packages/filegrc/src/obligation-rule.js | 16 +- packages/filegrc/src/obligations.js | 1 + packages/filegrc/src/policy-activation.js | 5 + packages/filegrc/src/program-readiness.js | 64 +- packages/filegrc/src/reporting-route-sets.js | 6 +- packages/filegrc/src/schedule-set.js | 142 + packages/filegrc/src/server.js | 1 + packages/filegrc/src/state.js | 5 + packages/filegrc/src/validate.js | 34 + packages/filegrc/src/web.js | 103 +- .../filegrc/src/workflow-history-integrity.js | 9 +- packages/filegrc/test/fixtures.js | 3 +- packages/filegrc/test/helpers.js | 10 + packages/filegrc/test/model-migration.test.js | 2 +- packages/filegrc/test/model.test.js | 13 +- .../filegrc/test/program-readiness.test.js | 47 +- packages/filegrc/test/render.test.js | 11 + .../filegrc/test/reporting-route-sets.test.js | 67 +- packages/filegrc/test/schedule-set.test.js | 224 + 46 files changed, 13212 insertions(+), 130 deletions(-) create mode 100644 docs/upgrading-to-model-v12.md create mode 100644 packages/filegrc/model/v12.json create mode 100644 packages/filegrc/src/activation-readiness.js create mode 100644 packages/filegrc/src/schedule-set.js create mode 100644 packages/filegrc/test/schedule-set.test.js diff --git a/AGENTS.md b/AGENTS.md index 355a469..9f69581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ Treat headless use as a first-class interface. An agent with no FileGRC context ## Data rules -The active authoritative model registry is the standalone `packages/filegrc/model/v11.json`. Models v1 through v10 are published and frozen for migrations, compatibility tests, and reference. Breaking changes belong in a new model version with an explicit migration path. Keep the active model, starter data, generated docs, and tests in sync. +The active authoritative model registry is the standalone `packages/filegrc/model/v12.json`. Models v1 through v11 are published and frozen for migrations, compatibility tests, and reference. Breaking changes belong in a new model version with an explicit migration path. Keep the active model, starter data, generated docs, and tests in sync. - Use UTF-8 JSON for structured records and Markdown for long-form content. - Store canonical long-form Markdown beside its structured JSON record. FileGRC derives companion names from the JSON location and Markdown slot; do not store those paths in record data. diff --git a/docs/architecture.md b/docs/architecture.md index 84bd2f4..1617b3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -769,3 +769,9 @@ Core derives an optional FileGRC Autopilot recommendation after Step 3 implement The persistent renderer entry links to the Autopilot setup guide when no valid connection marker exists. An always-visible Autopilot button sits in a compact explanatory box above the organization area at the bottom of the sidebar and opens setup before signup or the dashboard when a valid `.filegrc/hosted-automation.json` v1 marker exists. Configured repositories omit the page-level hosted offers and connection descriptions. Shared domain logic exposes configured local state through the browser, recommendations, and `filegrc automation`; live status remains unknown. Dashboard links include an optional normalized GitHub origin repository hint, never a raw remote or credential. Invalid and unsupported markers produce warnings without blocking local work. Removing the marker does not disconnect service. The hosted service reads Git content and sends follow-up while the local app is closed. Its dashboard manages connections, billing, confirmed person links, and delivery health. Core has no hosted credentials, dependencies, or automatic policy writes. Program contacts remain in Git; the service database holds notification settings, Person-to-Slack links, credentials, and operational delivery state. Source systems and local users still perform and record the work. At the Step 3 cutover, `program-path --next` names FileGRC Autopilot as `step.nextAction` and `primaryRecommendation`. `secondaryAction` names Continue to content activation without Autopilot and preserves the required activation action, context, and commands. The recommendation remains outside readiness calculations. Step 4 retains the prominent recommended offer beside the Work Queue for later opt-in, while `step.nextAction` continues to name operating work. The shared operating recommendation is discoverable through text and JSON `obligations` and `workflow` output, with the local Work Queue as the continuation path. Both Step 3 and Step 4 explain that owner reminders continue when the local app is closed, with email first and optional Slack escalation. They use the same blue gradient hero treatment; neither tracks a clicked link as setup completion. + +## Pre-activation review in model v12 + +Reporting Channel Set proposals require committed independent approval during Step 1. Step 3 checks the approved revision, live support, and exact effective interval at content cutover. The same derived reporting check runs in activation planners and validation. + +Each Program has an exact-revision Obligation Collection Review. Its complete population includes linked active schedules, unresolved proposals, and retired duplicates. Per-schedule decisions distinguish adopted rules, adopted replacements, retired schedules, and intentional inapplicability. Pending alternative rule proposals need explicit exclusion. Changes to schedules, rules, selected scope, or governing content invalidate this approval; the approved-to-active content transition preserves its design binding. Ordinary setup Collection Reviews retain their existing historical confirmation semantics. diff --git a/docs/data-model.md b/docs/data-model.md index 8e0e1d3..4752f22 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,8 +1,8 @@ # GRC Data Model - + -Model version: `11` +Model version: `12` Model v11 lets Control owners record implementation directly and moves Control oversight into periodic, Git-bound collection reviews. Independent Policy, Document, and Training approvals remain unchanged. @@ -162,8 +162,8 @@ Relationship fields use the named groups below. The registry expands each group | Group | Resource types | | --- | --- | | `accountable-party` | `person`, `team`, `appointment` | -| `collection-review-population` | `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control` | -| `appointment-scope` | `workspace`, `person`, `service-account`, `team`, `system`, `asset`, `document`, `evidence`, `obligation`, `obligation-event`, `framework`, `requirement`, `commitment`, `complementary-control`, `control`, `control-test`, `finding`, `exception`, `action-item`, `policy`, `policy-review`, `attestation`, `meeting`, `training`, `risk`, `risk-assessment`, `vendor`, `vendor-review`, `access-grant`, `access-review`, `vulnerability`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping`, `obligation-rule`, `obligation-occurrence`, `reporting-route`, `reporting-route-set` | +| `collection-review-population` | `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control`, `obligation` | +| `appointment-scope` | `workspace`, `person`, `service-account`, `team`, `system`, `asset`, `document`, `evidence`, `obligation`, `obligation-event`, `framework`, `requirement`, `commitment`, `complementary-control`, `control`, `control-test`, `finding`, `exception`, `action-item`, `policy`, `policy-review`, `attestation`, `meeting`, `training`, `risk`, `risk-assessment`, `vendor`, `vendor-review`, `access-grant`, `access-review`, `vulnerability`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping`, `obligation-rule`, `obligation-occurrence`, `reporting-route`, `reporting-route-set`, `program` | | `obligation-scope` | `workspace`, `person`, `appointment`, `service-account`, `team`, `system`, `asset`, `document`, `framework`, `requirement`, `commitment`, `complementary-control`, `control`, `policy`, `training`, `risk`, `vendor`, `access-grant`, `vulnerability`, `incident`, `audit`, `source-coverage`, `retention-schedule-item`, `requirement-mapping`, `component`, `information-type` | | `obligation-template` | `person`, `system`, `asset`, `document`, `control`, `policy`, `training`, `retention-schedule-item`, `requirement-mapping` | | `completion-record` | `access-grant`, `asset`, `control`, `document`, `evidence`, `control-test`, `finding`, `exception`, `action-item`, `policy-review`, `policy`, `attestation`, `meeting`, `risk-assessment`, `risk`, `vendor`, `vendor-review`, `access-review`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping` | @@ -188,6 +188,7 @@ FileGRC derives record issues, but it cannot infer that management finished an a | `information-type` | Information Type inventory | `complete`, `zero-population` | Review near-duplicate names and choose one canonical Information Type only after confirming they mean the same thing. Retire superseded records through a reviewed migration that rewrites every relationship. Confirm every active Information Type has an approved classification and a retention schedule item or an explicit management-review prompt. | | `retention-schedule-item` | Data Retention Schedule | `complete`, `zero-population` | Confirm the owner completed every row proposal, then review the Data Retention Schedule document, its Markdown, and every proposed row as one revision. Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses. Confirm every proposed item has a reviewed cutoff, retention period, disposition action, owner, and exact source revisions; keep undecided behavior planned. | | `control` | Control implementation oversight | `complete` | Perform this one setup review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content. Confirm owners, procedures, scope, operation patterns, mappings, Obligations, and authoritative evidence sources remain current. Record follow-up work separately when the review finds a gap. | +| `obligation` | Obligation schedule set | `complete`, `zero-population` | Review every active and proposed schedule and every pending rule revision against the governing requirements. Confirm adopted rules cover recurring and event work, owners, populations, deadlines, and the intended cutover. Name adopted replacements or explain exclusions; do not activate unused starter proposals. | ## Relationship constraints @@ -663,6 +664,17 @@ Key format: `namespace`. | `reason` | string | Yes | | | `evidenceIds` | array of id | Yes | References: `evidence` | +### `schedule-disposition` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `obligationId` | id | Yes | References: `obligation` | +| `disposition` | enum | Yes | Values: `adopted`, `replaced`, `retired`, `inapplicable` | +| `ruleId` | id | No | References: `obligation-rule` | +| `replacementObligationIds` | array of id | Conditional | References: `obligation` Required when `disposition` is `replaced`. | +| `excludedRuleIds` | array of id | No | References: `obligation-rule` | +| `rationale` | string | Yes | | + ## Common fields | Field | Type | Required | Meaning | @@ -968,7 +980,7 @@ Record Markdown: available when needed as an implicit companion file. | Field | Type | Required | Notes | | --- | --- | --- | --- | | `status` | enum | Yes | Values: `planned`, `active`, `retired` | -| `resourceType` | enum | Yes | Reviewed resource type Values: `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control` Values come from the `collectionReviews` registry. | +| `resourceType` | enum | Yes | Reviewed resource type Values: `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control`, `obligation` Values come from the `collectionReviews` registry. | | `decision` | enum | Conditional | Values: `complete`, `zero-population`, `externally-managed`, `not-applicable` Required when `status` is `active`. | | `rationale` | string | Conditional | Required when `status` is `active`. | | `reviewedByIds` | array of id | Conditional | Reviewed by References: `person` Required when `status` is `active`. | @@ -981,7 +993,8 @@ Record Markdown: available when needed as an implicit companion file. | `supersedesId` | id | No | References: `collection-review` | | `coverage` | object (`coverage-period`) | No | Reviewed temporal coverage | | `knowledgeCutoffAt` | timestamp | No | Knowledge cutoff | -| `populationResourceIds` | array of id | No | Reviewed population Relation group: `collection-review-population`. References: `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control` | +| `populationResourceIds` | array of id | No | Reviewed population Relation group: `collection-review-population`. References: `person`, `framework`, `vendor`, `system`, `complementary-control`, `component`, `classification`, `information-type`, `retention-schedule-item`, `control`, `obligation` | +| `scheduleDecisions` | array of object (`schedule-disposition`) | Conditional | Schedule decisions Required when `resourceType` is `obligation` and `status` is `active` and `decision` is `complete`. Allowed when `resourceType` is `obligation`. | #### `requirement-mapping` @@ -1075,7 +1088,7 @@ Record Markdown: available when needed as an implicit companion file. | `status` | enum | Yes | Values: `planned`, `active`, `ended` | | `appointmentKind` | string (id) | Yes | Appointment kind | | `holderId` | id | Conditional | Holder References: `person` Required when `status` is one of `active`, `ended`. | -| `scopeResourceIds` | array of id | Yes | Scope Relation group: `appointment-scope`. References: `workspace`, `person`, `service-account`, `team`, `system`, `asset`, `document`, `evidence`, `obligation`, `obligation-event`, `framework`, `requirement`, `commitment`, `complementary-control`, `control`, `control-test`, `finding`, `exception`, `action-item`, `policy`, `policy-review`, `attestation`, `meeting`, `training`, `risk`, `risk-assessment`, `vendor`, `vendor-review`, `access-grant`, `access-review`, `vulnerability`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping`, `obligation-rule`, `obligation-occurrence`, `reporting-route`, `reporting-route-set` | +| `scopeResourceIds` | array of id | Yes | Scope Relation group: `appointment-scope`. References: `workspace`, `person`, `service-account`, `team`, `system`, `asset`, `document`, `evidence`, `obligation`, `obligation-event`, `framework`, `requirement`, `commitment`, `complementary-control`, `control`, `control-test`, `finding`, `exception`, `action-item`, `policy`, `policy-review`, `attestation`, `meeting`, `training`, `risk`, `risk-assessment`, `vendor`, `vendor-review`, `access-grant`, `access-review`, `vulnerability`, `vulnerability-scan`, `incident`, `exercise`, `backup-test`, `penetration-test`, `data-request`, `audit`, `audit-population`, `audit-request`, `source-coverage`, `control-activity`, `retention-schedule-item`, `requirement-mapping`, `obligation-rule`, `obligation-occurrence`, `reporting-route`, `reporting-route-set`, `program` | | `startsOn` | date | Conditional | Required when `status` is one of `active`, `ended`. | | `endsOn` | date | Conditional | Required when `status` is `ended`. | | `appointedByIds` | array of id | No | Appointed by References: `person` | diff --git a/docs/upgrading-to-model-v12.md b/docs/upgrading-to-model-v12.md new file mode 100644 index 0000000..d6ebca6 --- /dev/null +++ b/docs/upgrading-to-model-v12.md @@ -0,0 +1,31 @@ +# Upgrade to model v12 + +Model v12 requires management to review the complete Obligation schedule set before activating program content. It also requires independent Reporting Channel Set approval in Step 1 and checks channel usability and effective coverage at Step 3 cutover. + +Commit the current v11 workspace first so existing activation history remains retrievable. Preview the migration, then apply the same target: + +```sh +npx filegrc migrate --to-model 12 --preview --json +npx filegrc migrate --to-model 12 --yes --json +``` + +The migration changes only the Workspace model version. It preserves content, approvals, operating rules, historical occurrences, and prior Collection Reviews. It does not infer adoption or exclusion decisions. Existing operation remains recorded, while readiness exposes missing reviews. Older workspaces migrate one version at a time. + +For each Program, inspect its Controls, governing content, linked Obligations, and all active and proposed rules. Commit the reviewed source records, then scaffold the review: + +```sh +npx filegrc review-collection obligation --program PROGRAM_ID --scaffold > schedules.json +npx filegrc review-collection obligation --program PROGRAM_ID schedules.json --preview --json +npx filegrc review-collection obligation --program PROGRAM_ID schedules.json --yes --json +``` + +Fill each `scheduleDecisions` entry with a rationale and one disposition: + +- `adopted`: the active schedule and approved rule cover the work at cutover. Name the current rule-chain head as `ruleId` for rule-based schedules; the exact review also covers its approved predecessors at their effective intervals. Name `ruleId` and explicitly list unused pending rule proposals in `excludedRuleIds`. +- `replaced`: name adopted `replacementObligationIds` covering the same selected Controls and required event. The original must be proposed or retired. +- `retired`: the schedule is a retired duplicate with its recorded lifecycle reason. +- `inapplicable`: explain why the proposed or retired schedule does not apply to this Program. + +The review needs an independent active Person, today's workspace-local date, and the exact displayed `expectedCollectionRevision`. It covers active rules as well as proposals. A changed schedule, rule, governing requirement, or selected scope requires another review before activation. Content activation itself does not reopen the schedule review. + +Do not activate every starter proposal. Resolve the intended schedules and exclusions, finish Control oversight, then preview `activate-content` with the intended effective date. Reporting channels need a committed independent approval of the exact proposal and must be usable and effective when the governing requirement starts. Approval alone does not establish operational readiness. diff --git a/package-lock.json b/package-lock.json index 44bfafa..074bf1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.36", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.36", + "version": "0.17.0", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.36", + "version": "0.17.0", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.36", + "version": "0.17.0", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 4ff5042..32a56c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.36", + "version": "0.17.0", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index a342c0b..0cd2e6a 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.36", + "version": "0.17.0", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index a2775c3..e4c99f1 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.16.36", + version: "0.17.0", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.36", + version: "0.17.0", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index f5c64a5..04ae0dd 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -254,3 +254,5 @@ After substantive edits: 5. Run `npm run build` when producing a read-only audit view. Do not loosen validation to make a bad record pass. Fix the record or update the installed engine through its normal versioned model process. + +Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index 9e2c4aa..ad09835 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -125,3 +125,5 @@ For a SOC 2 engagement, scope all 33 Security Common Criteria, all nine Descript Do not put plaintext credentials, private keys, tokens, recovery codes, or personal data that may need erasure into Git. Source-controlled ciphertext is allowed only under the Information Security Policy's approved encryption, separate-key, access, and rotation conditions. The editable local server has no authentication and binds to loopback by default. Learn more at [filegrc.com](https://filegrc.com) or [view the source on GitHub](https://github.com/Alignbase/filegrc). + +Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). diff --git a/packages/create-filegrc/template/data/AGENTS.md b/packages/create-filegrc/template/data/AGENTS.md index b51b1de..a29b93c 100644 --- a/packages/create-filegrc/template/data/AGENTS.md +++ b/packages/create-filegrc/template/data/AGENTS.md @@ -243,3 +243,5 @@ git diff Review every changed JSON, Markdown, and attachment. Confirm the diff contains no plaintext credentials, private keys, tokens, recovery codes, improperly controlled ciphertext, temporary files, source exports with prohibited data, or derived `.filegrc/` output. Make one focused commit whose message says why the compliance record changed. These commands are for CLI and agent work, which continues to manage Git explicitly. Browser saves in trunk mode commit automatically from the configured authoritative branch, then push in the background while the UI reports `Syncing`. Do not start another write until it reports `Synced`. Do not use a feature branch as a record approval state, and never include application changes when this workspace lives in a monorepo. + +Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). diff --git a/packages/create-filegrc/template/data/obligations/AGENTS.md b/packages/create-filegrc/template/data/obligations/AGENTS.md index 022b14d..2955003 100644 --- a/packages/create-filegrc/template/data/obligations/AGENTS.md +++ b/packages/create-filegrc/template/data/obligations/AGENTS.md @@ -13,3 +13,5 @@ An obligation is a reusable policy schedule or event template. It is not the rec - Pause or retire a template only when the underlying policy work no longer applies. Do not delete historical templates that explain prior periods. Use `npx filegrc obligations --json` to inspect calculated recurring work and preview every Policy Event task, owner, deadline, and requested proof. Run `npx filegrc complete OBLIGATION_ID --scaffold --window-start YYYY-MM-DD --completed-on YYYY-MM-DD > completion-mutation.json`, fill the actual work and proof, then pass that file to `npx filegrc complete OBLIGATION_ID completion-mutation.json`. The scaffold includes the current Obligation revision, and the final command creates and links the dated occurrence in one validated write. Use `npx filegrc trigger EVENT_TYPE ...` only after the matching event occurs; it adds all configured Action Items to the Work Queue atomically. + +Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). diff --git a/packages/create-filegrc/template/data/workspace.json b/packages/create-filegrc/template/data/workspace.json index c3cc715..54e9b62 100644 --- a/packages/create-filegrc/template/data/workspace.json +++ b/packages/create-filegrc/template/data/workspace.json @@ -1,5 +1,5 @@ { - "dataModelVersion": "11", + "dataModelVersion": "12", "id": "workspace", "type": "workspace", "title": "{{program_title}}", diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index ab1d88a..3785149 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.16.36", + "version": "0.17.0", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/create-filegrc/test/create.test.js b/packages/create-filegrc/test/create.test.js index f235d73..e95cf06 100644 --- a/packages/create-filegrc/test/create.test.js +++ b/packages/create-filegrc/test/create.test.js @@ -114,7 +114,7 @@ test("creates a complete generic repository with one dependency", async (context assert.equal(result.gitMode, "initialized"); assert.equal(result.gitBranch, "main"); const workspace = JSON.parse(await readFile(join(target, "data", "workspace.json"), "utf8")); - assert.equal(workspace.dataModelVersion, "11"); + assert.equal(workspace.dataModelVersion, "12"); const generatedJson = (await collectTextFiles(join(target, "data"))).filter((path) => path.endsWith(".json")); const generatedRecords = await Promise.all(generatedJson.map(async (path) => ( JSON.parse(await readFile(path, "utf8")) diff --git a/packages/filegrc/README.md b/packages/filegrc/README.md index e475d4e..ca9e310 100644 --- a/packages/filegrc/README.md +++ b/packages/filegrc/README.md @@ -44,7 +44,7 @@ npx filegrc policy-library The command shows an exact diff only when the current text still matches the prior starter default. It skips customized, approved, active, superseded, and retired Policy content. Accept one named proposal revision only with the command printed by the review, which includes `--accept`, `--proposal-revision`, and `--yes`. Acceptance fails if the proposal changed after review. It changes only the listed defaults and does not approve a Policy, activate it, or mark a Control implemented. -The normal runtime uses data model v11. Existing model v10 workspaces start with a read-only preview: +The normal runtime uses data model v12. Model v11 adds Control oversight; existing model v10 workspaces start with a read-only preview: ```sh npx filegrc migrate --to-model 11 --preview --json @@ -66,7 +66,7 @@ A model v1 workspace must migrate to v2 first: npx filegrc migrate --to-model 2 --preview --json ``` -Follow the [model v2 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v2.md), then migrate one model version at a time through model v11. +Follow the [model v2 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v2.md), then migrate one model version at a time through model v12. `filegrc serve --help` prints bind, port, environment, and safety options without starting the server. The editable server prefers `127.0.0.1:8787` and chooses another available port when that port is occupied. Set `FILEGRC_HOST`, `FILEGRC_PORT`, or the matching flags when needed. In trunk mode, browser saves synchronize, commit, and push from the authoritative branch. Use `--allow-non-authoritative-writes` for local development in a task checkout; the override never commits or pushes. @@ -117,3 +117,5 @@ Version 1 accepts exactly these fields. The ID contains 1–128 ASCII letters, d Core builds dashboard links as `https://app.filegrc.com/connections/?repository=`. It derives the optional repository hint from one supported GitHub `origin` URL without contacting GitHub, omitting it when unavailable or ambiguous. The hint grants no authorization. Invalid or unsupported markers produce warnings and leave local workflows available; Core never rewrites the marker. Track this file in Git. Existing repositories that ignore `.filegrc/` should use `.filegrc/*` and add `!.filegrc/hosted-automation.json`. Person mappings and delivery policy remain separate files. + +Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). diff --git a/packages/filegrc/model/index.js b/packages/filegrc/model/index.js index aa434b7..fdc3d60 100644 --- a/packages/filegrc/model/index.js +++ b/packages/filegrc/model/index.js @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; -export const ACTIVE_MODEL_VERSION = "11"; -export const SUPPORTED_MODEL_VERSIONS = Object.freeze(["2", "3", "4", "5", "6", "7", "8", "9", "10", ACTIVE_MODEL_VERSION]); +export const ACTIVE_MODEL_VERSION = "12"; +export const SUPPORTED_MODEL_VERSIONS = Object.freeze(["2", "3", "4", "5", "6", "7", "8", "9", "10", "11", ACTIVE_MODEL_VERSION]); export const MODEL_CAPABILITY_VERSIONS = Object.freeze({ "guided-workflow": 3, "program-scope": 4, @@ -18,7 +18,8 @@ export const MODEL_CAPABILITY_VERSIONS = Object.freeze({ "reporting-routes": 9, "reporting-route-sets": 10, "control-collection-oversight": 11, - "retention-schedule-approval": 11 + "retention-schedule-approval": 11, + "schedule-set-review": 12 }); export function modelSupports(modelOrVersion, capability) { @@ -35,7 +36,7 @@ export function loadModel(version = ACTIVE_MODEL_VERSION) { if (!SUPPORTED_MODEL_VERSIONS.includes(requested)) { const migrationTarget = requested === "1" ? "2" : requested === "2" ? "3" : requested === "3" ? "4" : requested === "4" ? "5" : requested === "5" ? "6" : requested === "6" ? "7" : requested === "7" ? "8" : requested === "8" ? "9" : requested === "9" ? "10" : "11"; throw new Error( - `Unsupported data model version "${requested}". This filegrc release supports models v2 through v11. ` + `Unsupported data model version "${requested}". This filegrc release supports models v2 through v12. ` + `Run \`npx filegrc migrate --to-model ${migrationTarget} --preview --json\` from the workspace root.` ); } diff --git a/packages/filegrc/model/v12.json b/packages/filegrc/model/v12.json new file mode 100644 index 0000000..a1fc51a --- /dev/null +++ b/packages/filegrc/model/v12.json @@ -0,0 +1,12298 @@ +{ + "modelVersion": "12", + "title": "FileGRC data model v11", + "description": "Model v11 lets Control owners record implementation directly and moves Control oversight into periodic, Git-bound collection reviews. Independent Policy, Document, and Training approvals remain unchanged.", + "recordContent": { + "slot": "record", + "label": "Record", + "defaultResourceTypes": [ + "system", + "control-test", + "finding", + "exception", + "policy-review", + "risk", + "risk-assessment", + "vendor-review", + "access-review", + "vulnerability", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit-population", + "source-coverage", + "control-activity", + "component", + "obligation-occurrence", + "reporting-route-set" + ] + }, + "auditReadiness": { + "managementDocuments": [ + { + "field": "systemDescriptionDocumentId", + "kind": "soc2-system-description", + "title": "System Description", + "engagementKinds": [ + "soc-2-type-1", + "soc-2-type-2" + ], + "timing": "Complete before the auditor finalizes the description of the system.", + "minimumWords": 250, + "requiredHeadings": [ + "DC1", + "DC2", + "DC3", + "DC4", + "DC5", + "DC6", + "DC7", + "DC8", + "DC9" + ], + "dateBinding": "engagement" + }, + { + "field": "managementAssertionDocumentId", + "kind": "soc2-management-assertion", + "title": "Management Assertion", + "engagementKinds": [ + "soc-2-type-1", + "soc-2-type-2" + ], + "timing": "Agree on final wording with the auditor and approve it for the reporting date or period.", + "minimumWords": 75, + "dateBinding": "engagement" + }, + { + "field": "periodCompletenessDocumentId", + "kind": "soc2-period-completeness", + "title": "Period Completeness Statement", + "engagementKinds": [ + "soc-2-type-2" + ], + "timing": "Complete after reconciling every audit population, including populations with zero items.", + "minimumWords": 100, + "dateBinding": "engagement" + }, + { + "field": "managementRepresentationDocumentId", + "kind": "soc2-management-representation", + "title": "Management Representation Letter", + "engagementKinds": [ + "soc-2-type-1", + "soc-2-type-2" + ], + "timing": "Reconcile and sign the auditor-provided letter near the end of fieldwork.", + "minimumWords": 100, + "dateBinding": "engagement" + } + ], + "populationTemplates": [ + { + "kind": "workforce-changes", + "title": "Workforce Starts, Role Changes, and Departures", + "sourcePrompt": "Workforce Component", + "sourceKind": "workforce", + "timing": "Export after the period closes and before the auditor selects samples.", + "controlCodes": [ + "HR-01", + "HR-02", + "HR-03", + "IAM-03" + ] + }, + { + "kind": "access-changes", + "title": "Access Grants, Changes, Reviews, and Removals", + "sourcePrompt": "Identity provider and application access sources", + "sourceKind": "identity-access", + "timing": "Export after the period closes and before access samples are selected. Split this population when different Components require different queries.", + "controlCodes": [ + "IAM-01", + "IAM-02", + "IAM-03" + ] + }, + { + "kind": "production-changes", + "title": "Production and Infrastructure Changes", + "sourcePrompt": "Source control, deployment, and infrastructure change sources", + "sourceKind": "production-change", + "timing": "Export after the period closes and before change samples are selected. Split software and infrastructure populations when their source reports differ.", + "controlCodes": [ + "CHG-01" + ] + }, + { + "kind": "security-incidents", + "title": "Security Events and Incidents", + "sourcePrompt": "Incident and security monitoring sources", + "sourceKind": "security-monitoring", + "timing": "Export after the period closes, including a source report that proves a zero count when management identified no incidents.", + "controlCodes": [ + "LOG-01", + "IR-01", + "IR-02" + ] + }, + { + "kind": "vulnerability-activity", + "title": "Vulnerabilities and Security Scans", + "sourcePrompt": "Vulnerability, dependency, and scanning tools", + "sourceKind": "vulnerability-management", + "timing": "Preserve complete source reports before retention or CI artifact expiry, then export the reconciled population after the period closes, including coverage, findings, failures, remediation, and exceptions.", + "controlCodes": [ + "VUL-01", + "PEN-01" + ] + }, + { + "kind": "vendor-changes", + "title": "Vendors and Vendor Changes", + "sourcePrompt": "Vendor inventory, contract, and purchasing sources", + "sourceKind": "vendor-management", + "timing": "Export after the period closes and reconcile additions, removals, material changes, and required reviews.", + "controlCodes": [ + "VEN-01", + "VEN-02" + ] + }, + { + "kind": "managed-assets", + "title": "Devices and Other Important Assets", + "sourcePrompt": "Device management and asset inventory sources", + "sourceKind": "endpoint-asset", + "timing": "Export after the period closes and reconcile assigned, active, lost, returned, and retired assets.", + "controlCodes": [ + "OPS-01", + "OPS-02", + "DATA-01" + ] + }, + { + "kind": "training-acknowledgements", + "title": "Training and Policy Acknowledgements", + "sourcePrompt": "Training, signature, and workforce sources", + "sourceKind": "training-acknowledgement", + "timing": "Export after the period closes and reconcile assignments, completions, acknowledgements, exceptions, and overdue work to the workforce population.", + "controlCodes": [ + "GOV-03", + "HR-01", + "HR-02" + ] + }, + { + "kind": "backup-recovery", + "title": "Backup Failures and Restoration Tests", + "sourcePrompt": "Backup, recovery, and monitoring sources", + "sourceKind": "backup-recovery", + "timing": "Export after the period closes and include scheduled jobs, failures, restorations, exercises, and follow-up work.", + "controlCodes": [ + "BCP-01", + "BCP-02" + ] + }, + { + "kind": "exceptions-findings", + "title": "Security Exceptions and Control Findings", + "sourcePrompt": "Exception, finding, ticketing, and risk sources", + "sourceKind": "exception-finding", + "timing": "Export after the period closes and reconcile open, closed, accepted, overdue, and remediated items.", + "controlCodes": [ + "MON-01", + "EXC-01" + ] + } + ] + }, + "evidenceSourceFamilies": [ + { + "id": "workforce", + "title": "Workforce", + "sourceKinds": [ + "workforce" + ], + "controlCodes": [ + "HR-01", + "HR-03", + "IAM-03" + ], + "evidenceForm": "export", + "evidencePrompt": "Export the workforce-change report used to manage starters, role changes, and departures.", + "description": "Connect the Component that is authoritative for starts, role changes, and departures. Bring the complete workforce-change population and reports used to reconcile access and responsibilities.", + "timing": "Identify the source during scoping. Preserve event records as changes occur and export the complete Type 2 population after the period closes." + }, + { + "id": "training-acknowledgement", + "title": "Training and Acknowledgements", + "sourceKinds": [ + "training-acknowledgement" + ], + "controlCodes": [ + "GOV-03", + "HR-01", + "HR-02" + ], + "evidenceForm": "export", + "evidencePrompt": "Export assignments and completions tied to a specific training or policy revision.", + "filegrcManaged": true, + "operationRecordTypes": [ + "training", + "attestation" + ], + "description": "filegrc records training assignments, content revisions, completions, acknowledgements, exceptions, and overdue follow-up during Step 4.", + "timing": "Set up training and acknowledgement records before assignments begin. Preserve completion proof as work occurs, then reconcile the complete Type 2 population to the workforce population after close." + }, + { + "id": "identity-access", + "title": "Identity and Access", + "sourceKinds": [ + "identity-access" + ], + "controlCodes": [ + "IAM-01", + "IAM-02", + "IAM-03" + ], + "evidenceForm": "export", + "evidencePrompt": "Export users, roles, privileged access, or authentication settings from the identity or access Component.", + "description": "Connect each Component that enforces access. Bring identity, role, privileged-access, authentication-setting, review, and removal exports.", + "timing": "Identify sources during scoping. Capture configuration near the Type 1 date or at the start and end of a Type 2 period; export complete change and review populations after the Type 2 period closes." + }, + { + "id": "production-change", + "title": "Production Change", + "sourceKinds": [ + "production-change" + ], + "controlCodes": [ + "CHG-01" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture a change from the source control or deployment Component showing review, testing, approval, deployment, and rollback information.", + "description": "Connect the source control, deployment, and infrastructure-change Components. Bring protection settings, reviews, test and approval records, deployments, emergency changes, and rollback evidence.", + "timing": "Identify sources before the audit period. Preserve per-change evidence as changes occur and export the complete period population after a Type 2 period closes." + }, + { + "id": "security-monitoring", + "title": "Security Monitoring", + "sourceKinds": [ + "security-monitoring" + ], + "controlCodes": [ + "LOG-01", + "IR-01", + "IR-02" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture logging or alert configuration and a delivered test alert from the monitoring Component.", + "description": "Connect logging and alerting Components. Bring configuration, coverage, alert delivery tests, alerts, investigations, and zero-event proof. filegrc records qualifying incidents and their response during Step 4.", + "timing": "Capture configuration and coverage at the Type 1 date or across the Type 2 period. Preserve cases as they occur and export complete alert and incident populations after close." + }, + { + "id": "vulnerability-management", + "title": "Vulnerability Management", + "sourceKinds": [ + "vulnerability-management" + ], + "controlCodes": [ + "VUL-01", + "PEN-01" + ], + "evidenceForm": "export", + "evidencePrompt": "Export a result from the vulnerability or dependency scanner showing scope, coverage, findings, and severity.", + "filegrcManaged": true, + "operationRecordTypes": [ + "vulnerability-scan", + "penetration-test" + ], + "description": "FileGRC records vulnerability scans and penetration tests during Step 4. Put the scanner output or independent report in an Evidence Artifact and link it from the operating record when fixed supporting proof is needed.", + "timing": "Confirm coverage before the audit period. Preserve scan and remediation evidence when generated and export the complete Type 2 population after close." + }, + { + "id": "endpoint-asset", + "title": "Endpoint Management", + "sourceKinds": [ + "endpoint-asset" + ], + "controlCodes": [ + "PHY-01", + "OPS-01", + "OPS-02", + "DATA-01" + ], + "evidenceForm": "export", + "evidencePrompt": "Export devices, security configuration, and compliance status from the endpoint-management Component.", + "description": "Connect device-management and endpoint-compliance Components. Bring the complete device population, security configuration, compliance status, and exceptions. filegrc records asset ownership, custody, loss, return, and disposal during Step 4.", + "timing": "Identify sources before devices receive access. Capture configuration near the Type 1 date or across the Type 2 period and export the complete Type 2 population after close." + }, + { + "id": "backup-recovery", + "title": "Backup and Recovery", + "sourceKinds": [ + "backup-recovery" + ], + "controlCodes": [ + "BCP-01", + "BCP-02" + ], + "evidenceForm": "export", + "evidencePrompt": "Export job history from the backup Component showing scheduled runs, successes, failures, and follow-up.", + "filegrcManaged": true, + "operationRecordTypes": [ + "backup-test", + "exercise" + ], + "description": "FileGRC records restoration tests and continuity exercises during Step 4. Put backup Component output in an Evidence Artifact and link it from the operating record when fixed supporting proof is needed.", + "timing": "Capture configuration at the Type 1 date or across the Type 2 period. Preserve restoration and exercise results when performed and export complete job and failure history after period close." + }, + { + "id": "vendor-management", + "title": "Vendors", + "sourceKinds": [ + "vendor-management" + ], + "controlCodes": [ + "VEN-01", + "VEN-02" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture a completed vendor review with its contract, assurance report, risk decision, and follow-up.", + "filegrcManaged": true, + "operationRecordTypes": [ + "vendor-review" + ], + "description": "filegrc records vendor reviews, risk decisions, supporting reports, exceptions, and follow-up during Step 4.", + "timing": "Identify relevant subservice organizations during scoping. Complete reviews during operation, obtain current assurance reports before fieldwork, and document bridge coverage when a report ends before the report period." + }, + { + "id": "exception-finding", + "title": "Exceptions and Findings", + "sourceKinds": [ + "exception-finding" + ], + "controlCodes": [ + "MON-01", + "EXC-01" + ], + "evidenceForm": "export", + "evidencePrompt": "Export open and closed exceptions or findings with owners, status, dates, and remediation verification.", + "filegrcManaged": true, + "operationRecordTypes": [ + "exception", + "finding", + "action-item" + ], + "description": "filegrc records control exceptions, findings, risk acceptance, remediation, verification, and overdue work during Step 4.", + "timing": "Record items as they arise. Keep their status and follow-up current, then include the complete period population in fieldwork." + }, + { + "id": "data-handling", + "title": "Data Protection Configuration", + "sourceKinds": [ + "data-handling" + ], + "controlCodes": [ + "DATA-02", + "DATA-03" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture encryption and retention settings from the relevant Component.", + "description": "Connect the Components authoritative for encryption settings and retention rules. Bring current configuration and approved schedules. Preserve completed disposal actions, exceptions, and verification during Step 4.", + "timing": "Confirm configuration and retention rules before the audit period. Preserve disposal evidence when work occurs and capture configuration near the Type 1 date or across the Type 2 period." + }, + { + "id": "network-security", + "title": "Network Security", + "sourceKinds": [ + "network-security" + ], + "controlCodes": [ + "NET-01" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture current firewall or network-access rules and confirm that remote and production access paths appear.", + "description": "Connect the Components authoritative for network boundaries, firewall rules, and remote access. Bring current configuration, rule reviews, approvals, changes, and exceptions.", + "timing": "Confirm boundary and remote-access configuration before the audit period. Preserve rule changes as they occur and capture current configuration near the Type 1 date or across the Type 2 period." + }, + { + "id": "governance", + "title": "Governance", + "sourceKinds": [ + "governance" + ], + "controlCodes": [ + "GOV-01", + "GOV-02" + ], + "evidenceForm": "capture", + "evidencePrompt": "Capture one completed oversight review or policy approval with the participants, decisions, dates, and follow-up work.", + "filegrcManaged": true, + "operationRecordTypes": [ + "policy-review", + "meeting" + ], + "description": "filegrc records policy approvals, oversight reviews, meetings, decisions, assigned actions, and completion proof in Steps 2 and 4.", + "timing": "Approve policies before they take effect. Preserve oversight records and decisions when the work occurs." + }, + { + "id": "risk-management", + "title": "Risk Management", + "sourceKinds": [ + "risk-management" + ], + "controlCodes": [ + "RSK-01" + ], + "evidenceForm": "export", + "evidencePrompt": "Export the risk register with owners, ratings, treatment decisions, review dates, and open actions.", + "filegrcManaged": true, + "operationRecordTypes": [ + "risk-assessment", + "risk" + ], + "description": "filegrc records risk assessments, risks, treatment decisions, reviews, and follow-up during Step 4.", + "timing": "Complete the initial assessment while operating the program. Preserve risk changes and reviews as they occur, then include the current register in fieldwork." + } + ], + "commonFields": { + "id": { + "type": "string", + "format": "id", + "required": true, + "label": "ID" + }, + "type": { + "type": "string", + "required": true, + "label": "Type" + }, + "title": { + "type": "string", + "required": true, + "label": "Title", + "search": true + }, + "tags": { + "type": "array", + "items": "string", + "label": "Tags", + "filter": true, + "search": true + }, + "extensions": { + "type": "object", + "label": "Extensions", + "objectType": "extensions" + }, + "externalIds": { + "type": "object", + "objectType": "external-identifiers", + "label": "External identifiers" + } + }, + "groups": [ + { + "id": "program", + "title": "Program" + }, + { + "id": "governance", + "title": "Governance" + }, + { + "id": "risk", + "title": "Risk" + }, + { + "id": "people-access", + "title": "People and Access" + }, + { + "id": "systems-vendors", + "title": "Systems and Vendors" + }, + { + "id": "security-operations", + "title": "Security Operations" + }, + { + "id": "resilience", + "title": "Resilience" + }, + { + "id": "evidence", + "title": "Evidence" + }, + { + "id": "work", + "title": "Issues and Remediation" + }, + { + "id": "audits", + "title": "Audits" + }, + { + "id": "repository", + "title": "Repository" + } + ], + "collectionReviews": { + "person": { + "title": "Program participants", + "description": "Confirm that every person who owns, approves, operates, reviews, or supports the SOC 2 program is represented and current.", + "reviewPoints": [ + "Think through who actually owns, approves, operates, reviews, or supports the program, and add anyone missing.", + "Confirm each person's current job title, affiliation, and status." + ], + "decisions": [ + "complete" + ] + }, + "framework": { + "title": "Framework and criteria sources", + "description": "Confirm that the active framework records match the SOC 2 categories and description criteria selected for this program.", + "reviewPoints": [ + "Confirm the selected Trust Services Categories match the service and planned audit.", + "Confirm any other legal, contractual, privacy, or security framework the service must follow.", + "Confirm the framework edition expected for the audit." + ], + "decisions": [ + "complete" + ] + }, + "vendor": { + "title": "Vendor inventory", + "description": "Finish each material external provider record, then confirm the Vendor inventory once as a complete collection separate from Program System and Component scope.", + "reviewPoints": [ + "Add Vendors for material external commercial or service relationships.", + "Confirm contracts, due diligence, criticality, information access, and monitoring.", + "Do not mirror every Vendor into a Component or infer audit subservice treatment." + ], + "decisions": [ + "complete", + "zero-population", + "externally-managed" + ] + }, + "system": { + "title": "Bounded System scope", + "description": "Confirm the complete bounded Systems selected by the Program.", + "reviewPoints": [ + "Start with the service management governs or the auditor will examine.", + "Confirm its purpose, services, boundary, exclusions, owners, and Information Types.", + "Do not list applications, platforms, providers, or devices as separate Systems when they are Components, Vendors, or Assets." + ], + "decisions": [ + "complete" + ] + }, + "complementary-control": { + "title": "Customer and provider responsibilities", + "description": "Finish each customer or carved-out provider responsibility record, then confirm the collection once, including when no such dependency exists.", + "reviewPoints": [ + "Think through the customer and carved-out provider actions that in-scope Controls depend on, and add any missing Complementary Control.", + "Confirm each statement names a specific action, responsible party, affected System, and dependent Control.", + "Use zero population only when no in-scope Control depends on a customer or carved-out provider action." + ], + "decisions": [ + "complete", + "zero-population" + ] + }, + "component": { + "title": "Scoped Components", + "description": "Finish each Component proposal, then confirm once that the collection materially participating in the selected Program Systems is complete.", + "reviewPoints": [ + "Start with each bounded System and include only Components that deliver its service, support Controls, produce authoritative Evidence, or support relevant operations.", + "Confirm every System use has the right role and a specific rationale.", + "Keep unrelated corporate tools and Vendor relationships outside the reviewed population." + ], + "decisions": [ + "complete", + "zero-population" + ] + }, + "classification": { + "title": "Information handling classifications", + "description": "Confirm that the active information handling classifications match management's current protection and handling decisions.", + "reviewPoints": [ + "Confirm each Classification represents a distinct handling level the organization actually uses.", + "Confirm the ordering and handling expectations match approved information protection decisions.", + "Retire unused or duplicate Classifications only after reviewing every record that references them." + ], + "decisions": [ + "complete" + ] + }, + "information-type": { + "title": "Information Type inventory", + "description": "Finish each Information Type proposal, then confirm once that the collection is complete, distinct, and linked to the Systems, Components, and Vendors that use or access it.", + "reviewPoints": [ + "Review near-duplicate names and choose one canonical Information Type only after confirming they mean the same thing.", + "Retire superseded records through a reviewed migration that rewrites every relationship.", + "Confirm every active Information Type has an approved classification and a retention schedule item or an explicit management-review prompt." + ], + "decisions": [ + "complete", + "zero-population" + ] + }, + "retention-schedule-item": { + "title": "Data Retention Schedule", + "description": "Complete each structured row proposal, then approve the schedule document and all proposed rows in one independent review. Include only actual information uses and keep undecided organization values planned.", + "reviewPoints": [ + "Confirm the owner completed every row proposal, then review the Data Retention Schedule document, its Markdown, and every proposed row as one revision.", + "Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses.", + "Confirm every proposed item has a reviewed cutoff, retention period, disposition action, owner, and exact source revisions; keep undecided behavior planned." + ], + "decisions": [ + "complete", + "zero-population" + ] + }, + "control": { + "title": "Control implementation oversight", + "description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate. Handle later changes through affected Controls, event work, and scheduled program reviews.", + "reviewPoints": [ + "Perform this one setup review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content.", + "Confirm owners, procedures, scope, operation patterns, mappings, Obligations, and authoritative evidence sources remain current.", + "Record follow-up work separately when the review finds a gap." + ], + "decisions": [ + "complete" + ] + }, + "obligation": { + "title": "Obligation schedule set", + "description": "Review the complete Program schedule set before activating governing content. Adopt required schedules and record replacements, retired duplicates, and intentionally inapplicable proposals.", + "reviewPoints": [ + "Review every active and proposed schedule and every pending rule revision against the governing requirements.", + "Confirm adopted rules cover recurring and event work, owners, populations, deadlines, and the intended cutover.", + "Name adopted replacements or explain exclusions; do not activate unused starter proposals." + ], + "decisions": [ + "complete", + "zero-population" + ] + } + }, + "resources": { + "workspace": { + "title": "Workspace", + "pluralTitle": "Workspace", + "group": "repository", + "singleton": "workspace.json", + "description": "Repository-wide organization identity and defaults. Program scope and assurance decisions belong on Program records.", + "guidance": { + "policyBasis": "Repository identity and default time settings apply across every Program in this workspace.", + "cadence": "Review after an organization, repository, or default timezone change." + }, + "required": [ + "dataModelVersion", + "organizationName", + "timezone" + ], + "fields": { + "dataModelVersion": { + "type": "string", + "const": "12", + "label": "Data model version" + }, + "organizationName": { + "type": "string", + "label": "Organization", + "search": true + }, + "timezone": { + "type": "string", + "format": "timezone", + "label": "Timezone" + }, + "description": { + "type": "string", + "label": "Description", + "search": true + } + }, + "listFields": [ + "title", + "organizationName", + "dataModelVersion" + ] + }, + "renderer-settings": { + "title": "Renderer settings", + "pluralTitle": "Renderer settings", + "group": "repository", + "singleton": "renderer.json", + "description": "Optional local interface and browser repository settings, including onboarding visibility and authoritative-branch synchronization. Renderer settings are not required for SOC 2 and do not change compliance records.", + "guidance": { + "policyBasis": "Renderer settings are a filegrc convenience, not a SOC 2 requirement, control, audit record, or substitute for evidence. Record lifecycle status represents approval; Git branches do not.", + "cadence": "Change it when the team wants to rerun or suppress optional onboarding or change repository synchronization." + }, + "required": [ + "showOnboarding", + "repositoryMode", + "authoritativeBranch", + "repositoryRemote" + ], + "fields": { + "showOnboarding": { + "type": "boolean", + "label": "Show onboarding" + }, + "repositoryMode": { + "type": "enum", + "values": [ + "trunk", + "manual" + ], + "label": "Repository mode" + }, + "authoritativeBranch": { + "type": "string", + "format": "git-name", + "label": "Authoritative branch" + }, + "repositoryRemote": { + "type": "string", + "format": "git-name", + "label": "Repository remote" + } + }, + "listFields": [ + "title", + "showOnboarding", + "repositoryMode", + "authoritativeBranch", + "repositoryRemote" + ] + }, + "person": { + "title": "Person", + "titleLabel": "Name", + "pluralTitle": "People", + "group": "people-access", + "collection": "people", + "description": "People who own, approve, review, or perform program work, or receive access and training. Record each person\u2019s actual organization job title here and keep named program authority in dated Appointments. Keep detailed personnel records in the HR system.", + "guidance": { + "policyBasis": "The information security policy assigns work to named people and requires onboarding, training, role-change, and offboarding records.", + "cadence": "Create before assigning work or access, update the job title after organizational changes, review Appointments separately, and mark inactive only after active Appointments are ended or transferred. Training follows the approved onboarding and recurring schedules.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "training", + "performance-review", + "workforce-review", + "role-training" + ] + }, + "required": [ + "status", + "affiliation" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "inactive" + ], + "filter": true + }, + "email": { + "type": "string", + "format": "email", + "search": true + }, + "jobTitle": { + "type": "string", + "label": "Organization job title", + "search": true + }, + "department": { + "type": "string", + "filter": true + }, + "managerId": { + "type": "id", + "relation": [ + "person" + ] + }, + "startDate": { + "type": "date" + }, + "endDate": { + "type": "date" + }, + "employmentType": { + "type": "string", + "filter": true + }, + "organization": { + "type": "string", + "search": true + }, + "affiliation": { + "type": "enum", + "values": [ + "internal", + "external" + ], + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "inactive" + ] + }, + "allowedWhen": { + "status": [ + "inactive" + ] + } + } + }, + "listFields": [ + "title", + "status", + "jobTitle", + "department" + ], + "formFields": [ + "statusTransition" + ] + }, + "appointment": { + "title": "Appointment", + "pluralTitle": "Appointments", + "group": "governance", + "collection": "appointments", + "description": "One person\u2019s dated appointment to a named organizational or program responsibility, such as CISO, DPO, Policy Owner, or team chair. Use the Person job title for the person\u2019s ordinary organizational position.", + "guidance": { + "policyBasis": "The information security policy assigns named authority and accountability, while workforce procedures require responsibility changes and departures to be reviewed and transferred.", + "cadence": "Create when management assigns a named responsibility. End the Appointment and create a new one when the holder changes. Reassign every linked responsibility before a holder departs.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "appointmentKind", + "scopeResourceIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "ended" + ], + "filter": true + }, + "appointmentKind": { + "type": "string", + "format": "id", + "label": "Appointment kind", + "filter": true, + "search": true + }, + "holderId": { + "type": "id", + "relation": [ + "person" + ], + "label": "Holder", + "requiredWhen": { + "status": [ + "active", + "ended" + ] + } + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "label": "Scope", + "relationGroup": "appointment-scope" + }, + "startsOn": { + "type": "date", + "requiredWhen": { + "status": [ + "active", + "ended" + ] + } + }, + "endsOn": { + "type": "date", + "requiredWhen": { + "status": "ended" + } + }, + "appointedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Appointed by" + }, + "responsibilities": { + "type": "string", + "search": true + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "ended" + ] + }, + "allowedWhen": { + "status": [ + "ended" + ] + } + }, + "independenceRationale": { + "type": "string", + "label": "Independence rationale" + } + }, + "listFields": [ + "title", + "status", + "appointmentKind", + "holderId", + "startsOn", + "endsOn" + ], + "formFields": [ + "statusTransition" + ] + }, + "service-account": { + "title": "Service account", + "titleLabel": "Name", + "pluralTitle": "Service accounts", + "group": "people-access", + "collection": "service-accounts", + "description": "Non-human identities that need separate tracking. Record the account\u2019s purpose, owner, Systems, authentication, privilege, review, and expiry here. A separate register is not required for SOC 2.", + "guidance": { + "policyBasis": "The information security policy requires important human and non-human identities to have an owner, protection, periodic review, and prompt removal when no longer needed.", + "cadence": "Create before use. Review privileged, production, and other important access on the approved risk-based schedules; retire or expire unused accounts.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "access-review", + "inventory-review" + ] + }, + "required": [ + "status", + "purpose", + "ownerIds", + "systemIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "disabled", + "retired" + ], + "filter": true + }, + "purpose": { + "type": "string", + "search": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "accountIdentifier": { + "type": "string", + "search": true + }, + "authenticationMethod": { + "type": "string" + }, + "privileged": { + "type": "boolean", + "filter": true + }, + "expiresOn": { + "type": "date" + }, + "lastReviewedOn": { + "type": "date" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "disabled", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "disabled", + "retired" + ] + } + }, + "reviewDueOn": { + "type": "date", + "label": "Next review due" + }, + "nonExpiringRationale": { + "type": "string", + "label": "Non-expiring rationale" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "purpose", + "privileged" + ], + "formFields": [ + "statusTransition" + ] + }, + "team": { + "title": "Team", + "titleLabel": "Name", + "pluralTitle": "Teams", + "group": "governance", + "collection": "teams", + "description": "Groups that share program responsibility, such as security oversight or incident response. Team records are not required for SOC 2 when named People hold the responsibilities directly.", + "guidance": { + "policyBasis": "The information security policy establishes security and risk oversight with independent review, while the continuity plan assigns response and recovery roles. Reviewers may be internal or external.", + "cadence": "The security and risk oversight group meets on its approved risk-based schedule. Update membership after responsibility or personnel changes, and preserve a chair who is separate from the policy owner.", + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "meeting" + ] + }, + "required": [ + "status", + "purpose", + "memberIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "inactive" + ], + "filter": true + }, + "purpose": { + "type": "string", + "search": true + }, + "memberIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "chairIds": { + "type": "array", + "items": "id", + "relation": [ + "person", + "appointment" + ] + }, + "charterDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "inactive" + ] + }, + "allowedWhen": { + "status": [ + "inactive" + ] + } + } + }, + "listFields": [ + "title", + "status", + "purpose" + ], + "formFields": [ + "chairIds", + "statusTransition" + ] + }, + "system": { + "title": "System", + "titleLabel": "Name", + "pluralTitle": "Systems", + "group": "systems-vendors", + "collection": "systems", + "description": "The complete bounded system being governed or examined, including its services, boundary, information, Components, Controls, dependencies, and any applicable continuity objectives.", + "guidance": { + "policyBasis": "A SOC 2 program starts with the bounded System and the service commitments and system requirements the company has chosen to meet.", + "cadence": "Define before selecting Components and Controls. Review after material service, boundary, architecture, information, continuity, or audit-scope changes.", + "reviewPoints": [ + "Describe the complete service boundary rather than a single tool or provider.", + "Record exclusions and the Information Types processed in the boundary.", + "Add Components only when they materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations." + ] + }, + "required": [ + "status", + "purpose", + "servicesProvided", + "boundary", + "criticality", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "deprecated", + "retired" + ], + "filter": true + }, + "purpose": { + "type": "string", + "search": true + }, + "servicesProvided": { + "type": "array", + "items": "string", + "search": true + }, + "boundary": { + "type": "string", + "search": true + }, + "exclusions": { + "type": "array", + "items": "string", + "search": true + }, + "criticality": { + "type": "enum", + "values": [ + "low", + "medium", + "high", + "critical" + ], + "filter": true + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "informationTypeIds": { + "type": "array", + "items": "id", + "relation": [ + "information-type" + ], + "label": "Information Types" + }, + "classificationId": { + "type": "id", + "relation": [ + "classification" + ], + "label": "Highest classification", + "filter": true + }, + "internetExposed": { + "type": "boolean", + "filter": true + }, + "continuityObjectives": { + "type": "object", + "objectType": "continuity-objectives" + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "deprecated", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "deprecated", + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "criticality", + "informationTypeIds", + "classificationId" + ], + "formFields": [ + "informationTypeIds", + "classificationId", + "continuityObjectives", + "statusTransition" + ] + }, + "asset": { + "title": "Asset", + "titleLabel": "Name", + "pluralTitle": "Assets", + "group": "systems-vendors", + "collection": "assets", + "description": "Important devices, media, software, records, and other items tracked through acquisition, custody, use, return, and disposal. Assets support controls but do not define the service boundary.", + "guidance": { + "policyBasis": "The information security policy requires important assets to have owners and custodians, protection based on classification, and secure return or disposal.", + "cadence": "Record acquisition and assignment, review the inventory on the approved risk-based schedule, update custody on change, and retire assets when use ends.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "inventory-review" + ] + }, + "required": [ + "status", + "assetKind", + "criticality", + "ownerIds", + "custodianIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "lost", + "retired", + "disposed" + ], + "filter": true + }, + "assetKind": { + "type": "string", + "filter": true + }, + "criticality": { + "type": "enum", + "values": [ + "low", + "medium", + "high", + "critical" + ], + "filter": true + }, + "custodianIds": { + "type": "array", + "items": "id", + "relation": [ + "person", + "team", + "appointment" + ] + }, + "businessPurpose": { + "type": "string", + "search": true + }, + "serialOrAssetTag": { + "type": "string", + "search": true + }, + "location": { + "type": "string", + "filter": true + }, + "exceptionIds": { + "type": "array", + "items": "id", + "relation": [ + "exception" + ] + }, + "acquiredOn": { + "type": "date" + }, + "retiredOn": { + "type": "date" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "classificationId": { + "type": "id", + "relation": [ + "classification" + ], + "label": "Classification", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "lost", + "retired", + "disposed" + ] + }, + "allowedWhen": { + "status": [ + "lost", + "retired", + "disposed" + ] + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "assetKind", + "criticality", + "location", + "classificationId" + ], + "formFields": [ + "componentIds", + "classificationId", + "statusTransition" + ] + }, + "document": { + "title": "Document", + "pluralTitle": "Documents", + "group": "governance", + "collection": "documents", + "description": "Governed plans, schedules, charters, procedures, standards, reports, and templates that are not Policies or another filegrc record type. A general Document catalog is not required for SOC 2. Required program Documents are approved before implementation and activated only after their requirements are implemented. Audit Documents stay with the engagement in Step 5.", + "guidance": { + "policyBasis": "Policies rely on governed documents for detailed plans, schedules, procedures, charters, and reports. Approval accepts the intended values and exact content. Activation records that the linked requirements were implemented and the approved Document was put into use.", + "cadence": "Complete required program Documents in Step 2, obtain approval from a reviewer who is separate from the owner, implement their linked requirements in Step 3, then activate the unchanged approved revision. Follow the linked review Obligation after activation. Prepare Audit Documents in Step 5, then approve and activate each one there as separate revision-bound updates.", + "reviewPoints": [ + "Make the companion Markdown read as a finished policy, procedure, evidence record, or narrative.", + "Replace every bracketed prompt with a reviewed fact before approval, activation, signing, or delivery.", + "Write in ordinary business language. Add resource relationships to this record, not its Markdown." + ], + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "policy-review", + "continuity-review" + ] + }, + "required": [ + "status", + "documentKind", + "workflowScope", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "approved", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "documentKind": { + "type": "string", + "filter": true + }, + "workflowScope": { + "type": "enum", + "values": [ + "program", + "engagement" + ], + "label": "Workflow scope", + "filter": true + }, + "template": { + "type": "boolean", + "filter": true + }, + "approverIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "disjointFrom": "ownerIds", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "version": { + "type": "string" + }, + "effectiveOn": { + "type": "date", + "requiredWhen": { + "status": "active" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "document" + ] + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "relatedDocumentIds": { + "type": "array", + "items": "id", + "relation": [ + "document" + ] + }, + "audience": { + "type": "array", + "items": "string", + "filter": true + }, + "acknowledgementRequired": { + "type": "boolean", + "filter": true + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "trainingIds": { + "type": "array", + "items": "id", + "relation": [ + "training" + ], + "label": "Related training" + }, + "classificationId": { + "type": "id", + "filter": true, + "label": "Classification", + "relation": [ + "classification" + ] + }, + "approvedContentRevisions": { + "type": "object", + "objectType": "content-revisions", + "label": "Approved content revisions", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "managed": true + }, + "activatedOn": { + "type": "date", + "label": "Activation date", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "activationBasis": { + "type": "enum", + "values": [ + "recorded", + "historical" + ], + "label": "Activation basis", + "requiredWhen": { + "status": "active" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "activatedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Activated by", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "activationBasis": "recorded" + } + }, + "activatedContentRevisions": { + "type": "object", + "objectType": "content-revisions", + "label": "Activated content revisions", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "activationBasis": "recorded" + }, + "managed": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "proposedEffectiveOn": { + "type": "date", + "label": "Proposed effective date", + "allowedWhen": { + "status": [ + "draft", + "approved" + ] + } + }, + "programRole": { + "type": "enum", + "values": [ + "required", + "conditional", + "alternative", + "supporting" + ], + "label": "Program role", + "filter": true, + "allowedWhen": { + "workflowScope": "program" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + }, + "reportingRouteRequirements": { + "type": "array", + "items": "object", + "itemObjectType": "reporting-route-requirement", + "label": "Reporting route requirements" + } + }, + "listFields": [ + "title", + "status", + "documentKind", + "version", + "approvedOn", + "activatedOn", + "effectiveOn", + "classificationId" + ], + "markdown": { + "content": { + "label": "Document", + "primary": true, + "required": true + } + }, + "formFields": [ + "classificationId", + "programRole", + "statusTransition" + ] + }, + "evidence": { + "title": "Evidence Artifact", + "pluralTitle": "Evidence Artifacts", + "group": "evidence", + "collection": "evidence", + "recordPath": "{id}/evidence.json", + "description": "A retained export, report, screenshot, signed record, fixed file, or approved external reference. FileGRC operating records may support an audit directly without wrappers.", + "guidance": { + "policyBasis": "The information security policy requires retained proof from authoritative Components when FileGRC operating records do not contain the full result.", + "cadence": "Create an Evidence Artifact only for a real retained artifact or approved reference. Identify its source Component or source records, Controls, collection and verification facts, coverage, revision, and classification.", + "emptyState": "No Evidence Artifacts have been collected. Add one only when a real retained artifact or approved external reference exists.", + "reviewPoints": [ + "For a signed management representation, choose \"Signed Record\" for \"Artifact Kind\" and enter \"signed-management-representation\" for \"Artifact Subtype\".", + "Enter when the representation was signed in \"Business event time\". Use \"Collected On\" for the date you received it.", + "For a final CPA report, choose \"Third Party Report\" for \"Artifact Kind\", enter \"soc2-report\" for \"Artifact Subtype\", and enter when the CPA firm issued it in \"Source generation time\"." + ], + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "artifactKind", + "sourceKind" + ], + "oneOf": [ + { + "when": { + "sourceKind": "authored-record", + "status": [ + "collected", + "verified", + "expired", + "withdrawn" + ] + }, + "fields": [ + "$markdown:content" + ] + } + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "collected", + "verified", + "withdrawn" + ], + "filter": true + }, + "artifactKind": { + "type": "enum", + "default": "business-record", + "values": [ + "population-export", + "system-export", + "configuration-export", + "capture", + "signed-record", + "third-party-report", + "rendered-page", + "business-record", + "other" + ], + "filter": true + }, + "artifactSubtype": { + "type": "string", + "filter": true + }, + "sourceKind": { + "type": "enum", + "default": "file", + "values": [ + "component", + "file", + "external-reference", + "rendered-page", + "authored-record" + ], + "filter": true + }, + "sourceDescription": { + "type": "string", + "search": true, + "requiredWhen": { + "status": [ + "collected", + "verified", + "withdrawn" + ] + } + }, + "collectedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "collected", + "verified", + "withdrawn" + ] + } + }, + "generatedAt": { + "type": "timestamp", + "label": "Generated at", + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "timezone": { + "type": "string", + "format": "timezone", + "label": "Report timezone", + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "queryDescription": { + "type": "string", + "label": "Query or report parameters", + "search": true, + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "populationCount": { + "type": "integer", + "minimum": 0, + "label": "Population count", + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "completenessValidation": { + "type": "string", + "label": "Completeness validation", + "search": true, + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "accuracyValidation": { + "type": "string", + "label": "Accuracy validation", + "search": true, + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "auditIds": { + "type": "array", + "items": "id", + "relation": [ + "audit" + ] + }, + "collectorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "collected", + "verified", + "withdrawn" + ] + } + }, + "verifierIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "verified" + }, + "visibleWhen": { + "status": "verified" + } + }, + "verifiedOn": { + "type": "date", + "requiredWhen": { + "status": "verified" + }, + "visibleWhen": { + "status": "verified" + } + }, + "expiresOn": { + "type": "date" + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "evidence-source-record" + }, + "filePaths": { + "type": "array", + "items": "data-path", + "requiredWhen": { + "sourceKind": "file", + "status": [ + "collected", + "verified", + "withdrawn" + ] + }, + "allowedWhen": { + "sourceKind": [ + "file", + "system", + "rendered-page", + "authored-record" + ] + } + }, + "externalReference": { + "type": "object", + "objectType": "external-reference", + "requiredWhen": { + "sourceKind": "external-reference", + "status": [ + "collected", + "verified", + "withdrawn" + ] + }, + "allowedWhen": { + "sourceKind": [ + "external-reference", + "system" + ] + } + }, + "sourceCommit": { + "type": "string", + "requiredWhen": { + "sourceKind": "rendered-page", + "status": [ + "collected", + "verified", + "withdrawn" + ] + } + }, + "capture": { + "type": "object", + "objectType": "page-capture", + "requiredWhen": { + "sourceKind": "rendered-page", + "status": [ + "collected", + "verified", + "withdrawn" + ] + }, + "allowedWhen": { + "sourceKind": "rendered-page" + } + }, + "classificationId": { + "type": "id", + "filter": true, + "requiredWhen": { + "status": [ + "collected", + "verified", + "withdrawn" + ] + }, + "label": "Classification", + "relation": [ + "classification" + ] + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "artifactKind": "population-export" + } + }, + "withdrawal": { + "type": "object", + "objectType": "withdrawal", + "requiredWhen": { + "status": "withdrawn" + }, + "allowedWhen": { + "status": "withdrawn" + } + }, + "businessEventAt": { + "type": "timestamp", + "label": "Business event time" + }, + "sourceGeneratedAt": { + "type": "timestamp", + "label": "Source generation time" + }, + "collectedAt": { + "type": "timestamp", + "label": "Collection time" + }, + "verifiedAt": { + "type": "timestamp", + "label": "Verification time" + }, + "readinessTest": { + "type": "boolean", + "label": "Pre-period readiness test", + "filter": true + }, + "coveredSourceFamilyIds": { + "type": "array", + "items": "string", + "label": "Covered source families" + }, + "retrievalMethodKey": { + "type": "string", + "format": "id", + "label": "Retrieval method key" + }, + "accessConfirmed": { + "type": "boolean", + "label": "Access confirmed" + }, + "retrievalResult": { + "type": "enum", + "values": [ + "passed", + "failed", + "partial" + ], + "label": "Retrieval result" + }, + "sourceComponentId": { + "type": "id", + "relation": [ + "component" + ], + "label": "Source Component", + "showWhenInactive": true, + "requiredWhen": { + "sourceKind": "component", + "status": [ + "collected", + "verified", + "withdrawn" + ] + }, + "allowedWhen": { + "sourceKind": [ + "component", + "rendered-page" + ] + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "artifactKind", + "sourceKind", + "collectedOn", + "classificationId" + ], + "formFields": [ + "auditIds", + "sourceComponentId", + "componentIds", + "classificationId", + "coverage", + "withdrawal" + ], + "markdown": { + "content": { + "label": "Evidence", + "primary": true, + "required": false + } + } + }, + "obligation": { + "title": "Obligation", + "pluralTitle": "Obligations", + "group": "work", + "collection": "obligations", + "description": "Reusable schedules for recurring or event-driven work. Obligations feed the Work Queue; completion records and linked Evidence prove that the work occurred. Obligations are not required for SOC 2.", + "guidance": { + "policyBasis": "FileGRC uses Obligations to turn approved schedules into owned, dated work linked to scope and required proof. An enabled Obligation remains dormant until every governing Policy and required program Document is active and effective.", + "cadence": "Configure and enable the schedule during Control implementation. Do not create occurrences while a governing Policy or required program Document is inactive. Start with the latest of the stored recurrence anchor, Policy effective dates, and governed Document effective dates, then create a separate completion record for every period.", + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ] + }, + "required": [ + "status", + "activityType", + "ownerIds", + "scheduleMode" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "proposed", + "active", + "paused", + "retired" + ], + "filter": true + }, + "activityType": { + "type": "enum", + "filter": true, + "registry": "obligationActivities" + }, + "customActivity": { + "type": "object", + "objectType": "custom-obligation-activity", + "requiredWhen": { + "activityType": "custom" + }, + "allowedWhen": { + "activityType": "custom" + }, + "label": "Custom activity definition" + }, + "scheduleMode": { + "type": "enum", + "values": [ + "rule", + "legacy" + ], + "filter": true + }, + "ruleIds": { + "type": "array", + "items": "id", + "relation": [ + "obligation-rule" + ] + }, + "activeRuleId": { + "type": "id", + "relation": [ + "obligation-rule" + ], + "requiredWhen": { + "scheduleMode": "rule", + "status": [ + "active", + "paused", + "retired" + ] + } + }, + "triggerPrompt": { + "type": "string", + "search": true + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "obligation-scope" + }, + "templateResourceId": { + "type": "id", + "relationGroup": "obligation-template" + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "policyIds": { + "type": "array", + "items": "id", + "relation": [ + "policy" + ] + }, + "completionResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "completion-record" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "paused", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "paused", + "retired" + ] + } + }, + "eventRiskLevels": { + "type": "array", + "items": "string", + "values": [ + "normal", + "high" + ], + "label": "Event risk levels" + }, + "recurrence": { + "type": "object", + "objectType": "recurrence", + "requiredWhen": { + "scheduleMode": "legacy" + }, + "allowedWhen": { + "scheduleMode": "legacy" + } + }, + "window": { + "type": "object", + "objectType": "obligation-window", + "allowedWhen": { + "scheduleMode": "legacy" + } + }, + "startsOn": { + "type": "date", + "allowedWhen": { + "scheduleMode": "legacy" + } + }, + "endsOn": { + "type": "date", + "allowedWhen": { + "scheduleMode": "legacy" + } + } + }, + "listFields": [ + "title", + "status", + "activityType", + "scheduleMode", + "activeRuleId" + ], + "markdown": { + "instructions": { + "label": "Instructions", + "primary": true, + "required": false + } + }, + "formFields": [ + "customActivity", + "scopeResourceIds", + "templateResourceId", + "controlIds", + "policyIds", + "window", + "statusTransition", + "ruleIds", + "activeRuleId" + ] + }, + "obligation-event": { + "title": "Policy Event", + "pluralTitle": "Policy Events", + "group": "work", + "collection": "obligation-events", + "description": "One occurrence of an event such as hiring, departure, material change, or incident. It connects the event to the checklist generated from applicable Obligations. Policy Events are not required for SOC 2.", + "guidance": { + "policyBasis": "Policies require specific, time-bound actions after certain events. A Policy Event preserves the trigger, applicable checklist, owners, and completion state.", + "cadence": "Create when the event occurs or is scheduled. Complete every generated action within its policy window, then close the event." + }, + "required": [ + "status", + "eventType", + "occurredOn", + "obligationIds", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "complete", + "canceled" + ], + "filter": true + }, + "eventType": { + "type": "string", + "filter": true, + "search": true + }, + "occurredOn": { + "type": "date" + }, + "occurredAt": { + "type": "timestamp" + }, + "subjectResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "event-subject" + }, + "obligationIds": { + "type": "array", + "items": "id", + "relation": [ + "obligation" + ] + }, + "completedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "riskLevel": { + "type": "enum", + "values": [ + "normal", + "high" + ], + "requiredWhen": { + "eventType": "person-ended" + }, + "label": "Departure risk" + } + }, + "listFields": [ + "title", + "status", + "eventType", + "occurredOn", + "ownerIds" + ], + "formFields": [ + "cancellation" + ] + }, + "framework": { + "title": "Framework", + "titleLabel": "Name", + "pluralTitle": "Frameworks", + "group": "program", + "collection": "frameworks", + "description": "Published criteria sets and versions used to define the program and audit scope.", + "guidance": { + "policyBasis": "The CPA examination uses the selected Framework. filegrc\u2019s starter references provide orientation but do not replace the publisher\u2019s official criteria.", + "cadence": "Add or retire a version only through a deliberate scope decision. Reconfirm the selected version when planning each audit." + }, + "required": [ + "status", + "version" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "retired" + ], + "filter": true + }, + "version": { + "type": "string" + }, + "publisher": { + "type": "string" + }, + "description": { + "type": "string", + "search": true + }, + "sourceReference": { + "type": "object", + "objectType": "source-reference" + }, + "effectiveOn": { + "type": "date" + }, + "retiredOn": { + "type": "date" + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "retired" + ] + }, + "allowedWhen": { + "status": [ + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "version", + "publisher" + ], + "formFields": [ + "statusTransition" + ] + }, + "requirement": { + "title": "Requirement", + "pluralTitle": "Requirements", + "group": "program", + "collection": "requirements", + "description": "Authoritative catalog criteria selected and tailored by Program-scoped applicability decisions.", + "guidance": { + "policyBasis": "The selected Framework defines the Requirements. Controls show how management addresses each applicable criterion; the publisher\u2019s official text remains authoritative.", + "cadence": "Review applicability during audit planning and after material scope, service, system, or framework changes.", + "reviewPoints": [ + "Decide whether this criterion applies to the selected service, Trust Services Categories, customer commitments, and planned audit.", + "Explain the decision using current service and system scope facts, not a generic statement.", + "Before choosing \"Not Applicable\", confirm that no in-scope promise or Control objective depends on it." + ] + }, + "required": [ + "frameworkId", + "reference" + ], + "fields": { + "frameworkId": { + "type": "id", + "relation": [ + "framework" + ] + }, + "reference": { + "type": "string", + "search": true + }, + "description": { + "type": "string", + "search": true + }, + "parentRequirementId": { + "type": "id", + "relation": [ + "requirement" + ] + } + }, + "listFields": [ + "reference", + "description", + "frameworkId" + ] + }, + "commitment": { + "title": "Commitment", + "pluralTitle": "Commitments", + "group": "program", + "collection": "commitments", + "description": "Customer promises, service requirements, and approved business objectives beyond the baseline Framework and Policies. Link them to the Systems and Controls that fulfill them.", + "guidance": { + "policyBasis": "Contracts, service descriptions, and approved management decisions can create commitments that the system description and control design must address.", + "cadence": "Create before relying on a promise, review during audit scoping, and supersede it when the service or agreement changes.", + "reviewPoints": [ + "Confirm the statement reflects a real customer promise, service requirement, or approved business objective that applies to the current scope.", + "Confirm which parts of the service the promise or objective affects. When a linked Policy or Document has independent sections, name the exact governing Markdown heading in sourceSectionHeadings before reviewing downstream mappings.", + "Use \"External\", \"Zero Population\", or \"Not Applicable\" only when it is true for the current scope." + ], + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "commitmentKind", + "statement", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "commitmentKind": { + "type": "enum", + "values": [ + "service", + "system-requirement", + "business-objective" + ], + "filter": true + }, + "statement": { + "type": "string", + "search": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "framework", + "requirement" + ] + }, + "sourceSectionHeadings": { + "type": "object", + "objectType": "string-map", + "label": "Reviewed source sections", + "description": "Map a source Policy or Document ID to one exact Markdown heading when the Commitment comes from that section." + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "customerFacing": { + "type": "boolean", + "filter": true + }, + "effectiveOn": { + "type": "date", + "requiredWhen": { + "status": "active" + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "commitment" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "applicabilityReview": { + "type": "object", + "objectType": "applicability-review", + "label": "Applicability review" + }, + "reportingRouteRequirements": { + "type": "array", + "items": "object", + "itemObjectType": "reporting-route-requirement", + "label": "Reporting route requirements" + } + }, + "listFields": [ + "title", + "status", + "commitmentKind", + "customerFacing" + ], + "formFields": [ + "systemIds", + "sourceResourceIds", + "sourceSectionHeadings", + "requirementIds", + "controlIds", + "applicabilityReview", + "statusTransition" + ] + }, + "complementary-control": { + "title": "Complementary control", + "pluralTitle": "Complementary controls", + "group": "program", + "collection": "complementary-controls", + "description": "Actions a customer or carved-out provider must perform for a linked Control to work as described. A Complementary Control record is not required for SOC 2 when no external dependency applies.", + "guidance": { + "policyBasis": "The system description must explain relevant customer and subservice-organization responsibilities so readers understand where management\u2019s Controls depend on others.", + "cadence": "Review for every audit and after material customer-responsibility, vendor, contract, integration, or service changes.", + "reviewPoints": [ + "Confirm that the linked Control actually depends on a customer or carved-out provider action.", + "Make the statement specific enough for the responsible party to perform and for the system description to explain.", + "Before choosing \"Not Applicable\", confirm that management is not responsible for the action." + ], + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "responsibleParty", + "statement", + "systemIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "responsibleParty": { + "type": "enum", + "values": [ + "user-entity", + "subservice-organization" + ], + "filter": true + }, + "statement": { + "type": "string", + "search": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "vendorId": { + "type": "id", + "relation": [ + "vendor" + ] + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "commitmentIds": { + "type": "array", + "items": "id", + "relation": [ + "commitment" + ] + }, + "relatedControlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "sourceDocumentIds": { + "type": "array", + "items": "id", + "relation": [ + "document" + ] + }, + "effectiveOn": { + "type": "date" + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "applicabilityReview": { + "type": "object", + "objectType": "applicability-review", + "label": "Applicability review" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "responsibleParty" + ], + "formFields": [ + "statusTransition" + ] + }, + "control": { + "title": "Control", + "pluralTitle": "Controls", + "group": "program", + "collection": "controls", + "description": "Management's actual Control implementation, mapped to Requirements, bounded Systems, operating Components, and authoritative evidence-source Components.", + "guidance": { + "policyBasis": "Controls translate approved Policies and applicable Requirements into owned procedures that management can operate and prove. FileGRC does not infer technical implementation from policy prose. Configuration facts belong in Controls, Components, Systems, Obligations, and Evidence.", + "cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review affected Controls after material changes and use scheduled program reviews for periodic oversight.", + "reviewPoints": [ + "Confirm the Control is needed for the applicable Requirements, Commitments, and service boundary.", + "Describe what people actually do to operate the Control.", + "Use \"Not Applicable\", \"External\", or \"Zero Population\" only when it is true for the current scope." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "risk-assessment", + "policy-review", + "control-design-review" + ] + }, + "required": [ + "status", + "statement", + "ownerIds", + "requirementIds", + "activity", + "operationMode", + "operationPattern" + ], + "markdown": { + "record": { + "label": "Procedure", + "primary": true, + "requiredWhen": { + "status": "implemented" + } + } + }, + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "implemented", + "partially-implemented", + "not-applicable", + "retired" + ], + "filter": true + }, + "statement": { + "type": "string", + "search": true + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "code": { + "type": "string", + "search": true + }, + "activity": { + "type": "string", + "search": true + }, + "controlType": { + "type": "enum", + "values": [ + "preventive", + "detective", + "corrective" + ], + "filter": true + }, + "operationMode": { + "type": "enum", + "values": [ + "manual", + "automated", + "hybrid" + ], + "filter": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ], + "requiredWhen": { + "status": "implemented" + } + }, + "policyIds": { + "type": "array", + "items": "id", + "relation": [ + "policy" + ] + }, + "effectiveOn": { + "type": "date", + "requiredWhen": { + "status": "implemented" + } + }, + "retiredOn": { + "type": "date" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "operationPattern": { + "type": "enum", + "values": [ + "continuous", + "event-driven", + "scheduled", + "mixed" + ], + "label": "Operation pattern", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "not-applicable", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "not-applicable", + "retired" + ] + } + }, + "applicabilityReview": { + "type": "object", + "objectType": "applicability-review", + "label": "Control applicability review" + }, + "procedureRevision": { + "type": "string", + "label": "Effective procedure revision" + }, + "procedureEffectiveOn": { + "type": "date", + "label": "Procedure effective date" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Operating and supporting Components" + }, + "evidenceSourceComponentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Authoritative evidence-source Components", + "requiredWhen": { + "status": "implemented" + } + } + }, + "listFields": [ + "code", + "title", + "status", + "controlType", + "operationMode", + "operationPattern", + "evidenceSourceComponentIds" + ], + "formFields": [ + "policyIds", + "componentIds", + "evidenceSourceComponentIds", + "statusTransition" + ] + }, + "control-test": { + "title": "Control test", + "pluralTitle": "Control tests", + "group": "program", + "collection": "control-tests", + "description": "A test of one Control\u2019s design or operation for a date or period, including method, population, samples, Evidence, result, and exceptions. Management-run Control Tests are not required for SOC 2 because the CPA firm tests independently.", + "guidance": { + "policyBasis": "The information security policy requires management to monitor Controls. The CPA firm performs its own independent testing for the SOC 2 examination.", + "cadence": "Plan from the Control operation pattern, linked Obligations, risk, and audit scope. Record the exact period or as-of date, link the complete population and sampled items when sampling applies, and complete review before relying on the result.", + "emptyState": "No management Control Tests are recorded. Add one only when management performs and reviews a real test. The CPA firm records its own testing separately.", + "reviewPoints": [ + "Confirm the test covers the intended Control, design or operating objective, and exact audit date or period.", + "Confirm the method, population, sample, and results support the conclusion.", + "Confirm every exception found during the test is recorded." + ], + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "controlId", + "testKinds", + "performedBy" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "not-performed", + "canceled" + ], + "filter": true + }, + "controlId": { + "type": "id", + "relation": [ + "control" + ] + }, + "testKinds": { + "type": "array", + "items": "string", + "filter": true + }, + "performedBy": { + "type": "enum", + "values": [ + "management", + "internal-audit", + "service-auditor" + ], + "filter": true + }, + "outcome": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "auditId": { + "type": "id", + "relation": [ + "audit" + ] + }, + "testerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "externalTester": { + "type": "object", + "objectType": "external-tester" + }, + "sampleSize": { + "type": "integer", + "minimum": 0 + }, + "populationId": { + "type": "id", + "relation": [ + "audit-population" + ] + }, + "sampleEvidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "exceptionCount": { + "type": "integer", + "minimum": 0 + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "completedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "sourceCommit": { + "type": "string" + }, + "notPerformedReason": { + "type": "string", + "requiredWhen": { + "status": "not-performed" + } + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "status": "complete" + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "controlId", + "performedBy", + "outcome", + "completedOn" + ], + "formFields": [ + "auditId", + "scheduledFor", + "coverage", + "cancellation" + ], + "oneOf": [ + { + "when": { + "status": "complete" + }, + "fields": [ + "testerIds", + "externalTester" + ] + } + ] + }, + "finding": { + "title": "Finding", + "pluralTitle": "Findings", + "group": "work", + "collection": "findings", + "description": "A confirmed gap that needs tracking after a control test, review, risk assessment, security test, incident review, management meeting, or audit. Keep observations and report details in the source record\u2019s Markdown. Create a Finding only when the gap needs its own owner, due date, remediation state, or verified closure. A Finding record is not required for SOC 2 when no confirmed gap needs tracking.", + "guidance": { + "policyBasis": "The information security policy requires confirmed issues from monitoring, audits, incidents, scans, and reviews to remain tracked until corrective action is verified.", + "cadence": "Create only after management confirms the gap. Use the Finding itself for straightforward remediation, add Action Items only for separate assigned tasks, and close only after remediation is verified.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "severity", + "sourceResourceId", + "description", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "accepted", + "remediating", + "resolved", + "closed" + ], + "filter": true + }, + "severity": { + "type": "rating", + "filter": true + }, + "sourceResourceId": { + "type": "id", + "relationGroup": "work-source" + }, + "description": { + "type": "string", + "search": true + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "exceptionId": { + "type": "id", + "relation": [ + "exception" + ], + "requiredWhen": { + "status": "accepted" + }, + "allowedWhen": { + "status": "accepted" + } + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "identifiedOn": { + "type": "date" + }, + "dueOn": { + "type": "date", + "requiredWhen": { + "status": [ + "open", + "remediating", + "resolved" + ] + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "resolvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "resolved", + "closed" + ] + } + }, + "verifiedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "closed" + } + }, + "verifiedOn": { + "type": "date", + "requiredWhen": { + "status": "closed" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "severity", + "identifiedOn", + "dueOn" + ] + }, + "exception": { + "title": "Exception", + "pluralTitle": "Exceptions", + "group": "risk", + "collection": "exceptions", + "description": "An approved, time-bound departure from a Policy or Control. Record its scope, reason, risk, compensating Controls, owner, approval, and expiry. An Exception record is not required for SOC 2 unless a departure is approved.", + "guidance": { + "policyBasis": "The information security policy allows departures only with a business reason, assessed risk, compensating safeguards, approval, and an expiry or review date.", + "cadence": "Approve before the departure begins, follow its linked review Obligation or expiry, and close or renew it through a new risk decision.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "scopeResourceIds", + "requestorIds", + "ownerIds", + "rationale", + "requestedOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "requested", + "approved", + "revoked", + "closed" + ], + "filter": true + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "obligation-scope" + }, + "requestorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "rationale": { + "type": "string", + "search": true + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "requestedOn": { + "type": "date" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "approval": { + "type": "object", + "objectType": "exception-approval", + "requiredWhen": { + "status": [ + "approved", + "revoked", + "closed" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "revoked", + "closed" + ] + } + }, + "resolution": { + "type": "object", + "objectType": "exception-resolution", + "requiredWhen": { + "status": [ + "revoked", + "closed" + ] + }, + "allowedWhen": { + "status": [ + "revoked", + "closed" + ] + } + }, + "reportingRouteSetId": { + "type": "id", + "relation": [ + "reporting-route-set" + ], + "label": "Reporting Route Set" + }, + "reportingRouteLanePair": { + "type": "enum", + "values": [ + "primary-alternate" + ], + "label": "Route lane pair" + }, + "dependencySystemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ], + "label": "Intersecting dependency Systems" + } + }, + "listFields": [ + "title", + "status", + "approval" + ], + "formFields": [ + "approval", + "resolution" + ] + }, + "action-item": { + "title": "Action item", + "pluralTitle": "Action items", + "group": "work", + "collection": "action-items", + "description": "One owned, dated follow-up task linked to the Finding, Policy Event, Risk, Incident, review, test, meeting, Exception, or request that created it. Action Items appear in Work Queue. Use one only when work needs separate assignment, timing, and completion proof. A separate Action Item tracker is not required for SOC 2.", + "guidance": { + "policyBasis": "Policies require material issues and review decisions to produce owned, dated corrective work with proof of completion.", + "cadence": "Create when separate follow-up is assigned. Set a deadline, work it from Work Queue, record blockers, and link completion evidence before closing." + }, + "required": [ + "status", + "assigneeIds", + "sourceResourceId" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "in-progress", + "blocked", + "done", + "canceled" + ], + "filter": true + }, + "assigneeIds": { + "type": "array", + "items": "id", + "relation": [ + "person", + "team", + "appointment" + ] + }, + "sourceResourceId": { + "type": "id", + "relationGroup": "work-source" + }, + "description": { + "type": "string", + "search": true + }, + "priority": { + "type": "rating", + "filter": true + }, + "obligationId": { + "type": "id", + "relation": [ + "obligation" + ] + }, + "obligationRuleId": { + "type": "id", + "relation": [ + "obligation-rule" + ], + "label": "Governing obligation rule" + }, + "completedOn": { + "type": "date", + "requiredWhen": { + "status": "done" + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "completionResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "completion-record" + }, + "blockingResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "work-blocker", + "requiredWhen": { + "status": "blocked" + }, + "allowedWhen": { + "status": "blocked" + } + }, + "completionWindow": { + "type": "object", + "objectType": "completion-window", + "label": "Completion window", + "requiredWhen": { + "status": [ + "open", + "in-progress", + "blocked" + ] + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "priority", + "completionWindow", + "assigneeIds" + ], + "formFields": [ + "cancellation" + ] + }, + "policy": { + "title": "Policy", + "pluralTitle": "Policies", + "group": "governance", + "collection": "policies", + "description": "Management-approved requirements for the program. Approval accepts the requirements; activation makes them effective. Store the Policy text in Markdown and its owner, separate approver, scope, status, dates, and linked Controls in the record.", + "guidance": { + "policyBasis": "A Policy says what the company commits to do by the date it takes effect. Approval means the company accepts those commitments. It does not prove the work is done. Controls and operating records describe how the company meets them and provide the proof.", + "cadence": "Move a draft through independent review and approval without requiring every linked Control to be implemented. During Step 3, finish Controls, Components, evidence sources, governed plans, and schedules, review the per-Policy activation assessment, then activate the approved revision on its real effective date. Do not backdate adoption. The approver is usually internal and may be external, but must be separate from the owner and from the CPA auditor role. Review on the approved risk-based schedule and after material changes.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "policy-review" + ] + }, + "required": [ + "status", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "in-review", + "approved", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "approverIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "disjointFrom": "ownerIds", + "requiredWhen": { + "status": [ + "in-review", + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "policyNumber": { + "type": "string", + "search": true + }, + "policyKind": { + "type": "string", + "filter": true + }, + "version": { + "type": "string" + }, + "effectiveOn": { + "type": "date", + "requiredWhen": { + "status": "active" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "policy" + ] + }, + "parentPolicyId": { + "type": "id", + "relation": [ + "policy" + ] + }, + "relatedPolicyIds": { + "type": "array", + "items": "id", + "relation": [ + "policy" + ] + }, + "relatedDocumentIds": { + "type": "array", + "items": "id", + "relation": [ + "document" + ] + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "audience": { + "type": "array", + "items": "string", + "filter": true + }, + "acknowledgementRequired": { + "type": "boolean", + "filter": true + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "approvedContentRevisions": { + "type": "object", + "objectType": "content-revisions", + "label": "Approved content revisions", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "managed": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "programRole": { + "type": "enum", + "values": [ + "required", + "conditional", + "alternative", + "supporting" + ], + "label": "Program role", + "filter": true + }, + "proposedEffectiveOn": { + "type": "date", + "label": "Proposed effective date", + "allowedWhen": { + "status": [ + "draft", + "in-review", + "approved" + ] + } + }, + "reportingRouteRequirements": { + "type": "array", + "items": "object", + "itemObjectType": "reporting-route-requirement", + "label": "Reporting route requirements" + } + }, + "listFields": [ + "title", + "status", + "policyKind", + "version", + "effectiveOn" + ], + "markdown": { + "content": { + "label": "Policy", + "primary": true, + "required": true + } + }, + "formFields": [ + "programRole", + "statusTransition" + ] + }, + "policy-review": { + "title": "Policy review", + "pluralTitle": "Policy reviews", + "group": "governance", + "collection": "policy-reviews", + "description": "The result of a scheduled or change-driven review of Policies or governed Documents, including reviewers, decision, evidence, and follow-up. Edit the source record separately when changes are approved.", + "guidance": { + "policyBasis": "The Information Security Policy and Security Incident and Recovery Plan require periodic review and another review after specified material changes.", + "cadence": "Complete on the approved risk-based schedule and after a triggering change, incident, disruption, or policy condition. Link the exact scope, reviewers, result, and evidence.", + "reviewPoints": [ + "Read the current Policy or governed Document.", + "Confirm it still matches the service, current requirements, and actual practice.", + "Record any needed change or follow-up." + ], + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "policy-review" + ] + }, + "required": [ + "status", + "scopeResourceIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document" + ] + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "in-progress", + "complete" + ] + } + }, + "outcome": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "changesRequired": { + "type": "boolean" + }, + "changeSummary": { + "type": "string" + }, + "approverIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "completedOn": { + "type": "date", + "label": "Completed on", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "completedOn", + "outcome", + "changesRequired" + ], + "formFields": [ + "scheduledFor", + "coverage", + "cancellation" + ] + }, + "attestation": { + "title": "Attestation", + "pluralTitle": "Attestations", + "group": "governance", + "collection": "attestations", + "description": "One person\u2019s acknowledgement, training completion, certification, or assigned-work confirmation. Bind it to the exact content revision and signed Evidence when required.", + "guidance": { + "policyBasis": "The information security policy requires people to complete assigned training and acknowledge applicable responsibilities.", + "cadence": "Assign on the approved onboarding and recurring schedules and after material content changes that require acknowledgement.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "training" + ] + }, + "required": [ + "status", + "subjectResourceIds", + "personId", + "attestationKind", + "assignedOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "pending", + "completed", + "waived" + ], + "filter": true + }, + "subjectResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "training", + "action-item" + ] + }, + "programId": { + "type": "id", + "relation": [ + "program" + ], + "label": "Program" + }, + "personId": { + "type": "id", + "relation": [ + "person" + ] + }, + "attestationKind": { + "type": "string", + "filter": true + }, + "assignedOn": { + "type": "date" + }, + "assignedAt": { + "type": "timestamp", + "label": "Assignment time" + }, + "dueOn": { + "type": "date" + }, + "completedOn": { + "type": "date", + "requiredWhen": { + "status": "completed" + }, + "allowedWhen": { + "status": "completed" + } + }, + "attestationMethod": { + "type": "enum", + "values": [ + "git-approval", + "signed-document", + "external-record" + ], + "filter": true, + "requiredWhen": { + "status": "completed" + }, + "allowedWhen": { + "status": "completed" + } + }, + "contentRevisions": { + "type": "object", + "objectType": "content-revisions", + "requiredWhen": { + "status": "completed", + "attestationMethod": "git-approval" + }, + "allowedWhen": { + "status": "completed", + "attestationMethod": "git-approval" + }, + "managed": true + }, + "expiresOn": { + "type": "date" + }, + "waivedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "waived" + }, + "allowedWhen": { + "status": "waived" + } + }, + "waiverReason": { + "type": "string", + "requiredWhen": { + "status": "waived" + }, + "allowedWhen": { + "status": "waived" + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "reportingRouteId": { + "type": "id", + "relation": [ + "reporting-route" + ], + "label": "Legacy delivered reporting route" + }, + "reportingRouteRevision": { + "type": "string", + "label": "Legacy delivered reporting route revision" + }, + "reportingRouteSetId": { + "type": "id", + "relation": [ + "reporting-route-set" + ], + "label": "Delivered reporting route set" + }, + "reportingRouteSetCommit": { + "type": "string", + "label": "Delivered reporting route set Git commit" + } + }, + "listFields": [ + "title", + "status", + "personId", + "attestationKind", + "dueOn", + "completedOn" + ], + "markdown": { + "statement": { + "label": "Statement", + "primary": true, + "required": false + } + }, + "oneOf": [ + { + "when": { + "status": "completed", + "attestationMethod": [ + "signed-document", + "external-record" + ] + }, + "fields": [ + "evidenceIds" + ] + } + ] + }, + "meeting": { + "title": "Meeting", + "pluralTitle": "Meetings", + "group": "governance", + "collection": "meetings", + "description": "One governance meeting, including its chair, attendees, agenda, minutes, decisions, raised issues, Evidence, and assigned follow-up.", + "guidance": { + "policyBasis": "The information security policy requires recorded security and risk oversight. The Security Incident and Recovery Plan requires management review of exercises and unresolved risks.", + "cadence": "Hold security and risk oversight meetings on the approved risk-based schedule. Create one immutable meeting record and Markdown minutes for each occurrence.", + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "meeting" + ] + }, + "required": [ + "status", + "teamId", + "scheduledFor", + "chairIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "complete", + "canceled" + ], + "filter": true + }, + "teamId": { + "type": "id", + "relation": [ + "team" + ] + }, + "chairIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "startedAt": { + "type": "timestamp", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "endedAt": { + "type": "timestamp", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "attendeeIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "externalAttendees": { + "type": "array", + "items": "object", + "itemObjectType": "external-attendee" + }, + "decisionSummary": { + "type": "string", + "search": true + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "teamId", + "scheduledFor" + ], + "markdown": { + "agenda": { + "label": "Agenda", + "primary": false, + "required": false + }, + "minutes": { + "label": "Minutes", + "primary": true, + "required": false, + "requiredWhen": { + "status": "complete" + } + } + }, + "oneOf": [ + { + "when": { + "status": "complete" + }, + "fields": [ + "attendeeIds", + "externalAttendees" + ] + } + ], + "formFields": [ + "cancellation" + ] + }, + "training": { + "title": "Training", + "titleLabel": "Name", + "pluralTitle": "Training", + "group": "governance", + "collection": "training", + "description": "Reusable, governed training content, including its audience, passing criteria, and linked Policies and Controls. Approve the exact content in Step 2, activate it during Step 3 implementation, define assignment schedules in Obligations, and record individual completions in Attestations.", + "guidance": { + "policyBasis": "The information security policy requires security training and added role-specific instruction when a person\u2019s responsibilities or data access warrant it.", + "cadence": "Approve the exact training content in Step 2. During Step 3, finish the linked Controls and Obligations, then activate the unchanged approved revision. Assign training through Obligations on approved onboarding and recurring schedules and after relevant material changes or incidents.", + "reviewPoints": [ + "Write the training for the people who must follow it and state the behavior you expect.", + "Include product-license or customer MFA requirements only when an approved requirement calls for them.", + "Link each completion to the exact Training revision the person completed." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "training" + ] + }, + "required": [ + "status", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "in-review", + "approved", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "audience": { + "type": "array", + "items": "string", + "filter": true + }, + "policyIds": { + "type": "array", + "items": "id", + "relation": [ + "policy" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "passingCriteria": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "approverIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "disjointFrom": "ownerIds", + "requiredWhen": { + "status": [ + "in-review", + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "training" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "effectiveOn": { + "type": "date", + "requiredWhen": { + "status": "active" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + } + }, + "approvedContentRevisions": { + "type": "object", + "objectType": "content-revisions", + "label": "Approved content revisions", + "requiredWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "active", + "superseded", + "retired" + ] + }, + "managed": true + }, + "activatedOn": { + "type": "date", + "label": "Activation date", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "activationBasis": { + "type": "enum", + "values": [ + "recorded" + ], + "label": "Activation basis", + "requiredWhen": { + "status": "active" + }, + "allowedWhen": { + "status": [ + "active", + "superseded", + "retired" + ] + } + }, + "activatedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Activated by", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "activationBasis": "recorded" + } + }, + "activatedContentRevisions": { + "type": "object", + "objectType": "content-revisions", + "label": "Activated content revisions", + "requiredWhen": { + "activationBasis": "recorded" + }, + "allowedWhen": { + "activationBasis": "recorded" + }, + "managed": true + }, + "proposedEffectiveOn": { + "type": "date", + "label": "Proposed effective date", + "allowedWhen": { + "status": [ + "draft", + "in-review", + "approved" + ] + } + } + }, + "listFields": [ + "title", + "status", + "audience", + "effectiveOn" + ], + "markdown": { + "content": { + "label": "Training", + "primary": true, + "required": true + } + }, + "formFields": [ + "statusTransition" + ] + }, + "risk": { + "title": "Risk", + "pluralTitle": "Risks", + "group": "risk", + "collection": "risks", + "description": "One identified threat or business impact that needs treatment or ongoing tracking. Record its owner, ratings, response, affected scope, Controls, acceptance, and follow-up.", + "guidance": { + "policyBasis": "The information security policy requires identified Risks to have an owner, rating, treatment decision, target date, and time-bound approval when accepted.", + "cadence": "Assess the register on the approved risk-based schedule and after material changes. Review High and Critical risks on their approved schedule and accepted risks by their review date.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "risk-assessment" + ] + }, + "required": [ + "status", + "description", + "ownerIds", + "categories", + "response", + "inherentRating" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "open", + "monitoring", + "closed", + "archived" + ], + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "categories": { + "type": "array", + "items": "string", + "filter": true + }, + "response": { + "type": "enum", + "values": [ + "avoid", + "mitigate", + "transfer", + "accept", + "monitor" + ], + "filter": true + }, + "inherentRating": { + "type": "object", + "objectType": "risk-rating" + }, + "residualRating": { + "type": "object", + "objectType": "risk-rating" + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "vendorIds": { + "type": "array", + "items": "id", + "relation": [ + "vendor" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "commitmentIds": { + "type": "array", + "items": "id", + "relation": [ + "commitment" + ] + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "acceptance": { + "type": "object", + "objectType": "risk-acceptance", + "requiredWhen": { + "response": "accept" + }, + "allowedWhen": { + "response": "accept" + } + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "closed", + "archived" + ] + }, + "allowedWhen": { + "status": [ + "closed", + "archived" + ] + } + }, + "treatmentTargetOn": { + "type": "date", + "label": "Treatment target date" + }, + "reviewDueOn": { + "type": "date", + "label": "Next review due" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + }, + "informationTypeIds": { + "type": "array", + "items": "id", + "relation": [ + "information-type" + ], + "label": "Affected Information Types" + }, + "reportingRouteRequirements": { + "type": "array", + "items": "object", + "itemObjectType": "reporting-route-requirement", + "label": "Reporting route requirements" + } + }, + "listFields": [ + "title", + "status", + "response", + "inherentRating", + "residualRating" + ], + "formFields": [ + "systemIds", + "componentIds", + "informationTypeIds", + "controlIds", + "acceptance", + "statusTransition" + ] + }, + "risk-assessment": { + "title": "Risk assessment", + "pluralTitle": "Risk assessments", + "group": "risk", + "collection": "risk-assessments", + "description": "One approved evaluation of a defined scope using the program\u2019s risk method. It records participants, Systems, Vendors, conclusions, Evidence, and the Risks created or reassessed.", + "guidance": { + "policyBasis": "The information security policy requires periodic and change-driven assessment of threats, assets, obligations, Controls, likelihood, impact, and treatment.", + "cadence": "Complete on the approved risk-based schedule and after a material change that could alter risk. Record new and changed risks instead of hiding them in the summary.", + "reviewPoints": [ + "Confirm the assessment covered the real service, material dependencies, data, commitments, and recent changes.", + "Confirm the ratings and responses match management's current view of each risk.", + "Confirm no material risk was left out." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "risk-assessment" + ] + }, + "required": [ + "status", + "assessmentKind", + "scope" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "assessmentKind": { + "type": "enum", + "values": [ + "enterprise-risk", + "system-risk", + "privacy-impact", + "vendor-risk", + "business-impact" + ], + "filter": true + }, + "scope": { + "type": "string", + "search": true + }, + "assessorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "in-progress", + "complete" + ] + } + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "disjointFrom": "assessorIds", + "requiredWhen": { + "status": "complete" + } + }, + "methodology": { + "type": "string", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "trigger": { + "type": "string" + }, + "attendeeIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "vendorIds": { + "type": "array", + "items": "id", + "relation": [ + "vendor" + ] + }, + "commitmentIds": { + "type": "array", + "items": "id", + "relation": [ + "commitment" + ] + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "newRiskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "changedRiskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "summary": { + "type": "string", + "search": true, + "requiredWhen": { + "status": "complete" + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "sourceCommit": { + "type": "string" + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "completedOn": { + "type": "date", + "label": "Completed on", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "assessmentKind", + "completedOn" + ], + "formFields": [ + "scheduledFor", + "cancellation" + ] + }, + "vendor": { + "title": "Vendor", + "titleLabel": "Name", + "pluralTitle": "Vendors", + "group": "systems-vendors", + "collection": "vendors", + "description": "External organizations and commercial relationships, including contracts, due diligence, risk reviews, monitoring, assurance reports, dates, and general information-access facts.", + "guidance": { + "policyBasis": "The information security policy requires an inventory of important providers, risk-based review before access or reliance, suitable contract terms, and ongoing monitoring.", + "cadence": "Create for a material external provider relationship. Review critical and high-risk Vendors on the approved risk-based schedule and after material relationship, service, access, or incident changes. Do not create a Component unless a supplied capability also meets the Component inclusion rules.", + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "inventory-review", + "vendor-review" + ] + }, + "required": [ + "status", + "category", + "criticality", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "evaluating", + "active", + "deprecated", + "terminated" + ], + "filter": true + }, + "category": { + "type": "string", + "filter": true + }, + "criticality": { + "type": "enum", + "values": [ + "low", + "medium", + "high", + "critical" + ], + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "standardAgreement": { + "type": "boolean" + }, + "agreementDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "startDate": { + "type": "date" + }, + "endDate": { + "type": "date" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "classificationId": { + "type": "id", + "relation": [ + "classification" + ], + "label": "Highest accessible classification", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "deprecated", + "terminated" + ] + }, + "allowedWhen": { + "status": [ + "deprecated", + "terminated" + ] + } + }, + "informationTypeIds": { + "type": "array", + "items": "id", + "relation": [ + "information-type" + ], + "label": "Information Types accessible" + } + }, + "listFields": [ + "title", + "status", + "category", + "criticality", + "classificationId" + ], + "formFields": [ + "informationTypeIds", + "classificationId", + "statusTransition" + ] + }, + "vendor-review": { + "title": "Vendor review", + "pluralTitle": "Vendor reviews", + "group": "systems-vendors", + "collection": "vendor-reviews", + "description": "One due-diligence or periodic review of a Vendor, covering the service, data, access, assurance, recovery, incidents, contracts, Risks, Evidence, and follow-up.", + "guidance": { + "policyBasis": "The information security policy requires review before a Vendor handles sensitive data or supports important services, plus periodic review of higher-risk providers.", + "cadence": "Complete before access, on the approved risk-based schedule for Critical and High-risk vendors, and after material service changes or incidents.", + "reviewPoints": [ + "Confirm the review covers the Vendor\u2019s current services, connected Systems, data access, criticality, subservice role, and contract terms.", + "Evaluate current assurance reports, security and privacy terms, incident history, resilience, access model, and material changes.", + "Make the approve, conditional, or reject decision match the risks and assign any needed follow-up." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "vendor-review" + ] + }, + "required": [ + "status", + "vendorId" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "in-progress", + "complete" + ] + } + }, + "scope": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "status": "complete" + } + }, + "decision": { + "type": "enum", + "values": [ + "approved", + "conditional", + "rejected" + ], + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "completedOn": { + "type": "date", + "label": "Completed on", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "vendorId": { + "type": "id", + "relation": [ + "vendor" + ] + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "vendorId", + "completedOn", + "decision" + ], + "formFields": [ + "scheduledFor", + "coverage", + "cancellation" + ] + }, + "access-grant": { + "title": "Access grant", + "pluralTitle": "Access grants", + "group": "people-access", + "collection": "access-grants", + "description": "One Person\u2019s or Service Account\u2019s access to one System, including business need, privilege, request, approval, provisioning, expiry, removal, ticket, and Evidence.", + "guidance": { + "policyBasis": "The information security policy requires unique identity, documented business need, least privilege, approval, authorized provisioning, and prompt removal.", + "cadence": "Record every grant and material change. Remove access at or before notice for involuntary or high-risk departures and within 24 hours for other departures.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "subjectId", + "componentId", + "accessLevel", + "privileged", + "requestedOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "requested", + "approved", + "active", + "revoked", + "expired" + ], + "filter": true + }, + "subjectId": { + "type": "id", + "relation": [ + "person", + "service-account" + ] + }, + "accessLevel": { + "type": "string", + "filter": true + }, + "privileged": { + "type": "boolean", + "filter": true + }, + "role": { + "type": "string" + }, + "requestedOn": { + "type": "date" + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "approved", + "active", + "revoked", + "expired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "approved", + "active", + "revoked", + "expired" + ] + } + }, + "provisionedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "active", + "revoked", + "expired" + ] + } + }, + "provisionedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "active", + "revoked", + "expired" + ] + } + }, + "expiresOn": { + "type": "date" + }, + "deprovisionedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "deprovisionedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "revoked", + "expired" + ] + }, + "allowedWhen": { + "status": [ + "revoked", + "expired" + ] + } + }, + "deprovisionReason": { + "type": "string", + "requiredWhen": { + "status": [ + "revoked", + "expired" + ] + }, + "allowedWhen": { + "status": [ + "revoked", + "expired" + ] + } + }, + "ticketReference": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "businessNeed": { + "type": "string", + "label": "Business need", + "search": true + }, + "componentId": { + "type": "id", + "relation": [ + "component" + ], + "label": "Component" + } + }, + "listFields": [ + "title", + "status", + "subjectId", + "componentId", + "accessLevel", + "privileged" + ], + "formFields": [] + }, + "access-review": { + "title": "Access review", + "pluralTitle": "Access reviews", + "group": "people-access", + "collection": "access-reviews", + "description": "One review of a defined access population for a date or period, including Systems, reviewers, Access Grant decisions, exceptions, approval, Evidence, and source revision.", + "guidance": { + "policyBasis": "The information security policy requires System owners to periodically confirm least privilege and remove dormant, expired, excessive, or unneeded access.", + "cadence": "Review privileged, production, and other important-system access on the approved risk-based schedules.", + "reviewPoints": [ + "Confirm the review population is complete for every System and exact date or period in scope.", + "Decide whether each person and service account still needs its exact access and privilege level.", + "Confirm every removal, change, or exception has follow-up." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "access-review" + ] + }, + "required": [ + "status", + "componentIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "in-progress", + "complete" + ] + } + }, + "scope": { + "type": "string" + }, + "outcome": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "grantDecisions": { + "type": "array", + "items": "object", + "itemObjectType": "access-grant-decision" + }, + "populationCount": { + "type": "integer", + "minimum": 0 + }, + "exceptionCount": { + "type": "integer", + "minimum": 0 + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "sourceCommit": { + "type": "string" + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "completedOn": { + "type": "date", + "label": "Completed on", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "completedOn", + "outcome", + "exceptionCount" + ], + "formFields": [ + "scheduledFor", + "coverage", + "cancellation" + ] + }, + "vulnerability": { + "title": "Vulnerability", + "pluralTitle": "Vulnerabilities", + "group": "security-operations", + "collection": "vulnerabilities", + "description": "One confirmed weakness that needs remediation, an approved Exception, or verified closure. A separate Vulnerability register is not required for SOC 2 when the scanner or ticket system retains complete, usable records.", + "guidance": { + "policyBasis": "The information security policy requires confirmed Vulnerabilities to receive owned, risk-based remediation or an approved Exception with compensating Controls.", + "cadence": "Assign an owner and approved risk-based remediation or patch deadline from severity, exposure, exploitability, and commitments. Track overdue work through approved time-bound Exceptions and verify closure.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "severity", + "description", + "systemIds", + "ownerIds", + "discoveredOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "in-progress", + "risk-accepted", + "remediated", + "closed", + "false-positive" + ], + "filter": true + }, + "severity": { + "type": "enum", + "values": [ + "informational", + "low", + "medium", + "high", + "critical", + "unknown" + ], + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "discoveredOn": { + "type": "date" + }, + "source": { + "type": "string", + "filter": true + }, + "externalIdentifier": { + "type": "string", + "search": true + }, + "cveIds": { + "type": "array", + "items": "string", + "search": true + }, + "cvss": { + "type": "number" + }, + "exploitability": { + "type": "string" + }, + "dueOn": { + "type": "date" + }, + "riskId": { + "type": "id", + "relation": [ + "risk" + ] + }, + "exceptionId": { + "type": "id", + "relation": [ + "exception" + ], + "requiredWhen": { + "status": "risk-accepted" + }, + "allowedWhen": { + "status": "risk-accepted" + } + }, + "remediatedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "remediated", + "closed" + ] + }, + "allowedWhen": { + "status": [ + "remediated", + "closed" + ] + } + }, + "dispositionRationale": { + "type": "string", + "requiredWhen": { + "status": "false-positive" + }, + "allowedWhen": { + "status": "false-positive" + } + }, + "verifiedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "remediated", + "closed", + "false-positive" + ] + } + }, + "verifiedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "remediated", + "closed", + "false-positive" + ] + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": [ + "remediated", + "closed" + ] + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "confirmedOn": { + "type": "date", + "label": "Confirmed on" + }, + "severityAssignedOn": { + "type": "date", + "label": "Severity assigned on" + }, + "targetRemediationOn": { + "type": "date", + "label": "Target remediation date" + }, + "severityChangedOn": { + "type": "date", + "label": "Severity changed on" + }, + "severityChangeReason": { + "type": "string", + "label": "Severity change reason" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "severity", + "systemIds", + "discoveredOn", + "dueOn" + ] + }, + "vulnerability-scan": { + "title": "Vulnerability scan", + "pluralTitle": "Vulnerability scans", + "group": "security-operations", + "collection": "vulnerability-scans", + "description": "One vulnerability scan activity, including tool, scope, Systems, operator, time, result, resulting Vulnerabilities, Evidence, failure reason, and review.", + "guidance": { + "policyBasis": "The information security policy requires management to monitor for weaknesses and scan internet-facing and production Systems.", + "cadence": "Use the approved risk-based scan and reconciliation schedules and reassess coverage after significant changes. Reconcile applicable host, network, dependency, web, and advisory sources in one periodic review. Investigate failed or incomplete scans separately from confirmed findings and retrieve complete evidence before source or CI artifact expiry.", + "reviewPoints": [ + "Confirm the scan covered the intended current Systems and attack surface.", + "Confirm any exclusion, authentication failure, unreachable target, or other coverage gap is understood.", + "Confirm every real finding has remediation or an accepted exception." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "vulnerability-scan" + ] + }, + "required": [ + "status", + "scanKind", + "scope", + "operatorIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "running", + "complete", + "failed" + ], + "filter": true + }, + "scanKind": { + "type": "string", + "filter": true + }, + "scope": { + "type": "string", + "search": true + }, + "operatorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "tools": { + "type": "array", + "items": "string" + }, + "startedAt": { + "type": "timestamp" + }, + "completedAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "complete", + "failed" + ] + }, + "allowedWhen": { + "status": [ + "complete", + "failed" + ] + } + }, + "resultSummary": { + "type": "string", + "search": true, + "requiredWhen": { + "status": "complete" + } + }, + "findingCountBySeverity": { + "type": "object", + "objectType": "integer-map" + }, + "vulnerabilityIds": { + "type": "array", + "items": "id", + "relation": [ + "vulnerability" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "failureReason": { + "type": "string", + "requiredWhen": { + "status": "failed" + }, + "allowedWhen": { + "status": "failed" + } + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "scanKind", + "scheduledFor", + "completedAt" + ], + "formFields": [] + }, + "incident": { + "title": "Incident", + "pluralTitle": "Incidents", + "group": "security-operations", + "collection": "incidents", + "description": "One suspected or confirmed security or privacy event, with severity, timeline, scope, owner, affected Systems and Vendors, Evidence, Findings, and corrective work. An Incident record is not required for SOC 2 when no incident occurred.", + "guidance": { + "policyBasis": "The information security policy requires prompt reporting, investigation, containment, recovery, evidence preservation, and review of notification duties.", + "cadence": "Create on report or detection, update material events as they progress, and complete a retrospective within one week after a material incident.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "severity", + "ownerIds", + "detectedAt", + "description" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "suspected", + "declared", + "contained", + "eradicated", + "recovered", + "closed" + ], + "filter": true + }, + "severity": { + "type": "rating", + "filter": true + }, + "detectedAt": { + "type": "timestamp" + }, + "description": { + "type": "string", + "search": true + }, + "occurredAt": { + "type": "timestamp" + }, + "declaredAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "declared", + "contained", + "eradicated", + "recovered", + "closed" + ] + } + }, + "containedAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "contained", + "eradicated", + "recovered", + "closed" + ] + } + }, + "recoveredAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "recovered", + "closed" + ] + } + }, + "closedAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "closed" + ] + } + }, + "reportedBy": { + "type": "object", + "objectType": "person-reference" + }, + "detectionSource": { + "type": "string" + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "vendorIds": { + "type": "array", + "items": "id", + "relation": [ + "vendor" + ] + }, + "riskIds": { + "type": "array", + "items": "id", + "relation": [ + "risk" + ] + }, + "vulnerabilityIds": { + "type": "array", + "items": "id", + "relation": [ + "vulnerability" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "closed" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "classificationId": { + "type": "id", + "label": "Classification", + "relation": [ + "classification" + ] + }, + "eradicatedAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "eradicated", + "recovered", + "closed" + ] + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "severity", + "detectedAt", + "closedAt", + "classificationId" + ], + "formFields": [ + "classificationId" + ], + "markdown": { + "record": { + "label": "Incident record and retrospective", + "primary": true, + "requiredWhen": { + "status": "closed" + } + } + } + }, + "exercise": { + "title": "Exercise", + "pluralTitle": "Exercises", + "group": "resilience", + "collection": "exercises", + "description": "One continuity, recovery, incident, or privacy simulation, including scenario, objective, participants, scope, result, Evidence, Findings, and follow-up.", + "guidance": { + "policyBasis": "The information security policy requires incident-response testing. The Security Incident and Recovery Plan requires exercises and another review after material change or disruption.", + "cadence": "Test incident response and continuity on the approved risk-based schedule. Repeat after a material change when the prior exercise no longer represents the environment.", + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "exercise" + ] + }, + "required": [ + "status", + "exerciseKind", + "scheduledFor", + "facilitatorIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "exerciseKind": { + "type": "string", + "filter": true + }, + "facilitatorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "scenario": { + "type": "string" + }, + "objective": { + "type": "string" + }, + "outcome": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "participantIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "teamIds": { + "type": "array", + "items": "id", + "relation": [ + "team" + ] + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "startedAt": { + "type": "timestamp" + }, + "completedAt": { + "type": "timestamp", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "exerciseKind", + "scheduledFor", + "outcome" + ], + "formFields": [ + "cancellation" + ] + }, + "backup-test": { + "title": "Backup test", + "pluralTitle": "Backup tests", + "group": "resilience", + "collection": "backup-tests", + "description": "One restore or recovery test, including the Systems and operators involved, timing, recovery result, reviewer, Evidence, Findings, and follow-up.", + "guidance": { + "policyBasis": "The information security policy and Security Incident and Recovery Plan require protected backups, monitored failures, and tested proof that important data can be restored and used.", + "cadence": "Test restoration on the approved risk-based schedule for important systems and after recovery changes that could invalidate prior evidence.", + "reviewPoints": [ + "Confirm the test restored the intended Systems and data.", + "Confirm the restored data was usable and the recovery targets were met.", + "Record any failure, missed target, or needed follow-up." + ], + "sourceResourceIds": [ + "policy-information-security", + "document-security-incident-recovery-plan" + ], + "obligationActivityTypes": [ + "backup-test" + ] + }, + "required": [ + "status", + "systemIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "running", + "complete", + "canceled" + ], + "filter": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "operatorIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "running", + "complete" + ] + } + }, + "outcome": { + "type": "outcome", + "filter": true, + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "startedAt": { + "type": "timestamp" + }, + "completedAt": { + "type": "timestamp", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "recoveryTimeMinutes": { + "type": "integer" + }, + "recoveryPointMinutes": { + "type": "integer" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "outcome", + "recoveryTimeMinutes" + ], + "formFields": [ + "scheduledFor", + "cancellation" + ] + }, + "penetration-test": { + "title": "Penetration test", + "pluralTitle": "Penetration tests", + "group": "security-operations", + "collection": "penetration-tests", + "description": "One internal or external penetration test, including provider, scope, period, method, result, affected Systems, Evidence, Vulnerabilities, Findings, and review.", + "guidance": { + "policyBasis": "The information security policy requires management to decide whether penetration testing is needed from exposure, material changes, customer commitments, technical capability, and risk, then track confirmed findings when testing is performed.", + "cadence": "Use the cadence approved in the applicable Control, customer commitment, or risk decision. Review applicability on the approved risk-based schedule and after material attack-surface or architecture changes; the review may conclude that no penetration test is required.", + "reviewPoints": [ + "Confirm the testing decision, tester independence when required, scope, method, and cadence match the approved reason for testing.", + "Confirm the report supports the recorded result.", + "Confirm every issue has remediation, an accepted exception, or verified closure." + ], + "sourceResourceIds": [ + "policy-information-security" + ], + "obligationActivityTypes": [ + "penetration-test" + ] + }, + "required": [ + "status", + "testKind", + "scope", + "ownerIds", + "coverage" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "complete", + "canceled" + ], + "filter": true + }, + "testKind": { + "type": "string", + "filter": true + }, + "scope": { + "type": "string", + "search": true + }, + "provider": { + "type": "string" + }, + "outcome": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + }, + "filter": true, + "allowedWhen": { + "status": "complete" + } + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "rulesOfEngagementDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "methodology": { + "type": "string" + }, + "resultSummary": { + "type": "string", + "search": true + }, + "vulnerabilityIds": { + "type": "array", + "items": "id", + "relation": [ + "vulnerability" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "complete" + } + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage" + }, + "scheduledFor": { + "type": "date", + "label": "Scheduled for" + }, + "completedOn": { + "type": "date", + "label": "Completed on", + "requiredWhen": { + "status": "complete" + }, + "allowedWhen": { + "status": "complete" + } + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "testKind", + "coverage", + "outcome" + ], + "formFields": [ + "scheduledFor", + "coverage", + "cancellation" + ] + }, + "data-request": { + "title": "Data request", + "pluralTitle": "Data requests", + "group": "governance", + "collection": "data-requests", + "description": "One privacy, contractual, or other data request tracked by opaque reference, with scope, due date, decision, Evidence, and completion. Data Request tracking is not required for a SOC 2 Security-only report.", + "guidance": { + "policyBasis": "The information security policy requires applicable requests to reach a responsible owner, meet the governing deadline, and keep erasable personal data out of immutable Git history.", + "cadence": "Create on receipt, set the deadline from applicable law or contract, verify identity outside this repository when needed, and record completion.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "requestKind", + "receivedOn", + "ownerIds", + "requesterReference", + "dueOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "received", + "verifying", + "in-progress", + "completed", + "denied", + "canceled" + ], + "filter": true + }, + "requestKind": { + "type": "string", + "filter": true + }, + "receivedOn": { + "type": "date" + }, + "requesterReference": { + "type": "string", + "search": true + }, + "jurisdiction": { + "type": "string", + "filter": true + }, + "dueOn": { + "type": "date" + }, + "verifiedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "in-progress", + "completed", + "denied" + ] + } + }, + "scope": { + "type": "string" + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "vendorIds": { + "type": "array", + "items": "id", + "relation": [ + "vendor" + ] + }, + "decision": { + "type": "enum", + "values": [ + "fulfilled", + "partially-fulfilled", + "denied" + ], + "requiredWhen": { + "status": [ + "completed", + "denied" + ] + } + }, + "decisionRationale": { + "type": "string", + "requiredWhen": { + "status": "denied" + } + }, + "decidedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "completed", + "denied" + ] + } + }, + "completedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "completed", + "denied", + "canceled" + ] + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": [ + "completed", + "denied" + ] + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ], + "label": "Components" + } + }, + "listFields": [ + "title", + "status", + "requestKind", + "receivedOn", + "dueOn" + ], + "formFields": [ + "cancellation" + ] + }, + "audit": { + "title": "Audit", + "pluralTitle": "Audits", + "group": "audits", + "collection": "audits", + "description": "One real SOC 2 engagement with a CPA firm, including the auditor-agreed scope and period, requests, fieldwork, Findings, opinion, and final report. Management\u2019s candidate dates remain on the Workspace.", + "guidance": { + "policyBasis": "A CPA firm independently examines the scoped service against the selected Framework. Management supplies the system description, Controls, operating records, and Evidence.", + "cadence": "Create one record after a CPA firm is engaged, or earlier only when a customer deadline makes early coordination useful. Keep management candidate dates on the workspace and record the auditor-agreed Type 1 date or Type 2 period here.", + "emptyState": "No audit engagement is recorded. Create one after engaging a CPA firm, or earlier only when a real customer deadline requires audit planning.", + "sourceResourceIds": [ + "policy-information-security" + ] + }, + "required": [ + "status", + "auditKind", + "programId", + "frameworkIds", + "scope", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "in-progress", + "fieldwork", + "report-draft", + "issued", + "delivered", + "complete" + ], + "filter": true + }, + "auditKind": { + "type": "enum", + "values": [ + "readiness", + "soc-2-type-1", + "soc-2-type-2" + ], + "filter": true + }, + "timezone": { + "type": "string", + "format": "timezone", + "description": "IANA timezone used to interpret this engagement's date boundaries and reporting-route coverage." + }, + "frameworkIds": { + "type": "array", + "items": "id", + "relation": [ + "framework" + ] + }, + "scope": { + "type": "string", + "search": true + }, + "auditorVendorId": { + "type": "id", + "relation": [ + "vendor" + ], + "requiredWhen": { + "status": [ + "in-progress", + "fieldwork", + "report-draft", + "issued", + "delivered", + "complete" + ] + } + }, + "fieldworkStart": { + "type": "date", + "requiredWhen": { + "status": [ + "in-progress", + "fieldwork", + "report-draft", + "issued", + "delivered", + "complete" + ] + } + }, + "fieldworkEnd": { + "type": "date", + "requiredWhen": { + "status": [ + "in-progress", + "fieldwork", + "report-draft", + "issued", + "delivered", + "complete" + ] + } + }, + "reportDate": { + "type": "date", + "requiredWhen": { + "status": [ + "issued", + "delivered", + "complete" + ] + } + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "contactIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "systemDescriptionDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "managementAssertionDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "periodCompletenessDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "managementRepresentationDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "complementaryControlIds": { + "type": "array", + "items": "id", + "relation": [ + "complementary-control" + ] + }, + "complementaryControlsConclusion": { + "type": "enum", + "values": [ + "identified", + "not-applicable" + ], + "label": "Complementary controls conclusion" + }, + "opinion": { + "type": "enum", + "values": [ + "unmodified", + "qualified", + "adverse", + "disclaimer", + "not-issued" + ], + "filter": true, + "requiredWhen": { + "status": [ + "issued", + "delivered", + "complete" + ], + "auditKind": [ + "soc-2-type-1", + "soc-2-type-2" + ] + } + }, + "opinionDate": { + "type": "date", + "requiredWhen": { + "status": [ + "issued", + "delivered", + "complete" + ], + "auditKind": [ + "soc-2-type-1", + "soc-2-type-2" + ] + } + }, + "reportEvidenceId": { + "type": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": [ + "issued", + "delivered", + "complete" + ] + } + }, + "managementResponseDocumentId": { + "type": "id", + "relation": [ + "document" + ] + }, + "supplementalDocumentIds": { + "type": "array", + "items": "id", + "relation": [ + "document" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage", + "requiredWhen": { + "status": [ + "in-progress", + "fieldwork", + "report-draft", + "issued", + "delivered", + "complete" + ] + } + }, + "engagementTermsDocumentId": { + "type": "id", + "relation": [ + "document" + ], + "label": "Engagement terms" + }, + "managementAcknowledgedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Management acknowledgement" + }, + "managementAcknowledgedOn": { + "type": "date", + "label": "Acknowledged on" + }, + "signatoryAppointmentIds": { + "type": "array", + "items": "id", + "relation": [ + "appointment" + ], + "label": "Authorized signatories" + }, + "scopeRevision": { + "type": "string", + "label": "Reviewed scope revision" + }, + "subsequentEventsReview": { + "type": "object", + "objectType": "subsequent-events-review" + }, + "packetDelivery": { + "type": "object", + "objectType": "packet-delivery" + }, + "retentionNotes": { + "type": "string" + }, + "retentionScheduleItemIds": { + "type": "array", + "items": "id", + "relation": [ + "retention-schedule-item" + ] + }, + "carryForwardActionIds": { + "type": "array", + "items": "id", + "relation": [ + "action-item" + ] + }, + "priorAuditId": { + "type": "id", + "relation": [ + "audit" + ], + "label": "Prior audit" + }, + "programId": { + "type": "id", + "relation": [ + "program" + ], + "label": "Program" + }, + "subserviceTreatments": { + "type": "array", + "items": "object", + "itemObjectType": "audit-subservice-treatment", + "label": "Subservice treatments" + }, + "subserviceConclusion": { + "type": "enum", + "values": [ + "not-applicable", + "identified" + ], + "label": "Subservice conclusion" + }, + "subserviceConclusionRationale": { + "type": "string", + "label": "Subservice conclusion rationale" + } + }, + "listFields": [ + "title", + "status", + "auditKind", + "coverage", + "opinion" + ], + "formFields": [ + "coverage", + "subserviceConclusion", + "subserviceConclusionRationale", + "subserviceTreatments" + ] + }, + "audit-population": { + "title": "Audit population", + "pluralTitle": "Audit populations", + "group": "audits", + "collection": "audit-populations", + "description": "One complete set of Control-relevant events or items for a Type 2 period, with its source Component, exact query, count, reconciliation, and fixed export, including zero-event populations.", + "guidance": { + "policyBasis": "The CPA firm needs complete and accurate populations to select samples and test Controls. The linked population-export Evidence preserves the exact source set management supplied.", + "cadence": "Plan at the start of the engagement, export for the exact audit period, reconcile before fieldwork, and retain proof for zero-event populations.", + "emptyState": "No audit populations are recorded. Add them for a real Type 2 engagement after the firm-agreed period and in-scope population needs are known." + }, + "required": [ + "status", + "auditId", + "populationKind", + "ownerIds", + "coverage" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "reconciled", + "not-applicable", + "superseded" + ], + "filter": true + }, + "auditId": { + "type": "id", + "relation": [ + "audit" + ] + }, + "populationKind": { + "type": "string", + "filter": true, + "search": true + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "sourceEvidenceId": { + "type": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": "reconciled" + } + }, + "reconciledByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "reconciled" + } + }, + "reconciledOn": { + "type": "date", + "requiredWhen": { + "status": "reconciled" + } + }, + "conclusion": { + "type": "enum", + "values": [ + "complete", + "complete-with-exceptions", + "incomplete" + ], + "requiredWhen": { + "status": "reconciled" + }, + "filter": true + }, + "reconciliationSummary": { + "type": "string", + "search": true + }, + "notApplicableReason": { + "type": "string", + "requiredWhen": { + "status": "not-applicable" + }, + "search": true + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage" + }, + "sourceComponentId": { + "type": "id", + "relation": [ + "component" + ], + "label": "Authoritative source Component", + "requiredWhen": { + "status": "reconciled" + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "audit-population" + ], + "label": "Superseded audit population" + } + }, + "listFields": [ + "title", + "status", + "populationKind", + "coverage", + "conclusion" + ], + "formFields": [ + "coverage" + ] + }, + "audit-request": { + "title": "Audit request", + "pluralTitle": "Audit requests", + "group": "audits", + "collection": "audit-requests", + "description": "One auditor request or prepared-by-client item, with its Audit, owner, due date, approved response, Requirements, Controls, Evidence, and follow-up. A separate Audit Request tracker is not required for SOC 2 when the CPA firm\u2019s portal is authoritative.", + "guidance": { + "policyBasis": "Audit Requests turn fieldwork into owned, dated deliverables and preserve the exact response and Evidence supplied to the CPA firm.", + "cadence": "Create on receipt, assign immediately, meet the auditor due date, bind evidence to the requested period and Git revision, and close only after acceptance.", + "emptyState": "No audit requests are recorded. Add each request when the CPA firm sends it, unless the firm's portal remains the approved source of truth." + }, + "required": [ + "status", + "auditId", + "requestReference", + "description", + "ownerIds", + "requestedOn", + "dueOn" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "in-progress", + "submitted", + "accepted", + "needs-follow-up", + "closed" + ], + "filter": true + }, + "auditId": { + "type": "id", + "relation": [ + "audit" + ] + }, + "requestReference": { + "type": "string", + "search": true + }, + "description": { + "type": "string", + "search": true + }, + "requestedOn": { + "type": "date" + }, + "dueOn": { + "type": "date" + }, + "submittedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "submitted", + "accepted", + "needs-follow-up", + "closed" + ] + } + }, + "closedOn": { + "type": "date", + "requiredWhen": { + "status": "closed" + } + }, + "requirementIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "requiredWhen": { + "status": [ + "submitted", + "accepted", + "needs-follow-up", + "closed" + ] + } + }, + "auditorNotes": { + "type": "string" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Coverage" + }, + "acceptedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "accepted", + "closed" + ] + } + }, + "acceptedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "accepted", + "closed" + ] + } + }, + "externalReference": { + "type": "object", + "objectType": "external-reference", + "label": "External request reference" + }, + "responseRevision": { + "type": "string", + "label": "Response revision" + }, + "deliveryEvidenceId": { + "type": "id", + "relation": [ + "evidence" + ], + "label": "Delivery evidence" + }, + "reconciledByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Reconciled by" + }, + "reconciledOn": { + "type": "date", + "label": "Reconciled on" + }, + "externalAuthorityComponentId": { + "type": "id", + "relation": [ + "component" + ], + "label": "External authority Component" + } + }, + "listFields": [ + "requestReference", + "title", + "status", + "dueOn", + "submittedOn" + ], + "markdown": { + "response": { + "label": "Response", + "primary": true, + "required": false, + "requiredWhen": { + "status": [ + "submitted", + "accepted", + "needs-follow-up", + "closed" + ] + } + } + }, + "formFields": [ + "coverage" + ] + }, + "reconciliation-dismissal": { + "title": "Reconciliation dismissal", + "pluralTitle": "Reconciliation dismissals", + "group": "repository", + "collection": "reconciliation-dismissals", + "description": "A reviewed decision that one exact Git transition candidate is a false positive and does not represent a real policy event.", + "guidance": { + "policyBasis": "Direct file changes can resemble real operational events. This record preserves the reviewer, date, rationale, and exact candidate fingerprint when management confirms that no event occurred.", + "cadence": "Create only from a current reconciliation candidate. A later change produces a different fingerprint and requires its own review." + }, + "required": [ + "transitionFingerprint", + "eventType", + "subjectResourceId", + "reviewedByIds", + "reviewedOn", + "rationale" + ], + "fields": { + "transitionFingerprint": { + "type": "string", + "label": "Transition fingerprint" + }, + "eventType": { + "type": "string", + "label": "Candidate event type", + "filter": true + }, + "subjectResourceId": { + "type": "id", + "relationGroup": "event-subject", + "label": "Candidate subject" + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Reviewed by" + }, + "reviewedOn": { + "type": "date", + "label": "Reviewed on" + }, + "rationale": { + "type": "string", + "search": true + } + }, + "listFields": [ + "title", + "eventType", + "subjectResourceId", + "reviewedOn" + ], + "formFields": [ + "transitionFingerprint", + "eventType", + "subjectResourceId", + "reviewedByIds", + "reviewedOn", + "rationale" + ] + }, + "collection-review": { + "title": "Collection review", + "pluralTitle": "Collection reviews", + "group": "program", + "collection": "collection-reviews", + "description": "A management confirmation that one model-defined resource collection was reviewed against the current scope and exact record revisions.", + "guidance": { + "policyBasis": "FileGRC cannot infer that an apparently complete or empty collection was reviewed. This record preserves who reviewed it, what conclusion they reached, and the exact collection state they reviewed.", + "cadence": "Review during initial scoping and again whenever the collection or related scope facts change." + }, + "required": [ + "status", + "resourceType", + "scopeResourceIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "retired" + ], + "filter": true + }, + "resourceType": { + "type": "enum", + "registry": "collectionReviews", + "label": "Reviewed resource type", + "filter": true + }, + "decision": { + "type": "enum", + "values": [ + "complete", + "zero-population", + "externally-managed", + "not-applicable" + ], + "requiredWhen": { + "status": "active" + }, + "filter": true + }, + "rationale": { + "type": "string", + "requiredWhen": { + "status": "active" + } + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "active" + }, + "label": "Reviewed by" + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "active" + }, + "label": "Reviewed on" + }, + "collectionRevision": { + "type": "string", + "requiredWhen": { + "status": "active" + }, + "label": "Reviewed collection revision" + }, + "scopeRevision": { + "type": "string", + "requiredWhen": { + "status": "active" + }, + "label": "Reviewed scope revision" + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "program", + "system", + "component" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": "retired" + }, + "allowedWhen": { + "status": "retired" + } + }, + "authoritativeComponentId": { + "type": "id", + "relation": [ + "component" + ], + "requiredWhen": { + "decision": "externally-managed", + "status": "active" + }, + "label": "Authoritative Component" + }, + "supersedesId": { + "type": "id", + "relation": [ + "collection-review" + ] + }, + "coverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Reviewed temporal coverage" + }, + "knowledgeCutoffAt": { + "type": "timestamp", + "label": "Knowledge cutoff" + }, + "populationResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "collection-review-population", + "label": "Reviewed population" + }, + "scheduleDecisions": { + "type": "array", + "items": "object", + "allowedWhen": { + "resourceType": "obligation" + }, + "label": "Schedule decisions", + "itemObjectType": "schedule-disposition", + "requiredWhen": { + "resourceType": "obligation", + "status": "active", + "decision": "complete" + } + } + }, + "listFields": [ + "title", + "resourceType", + "status", + "decision", + "reviewedOn", + "reviewedByIds" + ], + "formFields": [] + }, + "retention-schedule-item": { + "title": "Retention Schedule Item", + "pluralTitle": "Retention Schedule", + "group": "governance", + "collection": "retention-schedule-items", + "description": "One owner-completed proposal that states which Information Types and operational scope it covers, when retention starts, how long information is kept, and its proposed disposition. It becomes authoritative through the single whole-schedule approval.", + "guidance": { + "policyBasis": "Management must translate legal, contractual, privacy, security, and business needs into explicit retention and disposition instructions without treating starter periods as organization facts.", + "cadence": "Complete the proposal before whole-schedule approval and revisit it after a source Policy, Requirement, Commitment, information use, System, Component, Vendor, or legal-hold process changes.", + "reviewPoints": [ + "Confirm the Information Types and operational scope covered by this item.", + "Confirm the cutoff, retention period, and disposition action from management sources.", + "When a linked Policy or Document has independent sections, set sourceSectionHeadings to the exact governing Markdown heading. Keep a whole-source binding when the entire source governs the row.", + "Keep the item planned until the owner completes every organization-specific value and binds the current source revisions." + ], + "sourceResourceIds": [ + "policy-information-security", + "document-data-retention-schedule" + ] + }, + "required": [ + "status", + "description", + "scopeResourceIds", + "scheduleDocumentId", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "informationTypeIds": { + "type": "array", + "items": "id", + "relation": [ + "information-type" + ], + "label": "Information Types", + "requiredWhen": { + "status": "active" + } + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "program", + "system", + "component", + "vendor", + "audit", + "source-coverage" + ], + "label": "Operational scope" + }, + "scheduleDocumentId": { + "type": "id", + "relation": [ + "document" + ], + "label": "Retention schedule" + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "framework", + "requirement", + "commitment", + "control" + ], + "label": "Authority and source records" + }, + "sourceSectionHeadings": { + "type": "object", + "objectType": "string-map", + "label": "Reviewed source sections", + "description": "Map a source Policy or Document ID to one exact Markdown heading when only that section governs this decision. Omit the ID when the complete source governs it." + }, + "cutoff": { + "type": "object", + "objectType": "retention-cutoff", + "requiredWhen": { + "status": "active" + } + }, + "retentionPeriod": { + "type": "object", + "objectType": "retention-period", + "requiredWhen": { + "status": "active" + } + }, + "dispositionAction": { + "type": "enum", + "values": [ + "delete", + "destroy", + "erase", + "anonymize", + "transfer", + "retain-permanently" + ], + "requiredWhen": { + "status": "active" + }, + "filter": true + }, + "dispositionInstructions": { + "type": "string", + "requiredWhen": { + "status": "active" + }, + "search": true + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "label": "Legacy row reviewer" + }, + "approvedOn": { + "type": "date", + "label": "Legacy row review date" + }, + "reviewedSourceRevisions": { + "type": "object", + "objectType": "string-map", + "requiredWhen": { + "status": "active" + }, + "label": "Reviewed source revisions" + }, + "supersedesId": { + "type": "id", + "relation": [ + "retention-schedule-item" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + }, + "notes": { + "type": "string", + "search": true + } + }, + "listFields": [ + "title", + "status", + "informationTypeIds", + "scopeResourceIds", + "retentionPeriod", + "dispositionAction" + ], + "formFields": [ + "informationTypeIds", + "scopeResourceIds", + "scheduleDocumentId", + "sourceResourceIds", + "sourceSectionHeadings", + "cutoff", + "retentionPeriod", + "dispositionAction", + "dispositionInstructions", + "reviewedSourceRevisions", + "supersedesId", + "statusTransition" + ] + }, + "requirement-mapping": { + "title": "Requirement Mapping", + "pluralTitle": "Requirement Mappings", + "group": "program", + "collection": "requirement-mappings", + "description": "A reviewed assertion about how one set of Requirements, Commitments, or Controls relates to another without copying the authoritative source content.", + "guidance": { + "policyBasis": "Supplemental policies, contracts, privacy promises, and frameworks need explicit coverage semantics so a link is not mistaken for complete coverage.", + "cadence": "Review when either mapped source changes and before relying on the mapping for program or audit scope.", + "reviewPoints": [ + "Map at the smallest practical statement level.", + "Choose syntactic, semantic, or functional comparison and explain the rationale.", + "Do not infer equivalence from similar titles or missing mappings." + ] + }, + "required": [ + "status", + "sourceResourceIds", + "targetResourceIds", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement", + "commitment", + "control" + ] + }, + "targetResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "requirement", + "commitment", + "control" + ] + }, + "relationship": { + "type": "enum", + "values": [ + "equal-to", + "equivalent-to", + "subset-of", + "superset-of", + "intersects-with", + "no-relationship" + ], + "filter": true, + "requiredWhen": { + "status": "active" + } + }, + "method": { + "type": "enum", + "values": [ + "syntactic", + "semantic", + "functional" + ], + "filter": true, + "requiredWhen": { + "status": "active" + } + }, + "rationale": { + "type": "string", + "search": true, + "requiredWhen": { + "status": "active" + } + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "active" + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "active" + } + }, + "reviewedSourceRevisions": { + "type": "object", + "objectType": "string-map", + "requiredWhen": { + "status": "active" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "supersedesId": { + "type": "id", + "relation": [ + "requirement-mapping" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "relationship", + "method", + "sourceResourceIds", + "targetResourceIds" + ], + "formFields": [ + "sourceResourceIds", + "targetResourceIds", + "reviewedByIds", + "reviewedOn", + "reviewedSourceRevisions", + "supersedesId", + "statusTransition" + ] + }, + "source-coverage": { + "title": "Source coverage", + "pluralTitle": "Source coverage", + "group": "evidence", + "collection": "source-coverage", + "description": "A reviewed decision about where one evidence source family is authoritative for a defined scope and period.", + "guidance": { + "policyBasis": "Applicable control and audit work must identify an authoritative source for complete populations and retained evidence.", + "cadence": "Review before an evidence period, after a source or retrieval change, and during period reconciliation.", + "reviewPoints": [ + "Decide whether this source family applies to the current scope and audit period.", + "Confirm the chosen source can produce the complete population or evidence needed.", + "Use \"Not Applicable\" or \"Zero Population\" only when it is true." + ] + }, + "required": [ + "status", + "sourceFamilyId", + "coverageKind", + "scopeResourceIds", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "retired" + ], + "filter": true + }, + "sourceFamilyId": { + "type": "enum", + "values": [ + "workforce", + "training-acknowledgement", + "identity-access", + "production-change", + "security-monitoring", + "vulnerability-management", + "endpoint-asset", + "backup-recovery", + "vendor-management", + "exception-finding", + "data-handling", + "network-security", + "governance", + "risk-management" + ], + "label": "Source family", + "filter": true + }, + "coverageKind": { + "type": "enum", + "values": [ + "filegrc", + "external-component", + "not-applicable", + "zero-population" + ], + "label": "Coverage kind", + "filter": true + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "program", + "system", + "component", + "control", + "policy", + "person", + "team", + "vendor" + ] + }, + "excludedPopulation": { + "type": "string" + }, + "retrieverIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "coverageKind": "external-component", + "status": "active" + } + }, + "collectionCadence": { + "type": "string", + "requiredWhen": { + "status": "active" + } + }, + "retentionScheduleItemIds": { + "type": "array", + "items": "id", + "relation": [ + "retention-schedule-item" + ], + "label": "Retention schedule items" + }, + "retentionNotes": { + "type": "string", + "label": "Legacy retention notes" + }, + "reconciliationMethod": { + "type": "string", + "requiredWhen": { + "status": "active" + } + }, + "validFrom": { + "type": "date", + "requiredWhen": { + "status": "active" + } + }, + "validThrough": { + "type": "date" + }, + "applicabilityReview": { + "type": "object", + "objectType": "applicability-review", + "requiredWhen": { + "coverageKind": [ + "not-applicable", + "zero-population" + ], + "status": "active" + } + }, + "readinessTestEvidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": "retired" + }, + "allowedWhen": { + "status": "retired" + } + }, + "componentId": { + "type": "id", + "relation": [ + "component" + ], + "requiredWhen": { + "coverageKind": "external-component", + "status": "active" + }, + "label": "Authoritative Component" + } + }, + "listFields": [ + "title", + "status", + "sourceFamilyId", + "coverageKind", + "componentId", + "retentionScheduleItemIds", + "validFrom", + "validThrough" + ], + "formFields": [] + }, + "control-activity": { + "title": "Control activity", + "pluralTitle": "Control activities", + "group": "evidence", + "collection": "control-activities", + "description": "A completed or externally reconciled operating activity using a model-owned completion profile.", + "guidance": { + "policyBasis": "Recurring control operation needs dated facts about who performed and reviewed the work, its scope, result, evidence, and follow-up.", + "cadence": "Create one record for each required occurrence or reconciled external activity.", + "reviewPoints": [ + "Confirm the activity actually occurred for the stated scope and period.", + "Confirm the result and Evidence match the work performed.", + "Confirm every exception or failed objective has follow-up." + ] + }, + "required": [ + "status", + "profileId", + "scopeResourceIds", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "complete", + "externally-managed", + "canceled" + ], + "filter": true + }, + "profileId": { + "type": "enum", + "values": [ + "endpoint-verification", + "inventory-review", + "log-review", + "network-review", + "continuity-review", + "control-design-review", + "performance-review", + "workforce-review", + "personal-device-approval", + "security-scan" + ], + "label": "Completion profile", + "filter": true + }, + "obligationId": { + "type": "id", + "relation": [ + "obligation" + ] + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ] + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "obligation-scope" + }, + "performerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "completedAt": { + "type": "timestamp", + "requiredWhen": { + "status": "complete" + } + }, + "method": { + "type": "string", + "requiredWhen": { + "status": "complete" + } + }, + "result": { + "type": "outcome", + "requiredWhen": { + "status": "complete" + } + }, + "reviewerIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "complete" + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "status": "complete" + } + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "followupActionIds": { + "type": "array", + "items": "id", + "relation": [ + "action-item" + ] + }, + "externalActivity": { + "type": "object", + "objectType": "external-activity-reference", + "requiredWhen": { + "status": "externally-managed" + }, + "allowedWhen": { + "status": "externally-managed" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "cancellation": { + "type": "object", + "objectType": "cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + } + } + }, + "listFields": [ + "title", + "status", + "profileId", + "completedAt", + "result", + "ownerIds" + ] + }, + "program": { + "title": "Program", + "pluralTitle": "Programs", + "group": "program", + "collection": "programs", + "description": "A management-defined compliance or assurance program with its own goal, scope, criteria, Controls, owners, risk method, and candidate operating period.", + "guidance": { + "policyBasis": "Management defines the assurance objective, bounded Systems, applicable criteria, Controls, owners, and risk method for each program.", + "cadence": "Review before starting a candidate period, during audit planning, and after material scope, service, framework, or risk-method changes.", + "reviewPoints": [ + "Select only the bounded Systems governed or examined by this Program.", + "Record Requirement applicability against this Program's current scope.", + "Keep unrelated corporate inventory outside this Program." + ] + }, + "required": [ + "status", + "assuranceGoal" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "retired" + ], + "filter": true + }, + "assuranceGoal": { + "type": "enum", + "values": [ + "none", + "readiness", + "soc-2-type-1", + "soc-2-type-2" + ], + "label": "Program goal", + "filter": true + }, + "systemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ], + "label": "Systems", + "requiredWhen": { + "status": "active" + } + }, + "frameworkIds": { + "type": "array", + "items": "id", + "relation": [ + "framework" + ], + "label": "Frameworks", + "requiredWhen": { + "status": "active" + } + }, + "requirementApplicability": { + "type": "array", + "items": "object", + "itemObjectType": "program-applicability", + "label": "Requirement applicability" + }, + "controlIds": { + "type": "array", + "items": "id", + "relation": [ + "control" + ], + "label": "Controls", + "requiredWhen": { + "status": "active" + } + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true, + "requiredWhen": { + "status": "active" + } + }, + "riskMethodology": { + "type": "object", + "label": "Risk methodology", + "objectType": "risk-methodology", + "requiredWhen": { + "status": "active" + } + }, + "candidateCoverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Candidate period" + }, + "description": { + "type": "string", + "search": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": "retired" + }, + "allowedWhen": { + "status": "retired" + } + } + }, + "listFields": [ + "title", + "status", + "assuranceGoal", + "systemIds", + "candidateCoverage" + ], + "formFields": [ + "systemIds", + "frameworkIds", + "controlIds", + "candidateCoverage", + "statusTransition" + ] + }, + "classification": { + "title": "Classification", + "pluralTitle": "Classifications", + "group": "systems-vendors", + "collection": "classifications", + "description": "A controlled information-handling category used consistently across inventory and Evidence Artifacts.", + "guidance": { + "policyBasis": "Management defines handling categories so information and retained evidence receive consistent protection.", + "cadence": "Review with the data-handling policy and after a material legal, contractual, or information-use change." + }, + "required": [ + "status", + "rank", + "description" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "retired" + ], + "filter": true + }, + "rank": { + "type": "integer", + "minimum": 0, + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "handlingRequirements": { + "type": "string", + "search": true + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "deprecated", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "deprecated", + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "rank", + "description" + ] + }, + "information-type": { + "title": "Information Type", + "pluralTitle": "Information Types", + "group": "systems-vendors", + "collection": "information-types", + "description": "A stable category of information processed by Systems, Components, Vendors, and risk workflows.", + "guidance": { + "policyBasis": "Information categories connect system boundaries and operational processing to approved handling rules.", + "cadence": "Create when a stable information category enters scope and review after material processing or classification changes." + }, + "required": [ + "status", + "description" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "superseded", + "retired" + ], + "filter": true + }, + "classificationId": { + "type": "id", + "relation": [ + "classification" + ], + "label": "Default classification", + "filter": true, + "requiredWhen": { + "status": "active" + } + }, + "description": { + "type": "string", + "search": true + }, + "supersedesId": { + "type": "id", + "relation": [ + "information-type" + ] + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "superseded", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "superseded", + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "classificationId", + "description" + ], + "formFields": [ + "classificationId", + "supersedesId", + "statusTransition" + ] + }, + "component": { + "title": "Component", + "titleLabel": "Name", + "pluralTitle": "Components", + "group": "systems-vendors", + "collection": "components", + "description": "A logical operational or technical building block that materially delivers a System, supports a Control, produces authoritative Evidence, or supports relevant operations.", + "guidance": { + "policyBasis": "System boundaries and Control implementations need explicit operational building blocks and evidence sources.", + "cadence": "Create only when the capability materially participates in an in-scope System. After a change, inspect its System uses and affected Controls. If the change is material to risk, data use, evidence, access, or continuity, assess it through the relevant System change Policy Event and its linked work. Follow the scheduled policy and program reviews; do not repeat setup collection reviews.", + "reviewPoints": [ + "Choose at least one role for each related System and explain why the Component matters there.", + "Link a Vendor only when that external organization primarily supplies this Component.", + "Do not create Components for every Vendor, person, policy, procedure, data category, local utility, or occasional tool." + ] + }, + "required": [ + "status", + "componentKind", + "description", + "ownerIds" + ], + "markdown": { + "record": { + "label": "Operations and evidence retrieval", + "primary": true, + "required": false + } + }, + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "deprecated", + "retired" + ], + "filter": true + }, + "componentKind": { + "type": "enum", + "values": [ + "infrastructure", + "software", + "service", + "network", + "physical", + "external-system", + "interconnection" + ], + "filter": true + }, + "description": { + "type": "string", + "search": true + }, + "criticality": { + "type": "enum", + "values": [ + "low", + "medium", + "high", + "critical" + ], + "filter": true + }, + "environment": { + "type": "string", + "filter": true + }, + "vendorId": { + "type": "id", + "relation": [ + "vendor" + ] + }, + "systemUses": { + "type": "array", + "items": "object", + "itemObjectType": "system-use", + "label": "System uses", + "requiredWhen": { + "status": "active" + } + }, + "informationUses": { + "type": "array", + "items": "object", + "itemObjectType": "information-use", + "label": "Information use" + }, + "internetExposed": { + "type": "boolean", + "filter": true + }, + "evidenceSourceKinds": { + "type": "array", + "items": "string", + "label": "Evidence source roles", + "filter": true, + "search": true + }, + "evidenceOwnerIds": { + "type": "array", + "items": "id", + "relation": [ + "person", + "team", + "appointment" + ], + "label": "Evidence access owners" + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party", + "label": "Owners", + "filter": true + }, + "classificationId": { + "type": "id", + "relation": [ + "classification" + ], + "label": "Highest classification", + "filter": true + }, + "continuityObjectives": { + "type": "object", + "objectType": "continuity-objectives" + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": [ + "deprecated", + "retired" + ] + }, + "allowedWhen": { + "status": [ + "deprecated", + "retired" + ] + } + } + }, + "listFields": [ + "title", + "status", + "componentKind", + "vendorId", + "systemUses", + "evidenceSourceKinds" + ], + "formFields": [ + "vendorId", + "systemUses", + "informationUses", + "evidenceSourceKinds", + "evidenceOwnerIds", + "classificationId", + "statusTransition" + ] + }, + "obligation-rule": { + "title": "Obligation rule", + "pluralTitle": "Obligation rules", + "group": "work", + "collection": "obligation-rules", + "description": "One immutable reviewed revision of an Obligation schedule, population rule, and completion criteria.", + "guidance": { + "policyBasis": "A reusable schedule must be reviewed before it governs work. The rule records the exact management rationale or authoritative source used for that decision.", + "cadence": "Create a proposed revision, review it, then activate it at a real effective time. Never backdate a late decision or rewrite a prior effective rule." + }, + "required": [ + "status", + "obligationId", + "recurrence", + "rationale" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "proposed", + "approved", + "active", + "retired" + ], + "filter": true + }, + "obligationId": { + "type": "id", + "relation": [ + "obligation" + ] + }, + "activityDefinitionVersion": { + "type": "string" + }, + "recurrence": { + "type": "object", + "objectType": "recurrence" + }, + "window": { + "type": "object", + "objectType": "obligation-window" + }, + "selector": { + "type": "object", + "objectType": "scope-selector" + }, + "rationale": { + "type": "string", + "search": true + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "commitment", + "risk", + "appointment" + ] + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "approved", + "active", + "retired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "approved", + "active", + "retired" + ] + } + }, + "effectiveAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "active", + "retired" + ] + } + }, + "timezone": { + "type": "string", + "format": "timezone", + "requiredWhen": { + "status": [ + "approved", + "active", + "retired" + ] + } + }, + "cutoverDecision": { + "type": "enum", + "values": [ + "new-windows-only", + "supersede-open-window", + "keep-open-window" + ] + }, + "supersedesId": { + "type": "id", + "relation": [ + "obligation-rule" + ] + }, + "retiredOn": { + "type": "date", + "requiredWhen": { + "status": "retired" + } + } + }, + "listFields": [ + "title", + "status", + "obligationId", + "effectiveAt" + ] + }, + "obligation-occurrence": { + "title": "Obligation occurrence reconciliation", + "pluralTitle": "Obligation occurrence reconciliations", + "group": "work", + "collection": "obligation-occurrences", + "description": "The reviewed population and operating result for one rolled-up Obligation window or Policy Event action.", + "guidance": { + "policyBasis": "A rolled-up schedule passes only after management reconciles every expected member, including a reviewed zero population.", + "cadence": "Reconcile after the captured membership rule can no longer add members and every member deadline is resolved. Correct a finalized record only with a superseding occurrence." + }, + "required": [ + "status", + "programId", + "obligationId", + "ruleId", + "occurrenceKey", + "coverage", + "membershipCutoffAt", + "members", + "expectedCount", + "completedCount", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "open", + "reconciled", + "superseded" + ], + "filter": true + }, + "obligationId": { + "type": "id", + "relation": [ + "obligation" + ] + }, + "ruleId": { + "type": "id", + "relation": [ + "obligation-rule" + ] + }, + "occurrenceKey": { + "type": "string" + }, + "coverage": { + "type": "object", + "objectType": "coverage-period" + }, + "membershipCutoffAt": { + "type": "date" + }, + "collectionReviewId": { + "type": "id", + "relation": [ + "collection-review" + ] + }, + "collectionReviewCommit": { + "type": "string" + }, + "collectionReviewRevision": { + "type": "string" + }, + "collectionRevision": { + "type": "string" + }, + "scopeRevision": { + "type": "string" + }, + "members": { + "type": "array", + "items": "object", + "itemObjectType": "obligation-member", + "allowEmpty": true + }, + "expectedCount": { + "type": "integer", + "minimum": 0 + }, + "completedCount": { + "type": "integer", + "minimum": 0 + }, + "conclusion": { + "type": "enum", + "values": [ + "complete", + "complete-with-exceptions", + "incomplete", + "zero-population" + ], + "requiredWhen": { + "status": "reconciled" + }, + "filter": true + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "reconciled" + } + }, + "reconciledAt": { + "type": "timestamp", + "requiredWhen": { + "status": "reconciled" + } + }, + "supersedesId": { + "type": "id", + "relation": [ + "obligation-occurrence" + ] + }, + "auditPopulationIds": { + "type": "array", + "items": "id", + "relation": [ + "audit-population" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party" + }, + "programId": { + "type": "id", + "relation": [ + "program" + ] + } + }, + "listFields": [ + "title", + "status", + "obligationId", + "coverage", + "expectedCount", + "completedCount", + "conclusion" + ] + }, + "reporting-route": { + "title": "Reporting route", + "pluralTitle": "Reporting routes", + "group": "governance", + "collection": "reporting-routes", + "description": "A legacy model v9 record for one way people could send a report, such as an email address, phone number, or web form. Use Reporting Channel Sets for current work.", + "guidance": { + "policyBasis": "Workers need a usable route for security reports. An alternate is required only when an adopted Policy, Document, Commitment, or Risk decision calls for one.", + "cadence": "Review through a linked Obligation and communicate a changed route to the affected audience. Preserve the route revision delivered with each assignment." + }, + "required": [ + "status", + "purpose", + "priority", + "channelKind", + "route", + "ownerIds" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "planned", + "active", + "retired", + "deprecated-draft", + "historical" + ], + "filter": true + }, + "purpose": { + "type": "enum", + "values": [ + "security-reporting", + "continuity-communication" + ], + "filter": true + }, + "priority": { + "type": "enum", + "values": [ + "primary", + "alternate" + ], + "filter": true + }, + "channelKind": { + "type": "enum", + "values": [ + "email", + "phone", + "web", + "other" + ] + }, + "route": { + "type": "string", + "search": true + }, + "effectiveAt": { + "type": "timestamp", + "requiredWhen": { + "status": [ + "active", + "retired" + ] + } + }, + "endsAt": { + "type": "timestamp", + "requiredWhen": { + "status": "retired" + } + }, + "dependencySystemIds": { + "type": "array", + "items": "id", + "relation": [ + "system" + ] + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": [ + "active", + "retired" + ] + } + }, + "approvedOn": { + "type": "date", + "requiredWhen": { + "status": [ + "active", + "retired" + ] + } + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "commitment", + "risk" + ] + }, + "ownerIds": { + "type": "array", + "items": "id", + "relationGroup": "accountable-party" + } + }, + "listFields": [ + "title", + "status", + "purpose", + "priority", + "channelKind", + "effectiveAt" + ], + "deprecated": true + }, + "reporting-route-set": { + "title": "Reporting channel set", + "pluralTitle": "Reporting channel sets", + "group": "governance", + "collection": "reporting-route-sets", + "description": "The normal and fallback ways people send a report for one purpose, such as an email address and a hotline for security concerns. The set records where each channel leads, who keeps it usable, and when the approved pair applies.", + "guidance": { + "policyBasis": "A reporting channel is an email address, phone number, web form, in-person contact, or other destination people use to raise a concern. Policies and other approved decisions say when channels are required; this record stores the current contact details so governed text does not copy them.", + "cadence": "Choose the normal channel and a usable fallback, then commit the proposal before approval. Approve the exact committed pair with the actual authority, time, and verified fixed evidence of the approval event. Record cancellation evidence the same way. Create a successor when either channel changes instead of editing an approved set." + }, + "required": [ + "status", + "programId", + "purposeKey", + "purposeLabel", + "primaryLane", + "authorityAppointmentKind", + "approvalAppointmentKind" + ], + "formFields": [ + "alternateLane" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "draft", + "proposed", + "approved", + "canceled", + "historical" + ], + "filter": true + }, + "programId": { + "type": "id", + "relation": [ + "program" + ], + "filter": true + }, + "purposeKey": { + "type": "string", + "format": "id", + "label": "Stable purpose key", + "filter": true + }, + "purposeLabel": { + "type": "string", + "label": "Reports covered" + }, + "primaryLane": { + "type": "object", + "objectType": "reporting-route-lane", + "label": "Normal reporting channel" + }, + "alternateLane": { + "type": "object", + "objectType": "reporting-route-lane", + "label": "Fallback reporting channel" + }, + "authorityAppointmentKind": { + "type": "string", + "format": "id", + "label": "Role responsible for these channels" + }, + "approvalAppointmentKind": { + "type": "string", + "format": "id", + "label": "Role authorized to approve channel changes" + }, + "sourceResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "policy", + "document", + "commitment", + "risk" + ], + "label": "Rules requiring these channels" + }, + "predecessorId": { + "type": "id", + "relation": [ + "reporting-route-set" + ], + "label": "Prior channel set" + }, + "proposalCommit": { + "type": "string", + "requiredWhen": { + "status": [ + "approved", + "canceled" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "canceled" + ] + }, + "managed": true + }, + "approval": { + "type": "object", + "objectType": "reporting-route-approval", + "requiredWhen": { + "status": [ + "approved", + "canceled" + ] + }, + "allowedWhen": { + "status": [ + "approved", + "canceled" + ] + }, + "managed": true + }, + "cancellation": { + "type": "object", + "objectType": "reporting-route-cancellation", + "requiredWhen": { + "status": "canceled" + }, + "allowedWhen": { + "status": "canceled" + }, + "managed": true + }, + "legacyRouteIds": { + "type": "array", + "items": "id", + "relation": [ + "reporting-route" + ], + "allowedWhen": { + "status": "historical" + } + }, + "legacyBasis": { + "type": "string", + "allowedWhen": { + "status": "historical" + } + } + }, + "listFields": [ + "title", + "status", + "purposeLabel", + "programId", + "approval" + ], + "markdown": { + "instructions": { + "label": "Instructions", + "primary": true, + "required": true + } + } + } + }, + "primitives": { + "rating": [ + "low", + "medium", + "high", + "critical" + ], + "outcome": [ + "passed", + "passed-with-exceptions", + "failed", + "not-applicable", + "inconclusive" + ] + }, + "relationGroups": { + "accountable-party": [ + "person", + "team", + "appointment" + ], + "collection-review-population": [ + "person", + "framework", + "vendor", + "system", + "complementary-control", + "component", + "classification", + "information-type", + "retention-schedule-item", + "control", + "obligation" + ], + "appointment-scope": [ + "workspace", + "person", + "service-account", + "team", + "system", + "asset", + "document", + "evidence", + "obligation", + "obligation-event", + "framework", + "requirement", + "commitment", + "complementary-control", + "control", + "control-test", + "finding", + "exception", + "action-item", + "policy", + "policy-review", + "attestation", + "meeting", + "training", + "risk", + "risk-assessment", + "vendor", + "vendor-review", + "access-grant", + "access-review", + "vulnerability", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit", + "audit-population", + "audit-request", + "source-coverage", + "control-activity", + "retention-schedule-item", + "requirement-mapping", + "obligation-rule", + "obligation-occurrence", + "reporting-route", + "reporting-route-set", + "program" + ], + "obligation-scope": [ + "workspace", + "person", + "appointment", + "service-account", + "team", + "system", + "asset", + "document", + "framework", + "requirement", + "commitment", + "complementary-control", + "control", + "policy", + "training", + "risk", + "vendor", + "access-grant", + "vulnerability", + "incident", + "audit", + "source-coverage", + "retention-schedule-item", + "requirement-mapping", + "component", + "information-type" + ], + "obligation-template": [ + "person", + "system", + "asset", + "document", + "control", + "policy", + "training", + "retention-schedule-item", + "requirement-mapping" + ], + "completion-record": [ + "access-grant", + "asset", + "control", + "document", + "evidence", + "control-test", + "finding", + "exception", + "action-item", + "policy-review", + "policy", + "attestation", + "meeting", + "risk-assessment", + "risk", + "vendor", + "vendor-review", + "access-review", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit-population", + "audit-request", + "source-coverage", + "control-activity", + "retention-schedule-item", + "requirement-mapping" + ], + "event-subject": [ + "person", + "appointment", + "service-account", + "team", + "system", + "asset", + "document", + "policy", + "training", + "vendor", + "access-grant", + "vulnerability", + "incident", + "exception" + ], + "evidence-source-record": [ + "appointment", + "document", + "obligation", + "obligation-event", + "requirement", + "commitment", + "complementary-control", + "control", + "control-test", + "finding", + "exception", + "action-item", + "policy", + "policy-review", + "attestation", + "meeting", + "training", + "risk", + "risk-assessment", + "vendor", + "vendor-review", + "access-grant", + "access-review", + "vulnerability", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit", + "audit-population", + "audit-request", + "source-coverage", + "control-activity", + "retention-schedule-item", + "requirement-mapping", + "obligation-rule", + "obligation-occurrence", + "reporting-route", + "reporting-route-set" + ], + "work-source": [ + "control", + "control-test", + "finding", + "exception", + "obligation", + "obligation-event", + "policy-review", + "meeting", + "risk", + "risk-assessment", + "vendor-review", + "access-review", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit", + "audit-population", + "audit-request", + "source-coverage", + "control-activity", + "retention-schedule-item", + "requirement-mapping", + "obligation-rule", + "obligation-occurrence", + "reporting-route", + "reporting-route-set" + ], + "work-blocker": [ + "person", + "appointment", + "team", + "system", + "asset", + "document", + "evidence", + "obligation", + "obligation-event", + "control", + "control-test", + "finding", + "exception", + "action-item", + "policy", + "policy-review", + "training", + "risk", + "risk-assessment", + "vendor", + "vendor-review", + "access-grant", + "access-review", + "vulnerability", + "vulnerability-scan", + "incident", + "exercise", + "backup-test", + "penetration-test", + "data-request", + "audit", + "audit-population", + "audit-request", + "source-coverage", + "control-activity", + "retention-schedule-item", + "requirement-mapping", + "obligation-rule", + "obligation-occurrence", + "reporting-route", + "reporting-route-set" + ] + }, + "objectTypes": { + "string-map": { + "additionalProperties": { + "type": "string" + } + }, + "integer-map": { + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "json-map": { + "additionalProperties": true + }, + "extensions": { + "keyFormat": "namespace", + "additionalProperties": { + "type": "object", + "objectType": "json-map" + } + }, + "risk-methodology": { + "additionalProperties": false, + "required": [ + "method", + "likelihoodScale", + "impactScale", + "ratingBands" + ], + "properties": { + "method": { + "type": "string" + }, + "likelihoodScale": { + "type": "array", + "items": "string" + }, + "impactScale": { + "type": "array", + "items": "string" + }, + "ratingBands": { + "type": "object", + "objectType": "string-map" + } + } + }, + "continuity-objectives": { + "additionalProperties": false, + "properties": { + "recoveryTimeHours": { + "type": "integer", + "minimum": 0 + }, + "recoveryPointHours": { + "type": "integer", + "minimum": 0 + }, + "maximumTolerableDowntimeHours": { + "type": "integer", + "minimum": 0 + } + } + }, + "source-reference": { + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "publisher": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "external-reference": { + "additionalProperties": false, + "properties": { + "system": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "page-capture": { + "additionalProperties": false, + "required": [ + "route", + "filters", + "capturedAt", + "method" + ], + "properties": { + "route": { + "type": "string" + }, + "filters": { + "type": "object", + "objectType": "json-map" + }, + "capturedAt": { + "type": "timestamp" + }, + "method": { + "type": "string" + }, + "coverage": { + "type": "object", + "objectType": "coverage-period" + } + } + }, + "recurrence": { + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "enum", + "values": [ + "calendar", + "event" + ] + }, + "unit": { + "type": "enum", + "values": [ + "day", + "week", + "month", + "year" + ], + "requiredWhen": { + "mode": "calendar" + }, + "allowedWhen": { + "mode": "calendar" + } + }, + "interval": { + "type": "integer", + "minimum": 1, + "requiredWhen": { + "mode": "calendar" + }, + "allowedWhen": { + "mode": "calendar" + } + }, + "anchorDate": { + "type": "date", + "allowedWhen": { + "mode": "calendar" + } + }, + "eventType": { + "type": "string", + "format": "id", + "requiredWhen": { + "mode": "event" + }, + "allowedWhen": { + "mode": "event" + } + }, + "anchorMode": { + "type": "enum", + "values": [ + "fixed", + "activation" + ], + "description": "Fixed uses the configured anchorDate. Activation anchors the calendar to the rule effective date; anchorDate remains the proposal preview fallback. Omission preserves fixed scheduling.", + "allowedWhen": { + "mode": "calendar" + } + } + } + }, + "obligation-window": { + "additionalProperties": false, + "required": [ + "precision", + "dueAfter" + ], + "properties": { + "precision": { + "type": "enum", + "values": [ + "date", + "timestamp" + ] + }, + "startsAfter": { + "type": "integer" + }, + "dueAfter": { + "type": "integer" + } + } + }, + "completion-window": { + "additionalProperties": false, + "required": [ + "precision" + ], + "properties": { + "precision": { + "type": "enum", + "values": [ + "date", + "timestamp" + ] + }, + "startsOn": { + "type": "date", + "requiredWhen": { + "precision": "date" + }, + "allowedWhen": { + "precision": "date" + } + }, + "dueOn": { + "type": "date", + "requiredWhen": { + "precision": "date" + }, + "allowedWhen": { + "precision": "date" + } + }, + "overdueOn": { + "type": "date", + "requiredWhen": { + "precision": "date" + }, + "allowedWhen": { + "precision": "date" + } + }, + "startsAt": { + "type": "timestamp", + "requiredWhen": { + "precision": "timestamp" + }, + "allowedWhen": { + "precision": "timestamp" + } + }, + "dueAt": { + "type": "timestamp", + "requiredWhen": { + "precision": "timestamp" + }, + "allowedWhen": { + "precision": "timestamp" + } + }, + "overdueAt": { + "type": "timestamp", + "requiredWhen": { + "precision": "timestamp" + }, + "allowedWhen": { + "precision": "timestamp" + } + }, + "timezone": { + "type": "string", + "format": "timezone", + "requiredWhen": { + "precision": "timestamp" + }, + "allowedWhen": { + "precision": "timestamp" + } + } + } + }, + "external-tester": { + "additionalProperties": false, + "required": [ + "firm" + ], + "properties": { + "firm": { + "type": "string" + }, + "contactName": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + } + }, + "content-revisions": { + "keyFormat": "data-path", + "additionalProperties": { + "type": "string" + } + }, + "external-attendee": { + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "organization": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + } + }, + "risk-rating": { + "additionalProperties": false, + "required": [ + "likelihood", + "impact", + "rating" + ], + "properties": { + "likelihood": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "rating": { + "type": "rating" + }, + "score": { + "type": "number" + } + } + }, + "access-grant-decision": { + "additionalProperties": false, + "required": [ + "accessGrantId", + "decision" + ], + "properties": { + "accessGrantId": { + "type": "id", + "relation": [ + "access-grant" + ] + }, + "decision": { + "type": "enum", + "values": [ + "retain", + "change", + "remove" + ] + }, + "rationale": { + "type": "string" + } + } + }, + "person-reference": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "channel": { + "type": "string" + } + } + }, + "coverage-period": { + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "enum", + "values": [ + "as-of", + "range" + ] + }, + "on": { + "type": "date", + "requiredWhen": { + "kind": "as-of" + }, + "allowedWhen": { + "kind": "as-of" + } + }, + "startsOn": { + "type": "date", + "requiredWhen": { + "kind": "range" + }, + "allowedWhen": { + "kind": "range" + } + }, + "endsOn": { + "type": "date", + "requiredWhen": { + "kind": "range" + }, + "allowedWhen": { + "kind": "range" + } + } + } + }, + "risk-acceptance": { + "additionalProperties": false, + "required": [ + "rationale", + "acceptedByIds", + "acceptedOn", + "expiresOn" + ], + "properties": { + "rationale": { + "type": "string" + }, + "acceptedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "acceptedOn": { + "type": "date" + }, + "expiresOn": { + "type": "date" + } + } + }, + "exception-approval": { + "additionalProperties": false, + "required": [ + "approvedByIds", + "approvedOn", + "expiresOn" + ], + "properties": { + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "approvedOn": { + "type": "date" + }, + "expiresOn": { + "type": "date" + } + } + }, + "exception-resolution": { + "additionalProperties": false, + "required": [ + "resolvedByIds", + "resolvedOn", + "rationale" + ], + "properties": { + "resolvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "resolvedOn": { + "type": "date" + }, + "rationale": { + "type": "string" + } + } + }, + "cancellation": { + "required": [ + "canceledByIds", + "canceledOn", + "reason" + ], + "properties": { + "canceledByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "canceledOn": { + "type": "date" + }, + "reason": { + "type": "string" + } + } + }, + "withdrawal": { + "required": [ + "withdrawnByIds", + "withdrawnOn", + "reason" + ], + "properties": { + "withdrawnByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "withdrawnOn": { + "type": "date" + }, + "reason": { + "type": "string" + } + } + }, + "status-transition": { + "required": [ + "changedByIds", + "changedOn", + "reason" + ], + "properties": { + "changedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "changedOn": { + "type": "date" + }, + "reason": { + "type": "string" + } + } + }, + "applicability-review": { + "additionalProperties": false, + "required": [ + "decision", + "rationale", + "reviewedByIds", + "reviewedOn", + "scopeRevision" + ], + "properties": { + "decision": { + "type": "enum", + "values": [ + "applicable", + "not-applicable", + "externally-managed", + "zero-population" + ] + }, + "rationale": { + "type": "string" + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "reviewedOn": { + "type": "date" + }, + "scopeRevision": { + "type": "string" + } + } + }, + "external-activity-reference": { + "additionalProperties": false, + "required": [ + "componentId", + "reference", + "reconciledByIds", + "reconciledOn" + ], + "properties": { + "reference": { + "type": "string" + }, + "coverage": { + "type": "object", + "objectType": "coverage-period" + }, + "reconciledByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "reconciledOn": { + "type": "date" + }, + "componentId": { + "type": "id", + "relation": [ + "component" + ] + } + } + }, + "packet-delivery": { + "additionalProperties": false, + "required": [ + "classificationReviewedByIds", + "classificationReviewedOn", + "redactionDecision", + "recipient", + "deliverySystem", + "packetCommit", + "manifestChecksum", + "approvedByIds", + "approvedOn", + "deliveredOn" + ], + "properties": { + "classificationReviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "classificationReviewedOn": { + "type": "date" + }, + "redactionDecision": { + "type": "string" + }, + "recipient": { + "type": "string" + }, + "deliverySystem": { + "type": "string" + }, + "packetCommit": { + "type": "string" + }, + "manifestChecksum": { + "type": "string" + }, + "approvedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "approvedOn": { + "type": "date" + }, + "deliveredOn": { + "type": "date" + }, + "receiptReference": { + "type": "string" + } + } + }, + "subsequent-events-review": { + "additionalProperties": false, + "required": [ + "reviewedByIds", + "reviewedOn", + "throughOn", + "conclusion" + ], + "properties": { + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "reviewedOn": { + "type": "date" + }, + "throughOn": { + "type": "date" + }, + "conclusion": { + "type": "string" + }, + "incidentIds": { + "type": "array", + "items": "id", + "relation": [ + "incident" + ] + }, + "findingIds": { + "type": "array", + "items": "id", + "relation": [ + "finding" + ] + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + } + } + }, + "external-identifiers": { + "keyFormat": "namespace", + "additionalProperties": { + "type": "string" + } + }, + "program-applicability": { + "additionalProperties": false, + "required": [ + "requirementId", + "decision" + ], + "properties": { + "requirementId": { + "type": "id", + "relation": [ + "requirement" + ] + }, + "decision": { + "type": "enum", + "values": [ + "applicable", + "not-applicable", + "undetermined" + ] + }, + "rationale": { + "type": "string", + "requiredWhen": { + "decision": [ + "applicable", + "not-applicable" + ] + } + }, + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "decision": [ + "applicable", + "not-applicable" + ] + } + }, + "reviewedOn": { + "type": "date", + "requiredWhen": { + "decision": [ + "applicable", + "not-applicable" + ] + } + }, + "scopeRevision": { + "type": "string", + "requiredWhen": { + "decision": [ + "applicable", + "not-applicable" + ] + } + } + } + }, + "system-use": { + "additionalProperties": false, + "required": [ + "systemId", + "roles", + "rationale" + ], + "properties": { + "systemId": { + "type": "id", + "relation": [ + "system" + ] + }, + "roles": { + "type": "array", + "items": "enum", + "values": [ + "service-delivery", + "control-support", + "evidence-source", + "supporting-operations" + ] + }, + "rationale": { + "type": "string" + } + } + }, + "retention-cutoff": { + "additionalProperties": false, + "required": [ + "basis" + ], + "properties": { + "basis": { + "type": "enum", + "values": [ + "creation", + "receipt", + "calendar-year-end", + "fiscal-year-end", + "event" + ] + }, + "event": { + "type": "string", + "requiredWhen": { + "basis": "event" + }, + "allowedWhen": { + "basis": "event" + } + } + } + }, + "retention-period": { + "additionalProperties": false, + "required": [ + "basis" + ], + "properties": { + "basis": { + "type": "enum", + "values": [ + "fixed", + "until-event", + "permanent" + ] + }, + "amount": { + "type": "integer", + "minimum": 1, + "requiredWhen": { + "basis": "fixed" + }, + "allowedWhen": { + "basis": "fixed" + } + }, + "unit": { + "type": "enum", + "values": [ + "day", + "month", + "year" + ], + "requiredWhen": { + "basis": "fixed" + }, + "allowedWhen": { + "basis": "fixed" + } + }, + "event": { + "type": "string", + "requiredWhen": { + "basis": "until-event" + }, + "allowedWhen": { + "basis": "until-event" + } + } + } + }, + "custom-obligation-activity": { + "additionalProperties": false, + "required": [ + "title", + "completionResourceTypes" + ], + "properties": { + "title": { + "type": "string" + }, + "completionResourceTypes": { + "type": "array", + "items": "string" + } + } + }, + "information-use": { + "additionalProperties": false, + "required": [ + "informationTypeId", + "processingOperations" + ], + "properties": { + "informationTypeId": { + "type": "id", + "relation": [ + "information-type" + ] + }, + "processingOperations": { + "type": "array", + "items": "enum", + "values": [ + "collect", + "use", + "store", + "process", + "transmit", + "share", + "delete", + "erase", + "anonymize" + ] + } + } + }, + "audit-subservice-treatment": { + "additionalProperties": false, + "required": [ + "vendorId", + "componentIds", + "method", + "rationale" + ], + "properties": { + "vendorId": { + "type": "id", + "relation": [ + "vendor" + ] + }, + "componentIds": { + "type": "array", + "items": "id", + "relation": [ + "component" + ] + }, + "method": { + "type": "enum", + "values": [ + "carve-out", + "inclusive" + ] + }, + "rationale": { + "type": "string" + } + } + }, + "scope-selector": { + "additionalProperties": false, + "required": [ + "resourceType", + "membershipMode", + "cutoff" + ], + "properties": { + "resourceType": { + "type": "enum", + "values": [ + "person", + "service-account", + "system", + "asset", + "vendor" + ] + }, + "statuses": { + "type": "array", + "items": "string" + }, + "criticalities": { + "type": "array", + "items": "enum", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "membershipMode": { + "type": "enum", + "values": [ + "as-of" + ] + }, + "cutoff": { + "type": "enum", + "values": [ + "window-start", + "window-end" + ] + } + } + }, + "obligation-member": { + "additionalProperties": false, + "required": [ + "resourceId", + "disposition", + "result" + ], + "properties": { + "resourceId": { + "type": "id", + "relationGroup": "obligation-scope" + }, + "effectiveFrom": { + "type": "timestamp" + }, + "effectiveThrough": { + "type": "timestamp" + }, + "disposition": { + "type": "enum", + "values": [ + "expected", + "not-applicable", + "exception" + ] + }, + "result": { + "type": "enum", + "values": [ + "pending", + "passed", + "failed", + "partial" + ] + }, + "completionResourceIds": { + "type": "array", + "items": "id", + "relationGroup": "completion-record" + }, + "exceptionId": { + "type": "id", + "relation": [ + "exception" + ] + }, + "rationale": { + "type": "string" + } + } + }, + "reporting-route-lane": { + "additionalProperties": false, + "required": [ + "channelKind", + "destination" + ], + "properties": { + "channelKind": { + "type": "enum", + "label": "How people report", + "values": [ + "email", + "phone", + "web", + "in-person", + "other" + ] + }, + "destination": { + "type": "string", + "label": "Where the report goes", + "search": true + }, + "dependencySystemIds": { + "type": "array", + "items": "id", + "label": "Systems this channel needs", + "relation": [ + "system" + ], + "requiredWhen": { + "dependencyBasis": "cataloged" + }, + "allowedWhen": { + "dependencyBasis": "cataloged" + } + }, + "dependencyBasis": { + "type": "enum", + "label": "Dependency review conclusion", + "values": [ + "cataloged", + "none" + ] + }, + "dependencyRationale": { + "type": "string", + "label": "Why no supporting System is required", + "requiredWhen": { + "dependencyBasis": "none" + }, + "allowedWhen": { + "dependencyBasis": "none" + } + } + } + }, + "reporting-route-requirement": { + "additionalProperties": false, + "required": [ + "purposeKey", + "programScope", + "requiredLanes", + "effectiveAt", + "timezone" + ], + "properties": { + "purposeKey": { + "type": "string", + "format": "id", + "label": "Reports covered" + }, + "programScope": { + "type": "enum", + "values": [ + "all-programs", + "selected-programs" + ], + "label": "Programs covered" + }, + "programIds": { + "type": "array", + "items": "id", + "relation": [ + "program" + ], + "requiredWhen": { + "programScope": "selected-programs" + }, + "allowedWhen": { + "programScope": "selected-programs" + } + }, + "requiredLanes": { + "type": "array", + "items": "enum", + "label": "Required channels", + "values": [ + "primary", + "alternate" + ] + }, + "distinctChannels": { + "type": "boolean", + "label": "Require different channel types" + }, + "independentDependencies": { + "type": "boolean", + "label": "Require independent supporting systems" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ], + "label": "Evidence supporting a backfilled requirement" + }, + "effectiveAt": { + "type": "timestamp" + }, + "endsAt": { + "type": "timestamp" + }, + "timezone": { + "type": "string", + "format": "timezone" + } + } + }, + "reporting-route-approval": { + "additionalProperties": false, + "required": [ + "proposalCommit", + "approvedById", + "approvalAppointmentId", + "approvedAt", + "effectiveAt", + "timezone", + "evidenceIds" + ], + "properties": { + "proposalCommit": { + "type": "string" + }, + "approvedById": { + "type": "id", + "relation": [ + "person" + ] + }, + "approvalAppointmentId": { + "type": "id", + "relation": [ + "appointment" + ] + }, + "approvedAt": { + "type": "timestamp" + }, + "effectiveAt": { + "type": "timestamp" + }, + "timezone": { + "type": "string", + "format": "timezone" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + } + } + }, + "reporting-route-cancellation": { + "additionalProperties": false, + "required": [ + "canceledAt", + "canceledById", + "authorityAppointmentId", + "reason", + "evidenceIds" + ], + "properties": { + "canceledAt": { + "type": "timestamp" + }, + "canceledById": { + "type": "id", + "relation": [ + "person" + ] + }, + "authorityAppointmentId": { + "type": "id", + "relation": [ + "appointment" + ] + }, + "reason": { + "type": "string" + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + } + } + }, + "schedule-disposition": { + "required": [ + "obligationId", + "disposition", + "rationale" + ], + "properties": { + "obligationId": { + "type": "id", + "relation": [ + "obligation" + ] + }, + "disposition": { + "type": "enum", + "values": [ + "adopted", + "replaced", + "retired", + "inapplicable" + ] + }, + "ruleId": { + "type": "id", + "relation": [ + "obligation-rule" + ] + }, + "replacementObligationIds": { + "type": "array", + "items": "id", + "relation": [ + "obligation" + ], + "requiredWhen": { + "disposition": "replaced" + } + }, + "excludedRuleIds": { + "type": "array", + "items": "id", + "relation": [ + "obligation-rule" + ] + }, + "rationale": { + "type": "string" + } + } + } + }, + "obligationActivities": { + "custom": { + "title": "Custom activity", + "completionType": "evidence", + "completionResourceTypes": [ + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "workspace", + "person", + "appointment", + "service-account", + "team", + "system", + "asset", + "document", + "framework", + "requirement", + "commitment", + "complementary-control", + "control", + "policy", + "training", + "risk", + "vendor", + "access-grant", + "vulnerability", + "incident", + "audit", + "source-coverage", + "retention-schedule-item", + "requirement-mapping", + "component", + "information-type" + ] + }, + "access-change": { + "title": "Access change", + "completionType": "access-grant", + "completionResourceTypes": [ + "access-grant", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "service-account", + "system", + "component" + ], + "aggregate": { + "completionMemberField": "subjectId", + "passingStatuses": [ + "active", + "revoked" + ] + } + }, + "access-provisioning": { + "title": "Access provisioning", + "completionType": "access-grant", + "completionResourceTypes": [ + "access-grant", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "service-account", + "system", + "component" + ], + "aggregate": { + "completionMemberField": "subjectId", + "passingStatuses": [ + "active" + ] + } + }, + "access-removal": { + "title": "Access removal", + "completionType": "access-grant", + "completionResourceTypes": [ + "access-grant", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "service-account", + "system", + "component" + ], + "aggregate": { + "completionMemberField": "subjectId", + "passingStatuses": [ + "revoked", + "expired" + ] + } + }, + "access-review": { + "title": "Access review", + "completionType": "access-review", + "completionResourceTypes": [ + "access-review" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "service-account", + "team", + "component" + ], + "aggregate": { + "completionMemberField": "componentIds", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "passed", + "passed-with-exceptions" + ] + } + }, + "alert-path-test": { + "title": "Alert path test", + "completionType": "control-test", + "completionResourceTypes": [ + "control-test", + "exercise", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "document", + "control", + "component" + ] + }, + "asset-recovery": { + "title": "Asset recovery", + "completionType": "asset", + "completionResourceTypes": [ + "asset", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "asset" + ] + }, + "asset-registration": { + "title": "Asset registration", + "completionType": "asset", + "completionResourceTypes": [ + "asset", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "asset" + ] + }, + "backup-test": { + "title": "Backup test", + "completionType": "backup-test", + "completionResourceTypes": [ + "backup-test" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "component" + ], + "aggregate": { + "completionMemberField": "systemIds", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "passed", + "passed-with-exceptions" + ] + } + }, + "change-review": { + "title": "Change review", + "completionType": "meeting", + "completionResourceTypes": [ + "meeting", + "policy", + "control", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "policy", + "document", + "control", + "component" + ] + }, + "control-design-review": { + "title": "Control design and evidence-path review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "control", + "program", + "system", + "component" + ], + "completionProfile": "control-design-review" + }, + "continuity-review": { + "title": "Continuity review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "document", + "system", + "component" + ], + "completionProfile": "continuity-review" + }, + "document-review": { + "title": "Document review", + "completionType": "document", + "completionResourceTypes": [ + "document", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "document" + ] + }, + "exception-review": { + "title": "Exception review", + "completionType": "exception", + "completionResourceTypes": [ + "exception", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "exception" + ] + }, + "exercise": { + "title": "Exercise", + "completionType": "exercise", + "completionResourceTypes": [ + "exercise" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "document", + "system", + "team", + "component" + ] + }, + "incident-retrospective": { + "title": "Incident retrospective", + "completionType": "incident", + "completionResourceTypes": [ + "incident", + "document", + "evidence" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "incident" + ] + }, + "endpoint-verification": { + "title": "Endpoint verification", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "asset", + "component" + ], + "completionProfile": "endpoint-verification" + }, + "inventory-review": { + "title": "Inventory review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "service-account", + "system", + "asset", + "vendor", + "component" + ], + "completionProfile": "inventory-review" + }, + "log-review": { + "title": "Log review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "component" + ], + "completionProfile": "log-review", + "aggregate": { + "completionMemberField": "scopeResourceIds", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "passed", + "passed-with-exceptions" + ] + } + }, + "meeting": { + "title": "Meeting", + "completionType": "meeting", + "completionResourceTypes": [ + "meeting" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "team" + ] + }, + "network-review": { + "title": "Network review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "component" + ], + "completionProfile": "network-review", + "aggregate": { + "completionMemberField": "scopeResourceIds", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "passed", + "passed-with-exceptions" + ] + } + }, + "oversight-meeting": { + "title": "Oversight meeting", + "completionType": "meeting", + "completionResourceTypes": [ + "meeting" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "team" + ] + }, + "penetration-test": { + "title": "Penetration test", + "completionType": "penetration-test", + "completionResourceTypes": [ + "penetration-test" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "component" + ] + }, + "performance-review": { + "title": "Performance review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "person" + ], + "completionProfile": "performance-review", + "aggregate": { + "completionMemberField": "scopeResourceIds", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "passed", + "passed-with-exceptions" + ] + } + }, + "workforce-review": { + "title": "Workforce screening and competence review", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "person" + ], + "completionProfile": "workforce-review" + }, + "personal-device-approval": { + "title": "Personal device approval", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "event" + ], + "scopeResourceTypes": [ + "person", + "asset" + ], + "completionProfile": "personal-device-approval" + }, + "policy-review": { + "title": "Policy review", + "completionType": "policy-review", + "completionResourceTypes": [ + "policy-review" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "policy", + "document" + ] + }, + "remediation": { + "title": "Remediation", + "completionType": "action-item", + "completionResourceTypes": [ + "action-item", + "finding", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "finding", + "action-item", + "incident", + "vulnerability" + ] + }, + "retention-review": { + "title": "Retention review", + "completionType": "document", + "completionResourceTypes": [ + "document", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "document", + "system", + "component" + ] + }, + "risk-assessment": { + "title": "Risk assessment", + "completionType": "risk-assessment", + "completionResourceTypes": [ + "risk-assessment", + "risk", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "workspace", + "system", + "vendor", + "risk", + "control", + "program", + "component" + ] + }, + "role-training": { + "title": "Role training", + "completionType": "attestation", + "completionResourceTypes": [ + "attestation", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "person", + "training" + ] + }, + "security-scan": { + "title": "Security scan", + "completionType": "control-activity", + "completionResourceTypes": [ + "control-activity" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "asset", + "component" + ], + "completionProfile": "security-scan" + }, + "training": { + "title": "Training", + "completionType": "attestation", + "completionResourceTypes": [ + "attestation", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "person", + "training" + ] + }, + "vendor-contract": { + "title": "Vendor contract", + "completionType": "document", + "completionResourceTypes": [ + "document", + "vendor", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "vendor", + "document" + ] + }, + "vendor-remediation": { + "title": "Vendor remediation", + "completionType": "vendor", + "completionResourceTypes": [ + "vendor", + "risk", + "document", + "action-item", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "vendor", + "risk", + "finding", + "action-item" + ] + }, + "vendor-review": { + "title": "Vendor review", + "completionType": "vendor-review", + "completionResourceTypes": [ + "vendor-review", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "vendor" + ], + "aggregate": { + "completionMemberField": "vendorId", + "passingStatuses": [ + "complete" + ], + "passingResults": [ + "approved", + "conditional" + ] + } + }, + "vulnerability-scan": { + "title": "Vulnerability scan", + "completionType": "vulnerability-scan", + "completionResourceTypes": [ + "vulnerability-scan", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "system", + "component" + ], + "aggregate": { + "completionMemberField": "componentIds", + "passingStatuses": [ + "complete" + ] + } + }, + "workforce-acknowledgement": { + "title": "Workforce acknowledgement", + "completionType": "attestation", + "completionResourceTypes": [ + "attestation", + "evidence" + ], + "recurrenceModes": [ + "calendar", + "event" + ], + "scopeResourceTypes": [ + "person", + "policy", + "document" + ] + } + }, + "policyEvents": { + "person-started": { + "title": "New Worker", + "subjectRules": [ + { + "resourceType": "person", + "minimum": 1, + "maximum": 1 + } + ] + }, + "person-ended": { + "title": "Worker Departure", + "subjectRules": [ + { + "resourceType": "person", + "minimum": 1, + "maximum": 1 + } + ] + }, + "person-role-changed": { + "title": "Job or Responsibility Change", + "subjectRules": [ + { + "resourceType": "person", + "minimum": 1, + "maximum": 1 + } + ] + }, + "personal-device-access-planned": { + "title": "Personal Device Access", + "subjectRules": [ + { + "resourceType": "person", + "minimum": 1, + "maximum": 1 + }, + { + "resourceType": "asset", + "minimum": 0, + "maximum": 1 + } + ] + }, + "vendor-access-planned": { + "title": "Vendor Access", + "subjectRules": [ + { + "resourceType": "vendor", + "minimum": 1, + "maximum": 1 + } + ] + }, + "vendor-reassessment-needed": { + "title": "Vendor Reassessment", + "subjectRules": [ + { + "resourceType": "vendor", + "minimum": 1, + "maximum": 1 + } + ] + }, + "system-material-change": { + "title": "Material System Change", + "subjectRules": [ + { + "resourceType": "system", + "minimum": 1, + "maximum": 1 + }, + { + "resourceType": "component", + "minimum": 0, + "maximum": 1 + } + ] + }, + "material-incident": { + "title": "Material Incident", + "subjectRules": [ + { + "resourceType": "incident", + "minimum": 1, + "maximum": 1 + } + ] + }, + "vendor-activated": { + "title": "Vendor Activation", + "subjectRules": [ + { + "resourceType": "vendor", + "minimum": 1, + "maximum": 1 + } + ] + }, + "vendor-terminated": { + "title": "Vendor Termination", + "subjectRules": [ + { + "resourceType": "vendor", + "minimum": 1, + "maximum": 1 + } + ] + }, + "material-data-use-change": { + "title": "Material Data-Use Change", + "subjectRules": [ + { + "resourceType": "system", + "minimum": 1, + "maximum": 1 + }, + { + "resourceType": "component", + "minimum": 0, + "maximum": 1 + } + ] + }, + "incident-closed": { + "title": "Incident Closure", + "subjectRules": [ + { + "resourceType": "incident", + "minimum": 1, + "maximum": 1 + } + ] + }, + "policy-revised": { + "title": "Policy Revision", + "subjectRules": [ + { + "resourceType": "policy", + "minimum": 1, + "maximum": 1 + } + ] + }, + "emergency-change": { + "title": "Emergency Change", + "subjectRules": [ + { + "resourceType": "system", + "minimum": 1, + "maximum": 1 + }, + { + "resourceType": "component", + "minimum": 0, + "maximum": 1 + } + ] + }, + "exception-expired": { + "title": "Exception Expiry", + "subjectRules": [ + { + "resourceType": "exception", + "minimum": 1, + "maximum": 1 + } + ] + }, + "service-account-created": { + "title": "Service Account Creation", + "subjectRules": [ + { + "resourceType": "service-account", + "minimum": 1, + "maximum": 1 + } + ] + }, + "service-account-expired": { + "title": "Service Account Expiry", + "subjectRules": [ + { + "resourceType": "service-account", + "minimum": 1, + "maximum": 1 + } + ] + }, + "vulnerability-confirmed": { + "title": "Vulnerability Confirmation", + "subjectRules": [ + { + "resourceType": "vulnerability", + "minimum": 1, + "maximum": 1 + } + ] + }, + "vulnerability-overdue": { + "title": "Overdue Vulnerability", + "subjectRules": [ + { + "resourceType": "vulnerability", + "minimum": 1, + "maximum": 1 + } + ] + }, + "asset-disposed": { + "title": "Asset Disposal", + "subjectRules": [ + { + "resourceType": "asset", + "minimum": 1, + "maximum": 1 + } + ] + }, + "continuity-activated": { + "title": "Continuity Activation", + "subjectRules": [ + { + "resourceType": "system", + "minimum": 1, + "maximum": 1 + }, + { + "resourceType": "component", + "minimum": 0, + "maximum": 1 + } + ] + } + }, + "relationshipConstraints": { + "acyclic": [ + { + "resourceType": "person", + "field": "managerId" + }, + { + "resourceType": "requirement", + "field": "parentRequirementId" + }, + { + "resourceType": "commitment", + "field": "supersedesId" + }, + { + "resourceType": "policy", + "field": "parentPolicyId" + }, + { + "resourceType": "policy", + "field": "supersedesId" + }, + { + "resourceType": "document", + "field": "supersedesId" + }, + { + "resourceType": "reporting-route-set", + "field": "predecessorId" + } + ], + "unique": [ + { + "resourceType": "appointment", + "statuses": [ + "active" + ], + "fields": [ + "appointmentKind", + "scopeResourceIds" + ] + }, + { + "resourceType": "access-grant", + "statuses": [ + "active" + ], + "fields": [ + "subjectId", + "componentId", + "accessLevel", + "role", + "privileged" + ] + }, + { + "resourceType": "collection-review", + "statuses": [ + "active" + ], + "fields": [ + "resourceType", + "scopeResourceIds" + ] + } + ] + }, + "appointmentTemplates": { + "policy-owner": { + "title": "Policy Owner", + "requiredness": "core", + "independence": "owner", + "responsibilities": "Own the information security program and its policies, controls, plans, assigned work, and management decisions." + }, + "independent-policy-reviewer": { + "title": "Independent Policy Reviewer", + "requiredness": "core", + "independence": "independent-reviewer", + "responsibilities": "Review and approve policies and governed documents independently from their owners and chair independent security and risk oversight." + } + }, + "completionProfiles": { + "endpoint-verification": { + "title": "Endpoint verification", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "inventory-review": { + "title": "Inventory review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "log-review": { + "title": "Log review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "network-review": { + "title": "Network review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "continuity-review": { + "title": "Continuity review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "control-design-review": { + "title": "Control design and evidence-path review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "performance-review": { + "title": "Performance review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "workforce-review": { + "title": "Workforce screening and competence review", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "personal-device-approval": { + "title": "Personal-device approval", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + }, + "security-scan": { + "title": "Security scan", + "resourceType": "control-activity", + "requiredFacts": [ + "performerIds", + "scopeResourceIds", + "method", + "result", + "reviewerIds", + "completedAt" + ] + } + } +} diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 61eb3cd..08fcccf 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.36", + "version": "0.17.0", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/activation-readiness.js b/packages/filegrc/src/activation-readiness.js new file mode 100644 index 0000000..7376187 --- /dev/null +++ b/packages/filegrc/src/activation-readiness.js @@ -0,0 +1,90 @@ +import { getRecordIdentityHistory, getFileAtRevision } from "./git.js"; +import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js"; +import { modelSupports } from "../model/index.js"; +import { assessReportingRouteSets } from "./reporting-route-sets.js"; +import { reportingRouteSourceAppliesToProgram } from "./reporting-route-integrity.js"; +import { timestampFromLocalDateTime } from "./time.js"; + +export async function reportingCutoverIssues(loaded, updates) { + if (!modelSupports(loaded.model, "schedule-set-review")) return []; + const sources = updates.filter((record) => ["policy", "document", "commitment", "risk"].includes(record.type) + && Array.isArray(record.reportingRouteRequirements) && record.reportingRouteRequirements.length); + if (!sources.length) return []; + const updated = new Map(updates.map((record) => [record.id, record])); + const future = { ...loaded, resources: loaded.resources.map((record) => updated.get(record.id) || record) }; + const issues = []; + for (const source of sources) { + let effectiveAt; + try { effectiveAt = timestampFromLocalDateTime(`${source.effectiveOn || source.statusTransition?.changedOn || new Date().toISOString().slice(0, 10)}T00:00:00`, loaded.workspace?.timezone || "UTC"); } + catch { issues.push({ code: "invalid-cutover-date", sourceId: source.id, message: "The intended content effective date and timezone must be valid." }); continue; } + for (const program of future.resources.filter((record) => record.type === "program" && record.status !== "retired" + && reportingRouteSourceAppliesToProgram(source, record, future.resources))) { + for (const requirement of source.reportingRouteRequirements) { + if (!requirement || typeof requirement !== "object" || Array.isArray(requirement)) { issues.push({ code: "invalid-reporting-route-requirement", sourceId: source.id, programId: program.id, message: "Each reporting requirement must be an object." }); continue; } + if (requirement.programScope === "selected-programs" && !requirement.programIds?.includes(program.id)) continue; + const at = new Date(requirement.effectiveAt) > new Date(effectiveAt) ? requirement.effectiveAt : effectiveAt; + if (requirement.endsAt && new Date(requirement.endsAt) <= new Date(at)) continue; + const assessment = await assessReportingRouteSets(future, { programId: program.id, at }); + const candidateIds = new Set(assessment.routeSets.filter(({ record }) => record.purposeKey === requirement.purposeKey).map(({ record }) => record.id)); + const relevant = assessment.issues.filter((issue) => issue.resourceId === source.id || candidateIds.has(issue.resourceId)); + issues.push(...relevant.map((issue) => ({ ...issue, sourceId: source.id, programId: program.id }))); + } + } + } + return [...new Map(issues.map((issue) => [`${issue.code}:${issue.resourceId}:${issue.programId}:${issue.message}`, issue])).values()]; +} + +export async function requireReportingCutover(loaded, updates) { + const issues = await reportingCutoverIssues(loaded, updates); + if (issues.length) throw Object.assign(new Error(`Reporting channels are not ready for activation: ${issues[0].message}`), { code: "FILEGRC_ACTIVATION_NOT_READY" }); +} + +export async function scheduleCutoverIssues(loaded, updates, options = {}) { + if (!modelSupports(loaded.model, "schedule-set-review")) return []; + const { assessCollectionReview } = await import("./collection-review.js"); + const { recordBelongsToProgram } = await import("./program.js"); + const { scheduleSetIssues } = await import("./schedule-set.js"); + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const issues = []; + for (const source of updates.filter((record) => ["policy", "document", "training"].includes(record.type) + && record.workflowScope !== "engagement")) { + const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired" + && recordBelongsToProgram(source, record, byId, loaded.model)); + for (const program of programs) { + const assessment = assessCollectionReview(loaded, "obligation", { programId: program.id }); + let at; + try { at = timestampFromLocalDateTime(`${source.effectiveOn}T00:00:00`, loaded.workspace?.timezone || "UTC"); } + catch { issues.push({ code: "invalid-cutover-date", sourceId: source.id, programId: program.id, message: "The intended content effective date and timezone must be valid." }); continue; } + if (!assessment.complete) issues.push({ code: "schedule-set-approval-required", sourceId: source.id, programId: program.id, message: assessment.message }); + else if (assessment.review.reviewedOn > source.effectiveOn || source.activatedOn && assessment.review.reviewedOn > source.activatedOn) issues.push({ code: "schedule-set-approval-after-cutover", sourceId: source.id, programId: program.id, message: "The schedule set must be reviewed before the content's actual activation and intended effective date. Do not backdate operation before approval." }); + else issues.push(...scheduleSetIssues(loaded, program, assessment.review, { at }).map((issue) => ({ ...issue, sourceId: source.id, programId: program.id }))); + } + } + return issues; +} + +export async function requireScheduleCutover(loaded, updates, options = {}) { + const issues = await scheduleCutoverIssues(loaded, updates, options); + if (issues.length) throw Object.assign(new Error(`Schedule set is not ready for activation: ${issues[0].message}`), { code: "FILEGRC_ACTIVATION_NOT_READY" }); +} + +// A commit must not turn a rejected direct-file cutover into an accepted one. +// Content already active before v12 remains an operating fact during migration. +export function historicalContentCutover(loaded, record) { + if (!record || !["active", "open", "monitoring"].includes(record.status)) return null; + const key = (value) => JSON.stringify([value.status, value.effectiveOn, value.activatedOn, value.reportingRouteRequirements || []]); + let boundary = null; + for (const entry of getRecordIdentityHistory(loaded.root, record.id)) { + const source = getFileAtRevision(loaded.root, entry.commit, entry.path); + if (!source) break; + let prior; + try { prior = JSON.parse(source); } catch { break; } + if (key(prior) !== key(record)) break; + boundary = entry; + } + if (!boundary) return null; + const snapshot = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), boundary.commit); + if (!snapshot || !modelSupports(snapshot.model, "schedule-set-review")) return null; + const prior = snapshot.resources.find(({ id }) => id === record.id); + return prior ? { snapshot, record: prior } : null; +} diff --git a/packages/filegrc/src/collection-review-integrity.js b/packages/filegrc/src/collection-review-integrity.js index 789cab1..cdd0878 100644 --- a/packages/filegrc/src/collection-review-integrity.js +++ b/packages/filegrc/src/collection-review-integrity.js @@ -22,7 +22,8 @@ export function collectionReviewRevision(record) { populationResourceIds: record.populationResourceIds, scopeResourceIds: record.scopeResourceIds, authoritativeComponentId: record.authoritativeComponentId, - supersedesId: record.supersedesId + supersedesId: record.supersedesId, + ...(record.resourceType === "obligation" ? { scheduleDecisions: record.scheduleDecisions } : {}) }; return calculateRevision("collection-review", JSON.stringify(canonicalRevisionValue(reviewedFacts))); } diff --git a/packages/filegrc/src/collection-review.js b/packages/filegrc/src/collection-review.js index 1813457..fbb90bb 100644 --- a/packages/filegrc/src/collection-review.js +++ b/packages/filegrc/src/collection-review.js @@ -1,3 +1,4 @@ +import { scheduleSetIssues, scheduleReviewerConflicts } from "./schedule-set.js"; import { readFile } from "node:fs/promises"; import { modelSupports } from "../model/index.js"; import { @@ -68,6 +69,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { && (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11 || sameIds(review.populationResourceIds, records.map(({ id }) => id))) ); + const scheduleTemporalReview = resourceType !== "obligation" || Boolean( + review?.knowledgeCutoffAt && !Number.isNaN(new Date(review.knowledgeCutoffAt).getTime()) + && new Date(review.knowledgeCutoffAt) <= new Date() + && currentCalendarDate(loaded.workspace?.timezone || "UTC", new Date(review.knowledgeCutoffAt)) === review.reviewedOn + ); const allowsEmptyCollection = allowedDecisions.some((decision) => ( decision === "zero-population" || decision === "externally-managed" )); @@ -82,12 +88,16 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { currentRevision } ); - const reviewerEligibility = resourceType === "control" + const reviewerEligibility = resourceType === "obligation" + ? { reviewerConflictIds: scheduleReviewerConflicts(loaded, program), eligibleReviewerIds: loaded.resources.filter(({ type, status }) => type === "person" && status === "active").map(({ id }) => id).filter((id) => !scheduleReviewerConflicts(loaded, program).includes(id)) } + : resourceType === "control" ? assessControlReviewers(loaded, records) : resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") ? assessRetentionScheduleReviewers(loaded, records) : null; - const approvalIssues = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") + const approvalIssues = resourceType === "obligation" + ? scheduleSetIssues(loaded, program, review) + : resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") ? retentionScheduleApprovalIssues(loaded, retentionScope, records, { informationTypesReviewed: retentionScope.programIds.every((id) => ( assessCollectionReview(loaded, "information-type", { programId: id }).complete @@ -114,6 +124,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { && allowedDecisions.includes(review.decision) && revisionMatches && temporalReview + && scheduleTemporalReview && reviewScopeMatches && reviewersEligible && (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11 @@ -196,6 +207,8 @@ export async function scaffoldCollectionReview(input = process.cwd(), options = : null; return { resourceType, + ...(resourceType === "obligation" ? { programId: program.id } : {}), + ...(resourceType === "obligation" ? { scheduleDecisions: assessment.records.map((record) => ({ obligationId: record.id, disposition: null, ...(record.activeRuleId ? { ruleId: record.activeRuleId } : {}), rationale: null })) } : {}), ...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}), decision: priorDecisionStillValid ? priorReview.decision : (assessment.records.length ? "complete" @@ -205,7 +218,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options = reviewedOn: priorReview?.reviewedOn ? currentCalendarDate(loaded.workspace.timezone, now) : null, - ...(resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") + ...(resourceType === "obligation" || resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") ? { expectedCollectionRevision: assessment.collectionRevision } : {}), ...(v4 @@ -228,6 +241,12 @@ export async function planCollectionReview(input = process.cwd(), options = {}) if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !options.expectedCollectionRevision) { throw new Error("Data Retention Schedule approval requires the displayed collection revision. Reload or scaffold the current review before approving it."); } + if (resourceType === "obligation") { + if (!options.expectedCollectionRevision) throw new Error("Schedule-set approval requires the displayed exact collection revision."); + const issues = scheduleSetIssues(loaded, program, { scopeResourceIds: [program.id], scheduleDecisions: options.scheduleDecisions }); + if (issues.length) throw new Error(issues[0].message); + if (!(options.reviewedByIds || []).length || options.reviewedByIds.some((id) => !assessment.eligibleReviewerIds.includes(id))) throw new Error("Schedule-set approval needs an independent active Person who does not own the selected Controls or schedules."); + } await requireControlReviewReady(loaded, resourceType, program.id); await requireRetentionScheduleReady(loaded, resourceType, program, retentionScope); requireCollectionRecordProposalsReady(assessment); @@ -334,6 +353,7 @@ export async function planCollectionReview(input = process.cwd(), options = {}) rationale, reviewedByIds, reviewedOn, + ...(resourceType === "obligation" ? { scheduleDecisions: structuredClone(options.scheduleDecisions || []) } : {}), collectionRevision: currentRevision, scopeRevision, ...(workspaceWideRetentionReview ? { scopeResourceIds: retentionScope.programIds } : {}), diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index 7660082..2886fa6 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -25,6 +25,7 @@ export function legacyCollectionRevision(loaded, resourceType, options = {}) { export function collectionRevisionMatches(loaded, resourceType, storedRevision, options = {}) { if (!storedRevision) return false; + if (resourceType === "obligation") return revisionsMatch("collection", storedRevision, options.currentRevision || collectionRevision(loaded, resourceType, options)); const currentRevision = options.currentRevision || collectionRevision(loaded, resourceType, options); // Accept both the pre-0.16 scope: input and the digest-only input used by @@ -61,6 +62,15 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision, // Retention Schedule is an approval of exact content and keeps its binding. export function collectionReviewDecisionMatches(loaded, resourceType, review, records, options = {}) { if (!review) return false; + if (resourceType === "obligation") { + if (!Array.isArray(review.populationResourceIds)) return false; + if (!collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options)) return false; + if (JSON.stringify([...(review.populationResourceIds || [])].sort()) !== JSON.stringify(records.map(({ id }) => id).sort())) return false; + if (!loaded.root || !review.scopeRevision) return false; + const historical = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision); + const program = historical?.resources.find((record) => record.type === "program" && record.id === review.scopeResourceIds?.[0]); + return Boolean(program && revisionsMatch("collection", review.collectionRevision, collectionRevision(historical, "obligation", { program, historicalCommit: review.scopeRevision }))); + } if (resourceType === "retention-schedule-item" || Number(loaded.model.modelVersion) < 11) { return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options); } @@ -230,7 +240,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop ? loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) ?? null : getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`) - : readFileSync(resolveDataPath(loaded.root, path), "utf8"); + : loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) ?? null : loaded.historicalCommit ? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${path}`) : readFileSync(resolveDataPath(loaded.root, path), "utf8"); if (content === null) return []; return [{ path, revision: createHash("sha256").update(content).digest("hex") }]; } catch (error) { diff --git a/packages/filegrc/src/collection-scope.js b/packages/filegrc/src/collection-scope.js index 99de3cb..3c7cb7b 100644 --- a/packages/filegrc/src/collection-scope.js +++ b/packages/filegrc/src/collection-scope.js @@ -1,3 +1,4 @@ +import { scheduleSetRecords, scheduleSetInputs } from "./schedule-set.js"; import { modelSupports } from "../model/index.js"; import { programComponents, selectedRequirementIds } from "./program.js"; import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionSystemUses, retentionUses } from "./retention.js"; @@ -12,6 +13,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) { record.type === resourceType && !["retired", "superseded"].includes(record.status) )); } + if (resourceType === "obligation") return scheduleSetRecords(loaded, program); if (resourceType === "person") { return scopedProgramPeople(loaded, program); } @@ -201,6 +203,7 @@ function scopedProgramPeople(loaded, program = {}) { } export function collectionRevisionInputs(loaded, resourceType, program, options = {}) { + if (resourceType === "obligation") return scheduleSetInputs(loaded, program); const legacy = options.legacy === true; const currentModel = modelSupports(loaded.model, "retention-schedule-approval"); const reviewed = scopedCollectionRecords(loaded, resourceType, program); @@ -411,6 +414,7 @@ function retentionScheduleDocumentProposalValue(record) { } export function collectionScopeRevisionFacts(loaded, resourceType, program, options = {}) { + if (resourceType === "obligation") return { programId: program?.id, controlIds: sorted(program?.controlIds), systemIds: sorted(program?.systemIds), policyIds: sorted(program?.policyIds) }; if (!modelSupports(loaded.model, "program-scope")) { const common = { programId: program?.id ?? null }; if (resourceType === "framework") { diff --git a/packages/filegrc/src/document-activation.js b/packages/filegrc/src/document-activation.js index f25f7c3..1fc3514 100644 --- a/packages/filegrc/src/document-activation.js +++ b/packages/filegrc/src/document-activation.js @@ -1,3 +1,4 @@ +import { requireReportingCutover, requireScheduleCutover } from "./activation-readiness.js"; import { applyDocumentActivationBatch, applyGovernedContentActivationBatch, contentRevision } from "./files.js"; import { serializeWorkspaceMutation } from "./mutation.js"; import { modelSupports } from "../model/index.js"; @@ -114,6 +115,8 @@ export async function planDocumentActivation(input = process.cwd(), options = {} delete record.proposedEffectiveOn; return record; }); + await requireReportingCutover(loaded, update); + await requireScheduleCutover(loaded, update, { programId: options.programId }); return { operation: governedContentOperation ? "governed-content-activation" : "document-activation", workflowScope, @@ -134,6 +137,7 @@ export async function planDocumentActivation(input = process.cwd(), options = {} function controlOversightComplete(loaded, readiness) { if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true; + if (modelSupports(loaded.model, "schedule-set-review") && readiness.stages.find(({ id }) => id === "controls")?.items.find(({ id }) => id === "collection-review-obligation")?.status !== "complete") return false; return readiness.stages.find(({ id }) => id === "controls")?.items .find(({ id }) => id === "collection-review-control")?.status === "complete"; } diff --git a/packages/filegrc/src/historical-workspace.js b/packages/filegrc/src/historical-workspace.js index 44a0b5c..7c965dd 100644 --- a/packages/filegrc/src/historical-workspace.js +++ b/packages/filegrc/src/historical-workspace.js @@ -45,6 +45,7 @@ export function reviewHistoricalWorkspace(context, commit) { if (context.index?.available) return indexedHistoricalWorkspace(context, commit); if (context.workspacesByCommit.has(commit)) return context.workspacesByCommit.get(commit); const loaded = historicalWorkspace(context, commit); + if (loaded) loaded.historicalCommit = commit; context.workspacesByCommit.set(commit, loaded); if (context.workspacesByCommit.size > 128) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value); return loaded; @@ -121,7 +122,7 @@ export function indexedHistoricalWorkspace(context, commit) { try { const loaded = { root, entries, resources, workspace, model: historicalModel(context, workspace.dataModelVersion), - historicalFiles: state, byId: new Map(resources.map((record) => [record.id, record])) + historicalCommit: commit, historicalFiles: state, byId: new Map(resources.map((record) => [record.id, record])) }; context.workspacesByCommit.set(commit, loaded); if (context.workspacesByCommit.size > cacheLimit) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value); diff --git a/packages/filegrc/src/model-migration.js b/packages/filegrc/src/model-migration.js index 4fd4ee0..0820e86 100644 --- a/packages/filegrc/src/model-migration.js +++ b/packages/filegrc/src/model-migration.js @@ -1,3 +1,4 @@ +import { getGitSummary } from "./git.js"; import { createResourceId } from "./id.js"; import { applyModelMigrationBatch, applyResourceBatch, contentRevision } from "./files.js"; import { loadWorkspace } from "./workspace.js"; @@ -902,7 +903,7 @@ export async function planModelMigration(input = process.cwd(), options = {}) { : sourceVersion === "7" ? "8" : sourceVersion === "8" ? "9" : sourceVersion === "9" ? "10" - : ACTIVE_MODEL_VERSION; + : sourceVersion === "10" ? "11" : ACTIVE_MODEL_VERSION; if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget); if (sourceVersion === "1" && requestedTarget === "2") { return planV1ToV2Migration(input, options); @@ -931,9 +932,10 @@ export async function planModelMigration(input = process.cwd(), options = {}) { if (sourceVersion === "9" && requestedTarget === "10") { return planV9ToV10Migration(loaded); } - if (sourceVersion === "10" && requestedTarget === ACTIVE_MODEL_VERSION) { + if (sourceVersion === "10" && requestedTarget === "11") { return planV10ToV11Migration(loaded); } + if (sourceVersion === "11" && requestedTarget === "12") return planV11ToV12Migration(loaded); if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) { throw new Error( "Model v1 workspaces must migrate to model v2 first. " @@ -3559,3 +3561,23 @@ function migrationValueMissing(value) { || (typeof value === "string" && value.trim() === "") || (Array.isArray(value) && value.length === 0); } + +async function planV11ToV12Migration(loaded) { + const hasOperatingContent = loaded.resources.some((record) => ["policy", "document", "training"].includes(record.type) && record.status === "active"); + const git = hasOperatingContent ? getGitSummary(loaded.root) : null; + const unsupported = hasOperatingContent && (!git?.available || !git?.commit || !git?.clean) + ? [classifiedChange("unsupported", loaded.workspace.id, "scopeRevision", "Commit the current model v11 workspace before migration so existing activation history remains retrievable.")] + : []; + const workspace = { ...loaded.workspace, dataModelVersion: "12" }; + const entry = loaded.entries.find(({ record }) => record.id === workspace.id); + const reviews = loaded.resources.filter(({ type, status }) => type === "program" && status !== "retired").map((program) => classifiedChange("review-required", program.id, "scheduleDecisions", "Review the complete Obligation schedule set and reporting approvals before the next activation. Existing operation and decisions are preserved.")); + return { + schemaVersion: 2, sourceModelVersion: "11", targetModelVersion: "12", ready: unsupported.length === 0, + missing: [], conflicts: [], manualActions: [], + classifications: { automatic: [classifiedChange("automatic", workspace.id, "dataModelVersion", "Select model v12 without changing program records.")], reviewRequired: reviews, unsupported }, + notes: ["Existing content and operating rules remain unchanged. No adoption, replacement, or exclusion decision is inferred.", "New activation requires an exact Program schedule-set review and approved reporting channels covering cutover."], + migrationReport: {}, summary: { create: 0, update: 1, automatic: 1, reviewRequired: reviews.length, unsupported: unsupported.length }, + fileDiff: { create: [], update: [{ type: "workspace", id: workspace.id, before: loaded.workspace, after: workspace }] }, + changes: { create: [], update: [workspace], expectedRevisions: { [workspace.id]: contentRevision(entry.source) }, validateWholeWorkspace: true, targetModelVersion: "12" } + }; +} diff --git a/packages/filegrc/src/obligation-rule.js b/packages/filegrc/src/obligation-rule.js index c4bbf13..18b4b97 100644 --- a/packages/filegrc/src/obligation-rule.js +++ b/packages/filegrc/src/obligation-rule.js @@ -6,13 +6,15 @@ export function obligationRule(obligation, byId, options = {}) { : null; if (obligation?.scheduleMode !== "rule" && !proposedId) return null; let rule = byId.get(obligation.activeRuleId || proposedId); - if (rule?.status === "active" && rule.effectiveAt && options.now && new Date(rule.effectiveAt) > new Date(options.now)) { - const prior = byId.get(rule.supersedesId); - return prior?.type === "obligation-rule" - && prior.obligationId === obligation.id - && ["active", "retired"].includes(prior.status) - ? prior - : null; + if (rule?.status === "active" && options.now) { + const seen = new Set(); + while (rule?.type === "obligation-rule" && rule.obligationId === obligation.id + && ["active", "retired"].includes(rule.status) && !seen.has(rule.id)) { + seen.add(rule.id); + if (rule.effectiveAt && new Date(rule.effectiveAt) <= new Date(options.now)) return rule; + rule = byId.get(rule.supersedesId); + } + return null; } return rule?.type === "obligation-rule" && rule.obligationId === obligation.id diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index c5227a9..4a4a0e7 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -420,6 +420,7 @@ export function planObligations(resources, options = {}) { through, from: requestedFrom, programStatuses: Object.fromEntries(programStatusByObligationId), + effectiveRuleIds: Object.fromEntries(obligations.filter((record) => record.scheduleMode === "rule").map((record) => [record.id, obligationRule(record, byId, { now })?.id || null])), counts, items, calendarItems, diff --git a/packages/filegrc/src/policy-activation.js b/packages/filegrc/src/policy-activation.js index d7bd950..53d4cd2 100644 --- a/packages/filegrc/src/policy-activation.js +++ b/packages/filegrc/src/policy-activation.js @@ -1,3 +1,4 @@ +import { requireReportingCutover, requireScheduleCutover } from "./activation-readiness.js"; import { applyResourceBatch, contentRevision } from "./files.js"; import { serializeWorkspaceMutation } from "./mutation.js"; import { assessProgramReadiness } from "./program-readiness.js"; @@ -51,6 +52,7 @@ export async function planPolicyActivation(input = process.cwd(), options = {}) const update = policyIds.map((policyId) => { const entry = entryById.get(policyId); if (!entry || entry.record.type !== "policy") throw new Error(`Policy "${policyId}" was not found.`); + if (modelSupports(loaded.model, "program-scope") && !readiness.policyActivations.some((candidate) => candidate.policyId === policyId)) throw new Error(`Policy "${policyId}" is not selected for this Program. Scoped activation cannot use another Program’s review.`); if (entry.record.status !== "approved") { throw new Error(`Policy "${policyId}" must be approved and inactive before the Step 3 cutover.`); } @@ -61,6 +63,8 @@ export async function planPolicyActivation(input = process.cwd(), options = {}) delete record.proposedEffectiveOn; return record; }); + await requireReportingCutover(loaded, update); + await requireScheduleCutover(loaded, update, { programId: options.programId }); return { operation: "policy-activation", policyIds, @@ -78,6 +82,7 @@ export async function planPolicyActivation(input = process.cwd(), options = {}) function controlOversightComplete(loaded, readiness) { if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true; + if (modelSupports(loaded.model, "schedule-set-review") && readiness.stages.find(({ id }) => id === "controls")?.items.find(({ id }) => id === "collection-review-obligation")?.status !== "complete") return false; return readiness.stages.find(({ id }) => id === "controls")?.items .find(({ id }) => id === "collection-review-control")?.status === "complete"; } diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index fdb8410..cc3a46c 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -1,3 +1,5 @@ +import { reportingCutoverIssues } from "./activation-readiness.js"; +import { recordBelongsToProgram } from "./program.js"; import { readFile } from "node:fs/promises"; import { modelSupports } from "../model/index.js"; import { applicabilityReviewIsCurrent, createApplicabilityScopeIndex } from "./applicability-scope.js"; @@ -162,7 +164,8 @@ export async function assessProgramReadiness(input, options = {}) { }; const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model); - const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root, asOfTime); + const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root, asOfTime, collectionReviews.find(({ resourceType }) => resourceType === "obligation")); + controlStage.items.unshift(...collectionReviews.filter(({ resourceType }) => resourceType === "obligation").map(collectionReviewReadinessItem)); controlStage.items.unshift(...collectionReviews .filter(({ resourceType }) => resourceType === "complementary-control") .map(collectionReviewReadinessItem)); @@ -220,6 +223,17 @@ export async function assessProgramReadiness(input, options = {}) { ? item : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` }; })); + if (modelSupports(loaded.model, "schedule-set-review")) { + const content = [...requiredPolicies(scope, byId), ...requiredGovernedDocuments(scope, records, byId, loaded.model)] + .filter(({ status }) => ["approved", "active"].includes(status)) + .map((record) => ({ ...record, status: "active", effectiveOn: record.effectiveOn || record.proposedEffectiveOn || asOf })); + const issues = await reportingCutoverIssues(loaded, content); + if (content.some((record) => record.reportingRouteRequirements?.length)) controlStage.items.push(item( + "reporting-channel-cutover", issues.length ? "action" : "complete", "Verify reporting channels at cutover", + issues[0]?.message || "The committed approved channels are usable and cover the intended content effective date.", + { type: "reporting-route-set" }, { issues, commands: ["npx filegrc reporting-route-sets --json", "npx filegrc activate-content --scaffold"] } + )); + } controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations for scheduled or event work, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`; const scopeReadinessStage = scopeStage( program, @@ -651,6 +665,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews = } export function reportingRouteSetItem(assessment) { + const setupIssues = assessment.issues.filter(({ code }) => !["uncovered-reporting-route-interval", "overlapping-reporting-route-sets"].includes(code)); const current = assessment.routeSets.find(({ effective, canceled }) => effective && !canceled); const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status)); const required = assessment.requirements.length > 0; @@ -659,11 +674,12 @@ export function reportingRouteSetItem(assessment) { .map(({ purposeKey }) => purposeKey) .filter(Boolean))]; const preparedPurposeKeys = new Set(assessment.routeSets - .filter(({ record, committed, effective, canceled, proposedRequirementIssues }) => ( - (["proposed", "approved"].includes(record.status) || effective) + .filter(({ record, committed, effective, canceled, proposedRequirementIssues, issues = [] }) => ( + (record.status === "approved" || effective) && committed && !canceled && proposedRequirementIssues.length === 0 + && issues.length === 0 )) .map(({ record }) => record.purposeKey)); const requiredPurposeKeys = new Set(assessment.requirements.map(({ purposeKey }) => purposeKey).filter(Boolean)); @@ -671,32 +687,31 @@ export function reportingRouteSetItem(assessment) { ...requiredPurposeKeys, ...proposedPurposeKeys ])]; - const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey)); - const ready = assessment.issues.length === 0 - && (!required || Boolean(current)) + const unpreparedPurposeKeys = purposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey)); + const ready = setupIssues.length === 0 && unpreparedPurposeKeys.length === 0; - const needsAction = assessment.issues.length > 0 || unpreparedPurposeKeys.length > 0; + const needsAction = setupIssues.length > 0 || unpreparedPurposeKeys.length > 0; const target = current?.record || draft?.record || { type: "reporting-route-set" }; let message; - if (assessment.issues.length) { - message = assessment.issues[0].message; + if (setupIssues.length) { + message = setupIssues[0].message; } else if (unpreparedPurposeKeys.length) { - message = `Complete and commit a Reporting Channel Set proposal for ${unpreparedPurposeKeys.join(", ")} before Step 1 is complete. Approval and effectiveness remain part of the later implementation cutover.`; - } else if (required && ready && current) { - message = `${current.record.title} is committed, effective, and has a current responsible Appointment. Assignments bind its Git commit.`; + message = `Review and independently approve the exact committed Reporting Channel Set proposal for ${unpreparedPurposeKeys.join(", ")} before Step 1 is complete. Effectiveness remains part of the Step 3 cutover.`; + } else if (required && ready) { + message = "The required Reporting Channel Sets have committed independent approvals. Verify their effective intervals and operational usability at Step 3 cutover."; } else if (!required && proposed) { - message = "Every reporting-channel requirement in proposed program content has a committed Reporting Channel Set proposal. Approve the exact proposal before its governing content becomes active."; + message = "Every reporting-channel requirement in proposed program content has an independently approved, committed Reporting Channel Set. Verify effectiveness and usability at the Step 3 cutover."; } else if (!required && draft) { message = "No approved rule currently requires these reporting channels. Keep the draft for review or remove it; it is not a readiness gate."; } else if (!required) { message = "No active Policy, Document, Commitment, or Risk decision currently requires these reporting channels."; } else { - message = assessment.issues[0]?.message || "Set the normal and fallback reporting channels, commit the proposal, approve it, and commit the approval."; + message = setupIssues[0]?.message || "Set the normal and fallback reporting channels, commit the proposal, approve it, and commit the approval."; } return item( "security-reporting-route-set", needsAction ? "action" : required ? ready ? "complete" : "action" : proposed ? "complete" : "info", - needsAction ? "Prepare required reporting channels" : required ? "Approve required reporting channels" : proposed ? "Reporting channel proposals are ready" : "Reporting channels are not currently required", + needsAction ? "Prepare required reporting channels" : required ? "Approve required reporting channels" : proposed ? "Reporting channel approvals are ready" : "Reporting channels are not currently required", message, target, { @@ -704,18 +719,18 @@ export function reportingRouteSetItem(assessment) { proposedRequirements: assessment.proposedRequirements, proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues), unpreparedPurposeKeys, - issues: assessment.issues, + issues: setupIssues, progressUnits: purposeKeys.flatMap((purposeKey) => { const purposeResourceIds = new Set([ ...assessment.routeSets.filter(({ record }) => record.purposeKey === purposeKey).map(({ record }) => record.id), ...assessment.requirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId), ...assessment.proposedRequirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId) ].filter(Boolean)); - const purposeIssues = assessment.issues.filter(({ resourceId }) => ( + const purposeIssues = setupIssues.filter(({ resourceId }) => ( resourceId === assessment.programId || purposeResourceIds.has(resourceId) )); - const approvedSet = assessment.routeSets.find(({ record, effective, canceled }) => ( - record.purposeKey === purposeKey && effective && !canceled + const approvedSet = assessment.routeSets.find(({ record, committed, effective, canceled, issues = [] }) => ( + record.purposeKey === purposeKey && (record.status === "approved" || effective) && !canceled && committed && issues.length === 0 )); const proposedSet = assessment.routeSets.find(({ record, effective, canceled }) => ( record.purposeKey === purposeKey @@ -736,7 +751,7 @@ export function reportingRouteSetItem(assessment) { id: `reporting-route-approval-${purposeKey}`, status: approvedSet && purposeIssues.length === 0 ? "complete" - : requiredPurposeKeys.has(purposeKey) ? "action" : "later", + : "action", title: `${proposedSet?.record.title || purposeKey} approval` } ]; @@ -879,6 +894,7 @@ export function collectionReviewReadinessItem(assessment) { } function collectionApprovalIssueAction(assessment) { + if (assessment.resourceType === "obligation") return null; const issue = assessment.approvalIssues?.[0]; if (!issue || issue.code === "invalid-retention-schedule-approval-binding") return null; const rows = assessment.records || []; @@ -1270,7 +1286,7 @@ function requiredGovernedDocuments(scope, records, byId, model) { const selectedControlIds = new Set(scope.controls.map(({ id }) => id)); const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || [])); const obligationDocumentIds = new Set(records - .filter((record) => record.type === "obligation" && record.status !== "retired") + .filter((record) => record.type === "obligation" && record.status !== "retired" && recordBelongsToProgram(record, scope.program, byId, model)) .flatMap((record) => [ ...(record.scopeResourceIds || []), ...(record.templateResourceId ? [record.templateResourceId] : []) @@ -1297,7 +1313,7 @@ function requiredGovernedDocuments(scope, records, byId, model) { async function governedContentItems(scope, records, byId, readMarkdown, asOf, model, asOfTime) { const enabledObligations = records.filter((record) => ( - record.type === "obligation" && obligationIsEnabled(record) + record.type === "obligation" && obligationIsEnabled(record) && recordBelongsToProgram(record, scope.program, byId, model) )); const operatingSchedules = enabledObligations.filter((record) => obligationRuleIsEnabled(record, byId, asOfTime)); const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [ @@ -1682,7 +1698,7 @@ function legacyPolicyLibraryProposals(records) { }]; } -async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime) { +async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime, scheduleReview = null) { const items = []; const families = selectedControlFamilies(scope.controls, model); if (!scope.controls.length) { @@ -1718,6 +1734,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null applicability: control.applicabilityReview?.decision === "applicable" && applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model, root) } : {}), + ...(modelSupports(model, "schedule-set-review") ? { scheduleSetReviewed: scheduleReview?.complete === true } : {}), implemented: control.status === "implemented", owner: (control.ownerIds || []).length > 0, procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0, @@ -2136,6 +2153,7 @@ function controlCheckLabel(name) { procedureEffective: "procedure effective date", policyMapping: "policy mapping", criteriaMapping: "criteria mapping", + scheduleSetReviewed: "complete independently approved Obligation schedule set", workQueue: "enabled Obligation rules (work starts after cutover)" })[name] || name; } diff --git a/packages/filegrc/src/reporting-route-sets.js b/packages/filegrc/src/reporting-route-sets.js index 9f0eefe..d01b4d1 100644 --- a/packages/filegrc/src/reporting-route-sets.js +++ b/packages/filegrc/src/reporting-route-sets.js @@ -590,10 +590,9 @@ function assessRouteSet(record, loaded, at, repository) { && /^[a-f0-9]{40}$/i.test(String(record.proposalCommit || "")) ? reportingRouteRecordAtRevision(loaded, proposalEntry, record.proposalCommit) : null; + const proposalHistory = record.status === "proposed" && committed && repository.commit + ? reportingRouteExactHistoryEntry(loaded, record, repository.commit) : null; if (record.status === "proposed") { - const proposalHistory = committed && repository.commit - ? reportingRouteExactHistoryEntry(loaded, record, repository.commit) - : null; const proposalRecords = proposalHistory ? recordsAtRevision(loaded, proposalHistory.commit) : loaded.resources; const proposalRecord = proposalHistory ? proposalRecords.find(({ id }) => id === record.id) || record @@ -705,6 +704,7 @@ function assessRouteSet(record, loaded, at, repository) { authorities, approvalAssertionTiming, cancellationAssertionTiming, + proposalCommit: proposalHistory?.commit || record.proposalCommit || null, issues, state }; diff --git a/packages/filegrc/src/schedule-set.js b/packages/filegrc/src/schedule-set.js new file mode 100644 index 0000000..3fb5e61 --- /dev/null +++ b/packages/filegrc/src/schedule-set.js @@ -0,0 +1,142 @@ +import { personWasActiveOn } from "./soc2.js"; +import { currentCalendarDate } from "./time.js"; +import { modelSupports } from "../model/index.js"; +import { recordBelongsToProgram } from "./program.js"; +import { currentPartyPeople } from "./parties.js"; +import { obligationRule } from "./obligation-rule.js"; + +export function scheduleSetRecords(loaded, program) { + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + return loaded.resources.filter((record) => record.type === "obligation" + && recordBelongsToProgram(record, program, byId, loaded.model)); +} + +export function scheduleSetInputs(loaded, program) { + const obligations = scheduleSetRecords(loaded, program); + const ids = new Set(obligations.flatMap((record) => [record.id, ...(record.ruleIds || []), + ...(record.controlIds || []), ...(record.policyIds || []), ...(record.scopeResourceIds || []), record.templateResourceId]).filter(Boolean)); + for (const rule of loaded.resources.filter((record) => record.type === "obligation-rule" && obligations.some(({ id }) => id === record.obligationId))) { + ids.add(rule.id); + for (const id of rule.sourceResourceIds || []) ids.add(id); + } + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const pending = [...ids]; + const seen = new Set(); + while (pending.length) { + const id = pending.shift(); + if (seen.has(id)) continue; + seen.add(id); + const record = byId.get(id); + if (!record) continue; + const related = [...(record.ownerIds || []), ...(record.approverIds || []), ...(record.approvedByIds || []), + ...(record.policyIds || []), ...(record.relatedDocumentIds || []), ...(record.memberIds || []), ...(record.chairIds || []), record.holderId].filter(Boolean); + for (const relatedId of related) { ids.add(relatedId); pending.push(relatedId); } + } + return loaded.resources.filter(({ id }) => ids.has(id)).map((record) => { + const value = structuredClone(record); + if (value.type === "obligation") delete value.completionResourceIds; + // Content adoption is the cutover after this review, not a change to its approved design. + if (["policy", "document", "training"].includes(value.type) && ["approved", "active"].includes(value.status)) { + value.status = "approved"; + for (const name of ["effectiveOn", "proposedEffectiveOn", "activationBasis", "activatedByIds", "activatedOn", "activatedContentRevisions"]) delete value[name]; + } + return { record, value, includeContent: true }; + }); +} + +export function scheduleReviewerConflicts(loaded, program) { + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const controls = loaded.resources.filter((record) => record.type === "control" && program?.controlIds?.includes(record.id)); + return [...new Set([...controls, ...scheduleSetRecords(loaded, program)] + .flatMap((record) => [...currentPartyPeople(record.ownerIds || [], byId)]))].sort(); +} + +export function scheduleSetIssues(loaded, program, review, options = {}) { + if (!modelSupports(loaded.model, "schedule-set-review")) return []; + if (!review) return [{ code: "schedule-set-approval-required", resourceId: program?.id, message: "Review and independently approve the complete Program schedule set before activation." }]; + const obligations = scheduleSetRecords(loaded, program); + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const rawDecisions = review?.scheduleDecisions; + const decisions = Array.isArray(rawDecisions) ? rawDecisions.filter((value) => value && typeof value === "object" && !Array.isArray(value)) : []; + const issues = []; + const add = (id, message) => issues.push({ code: "unresolved-schedule-set", resourceId: id || program?.id, message }); + if (!Array.isArray(rawDecisions) || decisions.length !== rawDecisions.length) add(program?.id, "Schedule decisions must be an array of decision objects."); + if (review?.scopeResourceIds?.length !== 1 || review.scopeResourceIds[0] !== program?.id) add(program?.id, "Schedule-set approval must cover exactly one selected Program."); + const population = new Set(obligations.map(({ id }) => id)); + if (decisions.length !== population.size || new Set(decisions.map(({ obligationId }) => obligationId)).size !== population.size + || decisions.some(({ obligationId }) => !population.has(obligationId))) add(program?.id, "Review every linked Obligation, including active schedules, proposals, and retired duplicates, once in this Program's schedule set."); + const adopted = new Map(); + for (const decision of decisions) { + const obligation = byId.get(decision.obligationId); + if (!population.has(decision.obligationId)) continue; + if (typeof decision.rationale !== "string" || !decision.rationale.trim()) add(obligation.id, `${obligation.title} needs a schedule disposition rationale.`); + const rules = loaded.resources.filter((record) => record.type === "obligation-rule" && record.obligationId === obligation.id); + if (decision.disposition === "adopted") { + const rule = options.at ? obligationRule(obligation, byId, { now: options.at }) : byId.get(obligation.activeRuleId); + if (obligation.status !== "active" || (obligation.scheduleMode === "rule" + && (!rule || rule.obligationId !== obligation.id || !["active", "retired"].includes(rule.status) || decision.ruleId !== obligation.activeRuleId || !reviewedRuleChainContains(obligation, decision.ruleId, rule.id, byId) || !rule.approvedByIds?.length || !rule.approvedOn))) { + add(obligation.id, `${obligation.title} needs its adopted, approved rule effective at cutover.`); + } else if (rule && (rule.approvedOn > currentCalendarDate(rule.timezone || loaded.workspace?.timezone || "UTC") + || !Array.isArray(rule.approvedByIds) || rule.approvedByIds.some((id) => !personWasActiveOn(byId.get(id), rule.approvedOn)))) { + add(obligation.id, `${obligation.title} needs an actual rule approval by People active on its approval date.`); + } else adopted.set(obligation.id, obligation); + if (options.at && obligation.endsOn && obligation.endsOn < currentCalendarDate(loaded.workspace?.timezone || "UTC", new Date(options.at))) add(obligation.id, `${obligation.title} ends before the intended cutover.`); + const excluded = Array.isArray(decision.excludedRuleIds) ? decision.excludedRuleIds : []; + if (decision.excludedRuleIds !== undefined && !Array.isArray(decision.excludedRuleIds)) add(obligation.id, "Excluded rule IDs must be an array."); + if (excluded.some((id) => !rules.some((rule) => rule.id === id && ["proposed", "approved"].includes(rule.status))) + || rules.some((rule) => ["proposed", "approved"].includes(rule.status) && rule.id !== decision.ruleId && !excluded.includes(rule.id))) { + add(obligation.id, `${obligation.title} has unresolved rule proposals. Explicitly exclude unused proposals in the schedule review.`); + } + } else if (decision.disposition === "retired") { + if (obligation.status !== "retired") add(obligation.id, `${obligation.title} must be retired before it can be reviewed as a retired duplicate.`); + } else if (["replaced", "inapplicable"].includes(decision.disposition)) { + if (!["proposed", "retired"].includes(obligation.status)) add(obligation.id, `${obligation.title} is an adopted operating schedule. Retire it before replacing or excluding it.`); + } else add(obligation.id, `${obligation.title} needs an adopted, replaced, retired, or inapplicable decision.`); + } + for (const decision of decisions.filter(({ disposition }) => disposition === "replaced")) { + const original = byId.get(decision.obligationId); + const replacements = (Array.isArray(decision.replacementObligationIds) ? decision.replacementObligationIds : []).map((id) => adopted.get(id)); + if (!replacements.length || replacements.some((record) => !record) + || (original?.controlIds || []).filter((id) => program.controlIds?.includes(id)).some((id) => !replacements.some((record) => record?.controlIds?.includes(id)))) { + add(decision.obligationId, "A replacement must name adopted schedules covering the same selected Controls in this Program."); + } + if (original && !replacements.some((record) => { + if (!record) return false; + if (record.activityType === original.activityType) return true; + const requiredTypes = loaded.model.obligationActivities?.[original.activityType]?.completionResourceTypes || original.customActivity?.completionResourceTypes || []; + return record.activityType === "custom" && requiredTypes.length > 0 + && requiredTypes.every((type) => record.customActivity?.completionResourceTypes?.includes(type)); + })) add(decision.obligationId, "An adopted replacement must cover the original activity's required completion proof."); + const originalRule = byId.get(original?.activeRuleId) || rulesFor(original, loaded)[0]; + const event = originalRule?.recurrence?.eventType || original?.recurrence?.eventType; + const matching = replacements.filter((record) => { + if (!record) return false; + const rule = options.at ? obligationRule(record, byId, { now: options.at }) + : byId.get(decisions.find(({ obligationId }) => obligationId === record.id)?.ruleId); + const sameEvent = (rule?.recurrence?.eventType || record.recurrence?.eventType) === event; + const requiredTypes = loaded.model.obligationActivities?.[original?.activityType]?.completionResourceTypes || original?.customActivity?.completionResourceTypes || []; + const sameProof = record.activityType === original?.activityType || record.activityType === "custom" && requiredTypes.length > 0 && requiredTypes.every((type) => record.customActivity?.completionResourceTypes?.includes(type)); + return sameEvent && sameProof; + }); + const requiredRiskLevels = original?.eventRiskLevels?.length ? original.eventRiskLevels : loaded.model.resources.obligation.fields.eventRiskLevels.values; + if (event && requiredRiskLevels.some((level) => !matching.some((record) => !record.eventRiskLevels?.length || record.eventRiskLevels.includes(level)))) add(decision.obligationId, "Adopted replacements must cover every required event risk level for the same event and completion proof."); + if (event && !matching.length) add(decision.obligationId, `The replacement must cover the required ${event} event; otherwise record an explicit inapplicability decision.`); + } + return issues; +} + +function rulesFor(obligation, loaded) { + return loaded.resources.filter((record) => record.type === "obligation-rule" && record.obligationId === obligation?.id); +} + +function reviewedRuleChainContains(obligation, headId, targetId, byId) { + const seen = new Set(); + let rule = byId.get(headId); + while (rule?.type === "obligation-rule" && rule.obligationId === obligation.id + && ["active", "retired"].includes(rule.status) && !seen.has(rule.id)) { + seen.add(rule.id); + if (rule.id === targetId) return true; + rule = byId.get(rule.supersedesId); + } + return false; +} diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index fdf92c8..8eb5dfd 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1375,6 +1375,7 @@ function urlHost(host) { } function statusFor(error) { + if (error?.code === "FILEGRC_ACTIVATION_NOT_READY") return 400; if (error?.code === "FILEGRC_STATE_EXPIRED") return 409; if (error?.code === "FILEGRC_STATE_CAPACITY") return 429; if (error?.code === "FILEGRC_FINGERPRINT_BUDGET") return 503; diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index 7cc9557..becfdbc 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -1,3 +1,4 @@ +import { assessReportingRouteSets } from "./reporting-route-sets.js"; import { readFile } from "node:fs/promises"; import { assessAuditPreparation } from "./audit-preparation.js"; import { assessCollectionReviews } from "./collection-review.js"; @@ -368,6 +369,7 @@ function serializeCollectionReviewAssessment(assessment) { resourceType: assessment.resourceType, configuration: assessment.configuration, recordCount: assessment.recordCount, + ...(assessment.resourceType === "obligation" ? { recordIds: assessment.records.map(({ id }) => id) } : {}), review: assessment.review, reviewRevision: assessment.reviewRevision, collectionRevision: assessment.collectionRevision, @@ -430,6 +432,8 @@ function createResourceHistoryFromLoaded(loaded, type, id, options) { async function createStateEntry(loaded, entry, options) { const record = structuredClone(entry.record); + const routeAssessment = options.includeDetails && record.type === "reporting-route-set" && record.status === "proposed" + ? (await assessReportingRouteSets(loaded, { programId: record.programId })).routeSets.find((candidate) => candidate.record.id === record.id) : null; const content = {}; if (loaded.model.resources[record.type]) { for (const item of markdownEntries(loaded.model, record)) { @@ -449,6 +453,7 @@ async function createStateEntry(loaded, entry, options) { } return { record, + ...(routeAssessment ? { proposalCommit: routeAssessment.proposalCommit } : {}), relativePath: `data/${entry.relativePath}`, revision: contentRevision(entry.source), content, diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index a491da8..22a47f6 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,5 @@ +import { reportingCutoverIssues, scheduleCutoverIssues, historicalContentCutover } from "./activation-readiness.js"; +import { scheduleSetIssues, scheduleReviewerConflicts } from "./schedule-set.js"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { readFile, stat } from "node:fs/promises"; @@ -270,6 +272,21 @@ async function validateWorkspaceUnmeasured(input) { } if (modelSupports(loaded.model, "reporting-route-sets")) { validateReportingRouteSets(loaded, byId, pathById, diagnostics); + for (const issue of await reportingCutoverIssues(loaded, loaded.resources.filter((record) => ["active", "open", "monitoring"].includes(record.status)))) { + const source = byId.get(issue.sourceId); + diagnostics.push((contentCutoverChanged(loaded, source, pathById.get(source?.id)) || historicalContentCutover(loaded, source) ? error : warning)(issue.code, pathById.get(issue.sourceId), issue.message)); + } + } + if (modelSupports(loaded.model, "schedule-set-review")) { + const newCutovers = loaded.resources.filter((record) => record.status === "active" && record.effectiveOn + && ["policy", "document", "training"].includes(record.type) + && contentCutoverChanged(loaded, record, pathById.get(record.id))); + for (const issue of await scheduleCutoverIssues(loaded, newCutovers)) diagnostics.push(error(issue.code, pathById.get(issue.sourceId), issue.message)); + for (const record of loaded.resources.filter((record) => record.status === "active" && record.effectiveOn && ["policy", "document", "training"].includes(record.type) && !newCutovers.includes(record))) { + const cutover = historicalContentCutover(loaded, record); + if (!cutover) continue; + for (const issue of await scheduleCutoverIssues(cutover.snapshot, [cutover.record])) diagnostics.push(error(issue.code, pathById.get(record.id), `Committed activation lacked its required approval: ${issue.message}`)); + } } if (modelSupports(loaded.model, "temporal-collection-reviews")) { validateCollectionReviewSet(loaded.resources, pathById, diagnostics); @@ -1305,6 +1322,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) { currentRevision } ); + if (current && record.resourceType === "obligation") { + for (const issue of scheduleSetIssues(loaded, program, record)) diagnostics.push(error(issue.code, path, issue.message)); + const conflicts = scheduleReviewerConflicts(loaded, program); + if ((record.reviewedByIds || []).some((id) => conflicts.includes(id))) diagnostics.push(error("conflicted-schedule-set-reviewer", path, "Schedule-set approval requires an independent reviewer.")); + } const originalPopulationCurrent = current && revisionsMatch("collection", record.collectionRevision, currentRevision); if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) { diagnostics.push(error( @@ -2885,3 +2907,15 @@ function error(code, path, message) { function warning(code, path, message) { return { severity: "warning", code, path, message }; } + +function contentCutoverChanged(loaded, record, path) { + if (!record || !path) return true; + const head = getGitSummary(loaded.root).commit; + const source = head ? getFileBufferAtRevision(loaded.root, head, path) : null; + if (!source) return true; + try { + const prior = JSON.parse(source.toString("utf8")); + return prior.status !== record.status || prior.effectiveOn !== record.effectiveOn || prior.activatedOn !== record.activatedOn + || JSON.stringify(prior.reportingRouteRequirements || []) !== JSON.stringify(record.reportingRouteRequirements || []); + } catch { return true; } +} diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 62d859f..8cacdf0 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -1299,8 +1299,8 @@ function collectionReviewPanel(type, force = false) { if (!assessment || (!force && !collectionReviewVisible(type))) return ""; const configuration = assessment.configuration; const current = assessment.status === "current"; - const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval"); - const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null; + const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval") || type === "obligation" && modelSupports("schedule-set-review"); + const scheduleBlocker = type === "retention-schedule-item" && scheduleReview ? retentionScheduleApprovalBlocker() : null; const proposalBlocker = !current && assessment.incompleteRecordProposals?.length ? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review." : null; @@ -1531,22 +1531,32 @@ function openCollectionReviewDialog(type) { const decisions = allowedDecisions.map((decision) => ( '' )).join(""); + const scheduleRecords = (assessment.recordIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record).filter(Boolean); + const obligationReview = type === "obligation" && modelSupports("schedule-set-review"); + const scheduleDecisionsHtml = obligationReview ? '

Schedule decisions

' + scheduleRecords.map((record, index) => { + const prior = assessment.review?.scheduleDecisions?.find((decision) => decision.obligationId === record.id); + const rules = resourcesOfType("obligation-rule").filter(({ record: rule }) => rule.obligationId === record.id); + const choices = ["adopted", "replaced", "retired", "inapplicable"]; + return '
' + esc(record.title) + ' (' + esc(record.status) + ')

Review the rule, event coverage, population, owner, and deadline. ' + (record.activeRuleId ? 'Selected rule: ' + esc(record.activeRuleId) : 'No active rule.') + '

' + rules.filter(({ record: rule }) => ["proposed", "approved"].includes(rule.status)).map(({ record: rule }) => '').join('') + '
'; + }).join('') + '
' : ''; const dialog = document.createElement("dialog"); dialog.className = "commit-dialog event-dialog collection-review-dialog"; dialog.setAttribute("aria-labelledby", "collection-review-dialog-title"); const reviewerHelp = eligibleReviewerIds && !people.length ? '

No eligible reviewer is configured. Add an active Person who does not own ' + (scheduleReview ? 'the governing document or any included schedule row.' : 'these Controls or their enabled Obligations.') + '

' : ""; - const dialogKicker = scheduleReview ? "Schedule approval" : "Scope confirmation"; - const dialogTitle = scheduleReview ? "Review and approve Data Retention Schedule" : "Confirm " + configuration.title.toLowerCase(); - const checklistLabel = scheduleReview ? "Before approving" : "Before confirming"; - const saveDescription = scheduleReview + const dialogKicker = scheduleReview || obligationReview ? "Schedule approval" : "Scope confirmation"; + const dialogTitle = obligationReview ? "Review and approve Obligation schedule set" : scheduleReview ? "Review and approve Data Retention Schedule" : "Confirm " + configuration.title.toLowerCase(); + const checklistLabel = scheduleReview || obligationReview ? "Before approving" : "Before confirming"; + const saveDescription = obligationReview + ? "Approve this Program’s complete schedule set and explicit dispositions at the displayed revision. Changes to its schedules, rules, or governing design require another review before activation." + : scheduleReview ? 'Approve the governing document and ' + assessment.recordCount + ' current ' + pluralize("schedule row", assessment.recordCount) + ' as one revision. A later document, row, or material scope change requires another approval.' : 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.'; const changes = collectionReviewChangesHtml(assessment.changesSinceReview); const confirmSubmit = ''; - const submit = scheduleReview ? '' : confirmSubmit; - dialog.innerHTML = '

' + dialogKicker + '

' + esc(dialogTitle) + '

' + esc(configuration.description) + '

' + changes + '
' + checklistLabel + '
    ' + configuration.reviewPoints.map((point) => '
  • ' + esc(point) + '
  • ').join("") + '
' + reviewerHelp + '
What this saves

' + esc(saveDescription) + '

' + submit + '
'; + const submit = scheduleReview || obligationReview ? '' : confirmSubmit; + dialog.innerHTML = '

' + dialogKicker + '

' + esc(dialogTitle) + '

' + esc(configuration.description) + '

' + changes + '
' + checklistLabel + '
    ' + configuration.reviewPoints.map((point) => '
  • ' + esc(point) + '
  • ').join("") + '
' + reviewerHelp + scheduleDecisionsHtml + '
What this saves

' + esc(saveDescription) + '

' + submit + '
'; document.body.append(dialog); dialog.showModal(); const form = dialog.querySelector("form"); @@ -1579,6 +1589,13 @@ function openCollectionReviewDialog(type) { scopeRevision: state.git?.commit || undefined, [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined, expectedRevision: assessment.reviewRevision || undefined, + ...(obligationReview ? { scheduleDecisions: scheduleRecords.map((record, index) => ({ + obligationId: record.id, disposition: form.elements["scheduleDisposition" + index].value, + ...(record.activeRuleId ? { ruleId: record.activeRuleId } : {}), + replacementObligationIds: [...form.elements["scheduleReplacements" + index].selectedOptions].map((option) => option.value), + excludedRuleIds: [...form.querySelectorAll('input[name="excludeRule' + index + '"]:checked')].map((input) => input.value), + rationale: form.elements["scheduleRationale" + index].value.trim() + })) } : {}), expectedCollectionRevision: assessment.collectionRevision }; form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; }); @@ -4366,6 +4383,8 @@ function checkedFormValues(form, name) { } function openReportingRouteApproval(entry) { + const proposalCommit = entry.proposalCommit; + if (!proposalCommit) { showError("Commit the exact Reporting Channel Set proposal, then reload its review before approval."); return; } const appointments = reportingRouteAppointmentOptions(entry); const predecessor = entry.record.predecessorId ? state.resources.find(({ record }) => record.id === entry.record.predecessorId && record.type === "reporting-route-set" && record.status === "approved") @@ -4381,7 +4400,7 @@ function openReportingRouteApproval(entry) { : "Approve (commit required)"; const dialog = document.createElement("dialog"); dialog.className = "editor"; - dialog.innerHTML = '

Reporting channel approval

' + esc(approvalActionLabel) + '

Approve the exact normal and fallback channels proposed at ' + esc(state.git.shortCommit) + '. FileGRC records the actual approval and effective times; Git records when this approval is committed.

' + reportingRouteEvidenceField(entry.record.id, "evidenceIds", "Approval evidence") + '' + (predecessor ? '
Predecessor cutover

Approving this successor also cancels ' + esc(predecessor.record.title) + ' at the effective time.

' + reportingRouteEvidenceField(predecessor.record.id, "predecessorCancellationEvidenceIds", "Predecessor cancellation evidence") : "") + '
'; + dialog.innerHTML = '

Reporting channel approval

' + esc(approvalActionLabel) + '

Approve the exact normal and fallback channels proposed at ' + esc(proposalCommit.slice(0, 8)) + '. FileGRC records the actual approval and effective times; Git records when this approval is committed.

' + reportingRouteEvidenceField(entry.record.id, "evidenceIds", "Approval evidence") + '' + (predecessor ? '
Predecessor cutover

Approving this successor also cancels ' + esc(predecessor.record.title) + ' at the effective time.

' + reportingRouteEvidenceField(predecessor.record.id, "predecessorCancellationEvidenceIds", "Predecessor cancellation evidence") : "") + '
'; document.body.append(dialog); dialog.showModal(); dialog.querySelectorAll("[data-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close())); @@ -4396,21 +4415,23 @@ function openReportingRouteApproval(entry) { form.querySelector(".dialog-error").textContent = "Select fixed, verified Evidence for each approval or cancellation event."; return; } - mutateReportingRouteSet("approve", { - routeSetId: entry.record.id, - proposalCommit: state.git.commit, - approvalAppointmentId: form.elements.approvalAppointmentId.value, - evidenceIds, - approvedAt: zonedTimestampFromLocal(form.elements.approvedAt.value, state.workspace.timezone), - effectiveAt: zonedTimestampFromLocal(form.elements.effectiveAt.value, state.workspace.timezone), - timezone: state.workspace.timezone, - expectedRevision: entry.revision, - ...(predecessor ? { - predecessorCancellationAppointmentId: form.elements.predecessorCancellationAppointmentId.value, - predecessorCancellationEvidenceIds: predecessorEvidenceIds, - predecessorExpectedRevision: predecessor.revision - } : {}) - }, dialog); + try { + mutateReportingRouteSet("approve", { + routeSetId: entry.record.id, + proposalCommit, + approvalAppointmentId: form.elements.approvalAppointmentId.value, + evidenceIds, + approvedAt: zonedTimestampFromLocal(form.elements.approvedAt.value, state.workspace.timezone), + effectiveAt: zonedTimestampFromLocal(form.elements.effectiveAt.value, state.workspace.timezone), + timezone: state.workspace.timezone, + expectedRevision: entry.revision, + ...(predecessor ? { + predecessorCancellationAppointmentId: form.elements.predecessorCancellationAppointmentId.value, + predecessorCancellationEvidenceIds: predecessorEvidenceIds, + predecessorExpectedRevision: predecessor.revision + } : {}) + }, dialog); + } catch (error) { form.querySelector(".dialog-error").textContent = error.message; } }); } @@ -4432,15 +4453,17 @@ function openReportingRouteCancellation(entry) { form.querySelector(".dialog-error").textContent = "Select fixed, verified Evidence for the cancellation event."; return; } - mutateReportingRouteSet("cancel", { - routeSetId: entry.record.id, - approvalAppointmentId: form.elements.approvalAppointmentId.value, - evidenceIds, - canceledAt: zonedTimestampFromLocal(form.elements.canceledAt.value, state.workspace.timezone), - timezone: state.workspace.timezone, - reason: form.elements.reason.value, - expectedRevision: entry.revision - }, dialog); + try { + mutateReportingRouteSet("cancel", { + routeSetId: entry.record.id, + approvalAppointmentId: form.elements.approvalAppointmentId.value, + evidenceIds, + canceledAt: zonedTimestampFromLocal(form.elements.canceledAt.value, state.workspace.timezone), + timezone: state.workspace.timezone, + reason: form.elements.reason.value, + expectedRevision: entry.revision + }, dialog); + } catch (error) { form.querySelector(".dialog-error").textContent = error.message; } }); } @@ -4459,6 +4482,7 @@ function localDateTimeInput(date, timezone, midnight = false) { } function zonedTimestampFromLocal(value, timezone) { + value = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}$/.test(value) ? value + ":00" : value; const desired = new Date(value + "Z"); if (Number.isNaN(desired.getTime())) throw new Error("A valid local date and time is required."); let instant = desired; @@ -6326,6 +6350,9 @@ function formatObjectArray(items, objectType, compact = false) { }).join("") + ''; } function workQueueScheduleStatus(obligation) { + const scheduleReview = state.collectionReviews?.obligation; + const disposition = scheduleReview?.complete && scheduleReview.review?.scheduleDecisions?.find(({ obligationId }) => obligationId === obligation.id)?.disposition; + if (["replaced", "retired", "inapplicable"].includes(disposition)) return disposition; if (obligation.status === "proposed") { const pendingRules = resourcesOfType("obligation-rule") .map(({ record }) => record) @@ -6340,12 +6367,7 @@ function workQueueScheduleStatus(obligation) { if (obligation.scheduleMode === "rule" && rule?.status !== "active") { return "proposed"; } - if (rule?.effectiveAt && new Date(rule.effectiveAt) > new Date()) { - const prior = state.resources.find(({ record }) => record.id === rule.supersedesId)?.record; - if (prior?.obligationId !== obligation.id || !["active", "retired"].includes(prior.status)) { - return "waiting-schedule-effective"; - } - } + if (obligation.scheduleMode === "rule" && state.obligations?.effectiveRuleIds && !state.obligations.effectiveRuleIds[obligation.id]) return "waiting-schedule-effective"; const asOf = state.obligations?.asOf || new Date().toISOString().slice(0, 10); const policies = (obligation.policyIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record); if (policies.some((policy) => policy?.type !== "policy" || !["approved", "active"].includes(policy.status))) return "waiting-policy-approval"; @@ -6358,6 +6380,7 @@ function workQueueScheduleStatus(obligation) { } function obligationWorkQueueStatus(obligation) { const status = workQueueScheduleStatus(obligation); + if (["replaced", "retired", "inapplicable"].includes(status)) return '' + esc(properCase(status)) + 'Approved Program schedule decision'; if (status === "proposed") return 'ProposedReview schedule before activation'; if (status === "approved") return 'ApprovedActivate schedule before operation'; if (status === "running") return 'RunningEnabled and policy effective'; @@ -6737,7 +6760,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax( .collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.review-save-status{margin-right:auto;color:var(--muted);font-size:11px}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.workflow-guidance-more>summary{cursor:pointer;font-weight:750}.workflow-findings-more{margin-top:9px}.workflow-guidance-more>p{margin:8px 0 0}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.policy-lifecycle-note{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:24px;margin-top:18px}.policy-lifecycle-note h3{margin:5px 0 8px}.policy-lifecycle-note p:not(.kicker),.policy-library-proposals p{color:var(--muted);font-size:11px;line-height:1.55}.policy-library-proposals{display:grid;gap:8px}.policy-library-proposals article{padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.policy-library-proposals p{margin:5px 0 8px}.policy-activation{display:grid;gap:12px;margin-top:22px}.policy-activation-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.policy-activation-card{padding:17px 18px;border:1px solid #d8bd78;border-radius:10px;background:var(--panel)}.policy-activation-card.active-and-operating{border-color:#b9dac6}.policy-activation-card h3{margin:7px 0 0}.policy-activation-gaps{display:grid;gap:9px;margin-top:13px}.policy-activation-gaps>div{padding-top:8px;border-top:1px solid var(--line)}.policy-activation-gaps small{display:block;margin-bottom:6px;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.policy-activation-warning{margin:11px 0 0;padding:9px 10px;border-radius:7px;background:#f7e9cf;color:#6d4917;font-size:10.4px;line-height:1.45}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end} .stage-page-card-actions{display:flex;align-items:center;gap:10px}.stage-page-card-actions .stage-page-open{white-space:nowrap}.stage-page-open::after{content:"";position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-open:focus-visible::after{outline:2px solid var(--focus);outline-offset:2px}.stage-page-open>span{position:relative;z-index:2} .workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px} -.collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px} +.schedule-decisions{display:grid;gap:14px;margin:18px 0}.schedule-decisions fieldset{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:8px}.schedule-decisions legend{font-weight:700;font-size:13px}.schedule-decisions label{display:grid;gap:5px}.schedule-decisions select,.schedule-decisions textarea{width:100%}.schedule-decisions p{font-size:11px;color:var(--muted);margin:0}.collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px} .collection-review-change-list{margin:14px 0;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-change-list>strong{font-size:11px}.collection-review-change-list p{margin:7px 0 0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-change-list ul{max-height:180px;overflow:auto;margin:9px 0 0;padding-left:20px;font-size:11px;line-height:1.55}.collection-review-change-list li+li{margin-top:5px}.collection-review-change-list li span{color:var(--muted)} .collection-review-current-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.collection-review-panel.current .collection-review-details{flex:0 0 auto;margin:0;padding:0;border:0;background:transparent}.collection-review-panel.current .collection-review-details>summary{color:var(--accent);white-space:nowrap}.collection-review-panel.current .collection-review-details[open]{flex-basis:100%;order:3;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel.current .collection-review-details[open]>summary{margin-bottom:10px}.collection-review-panel.current .collection-review-details[open] .collection-review-detail-content{margin-top:0}.collection-review-panel.current .button{margin-left:auto} @media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}} diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 02d2e89..dce8962 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -225,11 +225,10 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { } } } - if ( - Array.isArray(first.record.reportingRouteRequirements) - && first.record.reportingRouteRequirements.length - && !sameJson(first.record.effectiveOn, entry.record.effectiveOn) - ) { + const firstEffectiveRequirement = protectedVersions.find(({ record }) => ( + Array.isArray(record.reportingRouteRequirements) && record.reportingRouteRequirements.length && record.effectiveOn + )); + if (firstEffectiveRequirement && !sameJson(firstEffectiveRequirement.record.effectiveOn, entry.record.effectiveOn)) { diagnostics.push(integrityError( entry, `${entry.record.type} "${entry.record.id}" changed the effective date governing its structured Reporting Route requirements. Preserve the original interval and create a successor decision.` diff --git a/packages/filegrc/test/fixtures.js b/packages/filegrc/test/fixtures.js index 3e8b0a2..692ed5c 100644 --- a/packages/filegrc/test/fixtures.js +++ b/packages/filegrc/test/fixtures.js @@ -104,7 +104,8 @@ const STATUS_OVERRIDES = { "audit-request": "submitted", }; -export async function makeComprehensiveWorkspace(root, version) { +// The compatibility baseline contains already operating records. New cutover tests select v12 explicitly. +export async function makeComprehensiveWorkspace(root, version = "11") { const key = version == null ? "active" : String(version); let fixturePromise = comprehensiveFixtures.get(key); if (!fixturePromise) { diff --git a/packages/filegrc/test/helpers.js b/packages/filegrc/test/helpers.js index 819843f..c51c6d9 100644 --- a/packages/filegrc/test/helpers.js +++ b/packages/filegrc/test/helpers.js @@ -273,3 +273,13 @@ export async function executeCli(runCli, executable, args, options = {}) { } return captured; } + +// Preserve a pre-v12 activation history when testing migration and later cutovers. +export async function initializeExistingModel12Workspace(root) { + const path = join(root, "data", "workspace.json"); + const workspace = JSON.parse(await readFile(path, "utf8")); + await writeJson(path, { ...workspace, dataModelVersion: "11" }); + await initializeGitWorkspace(root, { message: "Record existing model v11 operation" }); + await writeJson(path, workspace); + await commitWorkspaceFiles(root, "Select model v12 for future cutovers"); +} diff --git a/packages/filegrc/test/model-migration.test.js b/packages/filegrc/test/model-migration.test.js index 91d7143..7056290 100644 --- a/packages/filegrc/test/model-migration.test.js +++ b/packages/filegrc/test/model-migration.test.js @@ -752,7 +752,7 @@ test("requires an explicit v5 workflow scope for a Document used by both the pro test("keeps current migration help aligned with all supported model versions", async () => { const { stdout } = await execute(process.execPath, [cli, "migrate", "--help"]); - assert.match(stdout, /--to-model <2\|3\|4\|5\|6\|7\|8\|9\|10\|11>/); + assert.match(stdout, /--to-model <2\|3\|4\|5\|6\|7\|8\|9\|10\|11\|12>/); assert.match(stdout, /documentScopes/); assert.match(stdout, /model v5/i); }); diff --git a/packages/filegrc/test/model.test.js b/packages/filegrc/test/model.test.js index a952c10..41e1538 100644 --- a/packages/filegrc/test/model.test.js +++ b/packages/filegrc/test/model.test.js @@ -13,7 +13,8 @@ import { test("active model exposes the complete resource registry", () => { const model = loadModel(); - assert.equal(model.modelVersion, "11"); + assert.equal(model.modelVersion, "12"); + assert.ok(model.resources.appointment.fields.scopeResourceIds.relation.includes("program")); assert.equal(PROGRAM_PATH.length, 5); assert.equal(model.policyEvents["person-started"].title, "New Worker"); assert.deepEqual(model.policyEvents["person-started"].subjectRules, [ @@ -38,13 +39,13 @@ test("active model exposes the complete resource registry", () => { "rationale" ]); assert.ok(model.relationGroups["event-subject"].includes("exception")); - assert.deepEqual(Object.keys(model.collectionReviews), ["person", "framework", "vendor", "system", "complementary-control", "component", "classification", "information-type", "retention-schedule-item", "control"]); + assert.deepEqual(Object.keys(model.collectionReviews), ["person", "framework", "vendor", "system", "complementary-control", "component", "classification", "information-type", "retention-schedule-item", "control", "obligation"]); assert.equal(model.resources["collection-review"].fields.resourceType.registry, "collectionReviews"); assert.deepEqual( model.resources["collection-review"].fields.populationResourceIds.relation, Object.keys(model.collectionReviews) ); - for (const type of ["person", "framework", "vendor", "system", "complementary-control", "component", "classification", "information-type", "retention-schedule-item", "control"]) { + for (const type of ["person", "framework", "vendor", "system", "complementary-control", "component", "classification", "information-type", "retention-schedule-item", "control", "obligation"]) { assert.ok(model.collectionReviews[type].description.length >= 60); assert.ok(model.collectionReviews[type].reviewPoints.length >= 2); assert.ok(model.collectionReviews[type].reviewPoints.length <= 3); @@ -341,7 +342,7 @@ test("active model exposes the complete resource registry", () => { "authoritativeBranch", "repositoryRemote" ]); - assert.equal(model.resources.workspace.fields.dataModelVersion.const, "11"); + assert.equal(model.resources.workspace.fields.dataModelVersion.const, "12"); assert.ok(model.resources["source-coverage"]); assert.ok(model.resources["control-activity"]); assert.equal(model.obligationActivities["inventory-review"].completionType, "control-activity"); @@ -430,7 +431,9 @@ test("published model files remain byte-for-byte frozen", async () => { 6: "d5ed8888b4526d9866c2355f3120c8bbd4e5ba18549b5d5280a6e33cdf9fdd2f", 7: "ba3342bd08d62c163bdaac497af6377a2ad715ef30bf4f929ea3b6b59142dc78", 8: "4513bf03b8b8040af87cf5a85d28267706c526f2da1d6d570d44b3ff82578df2", - 9: "9af966f140331e25e094950a1705082c978b7898e70071eb30e64d88788dcd08" + 9: "9af966f140331e25e094950a1705082c978b7898e70071eb30e64d88788dcd08", + 10: "8f8d28a4e32b98c39196456ccc3fa0082a9b9a0ce4f6d30b942184d2eff5d47b", + 11: "3155cc9e5e470f35a44450a567197c294e45cbefee06c412f00da7ee9e3ebf61" }; for (const [version, digest] of Object.entries(expected)) { const source = await readFile(new URL(`../model/v${version}.json`, import.meta.url)); diff --git a/packages/filegrc/test/program-readiness.test.js b/packages/filegrc/test/program-readiness.test.js index 64b2821..70fe8b0 100644 --- a/packages/filegrc/test/program-readiness.test.js +++ b/packages/filegrc/test/program-readiness.test.js @@ -30,12 +30,13 @@ import { createResource, createResources, loadWorkspace, + scaffoldCollectionReview, scaffoldPolicyActivation, serveWorkspace, updateResource, validateWorkspace } from "../src/index.js"; -import { commitWorkspaceFiles, executeCli, initializeGitWorkspace, makeWorkspace } from "./helpers.js"; +import { commitWorkspaceFiles, executeCli, initializeExistingModel12Workspace, initializeGitWorkspace, makeWorkspace } from "./helpers.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; import { baselineRecordFiles } from "../../create-filegrc/src/defaults.js"; import { resolveWorkflowDestination } from "../src/web.js"; @@ -604,6 +605,31 @@ test("rolls collection proposals into one preparation unit and one review unit", assert.deepEqual(existingReview.destination, { kind: "collection-review", resourceType: "retention-schedule-item" }); }); +test("requires committed independent channel approval before completing proposed scope", () => { + const base = { + requirements: [], + proposedRequirements: [{ purposeKey: "security-reporting", sourceId: "policy-security" }], + issues: [] + }; + const route = { + record: { id: "channels", type: "reporting-route-set", status: "proposed", purposeKey: "security-reporting" }, + committed: true, + effective: false, + canceled: false, + proposedRequirementIssues: [], + issues: [] + }; + const proposed = reportingRouteSetItem({ ...base, routeSets: [route] }); + assert.equal(proposed.status, "action"); + assert.deepEqual(proposed.progressUnits.map(({ status }) => status), ["complete", "action"]); + const approved = { ...route, record: { ...route.record, status: "approved" } }; + assert.equal(reportingRouteSetItem({ ...base, routeSets: [approved] }).status, "complete"); + assert.equal(reportingRouteSetItem({ ...base, routeSets: [{ ...approved, committed: false }] }).status, "action"); + assert.equal(reportingRouteSetItem({ ...base, routeSets: [{ + ...approved, issues: [{ code: "changed-reporting-route-proposal", message: "Approval is stale." }] + }] }).status, "action"); +}); + test("does not count a canceled optional Reporting Channel Set as setup work", () => { const item = reportingRouteSetItem({ routeSets: [{ @@ -967,10 +993,10 @@ test("gives a planned Control plain Step 3 actions in readiness and workflow", a assert.deepEqual(getFinding.nextSteps, persistedItem.nextSteps); }); -test("moves a fresh model v11 workspace from Control launch through cutover to its first event", async (context) => { +for (const modelVersion of ["11", "12"]) test(`moves a fresh model v${modelVersion} workspace from Control launch through cutover to its first event`, async (context) => { const root = await mkdtemp(join(tmpdir(), "filegrc-first-operating-event-")); context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); - await makeComprehensiveWorkspace(root, "11"); + await makeComprehensiveWorkspace(root, modelVersion); let loaded = await loadWorkspace(root); const today = currentCalendarDate(loaded.workspace.timezone); const controlEntry = loaded.entries.find(({ record }) => record.id === "control-example"); @@ -1016,11 +1042,12 @@ test("moves a fresh model v11 workspace from Control launch through cutover to i obligationId: "obligation-training-launch", recurrence: { mode: "event", eventType: "person-started" } }, null, 2)}\n`); - await initializeGitWorkspace(root); + if (modelVersion === "12") await initializeExistingModel12Workspace(root); + else await initializeGitWorkspace(root); const before = await assessProgramReadiness(root, { asOf: today }); const beforeControls = before.stages.find(({ id }) => id === "controls").items; - assert.equal(beforeControls.find(({ id }) => id === "control-control-example").implementationState, "ready-to-implement"); + assert.equal(beforeControls.find(({ id }) => id === "control-control-example").implementationState, modelVersion === "12" ? "planned" : "ready-to-implement"); assert.equal(beforeControls.find(({ id }) => id === "source-family-control-sec").status, "complete"); await assert.rejects(createObligationEvent(root, { eventType: "person-role-changed", occurredOn: today, subjectResourceIds: ["person-example"] @@ -1037,6 +1064,16 @@ test("moves a fresh model v11 workspace from Control launch through cutover to i reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true }); await commitWorkspaceFiles(root, "Review customer responsibilities"); + if (modelVersion === "12") { + const beforeScheduleReview = await assessProgramReadiness(root); + assert.equal(beforeScheduleReview.stages.find(({ id }) => id === "controls").items.find(({ id }) => id === "control-control-example").checks.scheduleSetReviewed, false); + const scheduleScaffold = await scaffoldCollectionReview(root, { resourceType: "obligation", programId: program.id }); + await applyCollectionReview(root, { ...scheduleScaffold, + scheduleDecisions: scheduleScaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Required operation and training coverage." })), + rationale: "Reviewed the complete schedule set before cutover.", reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true + }); + await commitWorkspaceFiles(root, "Approve the exact schedule set"); + } await applyCollectionReview(root, { resourceType: "control", programId: program.id, decision: "complete", rationale: "Reviewed the implemented Control and its launch conditions.", diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index c9a4ffc..d4022c3 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -2071,8 +2071,10 @@ test("obligation schedule labels distinguish proposal, approval, and policy cuto obligation.status = "active"; obligation.ruleIds = [rule.id]; obligation.activeRuleId = rule.id; + state.obligations.effectiveRuleIds = { [obligation.id]: null }; assert.match(label(), /Waiting for schedule start/); rule.effectiveAt = "2000-01-01T00:00:00Z"; + state.obligations.effectiveRuleIds[obligation.id] = rule.id; assert.match(label(), /Waiting for policy activation/); assert.doesNotMatch(label(), /Waiting for policy approval/); policy.status = "active"; @@ -2945,3 +2947,12 @@ test("Step 3 combines activation into one box while preserving unavailable conte state.resources = []; assert.match(context.renderProgramActivation(), /data-review-program-activation disabled/); }); + +test("reporting approval accepts minute-precision local inputs and keeps DST checks", () => { + const start = APP_SCRIPT.indexOf("function localDateTimeInput("); + const end = APP_SCRIPT.indexOf("function resourceGuide(", start); + const convert = vm.runInNewContext(APP_SCRIPT.slice(start, end) + "\nzonedTimestampFromLocal", { Date, Intl }); + assert.equal(convert("2026-10-13T00:00", "America/Chicago"), "2026-10-13T00:00:00-05:00"); + assert.equal(convert("2026-10-12T12:04:32", "America/Chicago"), "2026-10-12T12:04:32-05:00"); + assert.throws(() => convert("2026-03-08T02:30", "America/Chicago"), /does not exist/); +}); diff --git a/packages/filegrc/test/reporting-route-sets.test.js b/packages/filegrc/test/reporting-route-sets.test.js index a1f5a93..0c8f148 100644 --- a/packages/filegrc/test/reporting-route-sets.test.js +++ b/packages/filegrc/test/reporting-route-sets.test.js @@ -1,3 +1,5 @@ +import { loadModel } from "../model/index.js"; +import { reportingCutoverIssues, requireReportingCutover } from "../src/activation-readiness.js"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -236,7 +238,7 @@ test("requires a real committed Reporting Channel Set proposal in Step 1", async .find(({ id }) => id === "security-reporting-route-set"); assert.equal(item.status, "action"); assert.deepEqual(item.unpreparedPurposeKeys, ["security-reporting"]); - assert.deepEqual(item.progressUnits.map(({ status }) => status), ["action", "later"]); + assert.deepEqual(item.progressUnits.map(({ status }) => status), ["action", "action"]); await assert.rejects(() => proposeReportingRouteSet(root, { routeSetId: route.id }), /real fallback reporting destination/); await writeRecord(root, "reporting-route-sets", { ...route, status: "proposed" }); @@ -446,11 +448,17 @@ test("requires a real committed Reporting Channel Set proposal in Step 1", async readiness = await assessProgramReadiness(root, { asOf: "2026-08-31" }); item = readiness.stages.find(({ id }) => id === "scope").items .find(({ id }) => id === "security-reporting-route-set"); - assert.equal(item.status, "complete"); - assert.deepEqual(item.unpreparedPurposeKeys, []); - assert.deepEqual(item.progressUnits.map(({ status }) => status), ["complete", "later"]); - assert.match(item.message, /committed Reporting Channel Set proposal/); + assert.equal(item.status, "action"); + assert.deepEqual(item.unpreparedPurposeKeys, ["security-reporting"]); + assert.deepEqual(item.progressUnits.map(({ status }) => status), ["complete", "action"]); + assert.match(item.message, /independently approve the exact committed/); + const originalProposalCommit = (await git(root, "rev-parse", "HEAD")).trim(); + await writeFile(join(root, "review-note.txt"), "Generic approval review note.\n"); + await git(root, "add", "review-note.txt"); + await gitCommitAt(root, "2026-08-30T13:00:00Z", "Record an unrelated review note"); + const proposalAssessment = await assessReportingRouteSets(root, { programId: program.id, at: "2026-08-31T00:00:00Z" }); + assert.equal(proposalAssessment.routeSets.find(({ record }) => record.id === route.id).proposalCommit, originalProposalCommit); const committedProposal = (await loadWorkspace(root)).resources.find(({ id }) => id === route.id); await writeRecord(root, "reporting-route-sets", { ...committedProposal, title: "Future-dated proposal commit" }); await git(root, "add", `data/reporting-route-sets/${route.id}.json`); @@ -505,7 +513,7 @@ test("requires a new proposal when proposed requirements outgrow a committed rou let readiness = await assessProgramReadiness(root, { asOf: "2026-08-31" }); let item = readiness.stages.find(({ id }) => id === "scope").items .find(({ id }) => id === "security-reporting-route-set"); - assert.equal(item.status, "complete"); + assert.equal(item.status, "action"); await writeRecord(root, "policies", { ...policy, @@ -885,6 +893,26 @@ test("commits an exact Route Set proposal before managed approval becomes effect await writeFile(approvalEvidenceAttachment, "Retained reporting-route decision record.\n"); }); +test("cutover requires the exact committed approval effective at the intended date", async (context) => { + const { root } = await cloneApprovedRouteWorkspace(context, "filegrc-route-cutover-"); + const loaded = await loadWorkspace(root); + loaded.model = loadModel("12"); + const policy = loaded.resources.find(({ id }) => id === "policy-example"); + const route = loaded.resources.find(({ type, status }) => type === "reporting-route-set" && status === "approved"); + const update = { ...policy, status: "active", effectiveOn: "2021-09-01" }; + assert.deepEqual(await reportingCutoverIssues(loaded, [update]), []); + const missing = { ...loaded, resources: loaded.resources.filter(({ id }) => id !== route.id) }; + assert.ok((await reportingCutoverIssues(missing, [update])).some(({ code }) => code === "uncovered-reporting-route-interval")); + const future = { ...loaded, resources: loaded.resources.map((record) => record.id === route.id ? { + ...record, approval: { ...record.approval, effectiveAt: "2099-01-01T00:00:00Z" } + } : record) }; + assert.ok((await reportingCutoverIssues(future, [update])).length); + const stale = { ...loaded, resources: loaded.resources.map((record) => record.id === route.id ? { + ...record, primaryLane: { ...record.primaryLane, destination: "changed@example.test" } + } : record) }; + assert.ok((await reportingCutoverIssues(stale, [update])).some(({ code }) => code === "changed-reporting-route-proposal")); +}); + test("rejects unsupported backfilled route decisions", async (context) => { const { root, byId, route } = await cloneApprovedRouteWorkspace(context, "filegrc-route-decisions-"); await git(root, "switch", "-c", "late-route-requirement"); @@ -1834,6 +1862,33 @@ async function git(root, ...args) { return (await execute("git", args, { cwd: root })).stdout; } +test("approved reporting requirements accept their first effective date and preserve it afterward", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-first-reporting-effective-")); + context.after(() => rm(root, { recursive: true, force: true })); + const { records } = await makeComprehensiveWorkspace(root); + const policy = records.find(({ id }) => id === "policy-example"); + policy.status = "approved"; + delete policy.effectiveOn; + policy.reportingRouteRequirements = [{ purposeKey: "security-reporting", programScope: "all-programs", requiredLanes: ["primary"], effectiveAt: "2020-01-01T00:00:00Z", timezone: "UTC" }]; + await writeRecord(root, "policies", policy); + await git(root, "init", "--initial-branch=main"); + await git(root, "config", "user.name", "Test User"); + await git(root, "config", "user.email", "test@example.test"); + await git(root, "add", "."); + await git(root, "commit", "-m", "Approve reporting requirements before cutover"); + const approvedWorkspace = await loadWorkspace(root); + const active = { ...policy, status: "active", effectiveOn: "2026-12-01" }; + await writeRecord(root, "policies", active); + let result = await validateWorkspace(root); + assert.equal(result.diagnostics.some(({ message }) => /changed the effective date/.test(message)), false); + await assert.rejects(requireReportingCutover({ ...approvedWorkspace, model: loadModel("12") }, [active]), { code: "FILEGRC_ACTIVATION_NOT_READY" }); + await git(root, "add", "."); + await git(root, "commit", "-m", "Record original effective interval"); + await writeRecord(root, "policies", { ...active, effectiveOn: "2026-12-02" }); + result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ message }) => /changed the effective date/.test(message))); +}); + async function gitCommitAt(root, date, message) { return (await execute("git", ["commit", "-m", message], { cwd: root, diff --git a/packages/filegrc/test/schedule-set.test.js b/packages/filegrc/test/schedule-set.test.js new file mode 100644 index 0000000..db985e2 --- /dev/null +++ b/packages/filegrc/test/schedule-set.test.js @@ -0,0 +1,224 @@ +import { scheduleCutoverIssues } from "../src/activation-readiness.js"; +import { validateWorkspace } from "../src/validate.js"; +import { migrateModel, planModelMigration } from "../src/model-migration.js"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { scheduleSetIssues, scheduleSetRecords } from "../src/schedule-set.js"; +import { collectionRevision } from "../src/collection-revision.js"; +const scheduleSetRevision = (loaded, program) => collectionRevision(loaded, "obligation", { program }); +import { obligationRule } from "../src/obligation-rule.js"; +import { assessCollectionReview, scaffoldCollectionReview, planCollectionReview, applyCollectionReview } from "../src/collection-review.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { initializeGitWorkspace, initializeExistingModel12Workspace, commitWorkspaceFiles, writeJson, executeCli } from "./helpers.js"; +import { runCli } from "../src/cli.js"; +import { currentCalendarDate } from "../src/time.js"; +import { fileURLToPath } from "node:url"; + +function fixture() { + const program = { id: "program-a", type: "program", controlIds: ["control-a"], systemIds: [] }; + const records = [program, { id: "owner", type: "person", status: "active" }, { id: "control-a", type: "control", ownerIds: ["owner"] }, + { id: "calendar", type: "obligation", title: "Calendar review", activityType: "access-review", status: "active", scheduleMode: "rule", activeRuleId: "rule-calendar", controlIds: ["control-a"] }, + { id: "event", type: "obligation", title: "Incident work", activityType: "access-review", status: "proposed", scheduleMode: "rule", ruleIds: ["rule-event"], controlIds: ["control-a"] }, + { id: "rule-calendar", type: "obligation-rule", obligationId: "calendar", status: "active", effectiveAt: "2020-01-01T00:00:00Z", approvedOn: "2020-01-01", approvedByIds: ["owner"], recurrence: { mode: "calendar" } }, + { id: "rule-event", type: "obligation-rule", obligationId: "event", status: "proposed", recurrence: { mode: "event", eventType: "incident-declared" } } + ]; + return { loaded: { resources: records, model: loadModel("12"), root: null }, program }; +} +const adopted = { obligationId: "calendar", disposition: "adopted", ruleId: "rule-calendar", rationale: "Required periodic review." }; +const exclusion = { obligationId: "event", disposition: "inapplicable", rationale: "This proposal duplicates the separately reviewed incident checklist." }; + +test("one adopted rule cannot hide unresolved required event coverage", () => { + const { loaded, program } = fixture(); + assert.ok(scheduleSetIssues(loaded, program, null).length); + assert.ok(scheduleSetIssues(loaded, program, { scopeResourceIds: [program.id], scheduleDecisions: [adopted] }).length); + assert.deepEqual(scheduleSetIssues(loaded, program, { scopeResourceIds: [program.id], scheduleDecisions: [adopted, exclusion] }), []); +}); + +test("reviews active rule alternatives, replacements, retired duplicates, and Program scope", () => { + const { loaded, program } = fixture(); + loaded.resources.push({ id: "rule-alternative", type: "obligation-rule", obligationId: "calendar", status: "proposed" }); + const review = { scopeResourceIds: [program.id], scheduleDecisions: [adopted, exclusion] }; + assert.match(scheduleSetIssues(loaded, program, review)[0].message, /unresolved rule proposals/); + review.scheduleDecisions[0] = { ...adopted, excludedRuleIds: ["rule-alternative"] }; + assert.deepEqual(scheduleSetIssues(loaded, program, review), []); + review.scheduleDecisions[1] = { ...exclusion, disposition: "replaced", replacementObligationIds: ["calendar"] }; + assert.ok(scheduleSetIssues(loaded, program, review).some(({ message }) => /required incident-declared event/.test(message))); + const rule = loaded.resources.find(({ id }) => id === "rule-calendar"); + rule.recurrence = { mode: "event", eventType: "incident-declared" }; + assert.deepEqual(scheduleSetIssues(loaded, program, review), []); + const event = loaded.resources.find(({ id }) => id === "event"); + event.status = "active"; + assert.match(scheduleSetIssues(loaded, program, review)[0].message, /adopted operating schedule/); + event.status = "retired"; + review.scheduleDecisions[1] = { ...exclusion, disposition: "retired" }; + assert.deepEqual(scheduleSetIssues(loaded, program, review), []); + loaded.resources.push({ id: "other", type: "obligation", controlIds: ["control-b"], status: "proposed" }); + assert.deepEqual(scheduleSetRecords(loaded, program).map(({ id }) => id), ["calendar", "event"]); + assert.ok(scheduleSetIssues(loaded, program, { ...review, scopeResourceIds: ["program-b"] }).length); +}); + +test("selects the historically effective rule across multiple cutovers", () => { + const obligation = { id: "schedule", scheduleMode: "rule", activeRuleId: "rule-3" }; + const byId = new Map([1, 2, 3].map((number) => [`rule-${number}`, { id: `rule-${number}`, type: "obligation-rule", obligationId: "schedule", status: number === 3 ? "active" : "retired", effectiveAt: `2026-0${number}-01T00:00:00Z`, supersedesId: number > 1 ? `rule-${number - 1}` : undefined }])); + assert.equal(obligationRule(obligation, byId, { now: "2026-01-15T00:00:00Z" }).id, "rule-1"); + assert.equal(obligationRule(obligation, byId, { now: "2026-02-01T00:00:00Z" }).id, "rule-2"); + assert.equal(obligationRule(obligation, byId, { now: "2025-12-31T23:59:59Z" }), null); + byId.get("rule-1").supersedesId = "rule-3"; + assert.equal(obligationRule(obligation, byId, { now: "2025-12-31T23:59:59Z" }), null); +}); + +test("schedule-set review is exact, independent, and available through the CLI", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-review-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "12"); + await initializeExistingModel12Workspace(root); + let loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "obligation", programId: program.id }); + assert.equal(scaffold.scheduleDecisions.length, scheduleSetRecords(loaded, program).length); + const cli = fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)); + const output = await executeCli(runCli, process.execPath, [cli, "review-collection", "obligation", "--program", program.id, "--scaffold", "--root", root]); + assert.deepEqual(JSON.parse(output.stdout), scaffold); + const payload = { ...scaffold, reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate("UTC"), rationale: "Reviewed the complete schedule set.", scheduleDecisions: scaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Required for the selected Control." })) }; + await assert.rejects(planCollectionReview(root, { ...payload, reviewedByIds: ["person-example"] }), /independent/); + const plan = await planCollectionReview(root, payload); + assert.deepEqual(plan.changes.create[0].scheduleDecisions, payload.scheduleDecisions); + await applyCollectionReview(root, { ...payload, confirmed: true }); + loaded = await loadWorkspace(root); + assert.equal(assessCollectionReview(loaded, "obligation", { programId: program.id }).complete, true); + const assessment = assessCollectionReview(loaded, "obligation", { programId: program.id }); + const incorrectPopulation = { ...loaded, resources: loaded.resources.map((record) => record.id === assessment.review.id ? { ...record, populationResourceIds: [] } : record), entries: loaded.entries.map((entry) => entry.record.id === assessment.review.id ? { ...entry, record: { ...entry.record, populationResourceIds: [] } } : entry) }; + assert.equal(assessCollectionReview(incorrectPopulation, "obligation", { programId: program.id }).complete, false); + const reviewEntry = loaded.entries.find(({ record }) => record.id === assessment.review.id); + for (const scheduleDecisions of [null, "invalid", [null], [{ ...payload.scheduleDecisions[0], rationale: 3 }]]) { + await writeJson(reviewEntry.path, { ...reviewEntry.record, scheduleDecisions }); + const invalid = await validateWorkspace(root); + assert.equal(invalid.ok, false); + assert.ok(invalid.diagnostics.some(({ code }) => ["invalid-field", "unresolved-schedule-set"].includes(code))); + } + await writeFile(reviewEntry.path, reviewEntry.source); + const policy = loaded.resources.find(({ type }) => type === "policy"); + const backdated = { ...policy, status: "active", effectiveOn: "2020-01-01" }; + assert.ok((await scheduleCutoverIssues(loaded, [backdated])).some(({ code }) => code === "schedule-set-approval-after-cutover")); + const originalRevision = scheduleSetRevision(loaded, program); + const operationOnly = { ...loaded, resources: loaded.resources.map((record) => record.type === "obligation" ? { ...record, completionResourceIds: ["new-operating-proof"] } : record) }; + assert.equal(scheduleSetRevision(operationOnly, program), originalRevision); + const schedule = loaded.entries.find(({ record }) => record.type === "obligation"); + await writeJson(join(root, "data", schedule.relativePath), { ...schedule.record, ownerIds: ["person-independent-approver-example"] }); + loaded = await loadWorkspace(root); + assert.notEqual(scheduleSetRevision(loaded, program), originalRevision); + assert.equal(assessCollectionReview(loaded, "obligation", { programId: program.id }).complete, false); + await assert.rejects(planCollectionReview(root, payload), /changed after it was displayed/); +}); + +test("v12 migration preserves operating records and leaves schedule decisions pending", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-v12-migration-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + await initializeGitWorkspace(root); + const before = await loadWorkspace(root); + const preview = await planModelMigration(root, { targetModelVersion: "12" }); + assert.equal(preview.ready, true); + assert.equal(preview.fileDiff.update.length, 1); + assert.equal(preview.classifications.reviewRequired.length, 1); + assert.deepEqual(preview.fileDiff.create, []); + await migrateModel(root, { targetModelVersion: "12" }); + const after = await loadWorkspace(root); + assert.equal(after.workspace.dataModelVersion, "12"); + assert.deepEqual(after.resources.filter(({ type }) => type !== "workspace"), before.resources.filter(({ type }) => type !== "workspace")); + assert.equal(assessCollectionReview(after, "obligation").complete, false); + assert.equal((await planModelMigration(root, { targetModelVersion: "12" })).summary.update, 0); +}); + +test("unrelated event replacements cannot supply missing risk coverage", () => { + const { loaded, program } = fixture(); + const event = loaded.resources.find(({ id }) => id === "event"); + event.eventRiskLevels = ["normal", "high"]; + const calendar = loaded.resources.find(({ id }) => id === "calendar"); + calendar.eventRiskLevels = ["high"]; + loaded.resources.find(({ id }) => id === "rule-calendar").recurrence = { mode: "event", eventType: "incident-declared" }; + loaded.resources.push({ ...calendar, id: "other-event", eventRiskLevels: ["normal"], activeRuleId: "rule-other" }, { ...loaded.resources.find(({ id }) => id === "rule-calendar"), id: "rule-other", obligationId: "other-event", recurrence: { mode: "event", eventType: "person-started" } }); + const review = { scopeResourceIds: [program.id], scheduleDecisions: [adopted, { ...adopted, obligationId: "other-event", ruleId: "rule-other" }, { ...exclusion, disposition: "replaced", replacementObligationIds: ["calendar", "other-event"] }] }; + assert.ok(scheduleSetIssues(loaded, program, review).some(({ message }) => /same event and completion proof/.test(message))); +}); + +test("malformed schedule decisions produce diagnostics without throwing", () => { + const { loaded, program } = fixture(); + for (const scheduleDecisions of [null, "invalid", [null], [{ ...adopted, rationale: 7 }, exclusion], [{ ...adopted, excludedRuleIds: "wrong" }, exclusion]]) { + assert.ok(scheduleSetIssues(loaded, program, { scopeResourceIds: [program.id], scheduleDecisions }).length); + } +}); + +test("committing a direct-file activation cannot bypass schedule approval", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-v12-direct-cutover-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "12"); + await initializeExistingModel12Workspace(root); + let loaded = await loadWorkspace(root); + const policyEntry = loaded.entries.find(({ record }) => record.type === "policy"); + const approved = { ...policyEntry.record, status: "approved" }; + delete approved.effectiveOn; + await writeJson(policyEntry.path, approved); + await commitWorkspaceFiles(root, "Prepare a new Policy cutover"); + await writeJson(policyEntry.path, { ...approved, status: "active", effectiveOn: currentCalendarDate(loaded.workspace.timezone) }); + let result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ code, severity }) => code === "schedule-set-approval-required" && severity === "error")); + await commitWorkspaceFiles(root, "Record an unapproved direct-file cutover"); + result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ code, severity }) => code === "schedule-set-approval-required" && severity === "error")); +}); + +test("direct and committed backdated cutovers reject later schedule approval", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-v12-backdated-cutover-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "12"); + await initializeExistingModel12Workspace(root); + let loaded = await loadWorkspace(root); + const policyEntry = loaded.entries.find(({ record }) => record.type === "policy"); + const approved = { ...policyEntry.record, status: "approved" }; + delete approved.effectiveOn; + await writeJson(policyEntry.path, approved); + await commitWorkspaceFiles(root, "Prepare reviewed program content"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "obligation" }); + await applyCollectionReview(root, { ...scaffold, rationale: "Reviewed exact schedule set.", reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate(loaded.workspace.timezone), scheduleDecisions: scaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Required work." })), confirmed: true }); + await commitWorkspaceFiles(root, "Approve the schedule set"); + await writeJson(policyEntry.path, { ...approved, status: "active", effectiveOn: "2020-01-01" }); + let result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ code }) => code === "schedule-set-approval-after-cutover")); + await commitWorkspaceFiles(root, "Record an invalid earlier cutover"); + result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ code }) => code === "schedule-set-approval-after-cutover")); +}); + +test("approved staged successors preserve predecessor coverage at earlier cutover", () => { + const { loaded, program } = fixture(); + const obligation = loaded.resources.find(({ id }) => id === "calendar"); + const predecessor = loaded.resources.find(({ id }) => id === "rule-calendar"); + predecessor.status = "retired"; + loaded.resources.push({ ...predecessor, id: "rule-successor", status: "active", supersedesId: predecessor.id, effectiveAt: "2099-12-01T00:00:00Z" }); + obligation.activeRuleId = "rule-successor"; + const review = { scopeResourceIds: [program.id], scheduleDecisions: [{ ...adopted, ruleId: "rule-successor" }, exclusion] }; + assert.deepEqual(scheduleSetIssues(loaded, program, review), []); + assert.deepEqual(scheduleSetIssues(loaded, program, review, { at: "2026-10-12T00:00:00Z" }), []); + assert.deepEqual(scheduleSetIssues(loaded, program, review, { at: "2099-12-01T00:00:00Z" }), []); + predecessor.approvedByIds = []; + assert.ok(scheduleSetIssues(loaded, program, review, { at: "2026-10-12T00:00:00Z" }).length); +}); + +test("replacement event coverage uses the rule effective at cutover", () => { + const { loaded, program } = fixture(); + const obligation = loaded.resources.find(({ id }) => id === "calendar"); + const predecessor = loaded.resources.find(({ id }) => id === "rule-calendar"); + predecessor.status = "retired"; + loaded.resources.push({ ...predecessor, id: "rule-successor", status: "active", supersedesId: predecessor.id, effectiveAt: "2099-12-01T00:00:00Z", recurrence: { mode: "event", eventType: "incident-declared" } }); + obligation.activeRuleId = "rule-successor"; + const review = { scopeResourceIds: [program.id], scheduleDecisions: [{ ...adopted, ruleId: "rule-successor" }, { ...exclusion, disposition: "replaced", replacementObligationIds: ["calendar"] }] }; + assert.deepEqual(scheduleSetIssues(loaded, program, review), []); + assert.ok(scheduleSetIssues(loaded, program, review, { at: "2026-10-13T00:00:00Z" }).some(({ message }) => /required incident-declared event/.test(message))); + assert.deepEqual(scheduleSetIssues(loaded, program, review, { at: "2099-12-01T00:00:00Z" }), []); +});