Skip to content

improvement(soa): persist applicability and justification on the SoA answer#3395

Merged
tofikwest merged 6 commits into
mainfrom
tofik/cs-731-soa-per-org-answers
Jul 13, 2026
Merged

improvement(soa): persist applicability and justification on the SoA answer#3395
tofikwest merged 6 commits into
mainfrom
tofik/cs-731-soa-per-org-answers

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes Statement of Applicability answers first-class, per-document records. Each control's applicability (Yes/No) and justification are now stored on the organization's own SOAAnswer for that document, alongside the existing answer text and versioning. SOAFrameworkConfiguration stays a shared template (control list, columns, objectives).

As a result, each organization's SoA — on screen, in the answered-count, and in the exported PDF — is derived entirely from that organization's own answers.

What changed

Data model

  • New SOAAnswer.isApplicable column (+ migration 20260713120000_add_soa_answer_is_applicable) so applicability lives with the answer and its justification.

API (apps/api/src/soa)

  • Auto-fill and manual save persist applicability + justification on SOAAnswer.
  • Export and the answered-count read from the document's own answers.
  • Justification is retained for both applicable (YES) and not-applicable (NO) controls, so every control on the SoA carries a justification (per ISO 27001).

Frontend (apps/app/.../statement-of-applicability)

  • Desktop and mobile rows read applicability + justification from the document's own answers (or an in-session autofill result), via a new shared resolveSoaDisplay helper that removes duplicated display logic across the two rows.

Rollout note

Existing SoAs surface their own justifications immediately. To populate the new per-answer applicability field for a SoA generated before this change, re-run Auto-fill once (or edit the control); newly generated or edited SoAs are complete with no extra step.

Testing

  • apps/api: npx jest src/soa — 55 passing (incl. export sourcing from the org's own answers).
  • apps/app: npx vitest run .../statement-of-applicability — 12 passing (incl. new soa-display.test.ts).
  • Typecheck: no new errors for touched files.

@linear

linear Bot commented Jul 13, 2026

Copy link
Copy Markdown

CS-731

@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comp-framework-editor Ready Ready Preview, Comment Jul 13, 2026 1:28pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
app Skipped Skipped Jul 13, 2026 1:28pm
portal Skipped Skipped Jul 13, 2026 1:28pm

Request Review

Make Statement of Applicability answers first-class per-document records:
each control's applicability (Yes/No) and justification are stored on the
organization's own SOAAnswer, alongside the existing answer text and
versioning. SOAFrameworkConfiguration stays a shared template (control
list, columns, objectives).

- add SOAAnswer.isApplicable (+ migration)
- auto-fill and manual save persist applicability + justification on the answer
- export and the answered-count read from the document's own answers
- keep the justification for both applicable and not-applicable controls
- desktop/mobile rows read from the document's answers via a shared
  resolveSoaDisplay helper

Adds API and frontend unit tests.
@tofikwest tofikwest force-pushed the tofik/cs-731-soa-per-org-answers branch from 2d75ca3 to 2394dca Compare July 13, 2026 12:09
@tofikwest tofikwest changed the title fix(soa): scope applicability and justification per organization feat(soa): persist applicability and justification on the SoA answer Jul 13, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

2 issues found and verified against the latest diff

Confidence score: 3/5

  • The biggest risk is in packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql together with resolveSoaDisplay.ts: legacy SOAAnswer rows can end up with isApplicable = NULL, and the new read path no longer falls back to shared config, so existing statements may render with incorrect applicability or missing answers after deploy — add a migration backfill/default (or restore a fallback path) before merging.
  • In apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts, introducing another local ProcessedResult-equivalent type alongside SOATableRow.tsx, SOATable.tsx, and SOAMobileRow.tsx increases drift risk, where one view updates while others silently diverge in behavior — consolidate to a single shared type to de-risk future changes.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts">

<violation number="1" location="apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts:3">
P2: The new `SOAProcessedResult` type duplicates identical local `ProcessedResult` types already defined in `SOATableRow.tsx`, `SOATable.tsx`, and `SOAMobileRow.tsx`. Keeping four independent definitions means any future change to the processed-result contract (e.g., adding a new flag or changing optionality) can be updated in one place but missed in the others, reintroducing the drift this helper was meant to prevent.

You already export `SOAProcessedResult` from this file; the consuming components should import it and delete their local `ProcessedResult` declarations so the display contract is defined once.</violation>
</file>

<file name="packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql">

<violation number="1" location="packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql:3">
P1: Legacy SOAAnswer rows receive `isApplicable = NULL` from this migration, and the new per-answer read paths no longer fall back to the shared configuration. In `resolveSoaDisplay.ts` a NULL `isApplicable` falls through to `displayIsApplicable: true`, while `exportDocument` emits `isApplicable: null`. This means existing completed documents will silently display/export incorrect applicability values—previously-applicable=false controls become YES—until each organization manually re-runs auto-fill. Since this PR is fixing cross-tenant data leakage (CS-731), consider adding a backfill or at minimum a post-deploy enforcement step so legacy documents are not left in an incorrect state.</violation>
</file>

Linked issue analysis

Linked issue: CS-731: "[Bug] Another org's name ('Lexcom') appears throughout BrandQuantum's generated ISO 27001 Statement of Applicability"

Status Acceptance criteria Notes
Investigate how another org's name/details ended up in BrandQuantum's generated SoA (is there context/template bleed between orgs?). PR documents the root cause (applicability/justifications were stored on a shared SOAFrameworkConfiguration row keyed only by frameworkId, and writes from autofill/manual save persisted into that shared row), and removes the code paths that wrote per-org data into the shared configuration.
Confirm whether this affects other orgs (i.e. is Lexcom's content appearing elsewhere?). PR explicitly states the blast radius and implements fixes treating the problem as cross-tenant; tests and code changes demonstrate the shared configuration was reused by every org and would cause last-writer bleed across tenants.
⚠️ Correct BrandQuantum's SoA justifications to reflect BrandQuantum, and confirm the content is regenerated from their actual context. The PR fixes the data model and code paths so future exports and frontend displays are driven by per-organization SOAAnswer rows (and auto-fill/manual saves now persist per-org isApplicable/justification). However, the PR does not perform the operational regeneration for existing documents in prod — the remediation notes instruct affected orgs to re-run Auto-fill to repopulate per-org applicability. That operational step is intentionally out-of-band (not included in this PR).

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

@@ -0,0 +1,3 @@
-- Store the Statement of Applicability applicability decision per organization
-- (on the answer) instead of on the shared, framework-level configuration.
ALTER TABLE "SOAAnswer" ADD COLUMN "isApplicable" BOOLEAN;

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Legacy SOAAnswer rows receive isApplicable = NULL from this migration, and the new per-answer read paths no longer fall back to the shared configuration. In resolveSoaDisplay.ts a NULL isApplicable falls through to displayIsApplicable: true, while exportDocument emits isApplicable: null. This means existing completed documents will silently display/export incorrect applicability values—previously-applicable=false controls become YES—until each organization manually re-runs auto-fill. Since this PR is fixing cross-tenant data leakage (CS-731), consider adding a backfill or at minimum a post-deploy enforcement step so legacy documents are not left in an incorrect state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql, line 3:

<comment>Legacy SOAAnswer rows receive `isApplicable = NULL` from this migration, and the new per-answer read paths no longer fall back to the shared configuration. In `resolveSoaDisplay.ts` a NULL `isApplicable` falls through to `displayIsApplicable: true`, while `exportDocument` emits `isApplicable: null`. This means existing completed documents will silently display/export incorrect applicability values—previously-applicable=false controls become YES—until each organization manually re-runs auto-fill. Since this PR is fixing cross-tenant data leakage (CS-731), consider adding a backfill or at minimum a post-deploy enforcement step so legacy documents are not left in an incorrect state.</comment>

<file context>
@@ -0,0 +1,3 @@
+-- Store the Statement of Applicability applicability decision per organization
+-- (on the answer) instead of on the shared, framework-level configuration.
+ALTER TABLE "SOAAnswer" ADD COLUMN "isApplicable" BOOLEAN;
</file context>
Fix with cubic

@@ -0,0 +1,79 @@
import type { SOATableAnswerData } from './soa-field-types';

export type SOAProcessedResult = {

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new SOAProcessedResult type duplicates identical local ProcessedResult types already defined in SOATableRow.tsx, SOATable.tsx, and SOAMobileRow.tsx. Keeping four independent definitions means any future change to the processed-result contract (e.g., adding a new flag or changing optionality) can be updated in one place but missed in the others, reintroducing the drift this helper was meant to prevent.

You already export SOAProcessedResult from this file; the consuming components should import it and delete their local ProcessedResult declarations so the display contract is defined once.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts, line 3:

<comment>The new `SOAProcessedResult` type duplicates identical local `ProcessedResult` types already defined in `SOATableRow.tsx`, `SOATable.tsx`, and `SOAMobileRow.tsx`. Keeping four independent definitions means any future change to the processed-result contract (e.g., adding a new flag or changing optionality) can be updated in one place but missed in the others, reintroducing the drift this helper was meant to prevent.

You already export `SOAProcessedResult` from this file; the consuming components should import it and delete their local `ProcessedResult` declarations so the display contract is defined once.</comment>

<file context>
@@ -0,0 +1,79 @@
+import type { SOATableAnswerData } from './soa-field-types';
+
+export type SOAProcessedResult = {
+  success: boolean;
+  isApplicable: boolean | null;
</file context>
Fix with cubic

- resolveSoaDisplay now shows an answer that has no applicability value yet as
  unanswered (N/A) — consistent with the export — instead of assuming the
  control is applicable, so pre-existing documents don't render a value that
  was never set for them.
- Consolidate the per-view processed-result types into a single shared
  SOAProcessedResult (used by the autofill hook and every SoA view) so the
  contract is defined once.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Thanks @cubic-dev-ai — both addressed in c6aebdb:

P1 (applicability of pre-existing answers). Good catch on the inconsistency: the table defaulted an answer with no applicability value to Yes while the export rendered N/A. A migration backfill isn't possible here — applicability was not previously stored on the answer, so there's no reliable per-answer value to populate — so the correct fix is to make the read paths consistent and honest: resolveSoaDisplay now renders an answer whose applicability is unset as N/A (unanswered), matching the export, instead of assuming applicable. A previously not-applicable control therefore no longer shows as Yes. Documents created before this change surface their justifications immediately and show N/A applicability until Auto-fill is re-run once (rollout note in the description); a new test covers this case.

P2 (duplicated processed-result type). Consolidated into a single SOAProcessedResult in soa-field-types.ts, now imported by the autofill hook and every SoA view; the per-view local copies are removed.

@vercel vercel Bot temporarily deployed to Preview – portal July 13, 2026 12:21 Inactive
@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @cubic-dev-ai — both addressed in c6aebdb:

P1 (applicability of pre-existing answers). Good catch on the inconsistency: the table defaulted an answer with no applicability value to Yes while the export rendered N/A. A migration backfill isn't possible here — applicability was not previously stored on the answer, so there's no reliable per-answer value to populate — so the correct fix is to make the read paths consistent and honest: resolveSoaDisplay now renders an answer whose applicability is unset as N/A (unanswered), matching the export, instead of assuming applicable. A previously not-applicable control therefore no longer shows as Yes. Documents created before this change surface their justifications immediately and show N/A applicability until Auto-fill is re-run once (rollout note in the description); a new test covers this case.

P2 (duplicated processed-result type). Consolidated into a single SOAProcessedResult in soa-field-types.ts, now imported by the autofill hook and every SoA view; the per-view local copies are removed.

@tofikwest Both look right to me.

P1 — The pre-migration answer path was the right call. Since isApplicable was never persisted on SOAAnswer, backfilling would mean guessing, and guessing wrong would be worse than showing N/A. The resolveSoaDisplay fallthrough now handles it cleanly: answerData present but isApplicable null → displayIsApplicable: null (N/A), matching the export behavior. The test at soa-display.test.ts:33 covers that exact case.

P2 — The type consolidation cleaned up four separate ProcessedResult-like locals across SOATable, SOATableRow, SOAMobileRow, and useSOAAutoFill, all now pointing at the single SOAProcessedResult from soa-field-types.ts. The diff shows those local types got removed and the import sources converged. Much easier to maintain.

No further concerns from my side on these two.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

1 issue found across 17 files

Confidence score: 3/5

  • In apps/api/src/soa/utils/soa-storage.ts, the per-organization countAnsweredAnswers logic can over/undercount answered items because it relies on SOAAnswer.questionId strings that are not constrained to the document’s current question configuration, which can skew completion/progress metrics after merge; before merging, scope the count to valid question IDs for the active configuration (or enforce referential integrity) and add a regression test for stale/mismatched questionId data.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/soa/utils/soa-storage.ts">

<violation number="1" location="apps/api/src/soa/utils/soa-storage.ts:108">
P1: The new per-organization `countAnsweredAnswers` counts every latest `SOAAnswer` with a non-null `isApplicable`, but `questionId` is only a plain string with no foreign-key constraint to the document's configuration questions, and `saveAnswer` does not verify that the submitted `questionId` exists in the document's configuration before creating a row. This means a caller can create applicability-bearing answers for arbitrary question IDs, inflating `answeredQuestions` and incorrectly driving document status to `completed` while real controls remain unanswered.

To fix this, validate `dto.questionId` against the document's `configuration.questions` array in `saveAnswer` before creating an answer, and also scope `countAnsweredAnswers` to count only rows whose `questionId` matches a control in the document's configuration.</violation>
</file>

Linked issue analysis

Linked issue: CS-731: "[Bug] Another org's name ('Lexcom') appears throughout BrandQuantum's generated ISO 27001 Statement of Applicability"

Status Acceptance criteria Notes
SoA export and UI read applicability + justification from the organization’s own answers (not from the shared SOAFrameworkConfiguration) Service/export and frontend display were changed to source isApplicable/justification from per-document SOAAnswer (not the shared configuration); tests verify exports use the org's answers over shared-config values.
Persist per-answer applicability (add isApplicable column and DB migration) Schema and migration add the isApplicable column and code stores/reads it from SOAAnswer; tests exercise saveAnswer behavior.
Stop writing per-organization applicability/justification into the shared framework configuration (prevent template bleed) Removed updateConfigurationWithResults logic and related helpers; tests assert the framework configuration is not updated with per-org data.
Investigate / confirm whether other orgs were affected by the bleed (root-cause investigation) Tests and code comments demonstrate the previous shared-configuration values could bleed into every org's export and the PR's changes close that vector; this constitutes an investigation and confirmation of the bleed behavior.
⚠️ Correct existing BrandQuantum SoA justifications and confirm content is regenerated from BrandQuantum’s actual context The PR ensures future exports and UI display reflect per-org answers and documents a rollout path to repair pre-migration documents (re-run Auto-fill or edit controls). However, it does not include an automated migration/one‑time job that proactively re-runs Auto-fill for existing documents — correction for existing BrandQuantum SoAs requires an explicit re-run of Auto-fill or manual edits as stated in the rollout note.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

*/
export async function getAnsweredCountFromConfiguration(
configurationId: string,
export async function countAnsweredAnswers(

@cubic-dev-ai cubic-dev-ai Bot Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new per-organization countAnsweredAnswers counts every latest SOAAnswer with a non-null isApplicable, but questionId is only a plain string with no foreign-key constraint to the document's configuration questions, and saveAnswer does not verify that the submitted questionId exists in the document's configuration before creating a row. This means a caller can create applicability-bearing answers for arbitrary question IDs, inflating answeredQuestions and incorrectly driving document status to completed while real controls remain unanswered.

To fix this, validate dto.questionId against the document's configuration.questions array in saveAnswer before creating an answer, and also scope countAnsweredAnswers to count only rows whose questionId matches a control in the document's configuration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/soa/utils/soa-storage.ts, line 108:

<comment>The new per-organization `countAnsweredAnswers` counts every latest `SOAAnswer` with a non-null `isApplicable`, but `questionId` is only a plain string with no foreign-key constraint to the document's configuration questions, and `saveAnswer` does not verify that the submitted `questionId` exists in the document's configuration before creating a row. This means a caller can create applicability-bearing answers for arbitrary question IDs, inflating `answeredQuestions` and incorrectly driving document status to `completed` while real controls remain unanswered.

To fix this, validate `dto.questionId` against the document's `configuration.questions` array in `saveAnswer` before creating an answer, and also scope `countAnsweredAnswers` to count only rows whose `questionId` matches a control in the document's configuration.</comment>

<file context>
@@ -137,25 +102,19 @@ export async function updateDocumentAfterAutoFill(
  */
-export async function getAnsweredCountFromConfiguration(
-  configurationId: string,
+export async function countAnsweredAnswers(
+  documentId: string,
 ): Promise<number> {
</file context>
Fix with cubic

Comment thread apps/api/src/soa/soa.service.ts Outdated
Comment thread apps/api/src/soa/soa.service.ts Outdated
- preserve prior applicability/justification when a field is omitted from a
  save, so a partial edit can't wipe a previously set decision
- validate that questionId belongs to the document's configuration before
  creating an answer, keeping the per-org answered-count accurate
- require a justification when a control is marked not applicable
- hydrate the answers map through a single mapper so the sync effect no longer
  sets savedIsApplicable (which would shadow in-session autofill results)
@tofikwest

Copy link
Copy Markdown
Contributor Author

Thanks @cubic-dev-ai — all four addressed in 788bb77:

P1 — sync effect set savedIsApplicable (real bug). Correct, and it was the worse of the two: the useState initializer used isApplicable but the sync useEffect still set savedIsApplicable, which is the top-priority flag in resolveSoaDisplay and would have shadowed in-session autofill results (the remediation path). Both builders now go through a single toAnswerData mapper that sets isApplicable only; savedIsApplicable is reserved for handleAnswerUpdate (an actual manual save). This also removes the drift risk between the two builders.

P1 — omitted isApplicable wiped the prior decision. Fixed: saveAnswer now preserves the existing applicability and justification when the field is omitted from the payload (only an explicit value changes it), restoring the merge semantics the previous shared-config path had.

P1 — questionId not constrained. saveAnswer now validates the questionId against the document's configuration and rejects unknown IDs, so answers (and the answered-count) can't be created for arbitrary question IDs — referential integrity is enforced at the write path.

P2 — NO without justification. saveAnswer now rejects isApplicable: false with a blank/absent justification, matching the existing client-side rule and the ISO requirement (auto-fill uses a separate path and already supplies a justification).

Added service tests for the invalid-questionId, NO-without-justification, and preserve-on-omit cases (API SoA suite: 58 passing; app SoA suite: 13 passing).

@vercel vercel Bot temporarily deployed to Preview – portal July 13, 2026 12:35 Inactive
@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @cubic-dev-ai — all four addressed in 788bb77:

P1 — sync effect set savedIsApplicable (real bug). Correct, and it was the worse of the two: the useState initializer used isApplicable but the sync useEffect still set savedIsApplicable, which is the top-priority flag in resolveSoaDisplay and would have shadowed in-session autofill results (the remediation path). Both builders now go through a single toAnswerData mapper that sets isApplicable only; savedIsApplicable is reserved for handleAnswerUpdate (an actual manual save). This also removes the drift risk between the two builders.

P1 — omitted isApplicable wiped the prior decision. Fixed: saveAnswer now preserves the existing applicability and justification when the field is omitted from the payload (only an explicit value changes it), restoring the merge semantics the previous shared-config path had.
...

@tofikwest

Those all look good to me. A few quick observations:

Merge semantics on partial save — The preserve-on-omit logic in saveAnswer looks right. Using dto.isApplicable === undefined (rather than a falsy check) correctly distinguishes "not sent" from "sent as false", and dto.justification !== undefined ? dto.justification : dto.answer !== undefined ? dto.answer : existingAnswer.answer handles the three-input merge. The test at soa.service.spec.ts:613 (preserves existing applicability when omitted) confirms it.

toAnswerData mapper — The key fix was removing the useEffect that was setting savedIsApplicable on every document reload, which would have overridden in-session autofill results. Having both the useState initializer and the sync effect go through the same toAnswerData that only sets isApplicable is much cleaner, and reserving savedIsApplicable for handleAnswerUpdate is the right contract.

Question ID validationsaveAnswer now rejects out-of-config IDs with a BadRequestException before touching the DB. Worth noting: this is a security-adjacent fix since it also prevents inflating the answered-count for arbitrary question IDs.

NO + blank justification — The guard at soa.service.ts:128 uses !justification || justification.trim().length === 0, which catches all the falsy variants including a string of whitespace. Matches the existing learning from f956e133.

No issues from my side.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

…ions

countAnsweredAnswers now counts only latest answered rows whose questionId
belongs to the document's active configuration, so stale or mismatched answer
rows can't skew completion/progress metrics. Both callers (manual save and
auto-fill) pass the configured question IDs. Adds a regression test.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Thanks @cubic-dev-ai — done in 56b4132. In addition to the write-path validation added earlier (saveAnswer rejects a questionId not in the document's configuration), countAnsweredAnswers is now also scoped to the document's configured question IDs:

where: {
  documentId,
  isLatestAnswer: true,
  isApplicable: { not: null },
  questionId: { in: validQuestionIds },
}

Both callers (manual save and auto-fill) pass the configured IDs, so stale or mismatched answer rows — e.g. an answer for a control later removed from the configuration — can no longer inflate the completion count. Added soa-storage.spec.ts with a regression test covering the scoping and the empty-configuration case (API SoA suite now 60 passing).

@vercel vercel Bot temporarily deployed to Preview – portal July 13, 2026 12:54 Inactive
@vercel vercel Bot temporarily deployed to Preview – app July 13, 2026 12:54 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

All reported issues were addressed across 18 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Linked issue analysis

Linked issue: CS-731: "[Bug] Another org's name ('Lexcom') appears throughout BrandQuantum's generated ISO 27001 Statement of Applicability"

Status Acceptance criteria Notes
SoA justifications are sourced from the organization's own answers (no shared-framework bleed) Service, storage, export and frontend code were changed to read applicability/justification from SOAAnswer (per-document/org) instead of the shared SOAFrameworkConfiguration. Tests assert the export and UI use the org's answers rather than shared config.
Frontend and export/answered-count behavior updated so future SoAs are generated from the org's context Answered-count now counts per-org latest answers scoped to the document's question IDs; frontend reads applicability/justification from document answers or in-session autofill, and export uses those answers.
⚠️ Investigation: confirm whether other orgs' SoAs (e.g. Lexcom) were affected The PR demonstrates how bleed could happen (shared config containing other-org justification) and adds tests showing the problem and the fix, but it does not include a database audit or script that enumerates which org documents were actually affected in production.
⚠️ Correct BrandQuantum's SoA justifications and regenerate content for that org The code and schema changes enable correct regeneration: after this change, re-running Auto-fill or editing a control will populate per-answer applicability/justification for pre-existing documents. However, the PR does not include an automated one-off migration or an executed re-run for BrandQuantum's document — remediation is manual (re-run Auto-fill) per the rollout note.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Remove the display-only override that forced physical-security (7.x) controls
to "not applicable" for fully-remote orgs on screen. The rule is already
applied at generation time (auto-fill persists isApplicable=false with a
justification and the field is edit-locked), so the persisted answer already
reflects it. Reading it from there keeps the on-screen SoA and the exported
PDF in agreement instead of applying a rule in one channel but not the other.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Thanks @cubic-dev-ai — good catch, fixed in bd7aaa7 by removing the frontend-only override so both channels read the same source.

The "fully remote → physical-security (7.x) controls are not applicable" rule is already enforced at the data layer: auto-fill persists isApplicable = false with the fully-remote justification (createFullyRemoteResult), and the field is edit-locked for fully-remote orgs. So the persisted answer already reflects the rule. resolveSoaDisplay now just reads the persisted answer (or the in-session autofill result), exactly like the export does — so the on-screen SoA and the PDF always agree.

I chose removing the override over re-applying the rule in the export + answered-count because the latter would duplicate the same rule across four places (display, export, count, generation) and force the export service to look up the org's fully-remote status — the drift risk this review has been guarding against. Enforcing it once at generation and reading the persisted value everywhere keeps a single source of truth. Updated the helper's tests accordingly (app SoA suite: 12 passing).

@vercel vercel Bot temporarily deployed to Preview – portal July 13, 2026 13:10 Inactive
@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @cubic-dev-ai — good catch, fixed in bd7aaa7 by removing the frontend-only override so both channels read the same source.

The "fully remote → physical-security (7.x) controls are not applicable" rule is already enforced at the data layer: auto-fill persists isApplicable = false with the fully-remote justification (createFullyRemoteResult), and the field is edit-locked for fully-remote orgs. So the persisted answer already reflects the rule. resolveSoaDisplay now just reads the persisted answer (or the in-session autofill result), exactly like the export does — so the on-screen SoA and the PDF always agree.

I chose removing the override over re-applying the rule in the export + answered-count because the latter would duplicate the same rule across four places (display, export, count, generation) and force the export service to look up the org's fully-remote status — the drift risk this review has been guarding against. Enforcing it once at generation and reading the persisted value everywhere keeps a single source of truth. Updated the helper's tests accordingly (app SoA suite: 12 passing).

@tofikwest That reasoning is sound. The fully-remote rule was applied in two places (generation and display), which meant they could drift — and they already did, since the export was always reading the persisted answer while the screen had the display-only override. Now both paths converge on the same persisted value.

The key insight here is that the rule is an input to the decision, not an output property. It gets applied once at generation time (auto-fill), persisted as isApplicable = false on the org's own answer, and every read path — screen, export, answered-count — just reads what's there. No need to re-derive the rule anywhere else.

The alternative (re-applying in export + count) would have introduced the exact kind of duplication you're eliminating here: the rule would live in four places, and a change to the scoping logic (say, a control outside 7.x becomes not-applicable for remote orgs) would need coordinated updates across all of them. Single source of truth is the right call.

The diff confirms the override is gone from both SOATableRow and SOAMobileRow, and resolveSoaDisplay has no reference to isFullyRemote or isControl7. Clean.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

All reported issues were addressed across 18 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Linked issue analysis

Linked issue: CS-731: "[Bug] Another org's name ('Lexcom') appears throughout BrandQuantum's generated ISO 27001 Statement of Applicability"

Status Acceptance criteria Notes
Persist applicability + justification on each organization's SOAAnswer (DB + schema + service) PR adds isApplicable to the SOAAnswer model, a migration, and save/export paths that persist and read isApplicable and justification per-document.
Ensure exports and the UI derive SoA content from the document's own answers rather than the shared framework configuration Export mapping and UI display now source applicability/justification from the document answers; the resolveSoaDisplay helper and updated export logic prevent reading stale shared-config justifications.
Stop writing per-organization data into the shared SOAFrameworkConfiguration (prevent template bleed) Code that previously updated the shared configuration has been removed and tests assert the shared configuration is not written to during saves/auto-fill.
Investigate root cause and confirm scope (i.e. other orgs affected) PR contains tests and comments that demonstrate the previous behavior (shared-config values could bleed into exports) and now asserts exports are sourced from the org's own answers, which confirms and addresses cross-org bleed.
⚠️ Correct BrandQuantum's SoA justifications and confirm content is regenerated for that org PR provides the necessary migration and ensures future/ regenerated exports will be correct; however it does not itself re-run auto-fill or modify existing BrandQuantum SoA documents — the rollout note says re-run Auto‑fill (or edit) to populate the new per-answer applicability field.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/api/src/soa/soa.service.ts
saveAnswer previously retired the prior answer (isLatestAnswer=false) before
running the not-applicable-requires-justification check, so a rejected save
could leave a control with no latest answer. Move all validation ahead of any
write, and wrap the retire-previous + create-new steps in a single
transaction so a failure can never leave a control without a latest answer.
Apply the same atomic retire+create in the auto-fill save path. Adds a
regression test asserting a failed validation does not retire the prior answer.
@tofikwest

Copy link
Copy Markdown
Contributor Author

Thanks @cubic-dev-ai — real bug, fixed in 2cf26d6.

saveAnswer now runs all validation (question-in-config, and not-applicable-requires-justification) before any write, and the retire-previous + create-new steps are wrapped in a single db.$transaction, so a rejected or failed save can never leave a control without a latest answer. Applied the same atomic retire+create to the auto-fill save path (saveAnswersToDatabase) so both write paths are consistent. Added a regression test asserting a failed validation does not retire the prior answer (no update/$transaction calls). API SoA suite: 61 passing.

I also did a full pass over the diff for the same class of issue: those are the only two retire-then-create sites, both now atomic; all validation precedes writes; and no per-org data is read from the shared configuration anywhere.

@vercel vercel Bot temporarily deployed to Preview – portal July 13, 2026 13:26 Inactive
@vercel vercel Bot temporarily deployed to Preview – app July 13, 2026 13:26 Inactive
@cubic-dev-ai

cubic-dev-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @cubic-dev-ai — real bug, fixed in 2cf26d6.

saveAnswer now runs all validation (question-in-config, and not-applicable-requires-justification) before any write, and the retire-previous + create-new steps are wrapped in a single db.$transaction, so a rejected or failed save can never leave a control without a latest answer. Applied the same atomic retire+create to the auto-fill save path (saveAnswersToDatabase) so both write paths are consistent. Added a regression test asserting a failed validation does not retire the prior answer (no update/$transaction calls). API SoA suite: 61 passing.

I also did a full pass over the diff for the same class of issue: those are the only two retire-then-create sites, both now atomic; all validation precedes writes; and no per-org data is read from the shared configuration anywhere.

@tofikwest Looks solid from the diff. A few things that stand out as exactly right:

Validation-first ordering — In soa.service.ts, the questionId check and the isApplicable === false && !justification check both run before the $transaction block. No write touches the DB before validation passes. The regression test at soa.service.spec.ts:591 confirms this: it asserts no update/$transaction/create calls when validation fails.

Atomic retire+create — Both soa.service.ts:saveAnswer and soa-storage.ts:saveAnswersToDatabase now wrap the retire and create in a single db.$transaction. Before, a crash between the two steps would leave the old answer retired with no new latest answer — now the whole operation either commits or rolls back as one.

Both paths consistent — I checked: saveAnswersToDatabase (auto-fill) had the same pattern of update → separate create, with validation happening per-iteration inside the for loop. Now it uses the same $transaction([...updates, create]) pattern with isApplicable persisted alongside the justification. Both paths also pass configQuestions.map(q => q.id) to countAnsweredAnswers, so the scoped count is consistent too.

No shared-config writesupdateQuestionMapping is gone entirely, and the sOAFrameworkConfiguration.update call that used to live in the auto-fill path (updateConfigurationWithResults) is deleted. Per-org data now only touches SOAAnswer.

61 passing and the regression test covers the hole — looks complete.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@tofikwest tofikwest changed the title feat(soa): persist applicability and justification on the SoA answer improvement(soa): persist applicability and justification on the SoA answer Jul 13, 2026
@tofikwest tofikwest merged commit a29a7b5 into main Jul 13, 2026
10 checks passed
@tofikwest tofikwest deleted the tofik/cs-731-soa-per-org-answers branch July 13, 2026 13:39
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.101.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants