Repository: ethyca/fides
Pull request #6155: Support Additional Consent string v2
Author: login: gilluminate
Created: 2025-05-19T17:38:36Z | Merged: 2025-05-19T20:49:39Z

Closes [ENG-443]

### Description Of Changes

Updated the Additional Consent (AC) string format from version 1 to version 2, which now includes a disclosed vendors section with the "dv." prefix. This change ensures compatibility with [Google's updated AC string specification](https://support.google.com/admanager/answer/9681920?hl=en).

### Code Changes

* Updated AC string format from version 1 (`1~id1.id2.id3`) to version 2 (`2~id1.id2.id3~dv.id4.id5.id6`)
* Renamed `idsFromAcString` function to `consentIdsFromAcString` for better clarity
* Enhanced string validation in `consentIdsFromAcString` to handle both version 1 and version 2 AC strings
* Updated all test cases with the new AC string format
* Improved documentation with clearer code examples showing the new format

### Steps to Confirm
1. In Admin UI, add some systems that are not part of GVL (AC)
2. Configure and enable the TCF experience
3. Visit the TCF experience in the Privacy Center demo page
4. Open the TCF Modal and switch to the Vendors tab
5. Non-GVL vendors will appear in an "Other vendors" category, consent to some, but not others and save the consent.
7. Look at the fides_string portion of the created cookie
   - verify the first slot of the AC string is now a `2` (previously `1`)
   - verify the 2nd slot (after the first `~`) contains a dot separated list of the vendors IDs of the vendors you consented to
   - verify the 3rd slot (after the second `~`) contains the `dv.` prefix, followed by all of the vendor ID's that were presented in the UI, but were not consented.
8. Re-open the TCF modal and Opt-in to all. Verify all vendors are in the 2nd slot and no vendors are in the 3rd slot after the `dv.` prefix
9. Re-open the TCF modal and Opt-out of all. Verify that no vendors appear in the 2nd slot and all vendors are in the 3rd slot.

### Pre-Merge Checklist

* [x] Issue requirements met
* [x] All CI pipelines succeeded
* [x] `CHANGELOG.md` updated
  * [ ] Add a https://github.com/ethyca/fides/labels/db-migration label to the entry if your change includes a DB migration
  * [ ] Add a https://github.com/ethyca/fides/labels/high-risk label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
  * [ ] Updates unreleased work already in Changelog, no new entry necessary
* Followup issues:
  * [ ] Followup issues created
  * [x] No followup issues
* Database migrations:
  * [ ] Ensure that your downrev is up to date with the latest revision on `main`
  * [ ] Ensure that your `downgrade()` migration is correct and works
    * [ ] If a downgrade migration is not possible for this change, please call this out in the PR description!
  * [x] No migrations
* Documentation:
  * [ ] Documentation complete, [PR opened in fidesdocs](https://github.com/ethyca/fidesdocs/pulls)
  * [ ] Documentation [issue created in fidesdocs](https://github.com/ethyca/fidesdocs/issues/new/choose)
  * [x] If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
  * [ ] No documentation updates required


[ENG-443]: https://ethyca.atlassian.net/browse/ENG-443?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1d04d52ea1..934e2cfc267 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
 
 ### Changed
 - Attachment uploads now check for file extension types, retrieving and attachment also returns the file size. [#6124](https://github.com/ethyca/fides/pull/6124)
+- Updated the AC string version from v1 to v2 format, which now includes a disclosed vendors section [#6155](https://github.com/ethyca/fides/pull/6155)
 
 ### Developer Experience
 - Refactored Fides initialization code to reduce duplication and improve maintainability. [#6143](https://github.com/ethyca/fides/pull/6143)
diff --git a/clients/fides-js/__tests__/lib/consent-utils.test.ts b/clients/fides-js/__tests__/lib/consent-utils.test.ts
index 92cd07f3171..cdf54ad8a69 100644
--- a/clients/fides-js/__tests__/lib/consent-utils.test.ts
+++ b/clients/fides-js/__tests__/lib/consent-utils.test.ts
@@ -16,6 +16,7 @@ import {
   encodeNoticeConsentString,
   getWindowObjFromPath,
   isPrivacyExperience,
+  isValidAcString,
   shouldResurfaceBanner,
 } from "~/lib/consent-utils";
 import { parseFidesDisabledNotices } from "~/lib/shared-consent-utils";
@@ -626,3 +627,21 @@ describe("applyOverridesToConsent", () => {
     });
   });
 });
+
+describe("isValidAcString", () => {
+  it("should return true for valid AC strings", () => {
+    expect(isValidAcString("1~1.2.3")).toBe(true);
+    expect(isValidAcString("2~1.2.3~dv.4.5")).toBe(true);
+    expect(isValidAcString("1~")).toBe(true);
+    expect(isValidAcString("2~")).toBe(false);
+    expect(isValidAcString("2~~dv.")).toBe(true);
+  });
+
+  it("should return false for invalid AC strings", () => {
+    expect(isValidAcString("1~1.2.3~dv.4.5")).toBe(false);
+    expect(isValidAcString("3~1.2.3")).toBe(false);
+    expect(isValidAcString("2~1.2.3~dv.4.5~dv.6.7")).toBe(false);
+    expect(isValidAcString("")).toBe(false);
+    expect(isValidAcString("1.2.3")).toBe(false);
+  });
+});
diff --git a/clients/fides-js/__tests__/lib/fides-string.test.ts b/clients/fides-js/__tests__/lib/fides-string.test.ts
index 784b6be2fa2..ab3c27e3cfc 100644
--- a/clients/fides-js/__tests__/lib/fides-string.test.ts
+++ b/clients/fides-js/__tests__/lib/fides-string.test.ts
@@ -1,9 +1,9 @@
 import { CmpApi } from "@iabgpp/cmpapi";
 
 import {
+  consentIdsFromAcString,
   decodeFidesString,
   formatFidesStringWithGpp,
-  idsFromAcString,
 } from "../../src/lib/fides-string";
 
 describe("fidesString", () => {
@@ -51,16 +51,16 @@ describe("fidesString", () => {
       },
       // Invalid case of only AC string- need core TC string
       {
-        fidesString: ",1~2.3.4",
+        fidesString: ",2~2.3.4~dv.1.5",
         expected: { tc: "", ac: "", gpp: "", nc: "" },
       },
       // Both TC and AC string
       {
         fidesString:
-          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,1~2.3.4",
+          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,2~2.3.4~dv.1.5",
         expected: {
           tc: "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA",
-          ac: "1~2.3.4",
+          ac: "2~2.3.4~dv.1.5",
           gpp: "",
           nc: "",
         },
@@ -84,10 +84,10 @@ describe("fidesString", () => {
       // Complete string (TC + AC + GPP)
       {
         fidesString:
-          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,1~2.3.4,DBABLA~BVAUAAAAAWA.QA",
+          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,2~2.3.4~dv.1.5,DBABLA~BVAUAAAAAWA.QA",
         expected: {
           tc: "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA",
-          ac: "1~2.3.4",
+          ac: "2~2.3.4~dv.1.5",
           gpp: "DBABLA~BVAUAAAAAWA.QA",
           nc: "",
         },
@@ -95,10 +95,10 @@ describe("fidesString", () => {
       // No trailing comma when no GPP
       {
         fidesString:
-          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,1~2.3.4",
+          "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA,2~2.3.4~dv.1.5",
         expected: {
           tc: "CPzvOIAPzvOIAGXABBENAUEAAACAAAAAAAAAAAAAAAAA.IAAA",
-          ac: "1~2.3.4",
+          ac: "2~2.3.4~dv.1.5",
           gpp: "",
           nc: "",
         },
@@ -112,7 +112,7 @@ describe("fidesString", () => {
     );
   });
 
-  describe("idsFromAcString", () => {
+  describe("consentIdsFromAcString", () => {
     it.each([
       // Empty string
       {
@@ -121,7 +121,7 @@ describe("fidesString", () => {
       },
       // String without ids
       {
-        acString: "1~",
+        acString: "2~~dv.",
         expected: [],
       },
       // Invalid string
@@ -129,15 +129,20 @@ describe("fidesString", () => {
         acString: "invalid",
         expected: [],
       },
-      // Proper string
+      // Proper v1 string
       {
-        acString: "1~1.2.3",
+        acString: "2~1.2.3~dv.4.5",
+        expected: ["gacp.1", "gacp.2", "gacp.3"],
+      },
+      // Proper v2 string
+      {
+        acString: "2~1.2.3~dv.4.5",
         expected: ["gacp.1", "gacp.2", "gacp.3"],
       },
     ])(
       "can decode a fides string of varying formats",
       ({ acString, expected }) => {
-        const result = idsFromAcString(acString);
+        const result = consentIdsFromAcString(acString);
         expect(result).toEqual(expected);
       },
     );
@@ -168,13 +173,15 @@ describe("fidesString", () => {
 
       it("appends GPP string to existing TC and AC strings", () => {
         const cmpApi = new CmpApi(1, 1);
-        window.Fides.fides_string = "CPzvOIA.IAAA,1~2.3.4";
+        window.Fides.fides_string = "CPzvOIA.IAAA,2~2.3.4~dv.1.5";
         jest
           .spyOn(cmpApi, "getGppString")
           .mockReturnValue("DBABLA~BVAUAAAAAWA.QA");
 
         const result = formatFidesStringWithGpp(cmpApi);
-        expect(result).toBe("CPzvOIA.IAAA,1~2.3.4,DBABLA~BVAUAAAAAWA.QA");
+        expect(result).toBe(
+          "CPzvOIA.IAAA,2~2.3.4~dv.1.5,DBABLA~BVAUAAAAAWA.QA",
+        );
       });
 
       it("appends GPP string to TC string when AC string is empty", () => {
@@ -201,14 +208,14 @@ describe("fidesString", () => {
       it("injects GPP string to existing TC, AC, and NC strings", () => {
         const cmpApi = new CmpApi(1, 1);
         window.Fides.fides_string =
-          "CPzvOIA.IAAA,1~2.3.4,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9";
+          "CPzvOIA.IAAA,2~2.3.4~dv.1.5,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9";
         jest
           .spyOn(cmpApi, "getGppString")
           .mockReturnValue("DBABLA~BVAUAAAAAWA.QA");
 
         const result = formatFidesStringWithGpp(cmpApi);
         expect(result).toBe(
-          "CPzvOIA.IAAA,1~2.3.4,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9",
+          "CPzvOIA.IAAA,2~2.3.4~dv.1.5,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9",
         );
       });
 
diff --git a/clients/fides-js/__tests__/lib/gpp/string-to-consent-tcf.test.ts b/clients/fides-js/__tests__/lib/gpp/string-to-consent-tcf.test.ts
index eff8f14836c..a1aa71cb5e5 100644
--- a/clients/fides-js/__tests__/lib/gpp/string-to-consent-tcf.test.ts
+++ b/clients/fides-js/__tests__/lib/gpp/string-to-consent-tcf.test.ts
@@ -145,7 +145,7 @@ describe("fidesStringToConsent", () => {
     const cmpApi = new CmpApi(1, 1);
     fidesStringToConsent({
       fidesString:
-        "CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA,1~,DBABMA~CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA",
+        "CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA,2~~dv.,DBABMA~CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA",
       cmpApi,
     });
     expect(updateConsentPreferences).toHaveBeenCalledWith(
@@ -183,7 +183,7 @@ describe("fidesStringToConsent", () => {
     const cmpApi = new CmpApi(1, 1);
     fidesStringToConsent({
       fidesString:
-        "CQNvpkAQNvpkAGXABBENBfFgAAAAAAAAAAAAAAAAAAAA,1~,DBABMA~CQNvpkAQNvpkAGXABBENBfFgAAAAAAAAAAAAAAAAAAAA",
+        "CQNvpkAQNvpkAGXABBENBfFgAAAAAAAAAAAAAAAAAAAA,2~~dv.,DBABMA~CQNvpkAQNvpkAGXABBENBfFgAAAAAAAAAAAAAAAAAAAA",
       cmpApi,
     });
     expect(updateConsentPreferences).toHaveBeenCalledWith(
@@ -208,7 +208,7 @@ describe("fidesStringToConsent", () => {
     const cmpApi = new CmpApi(1, 1);
     fidesStringToConsent({
       fidesString:
-        "CQNvpkAQNvpkAGXABBENBfFgAJAAAAIAAAAAAAAAAAAA,1~,DBABMA~CQNvpkAQNvpkAGXABBENBfFgAJAAAAIAAAAAAAAAAAAA",
+        "CQNvpkAQNvpkAGXABBENBfFgAJAAAAIAAAAAAAAAAAAA,2~~dv.,DBABMA~CQNvpkAQNvpkAGXABBENBfFgAJAAAAIAAAAAAAAAAAAA",
       cmpApi,
     });
     expect(updateConsentPreferences).toHaveBeenCalledWith(
diff --git a/clients/fides-js/docs/interfaces/Fides.md b/clients/fides-js/docs/interfaces/Fides.md
index 489b84c5766..505a61715c1 100644
--- a/clients/fides-js/docs/interfaces/Fides.md
+++ b/clients/fides-js/docs/interfaces/Fides.md
@@ -100,7 +100,7 @@ The string consists of four parts separated by commas in the format:
 
 ```ts
 console.log(Fides.fides_string);
-// "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+// "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
 ```
 
 ***
diff --git a/clients/fides-js/docs/interfaces/FidesOptions.md b/clients/fides-js/docs/interfaces/FidesOptions.md
index a820c49f499..bfea03b5160 100644
--- a/clients/fides-js/docs/interfaces/FidesOptions.md
+++ b/clients/fides-js/docs/interfaces/FidesOptions.md
@@ -175,17 +175,25 @@ The string consists of four parts separated by commas in the format:
 
 #### Example
 
-// Complete string with all parts:
-// "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+Complete string with all parts:
+```
+"CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+```
 
-// TC and AC strings only:
-// "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70"
+TC and AC strings only:
+```
+"CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33"
+```
 
-// GPP string only:
-// ",,DBABLA~BVAUAAAAAWA.QA"
+GPP string only:
+```
+",,DBABLA~BVAUAAAAAWA.QA"
+```
 
-// Notice Consent string only:
-// ",,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+Notice Consent string only:
+```
+",,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+```
 
 To properly encode the Notice Consent string, use the
 `window.Fides.encodeNoticeConsentString` function (see [Fides.encodeNoticeConsentString](Fides.md#encodenoticeconsentstring)) or write your own function that
diff --git a/clients/fides-js/rollup.config.mjs b/clients/fides-js/rollup.config.mjs
index 14cd953ff19..876f8ffeb2e 100644
--- a/clients/fides-js/rollup.config.mjs
+++ b/clients/fides-js/rollup.config.mjs
@@ -53,7 +53,7 @@ const fidesScriptPlugins = ({ name, gzipWarnSizeKb, gzipErrorSizeKb }) => [
     IS_DEV
       ? {}
       : {
-          include: ["**/*.ts"],
+          include: ["**/*.ts", "**/*.tsx"],
           functions: ["fidesDebugger"],
         },
   ),
diff --git a/clients/fides-js/src/docs/fides-options.ts b/clients/fides-js/src/docs/fides-options.ts
index e074548cadf..9eefbde9c32 100644
--- a/clients/fides-js/src/docs/fides-options.ts
+++ b/clients/fides-js/src/docs/fides-options.ts
@@ -158,17 +158,25 @@ export interface FidesOptions {
    * - NC_STRING: Base64 encoded string of the user's Notice Consent preferences.
    *
    * @example
-   * // Complete string with all parts:
-   * // "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+   * Complete string with all parts:
+   * ```
+   * "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+   * ```
    *
-   * // TC and AC strings only:
-   * // "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70"
+   * TC and AC strings only:
+   * ```
+   * "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33"
+   * ```
    *
-   * // GPP string only:
-   * // ",,DBABLA~BVAUAAAAAWA.QA"
+   * GPP string only:
+   * ```
+   * ",,DBABLA~BVAUAAAAAWA.QA"
+   * ```
    *
-   * // Notice Consent string only:
-   * // ",,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+   * Notice Consent string only:
+   * ```
+   * ",,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+   * ```
    *
    * To properly encode the Notice Consent string, use the
    * `window.Fides.encodeNoticeConsentString` function (see {@link Fides.encodeNoticeConsentString}) or write your own function that
diff --git a/clients/fides-js/src/docs/fides.ts b/clients/fides-js/src/docs/fides.ts
index 26c67d352e3..044f6c2eee9 100644
--- a/clients/fides-js/src/docs/fides.ts
+++ b/clients/fides-js/src/docs/fides.ts
@@ -90,7 +90,7 @@ export interface Fides {
    *
    * @example
    * console.log(Fides.fides_string);
-   * // "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,1~61.70,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
+   * // "CPzHq4APzHq4AAMABBENAUEAALAAAEOAAAAAAEAEACACAAAA,2~61.70~dv.33,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjowLCJhbmFseXRpY3MiOjF9"
    */
   fides_string?: string;
 
diff --git a/clients/fides-js/src/lib/consent-utils.ts b/clients/fides-js/src/lib/consent-utils.ts
index a2ab657374c..e9103d4f74b 100644
--- a/clients/fides-js/src/lib/consent-utils.ts
+++ b/clients/fides-js/src/lib/consent-utils.ts
@@ -620,3 +620,14 @@ export const applyOverridesToConsent = (
 
   return consentValues;
 };
+
+export const isValidAcString = (acString: string) => {
+  const acVersion = acString.split("~")[0];
+  return Boolean(
+    acVersion &&
+      ["1", "2"].includes(acVersion) &&
+      acString?.match(
+        acVersion === "1" ? /\d~[0-9.]*$/ : /\d~[0-9.]*~dv.[0-9.]*$/,
+      ),
+  );
+};
diff --git a/clients/fides-js/src/lib/fides-string.ts b/clients/fides-js/src/lib/fides-string.ts
index 0e247c72d79..a5ff6d71f34 100644
--- a/clients/fides-js/src/lib/fides-string.ts
+++ b/clients/fides-js/src/lib/fides-string.ts
@@ -1,5 +1,6 @@
 import { CmpApi } from "@iabgpp/cmpapi";
 
+import { isValidAcString } from "./consent-utils";
 import { FIDES_SEPARATOR } from "./tcf/constants";
 import { VendorSources } from "./tcf/vendors";
 
@@ -19,21 +20,21 @@ export interface DecodedFidesString {
  *
  * The Fides string format is: `TC_STRING,AC_STRING,GPP_STRING,NC_STRING` where:
  * - TC_STRING: The TCF (Transparency & Consent Framework) string
- * - AC_STRING: The Additional Consent string, which is derived from TC_STRING
+ * - AC_STRING: The Additional Consent string
  * - GPP_STRING: The Global Privacy Platform string
  * - NC_STRING: A Base64 encoded stringified JSON object containing Notice Consent preferences
  *
  * Rules:
  * 1. If the string is empty or undefined, all parts are empty strings
  * 2. If only one part exists, it's treated as the TC string
- * 3. AC string can only exist if TC string exists (as it's derived from TC)
- * 4. GPP string is independent and can exist with or without TC/AC strings
- * 5. Notice Consent String is an optional part that can be used to pass notice consent preferences programatically
+ * 3. AC string can only exist if TC string exists. An AC string will not be processed if the TC string is not present.
+ * 4. GPP string is independent and can exist with or without other string parts
+ * 5. Notice Consent String is an optional part that can be used to pass notice consent preferences programatically. This can also exist independently.
  *
  * @example
  * // Complete string with all parts
- * decodeFidesString("CPzvOIA.IAAA,1~2.3.4,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9")
- * // Returns { tc: "CPzvOIA.IAAA", ac: "1~2.3.4", gpp: "DBABLA~BVAUAAAAAWA.QA", nc: "eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9" }
+ * decodeFidesString("CPzvOIA.IAAA,2~2.3.4~dv.1.5,DBABLA~BVAUAAAAAWA.QA,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9")
+ * // Returns { tc: "CPzvOIA.IAAA", ac: "2~2.3.4~dv.1.5", gpp: "DBABLA~BVAUAAAAAWA.QA", nc: "eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9" }
  *
  * // TC string only
  * decodeFidesString("CPzvOIA.IAAA")
@@ -43,6 +44,10 @@ export interface DecodedFidesString {
  * decodeFidesString(",,DBABLA~BVAUAAAAAWA.QA")
  * // Returns { tc: "", ac: "", gpp: "DBABLA~BVAUAAAAAWA.QA", nc: "" }
  *
+ * // Notice Consent String only
+ * decodeFidesString(",,,eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9")
+ * // Returns { tc: "", ac: "", gpp: "", nc: "eyJkYXRhX3NhbGVzX2FuZF9zaGFyaW5nIjoxLCJhbmFseXRpY3MiOjB9" }
+ *
  * @param fidesString - The combined Fides string to decode
  * @returns An object containing the decoded TC, AC, GPP, and Notice Consent strings
  */
@@ -58,14 +63,21 @@ export const decodeFidesString = (fidesString: string): DecodedFidesString => {
 };
 
 /**
- * Given an AC string, return a list of its ids, encoded
+ * Given an AC string, return a list of its ids, encoded.
+ * V2 AC strings include a list of disclosed vendors which we can ignore for the return value but
+ * we do use to validate the AC string format. The consent id list is formatted the same as V1.
  *
- * @example
+ * @example // V1 AC string
+ * consentIdsFromAcString("1~1.2.3")
+ * // returns [gacp.1, gacp.2, gacp.3]
+ *
+ * @example // V2 AC string
+ * consentIdsFromAcString("2~1.2.3~dv.4.5")
  * // returns [gacp.1, gacp.2, gacp.3]
- * idsFromAcString("1~1.2.3")
  */
-export const idsFromAcString = (acString: string) => {
-  if (!acString?.match(/\d~[0-9.]+$/)) {
+export const consentIdsFromAcString = (acString: string) => {
+  const isValid = isValidAcString(acString);
+  if (!isValid) {
     fidesDebugger(
       acString && `Received invalid AC string "${acString}", returning no ids`,
     );
diff --git a/clients/fides-js/src/lib/tcf.ts b/clients/fides-js/src/lib/tcf.ts
index 9d2f3262fb7..9b1608ae8e1 100644
--- a/clients/fides-js/src/lib/tcf.ts
+++ b/clients/fides-js/src/lib/tcf.ts
@@ -32,39 +32,48 @@ const CMP_VERSION = 1;
 const FORBIDDEN_LEGITIMATE_INTEREST_PURPOSE_IDS = [1, 3, 4, 5, 6];
 
 // AC
-const AC_SPECIFICATION_VERSION = 1;
+const AC_SPECIFICATION_VERSION = 2;
 
 /**
- * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3` where:
- * - version: The AC specification version (currently 1)
- * - id1.id2.id3: A sorted, dot-separated list of vendor IDs that have consent
+ * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3~id4.id5.id6` where:
+ * - version: The AC specification version
+ * - id1.id2.id3: A sorted, dot-separated list of user-consented vendor IDs.
+ * - id4.id5.id6: "dv." followed by a sorted, dot-separated list of non-user-consented disclosed
+ *  vendor IDs.
  *
- * The function combines vendors from both consent and legitimate interest lists,
- * filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
+ * Vendors included in the user-consented list should not be included in the disclosed vendor list
+ * to reduce the string size.
+ *
+ * The function filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
  * and joins them in ascending order.
  *
  * @example
- * // Given vendors ["gacp.42", "gacp.33", "gvl.12", "gacp.49"]
- * generateAcString({tcStringPreferences: {
- *   vendorsConsent: ["gacp.42"],
- *   vendorsLegint: ["gacp.33", "gvl.12", "gacp.49"]
- * }})
- * // Returns "1~33.42.49"
+ * generateAcString({
+ *   userConsentedVendorIds: ["gacp.42"],
+ *   disclosedVendorIds: ["gacp.49", "gacp.33", "gvl.12", "gacp.42", "gvl.123"]
+ * })
+ * // Returns "2~42~dv.33.49"
  */
 const generateAcString = ({
-  tcStringPreferences,
+  userConsentedVendorIds,
+  disclosedVendorIds,
 }: {
-  tcStringPreferences: Pick<EnabledIds, "vendorsConsent" | "vendorsLegint">;
+  userConsentedVendorIds: string[];
+  disclosedVendorIds: string[];
 }) => {
-  const vendorIds = [
-    ...tcStringPreferences.vendorsConsent,
-    ...tcStringPreferences.vendorsLegint,
-  ]
-    .filter(vendorIsAc)
-    .map((id) => decodeVendorId(id).id)
-    .sort((a, b) => Number(a) - Number(b))
-    .join(".");
-  return `${AC_SPECIFICATION_VERSION}~${vendorIds}`;
+  const processIds = (ids: string[]) =>
+    ids
+      .filter(vendorIsAc)
+      .map((id) => decodeVendorId(id).id)
+      .sort((a, b) => Number(a) - Number(b))
+      .join(".");
+
+  const consentedIds = processIds(userConsentedVendorIds);
+  const disclosedIds = processIds(
+    disclosedVendorIds.filter((id) => !userConsentedVendorIds.includes(id)),
+  );
+
+  return `${AC_SPECIFICATION_VERSION}~${consentedIds}~dv.${disclosedIds}`;
 };
 
 /**
@@ -233,8 +242,16 @@ export const generateFidesString = async ({
         segments: [Segment.CORE],
       });
 
-      // Attach the AC string
-      const acString = generateAcString({ tcStringPreferences });
+      // Attach the AC string, which only applies to tcf_vendor_consents (no LI exists in AC)
+      const disclosedVendorIds = experience.minimal_tcf
+        ? (experience as PrivacyExperienceMinimal).tcf_vendor_consent_ids
+        : (experience as PrivacyExperience).tcf_vendor_consents?.map(
+            (vendor) => vendor.id,
+          );
+      const acString = generateAcString({
+        userConsentedVendorIds: tcStringPreferences?.vendorsConsent ?? [],
+        disclosedVendorIds: disclosedVendorIds ?? [],
+      });
       encodedString = `${encodedString}${FIDES_SEPARATOR}${acString}`;
 
       // GPP string portion is handled by the GPP extension
diff --git a/clients/fides-js/src/lib/tcf/utils.ts b/clients/fides-js/src/lib/tcf/utils.ts
index d81e53bf29c..1a74d40d1eb 100644
--- a/clients/fides-js/src/lib/tcf/utils.ts
+++ b/clients/fides-js/src/lib/tcf/utils.ts
@@ -16,9 +16,9 @@ import {
   transformTcfPreferencesToCookieKeys,
 } from "../cookie";
 import {
+  consentIdsFromAcString,
   DecodedFidesString,
   decodeFidesString,
-  idsFromAcString,
 } from "../fides-string";
 import {
   transformConsentToFidesUserPreference,
@@ -96,7 +96,7 @@ export const buildTcfEntitiesFromCookieAndFidesString = (
     if (!tcString) {
       return tcfEntities;
     }
-    const acStringIds = idsFromAcString(acString);
+    const consentedAcStringIds = consentIdsFromAcString(acString);
 
     // Populate every field from tcModel
     const tcModel = TCString.decode(tcString);
@@ -107,13 +107,13 @@ export const buildTcfEntitiesFromCookieAndFidesString = (
       const tcIds = Array.from(tcModel[tcfModelKey])
         .filter(([, consented]) => consented)
         .map(([id]) => (isVendorKey ? `gvl.${id}` : id));
-      // @ts-ignore the array map should ensure we will get the right record type
+      // @ts-expect-error the array map should ensure we will get the correct record type
       tcfEntities[experienceKey] = experience[experienceKey]?.map((item) => {
         let consented = !!tcIds.find((id) => id === item.id);
         // Also check the AC string, which only applies to tcf_vendor_consents
         if (
           experienceKey === "tcf_vendor_consents" &&
-          acStringIds.find((id) => id === item.id)
+          consentedAcStringIds.find((id) => id === item.id)
         ) {
           consented = true;
         }
diff --git a/clients/privacy-center/cypress/e2e/consent-banner-gpp.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner-gpp.cy.ts
index 473e318790a..d5fd388711a 100644
--- a/clients/privacy-center/cypress/e2e/consent-banner-gpp.cy.ts
+++ b/clients/privacy-center/cypress/e2e/consent-banner-gpp.cy.ts
@@ -344,7 +344,7 @@ describe("Fides-js GPP extension", () => {
     it("can handle a fides string being passed in", () => {
       cy.setCookie(
         "fides_string",
-        "CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA,1~,DBABMA~CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA",
+        "CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA,2~~dv.,DBABMA~CQNvpkAQNvpkAGXABBENBfFgALAAAENAAAAAFyQAQFyAXJABAXIAAAAA",
       );
       const cookie = mockCookie({
         tcf_version_hash: TCF_VERSION_HASH,
diff --git a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts
index bc8a8066131..0b581e1a8b8 100644
--- a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts
+++ b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts
@@ -134,7 +134,7 @@ const assertTcOptIns = ({
   const tcString = fidesString?.split(FIDES_SEPARATOR)[0];
   expect(tcString).to.be.a("string");
   expect(tcString).to.not.equal("");
-  const model = TCString.decode(tcString!);
+  const model = TCString.decode(tcString);
   const values = Array.from(model[modelType].values()).sort();
   expect(values).to.eql(ids.sort());
 };
@@ -147,9 +147,10 @@ const assertAcOptIns = ({
   ids: number[];
 }) => {
   const { fides_string: fidesString } = cookie;
-  const acString = fidesString?.split("1~")[1];
+  const acString = fidesString?.split(",")[1];
   expect(acString).to.be.a("string");
-  const values = acString!
+  const acConsent = acString.split("~")[1];
+  const values = acConsent
     .split(".")
     .map((id) => +id)
     .sort();
@@ -551,7 +552,7 @@ describe("Fides-js TCF", () => {
                   consentMethod: "accept",
                 },
               });
-              expect(call.Fides.fides_string).to.contain(",1~");
+              expect(call.Fides.fides_string).to.contain(",2~~dv.");
             });
 
           // FidesUpdated call
@@ -572,7 +573,7 @@ describe("Fides-js TCF", () => {
                   consentMethod: "accept",
                 },
               });
-              expect(call.Fides.fides_string).to.contain(",1~");
+              expect(call.Fides.fides_string).to.contain(",2~~dv.");
             });
         });
 
@@ -1240,7 +1241,7 @@ describe("Fides-js TCF", () => {
                     consentMethod: "accept",
                   },
                 });
-                expect(call.Fides.fides_string).to.contain(",1~");
+                expect(call.Fides.fides_string).to.contain(",2~~dv.");
               });
 
             // FidesUpdated call
@@ -1261,7 +1262,7 @@ describe("Fides-js TCF", () => {
                     consentMethod: "accept",
                   },
                 });
-                expect(call.Fides.fides_string).to.contain(",1~");
+                expect(call.Fides.fides_string).to.contain(",2~~dv.");
               });
           });
 
@@ -1801,7 +1802,7 @@ describe("Fides-js TCF", () => {
                 expect(args[0]).to.equal(ConsentMethod.REJECT);
                 expect(args[1]).to.be.a("object");
                 // the TC str is dynamically updated upon save preferences with diff timestamp, so we do a fuzzy match
-                expect(args[2]).to.contain("AA,1~");
+                expect(args[2]).to.contain("AA,2~~dv.");
                 expect(args[3]).to.be.a("object");
                 // timeout means API call not made, which is expected
                 cy.on("fail", (error) => {
@@ -2586,7 +2587,7 @@ describe("Fides-js TCF", () => {
       setFidesCookie();
       // Purpose 7, Special Feature 1
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       stubTCFExperience({ stubOptions: { fidesString: fidesStringOverride } });
       cy.window().then((win) => {
@@ -2677,7 +2678,7 @@ describe("Fides-js TCF", () => {
      */
     it("prefers preferences from fides_string option when both fides_string and experience is provided and cookie does not exist", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       stubTCFExperience({ stubOptions: { fidesString: fidesStringOverride } });
       cy.window().then((win) => {
@@ -2769,7 +2770,7 @@ describe("Fides-js TCF", () => {
      */
     it("does nothing when fides_string option when both fides_string option and cookie exist but no experience exists (neither prefetch nor API)", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       setFidesCookie();
       stubConfig(
         {
@@ -2800,7 +2801,7 @@ describe("Fides-js TCF", () => {
      */
     it("prefers preferences from fides_string option when both fides_string option and cookie exist and experience is fetched from API", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       setFidesCookie();
       cy.fixture("consent/geolocation_tcf.json").then((geo) => {
@@ -2898,7 +2899,7 @@ describe("Fides-js TCF", () => {
      * EXPECTED RESULT: ignore invalid fides_string option and render experience as-is
      */
     it("can handle an invalid fides_string option and continue rendering the experience", () => {
-      const fidesStringOverride = "invalid-string,1~";
+      const fidesStringOverride = "invalid-string,2~~dv.";
       cy.fixture("consent/geolocation_tcf.json").then((geo) => {
         stubTCFExperience({
           stubOptions: { fidesString: fidesStringOverride },
@@ -2949,8 +2950,8 @@ describe("Fides-js TCF", () => {
     it("prefers preferences from fides_string option when fides_string override, custom pref API and cookie exist and experience is fetched from API", () => {
       // This fide str override opts in to all
       const fidesStringOverride =
-        "CP0gqMAP0gqMAGXABBENATEIABaAAEAAAAAAABEAAAAA,1~";
-      const fidesString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CP0gqMAP0gqMAGXABBENATEIABaAAEAAAAAAABEAAAAA,2~~dv.";
+      const fidesString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       setFidesCookie();
       cy.fixture("consent/geolocation_tcf.json").then((geo) => {
@@ -3054,7 +3055,7 @@ describe("Fides-js TCF", () => {
      * EXPECTED RESULT: use preferences from preferences API, overrides cookie tcf_version_hash if returned in preferences API
      */
     it("prefers preferences from preferences API when custom pref API and cookie exist and experience is fetched from API", () => {
-      const fidesString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+      const fidesString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const versionHash = "091834y";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       setFidesCookie();
@@ -3248,7 +3249,7 @@ describe("Fides-js TCF", () => {
     it("can use a fides_string to override a vendor consent", () => {
       // Opts in to all
       const fidesStringOverride =
-        "CP0gqMAP0gqMAGXABBENATEIABaAAEAAAAAAABEAAAAA,1~";
+        "CP0gqMAP0gqMAGXABBENATEIABaAAEAAAAAAABEAAAAA,2~~dv.";
       stubTCFExperience({ stubOptions: { fidesString: fidesStringOverride } });
       cy.window().then((win) => {
         win.__tcfapi("addEventListener", 2, cy.stub().as("TCFEvent"));
@@ -3286,7 +3287,7 @@ describe("Fides-js TCF", () => {
   describe("fides_string override options", () => {
     it("uses fides_string when set via cookie", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       cy.getCookie("fides_string").should("not.exist");
       cy.setCookie("fides_string", fidesStringOverride);
@@ -3327,7 +3328,7 @@ describe("Fides-js TCF", () => {
 
     it("uses fides_string when set via query param", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       cy.getCookie("fides_string").should("not.exist");
       stubTCFExperience({
@@ -3369,7 +3370,7 @@ describe("Fides-js TCF", () => {
 
     it("uses fides_string when set via window obj", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors
       cy.getCookie("fides_string").should("not.exist");
       stubTCFExperience({
@@ -3411,7 +3412,7 @@ describe("Fides-js TCF", () => {
 
     it("uses fides_string when set via window obj at custom config path", () => {
       const fidesStringOverride =
-        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,1~";
+        "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~~dv.";
       const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA";
       cy.getCookie("fides_string").should("not.exist");
       stubTCFExperience({
@@ -3517,8 +3518,9 @@ describe("Fides-js TCF", () => {
 
   describe("ac string", () => {
     const AC_IDS = [42, 33, 49];
-    const acceptAllAcString = `1~${AC_IDS.sort().join(".")}`;
-    const rejectAllAcString = "1~";
+    const acceptAllAcString = `2~${AC_IDS.sort().join(".")}~dv.`;
+    const acceptSomeAcString = `2~33.42~dv.49`;
+    const rejectAllAcString = "2~~dv.33.42.49";
     beforeEach(() => {
       cy.fixture("consent/experience_tcf.json").then((payload) => {
         const experience = payload.items[0];
@@ -3554,7 +3556,7 @@ describe("Fides-js TCF", () => {
       });
     });
 
-    it("can opt in to AC vendors and generate string", () => {
+    it("can opt in to all AC vendors and generate string", () => {
       cy.get("#fides-tab-vendors").click();
       AC_IDS.forEach((id) => {
         // Turn all ACs on
@@ -3584,7 +3586,39 @@ describe("Fides-js TCF", () => {
       });
     });
 
-    it("can opt out of AC vendors and generate string", () => {
+    it("can opt in to some AC vendors and generate string", () => {
+      cy.get("#fides-tab-vendors").click();
+      AC_IDS.slice(0, 2).forEach((id) => {
+        cy.getByTestId(`toggle-AC ${id}`).click();
+      });
+      cy.get("button").contains("Save").click();
+      cy.wait("@patchPrivacyPreference").then((interception) => {
+        const { body } = interception.request;
+        const expected = [
+          { id: VENDOR_1.id, preference: "opt_out" },
+          ...AC_IDS.map((id) => ({
+            id: `gacp.${id}`,
+            preference: id === 49 ? "opt_out" : "opt_in",
+          })),
+        ];
+        expect(body.vendor_consent_preferences).to.eql(expected);
+        expect(body.method).to.eql(ConsentMethod.SAVE);
+
+        // Check the cookie
+        cy.waitUntilCookieExists(CONSENT_COOKIE_NAME).then(() => {
+          cy.getCookie(CONSENT_COOKIE_NAME).then((cookie) => {
+            const cookieKeyConsent: FidesCookie = JSON.parse(
+              decodeURIComponent(cookie!.value),
+            );
+            const { fides_string: tcString } = cookieKeyConsent;
+            const acString = tcString?.split(",")[1];
+            expect(acString).to.eql(acceptSomeAcString);
+          });
+        });
+      });
+    });
+
+    it("can opt out of all AC vendors and generate string", () => {
       cy.get("#fides-tab-vendors").click();
       cy.getByTestId("consent-modal").within(() => {
         cy.get("button").contains("Opt out of all").click();
@@ -3651,6 +3685,7 @@ describe("Fides-js TCF", () => {
           const parts = tcString.split(",");
           expect(parts.length).to.eql(1);
           expect(parts[0]).to.not.contain("1~");
+          expect(parts[0]).to.not.contain("2~");
           // But we can still access the AC string via `addtlConsent`
           expect(tcData.addtlConsent).to.eql(acceptAllAcString);
         });
@@ -3683,7 +3718,7 @@ describe("Fides-js TCF", () => {
       });
     });
 
-    it("can initialize from an AC string", () => {
+    it("can initialize from a v1 AC string", () => {
       const cookie = mockCookie({
         fides_string: "CPzbcgAPzbcgAGXABBENATEIAACAAAAAAAAAABEAAAAA",
       });
@@ -3709,6 +3744,33 @@ describe("Fides-js TCF", () => {
           assertAcOptIns({ cookie: updatedCookie, ids: [42, 43, 44] });
         });
     });
+
+    it("can initialize from a v2 AC string", () => {
+      const cookie = mockCookie({
+        fides_string: "CPzbcgAPzbcgAGXABBENATEIAACAAAAAAAAAABEAAAAA",
+      });
+      cy.setCookie(CONSENT_COOKIE_NAME, JSON.stringify(cookie));
+      stubTCFExperience({
+        stubOptions: {
+          fidesString:
+            "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA,2~42.43.44~dv.33.49",
+        },
+      });
+
+      cy.get("@FidesInitialized")
+        .should("have.been.calledTwice")
+        .its("lastCall.args.0.detail")
+        .then((updatedCookie: FidesCookie) => {
+          // TC string setting worked
+          assertTcOptIns({
+            cookie: updatedCookie,
+            modelType: "purposeConsents",
+            ids: [PURPOSE_7.id],
+          });
+          // AC string setting worked
+          assertAcOptIns({ cookie: updatedCookie, ids: [42, 43, 44] });
+        });
+    });
   });
 
   describe("paging", () => {


Reviews:
user: login: eastandwestwind
body: Nice work parsing all the complexities here @gilluminate ! I mostly have questions for my own understanding, but nothing blocking.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T19:03:18Z
user: login: gilluminate
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T19:19:01Z
user: login: gilluminate
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T19:24:18Z
user: login: gilluminate
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T19:25:29Z

Review comments:
diff_hunk: @@ -60,12 +64,22 @@ export const decodeFidesString = (fidesString: string): DecodedFidesString => {
 /**
  * Given an AC string, return a list of its ids, encoded
  *
- * @example
+ * @example // V1 AC string
+ * consentIdsFromAcString("1~1.2.3")
+ * // returns [gacp.1, gacp.2, gacp.3]
+ *
+ * @example // V2 AC string
+ * consentIdsFromAcString("2~1.2.3~dv.4.5")
  * // returns [gacp.1, gacp.2, gacp.3]
- * idsFromAcString("1~1.2.3")
  */
-export const idsFromAcString = (acString: string) => {
-  if (!acString?.match(/\d~[0-9.]+$/)) {
+export const consentIdsFromAcString = (acString: string) => {
+  const acVersion = acString.split("~")[0];
+  if (
path: clients/fides-js/src/lib/fides-string.ts
user: login: eastandwestwind
body: Can you leave a small code comment saying what exactly this is checking between versions?
created_at: 2025-05-19T18:44:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,39 +32,48 @@ const CMP_VERSION = 1;
 const FORBIDDEN_LEGITIMATE_INTEREST_PURPOSE_IDS = [1, 3, 4, 5, 6];
 
 // AC
-const AC_SPECIFICATION_VERSION = 1;
+const AC_SPECIFICATION_VERSION = 2;
 
 /**
- * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3` where:
- * - version: The AC specification version (currently 1)
- * - id1.id2.id3: A sorted, dot-separated list of vendor IDs that have consent
+ * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3~id4.id5.id6` where:
+ * - version: The AC specification version
+ * - id1.id2.id3: A sorted, dot-separated list of user-consented vendor IDs.
+ * - id4.id5.id6: "dv." followed by a sorted, dot-separated list of non-user-consented disclosed
+ *  vendor IDs.
  *
- * The function combines vendors from both consent and legitimate interest lists,
- * filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
+ * Vendors included in the user-consented list should not be included in the disclosed vendor list
+ * to reduce the string size.
+ *
+ * The function filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
  * and joins them in ascending order.
  *
  * @example
- * // Given vendors ["gacp.42", "gacp.33", "gvl.12", "gacp.49"]
- * generateAcString({tcStringPreferences: {
- *   vendorsConsent: ["gacp.42"],
- *   vendorsLegint: ["gacp.33", "gvl.12", "gacp.49"]
- * }})
- * // Returns "1~33.42.49"
+ * generateAcString({
+ *   userConsentedVendorIds: ["gacp.42"],
+ *   disclosedVendorIds: ["gacp.49", "gacp.33", "gvl.12", "gacp.42", "gvl.123"]
+ * })
+ * // Returns "2~42~dv.33.49"
  */
 const generateAcString = ({
-  tcStringPreferences,
+  userConsentedVendorIds,
+  disclosedVendorIds,
 }: {
-  tcStringPreferences: Pick<EnabledIds, "vendorsConsent" | "vendorsLegint">;
+  userConsentedVendorIds: string[];
+  disclosedVendorIds: string[];
 }) => {
-  const vendorIds = [
-    ...tcStringPreferences.vendorsConsent,
-    ...tcStringPreferences.vendorsLegint,
-  ]
-    .filter(vendorIsAc)
-    .map((id) => decodeVendorId(id).id)
-    .sort((a, b) => Number(a) - Number(b))
-    .join(".");
-  return `${AC_SPECIFICATION_VERSION}~${vendorIds}`;
+  const processIds = (ids: string[]) =>
+    ids
+      .filter(vendorIsAc)
+      .map((id) => decodeVendorId(id).id)
+      .sort((a, b) => Number(a) - Number(b))
+      .join(".");
+
+  const consentedIds = processIds(userConsentedVendorIds);
+  const disclosedIds = processIds(
+    disclosedVendorIds.filter((id) => !userConsentedVendorIds.includes(id)),
+  );
+
+  return `${AC_SPECIFICATION_VERSION}~${consentedIds}~dv.${disclosedIds}`;
path: clients/fides-js/src/lib/tcf.ts
user: login: eastandwestwind
body: Ah so for the `consentedIds` portion ... instead of using the vendor ids in both `vendorsConsent` and `vendorsLegint`, we now only consider `vendorsConsent` right? That's the key change there?
created_at: 2025-05-19T19:00:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -233,8 +242,16 @@ export const generateFidesString = async ({
         segments: [Segment.CORE],
       });
 
-      // Attach the AC string
-      const acString = generateAcString({ tcStringPreferences });
+      // Attach the AC string, which only applies to tcf_vendor_consents (no LI exists in AC)
path: clients/fides-js/src/lib/tcf.ts
user: login: eastandwestwind
body: Are `userConsentedVendorIds` a subset of `disclosedVendorIds`? Trying to wrap my head around how to think about this relationship
created_at: 2025-05-19T19:02:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,39 +32,48 @@ const CMP_VERSION = 1;
 const FORBIDDEN_LEGITIMATE_INTEREST_PURPOSE_IDS = [1, 3, 4, 5, 6];
 
 // AC
-const AC_SPECIFICATION_VERSION = 1;
+const AC_SPECIFICATION_VERSION = 2;
 
 /**
- * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3` where:
- * - version: The AC specification version (currently 1)
- * - id1.id2.id3: A sorted, dot-separated list of vendor IDs that have consent
+ * Generates an Additional Consent (AC) string in the format `version~id1.id2.id3~id4.id5.id6` where:
+ * - version: The AC specification version
+ * - id1.id2.id3: A sorted, dot-separated list of user-consented vendor IDs.
+ * - id4.id5.id6: "dv." followed by a sorted, dot-separated list of non-user-consented disclosed
+ *  vendor IDs.
  *
- * The function combines vendors from both consent and legitimate interest lists,
- * filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
+ * Vendors included in the user-consented list should not be included in the disclosed vendor list
+ * to reduce the string size.
+ *
+ * The function filters for AC vendors only (those with 'gacp' prefix), extracts their numeric IDs,
  * and joins them in ascending order.
  *
  * @example
- * // Given vendors ["gacp.42", "gacp.33", "gvl.12", "gacp.49"]
- * generateAcString({tcStringPreferences: {
- *   vendorsConsent: ["gacp.42"],
- *   vendorsLegint: ["gacp.33", "gvl.12", "gacp.49"]
- * }})
- * // Returns "1~33.42.49"
+ * generateAcString({
+ *   userConsentedVendorIds: ["gacp.42"],
+ *   disclosedVendorIds: ["gacp.49", "gacp.33", "gvl.12", "gacp.42", "gvl.123"]
+ * })
+ * // Returns "2~42~dv.33.49"
  */
 const generateAcString = ({
-  tcStringPreferences,
+  userConsentedVendorIds,
+  disclosedVendorIds,
 }: {
-  tcStringPreferences: Pick<EnabledIds, "vendorsConsent" | "vendorsLegint">;
+  userConsentedVendorIds: string[];
+  disclosedVendorIds: string[];
 }) => {
-  const vendorIds = [
-    ...tcStringPreferences.vendorsConsent,
-    ...tcStringPreferences.vendorsLegint,
-  ]
-    .filter(vendorIsAc)
-    .map((id) => decodeVendorId(id).id)
-    .sort((a, b) => Number(a) - Number(b))
-    .join(".");
-  return `${AC_SPECIFICATION_VERSION}~${vendorIds}`;
+  const processIds = (ids: string[]) =>
+    ids
+      .filter(vendorIsAc)
+      .map((id) => decodeVendorId(id).id)
+      .sort((a, b) => Number(a) - Number(b))
+      .join(".");
+
+  const consentedIds = processIds(userConsentedVendorIds);
+  const disclosedIds = processIds(
+    disclosedVendorIds.filter((id) => !userConsentedVendorIds.includes(id)),
+  );
+
+  return `${AC_SPECIFICATION_VERSION}~${consentedIds}~dv.${disclosedIds}`;
path: clients/fides-js/src/lib/tcf.ts
user: login: gilluminate
body: There is no such thing as an AC vendor with Legitimate Interest, so including that in the first place was incorrect. We add all of them to the `tcf_vendor_consents` prop in the experience. V2 now includes the disclosed IDs in addition to the consent Ids, which is the key change.
created_at: 2025-05-19T19:19:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -233,8 +242,16 @@ export const generateFidesString = async ({
         segments: [Segment.CORE],
       });
 
-      // Attach the AC string
-      const acString = generateAcString({ tcStringPreferences });
+      // Attach the AC string, which only applies to tcf_vendor_consents (no LI exists in AC)
path: clients/fides-js/src/lib/tcf.ts
user: login: gilluminate
body: Yes. Basically the logic is that all of the disclosed vendors end up in the `dv.` list. However vendors included in the consent list should not be included in disclosed vendors list to reduce string length. So, for all intents and purposes, the DV list ends up being non-consented, disclosed.
created_at: 2025-05-19T19:24:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,12 +64,22 @@ export const decodeFidesString = (fidesString: string): DecodedFidesString => {
 /**
  * Given an AC string, return a list of its ids, encoded
  *
- * @example
+ * @example // V1 AC string
+ * consentIdsFromAcString("1~1.2.3")
+ * // returns [gacp.1, gacp.2, gacp.3]
+ *
+ * @example // V2 AC string
+ * consentIdsFromAcString("2~1.2.3~dv.4.5")
  * // returns [gacp.1, gacp.2, gacp.3]
- * idsFromAcString("1~1.2.3")
  */
-export const idsFromAcString = (acString: string) => {
-  if (!acString?.match(/\d~[0-9.]+$/)) {
+export const consentIdsFromAcString = (acString: string) => {
+  const acVersion = acString.split("~")[0];
+  if (
path: clients/fides-js/src/lib/fides-string.ts
user: login: gilluminate
body: added!
created_at: 2025-05-19T19:25:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-19T17:38:41Z
body: [vc]: #+Gx00/GwikDN3nh8Af7MrwJhHbZKNb+8nb1C7TA7q1o=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmaWRlcy1wbHVzLW5pZ2h0bHkiLCJyb290RGlyZWN0b3J5IjoiY2xpZW50cy9hZG1pbi11aSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2V0aHljYS9maWRlcy1wbHVzLW5pZ2h0bHkvRVg0VWphNjRMQU5pM1ZUWnZHbnJlWkFzUVViUyIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6ImZpZGVzLXBsdXMtbmlnaHRseS1naXQtZ2lsbC1sai01OThhYy1zdHJpbmctdjItZXRoeWNhLnZlcmNlbC5hcHAifSx7Im5hbWUiOiJmaWRlcy1wcml2YWN5LWNlbnRlciIsInJvb3REaXJlY3RvcnkiOiJjbGllbnRzL3ByaXZhY3ktY2VudGVyIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImZpZGVzLXByaXZhY3ktY2VudGVyLWdpdC1naWxsLWxqLTU5OGFjLXN0cmluZy12Mi1ldGh5Y2EudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vZXRoeWNhL2ZpZGVzLXByaXZhY3ktY2VudGVyL0N1TVd2VWlubmtWM1FudWJONWpnNnBFNEozTTYiLCJwcmV2aWV3VXJsIjoiZmlkZXMtcHJpdmFjeS1jZW50ZXItZ2l0LWdpbGwtbGotNTk4YWMtc3RyaW5nLXYyLWV0aHljYS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **fides-privacy-center** | ✅ Ready ([Inspect](https://vercel.com/ethyca/fides-privacy-center/CuMWvUinnkV3QnubN5jg6pE4J3M6)) | [Visit Preview](https://fides-privacy-center-git-gill-lj-598ac-string-v2-ethyca.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/fides-privacy-center-git-gill-lj-598ac-string-v2-ethyca.vercel.app?via=pr-comment-feedback-link) | May 19, 2025 8:07pm |

<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **fides-plus-nightly** | ⬜️ Ignored ([Inspect](https://vercel.com/ethyca/fides-plus-nightly/EX4Uja64LANi3VTZvGnreZAsQUbS)) | [Visit Preview](https://fides-plus-nightly-git-gill-lj-598ac-string-v2-ethyca.vercel.app) |  | May 19, 2025 8:07pm |
</details>



author_association: CONTRIBUTOR
user: login: cypress[bot]
created_at: 2025-05-19T21:07:00Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/bauzeh?utm_source=github&utm_medium=PASSED&utm_campaign=project">fides</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #12931</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#12931</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ethyca/fides/commit/f1678785da362ec733fbead7a3eac0fd00ebe32b">f1678785da</a>: Support Additional Consent string v2 (#6155)</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/bauzeh?utm_source=github&utm_medium=PASSED&utm_campaign=project">fides</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/bauzeh/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=6155">main</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#12931</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>00m 52s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ethyca/fides/commit/f1678785da362ec733fbead7a3eac0fd00ebe32b">f1678785da</a>: Support Additional Consent string v2 (#6155)</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Jason Gill</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/bauzeh/runs/12931/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>5</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/bauzeh/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=6155">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE