Issue Summary
Policies linked to a control from a framework control page are not visible on that policy's Mappings tab. The mapping exists and renders correctly on the control side, but the policy side reports no such control.
The cause is that the two pages read and write different storage. Since #2829 (feat/framework-control-link-isolation, May 2026), control↔policy links are stored per framework instance in FrameworkControlPolicyLink. That PR updated the controls API and the framework UI to be framework-aware, but GET /v1/policies/:id/controls was not updated — it still reads only the pre-migration implicit many-to-many relation _ControlToPolicy (Policy.controls ↔ Control.policies).
Concretely:
| Operation |
Writes / reads |
Table |
POST /v1/controls/:id/policies/link?frameworkInstanceId=… |
writes |
FrameworkControlPolicyLink only |
GET /v1/policies/:id/controls |
reads |
_ControlToPolicy only |
Because the framework control page always supplies frameworkInstanceId, linkPolicies() always takes the framework-scoped branch and never touches the direct relation. The policy page therefore never sees the link.
The bug is masked in most orgs because organisation onboarding writes to both tables (frameworks-upsert.helper.ts:314 connects policies, :391 creates frameworkControlPolicyLink rows). Seeded mappings look correct on both pages. Only manually created links diverge.
Two related defects in the same area:
- Mirror case.
POST /v1/policies/:id/controls writes only controls: { connect } (the direct relation). On the control side, direct policies are gated behind isCustomFramework ? control.policies : [], so for a built-in framework the link created from the policy page does not appear on the framework control page either. The asymmetry runs in both directions.
- Unlink is a no-op for scoped links.
DELETE /v1/policies/:id/controls/:controlId only disconnects the direct relation, so the unlink button on the policy Mappings tab cannot remove a framework-scoped link. It appears to succeed and changes nothing.
Steps to Reproduce
- Use an organisation with a built-in framework instance (reproduced on SOC 2).
- Navigate to Frameworks → SOC 2 → P1.1 → Data Privacy (a control detail page).
URL shape: /{orgId}/frameworks/{frameworkInstanceId}/controls/{controlId}
- Click Link Policy and select an existing policy — e.g. Authentication & Password.
- Confirm the policy now appears in the control's Policies tab. It does.
- Navigate to Policies → Authentication & Password → Mappings.
- Look at the Controls table.
Reverse case (same root cause):
- Open any policy → Mappings → Link control, and link a control that belongs to a built-in framework.
- Open that control from the framework page.
- The policy is absent.
Unlink case:
- Take a policy that has a control mapping in
FrameworkControlPolicyLink and is therefore visible on the policy Mappings tab (a seeded mapping from org onboarding works, since onboarding writes both tables).
- Click the unlink icon next to it.
- The row disappears optimistically, but the
FrameworkControlPolicyLink row is still present after a refresh.
Actual Results
- The policy's Mappings → Controls table does not list the control that was just linked from the framework control page.
- Only controls whose mapping exists in
_ControlToPolicy are listed — in practice, only those created during org onboarding.
- The control detail page shows the mapping correctly, so the two pages disagree about the same relationship.
- Linking from the policy page produces the inverse discrepancy on built-in frameworks.
- Unlinking from the policy page silently fails to remove framework-scoped links.
Expected Results
- A control↔policy mapping should be visible from both sides regardless of which page created it.
- After linking a policy from a framework control page, that control should appear in the policy's Mappings → Controls table.
- After linking a control from a policy page, that policy should appear on the framework control page.
- Unlinking a control from the policy Mappings tab should actually remove the mapping, and it should stay removed after a refresh.
- The two pages should never disagree about whether a mapping exists.
Technical details
Root cause — file references
Schema
_ControlToPolicy — implicit Prisma m2m, columns A (controlId) / B (policyId). Created in 20250519172353_drop_artifacts.
FrameworkControlPolicyLink — (frameworkInstanceId, controlId, policyId), unique on all three. Created in 20260513150611_framework_scoped_control_links.
Why the divergence exists (and why it is not a schema bug)
Framework scoping is deliberate and correct — a single Control is shared across framework instances, and unlinking a policy within SOC 2 must not remove it from ISO 27001. PR #2829 describes this as preventing "cross-framework bleed." The defect is that the policy module was never migrated to the new model.
Environment
- Node.js: 22 (per
apps/api/Dockerfile.multistage)
- Browser:
- Deployment:
- Affects: any organisation using a built-in framework instance
Evidence
Screenshots
control-page.png — Frameworks → SOC 2 → P1.1 → Data Privacy, Policies (6) tab listing Authentication & Password.
policy-page.png — Policies → Authentication & Password → Mappings, Controls table showing only Access Rights and Credential Management. Data Privacy is absent.
Database verification
After performing steps 1–4, both queries were run against the same organisation:
-- Framework-scoped link created by the UI action — present
SELECT id, "frameworkInstanceId", "controlId", "policyId"
FROM "FrameworkControlPolicyLink"
WHERE "controlId" = '<controlId>' AND "policyId" = '<policyId>';
--> 1 row
-- Direct relation read by the policy page — absent
SELECT * FROM "_ControlToPolicy"
WHERE "A" = '<controlId>' AND "B" = '<policyId>';
--> 0 rows
This confirms the link was persisted, and that the policy endpoint queries a store it was never written to.
API verification
# Control side — policy present
curl -s "$API/v1/controls/<controlId>?frameworkInstanceId=<frameworkInstanceId>" \
-H "x-api-key: $KEY" | jq '.policies[].name'
#> "Authentication & Password"
# Policy side — control absent
curl -s "$API/v1/policies/<policyId>/controls" \
-H "x-api-key: $KEY" | jq '.mappedControls[].name'
#> "Access Rights"
#> "Credential Management"
Related
- PR #2829 —
feat/framework-control-link-isolation, introduced the framework-scoped tables
- Commit
a32ec3798 — fix(controls): include direct policy/task links in custom framework view, which patched four read paths in controls.service.ts. getPolicyControls in policies.controller.ts was not among them.
- Commit
7cbbaa032 — fix(frameworks): populate framework-scoped link tables during org onboarding
Issue Summary
Policies linked to a control from a framework control page are not visible on that policy's Mappings tab. The mapping exists and renders correctly on the control side, but the policy side reports no such control.
The cause is that the two pages read and write different storage. Since #2829 (
feat/framework-control-link-isolation, May 2026), control↔policy links are stored per framework instance inFrameworkControlPolicyLink. That PR updated the controls API and the framework UI to be framework-aware, butGET /v1/policies/:id/controlswas not updated — it still reads only the pre-migration implicit many-to-many relation_ControlToPolicy(Policy.controls↔Control.policies).Concretely:
POST /v1/controls/:id/policies/link?frameworkInstanceId=…FrameworkControlPolicyLinkonlyGET /v1/policies/:id/controls_ControlToPolicyonlyBecause the framework control page always supplies
frameworkInstanceId,linkPolicies()always takes the framework-scoped branch and never touches the direct relation. The policy page therefore never sees the link.The bug is masked in most orgs because organisation onboarding writes to both tables (
frameworks-upsert.helper.ts:314connectspolicies,:391createsframeworkControlPolicyLinkrows). Seeded mappings look correct on both pages. Only manually created links diverge.Two related defects in the same area:
POST /v1/policies/:id/controlswrites onlycontrols: { connect }(the direct relation). On the control side, direct policies are gated behindisCustomFramework ? control.policies : [], so for a built-in framework the link created from the policy page does not appear on the framework control page either. The asymmetry runs in both directions.DELETE /v1/policies/:id/controls/:controlIdonlydisconnects the direct relation, so the unlink button on the policy Mappings tab cannot remove a framework-scoped link. It appears to succeed and changes nothing.Steps to Reproduce
URL shape:
/{orgId}/frameworks/{frameworkInstanceId}/controls/{controlId}Reverse case (same root cause):
Unlink case:
FrameworkControlPolicyLinkand is therefore visible on the policy Mappings tab (a seeded mapping from org onboarding works, since onboarding writes both tables).FrameworkControlPolicyLinkrow is still present after a refresh.Actual Results
_ControlToPolicyare listed — in practice, only those created during org onboarding.Expected Results
Technical details
Root cause — file references
apps/api/src/controls/controls.service.ts:662-678—linkPolicies(); theframeworkInstanceIdbranch writes onlyframeworkControlPolicyLinkapps/api/src/policies/policies.controller.ts:287-300—getPolicyControls(); selectscontrols:onlyapps/api/src/policies/policies.controller.ts:1134-1141—controls: { connect }onlyapps/api/src/policies/policies.controller.ts:1174—disconnectonlyapps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx:65— always sendsframeworkInstanceIdapps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsxSchema
_ControlToPolicy— implicit Prisma m2m, columnsA(controlId) /B(policyId). Created in20250519172353_drop_artifacts.FrameworkControlPolicyLink—(frameworkInstanceId, controlId, policyId), unique on all three. Created in20260513150611_framework_scoped_control_links.Why the divergence exists (and why it is not a schema bug)
Framework scoping is deliberate and correct — a single
Controlis shared across framework instances, and unlinking a policy within SOC 2 must not remove it from ISO 27001. PR #2829 describes this as preventing "cross-framework bleed." The defect is that the policy module was never migrated to the new model.Environment
apps/api/Dockerfile.multistage)Evidence
Screenshots
control-page.png— Frameworks → SOC 2 → P1.1 → Data Privacy, Policies (6) tab listing Authentication & Password.policy-page.png— Policies → Authentication & Password → Mappings, Controls table showing only Access Rights and Credential Management. Data Privacy is absent.Database verification
After performing steps 1–4, both queries were run against the same organisation:
This confirms the link was persisted, and that the policy endpoint queries a store it was never written to.
API verification
Related
feat/framework-control-link-isolation, introduced the framework-scoped tablesa32ec3798— fix(controls): include direct policy/task links in custom framework view, which patched four read paths incontrols.service.ts.getPolicyControlsinpolicies.controller.tswas not among them.7cbbaa032— fix(frameworks): populate framework-scoped link tables during org onboarding