feat(core-consent): scaffold package with types, IConsent, withConsent brand wrapper

- Add packages/core-consent with ConsentCategory, ConsentState,
  UserConsentState types and IConsent interface
- Add withConsent wrapper attaching __consentChecked brand at bind time;
  unit tests assert brand attachment and factory passthrough
- Add ConsentChecked<F> type to core-shared/conformance/brands.ts and
  isConsentChecked helper to brand-runtime.ts
- Extend FeatureManifest with requiresConsent?: readonly string[] field
- Extend assertFeatureConformance to require __consentChecked brand when
  requiresConsent.length > 0; synthetic fixture tests cover pass/fail cases
- Propagate __consentChecked in withSpan PROPAGATED_BRANDS so the outermost
  binding carries the brand

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 10:40:40 +00:00
parent f5d08dc84a
commit 9cb2fa321c
20 changed files with 410 additions and 27 deletions

View File

@@ -1,18 +1,18 @@
{ {
"generatedAt": "2026-05-19T10:16:15.209Z", "generatedAt": "2026-05-19T10:39:50.009Z",
"commit": "8cf9f4b", "commit": "f5d08dc",
"repo": { "repo": {
"statements": 96.48, "statements": 96.5,
"branches": 91.72, "branches": 91.82,
"functions": 96.83, "functions": 96.89,
"lines": 96.48, "lines": 96.5,
"counts": { "counts": {
"lf": 4264, "lf": 4291,
"lh": 4114, "lh": 4141,
"brf": 809, "brf": 819,
"brh": 742, "brh": 752,
"fnf": 252, "fnf": 257,
"fnh": 244 "fnh": 249
} }
}, },
"byPackage": { "byPackage": {
@@ -58,18 +58,32 @@
"fnh": 10 "fnh": 10
} }
}, },
"@repo/core-shared": { "@repo/core-consent": {
"statements": 98, "statements": 100,
"branches": 95.81, "branches": 100,
"functions": 92, "functions": 100,
"lines": 98, "lines": 100,
"counts": { "counts": {
"lf": 1050, "lf": 10,
"lh": 1029, "lh": 10,
"brf": 310, "brf": 4,
"brh": 297, "brh": 4,
"fnf": 100, "fnf": 4,
"fnh": 92 "fnh": 4
}
},
"@repo/core-shared": {
"statements": 98.03,
"branches": 95.89,
"functions": 92.08,
"lines": 98.03,
"counts": {
"lf": 1067,
"lh": 1046,
"brf": 316,
"brh": 303,
"fnf": 101,
"fnh": 93
} }
}, },
"@repo/marketing-pages": { "@repo/marketing-pages": {

View File

@@ -0,0 +1,29 @@
# @repo/core-consent
Optional core package providing a vendor-neutral consent management interface. Scaffold via `pnpm turbo gen core-package consent` (once the generator supports it).
## Structure
```
src/
consent-types.ts # ConsentCategory, ConsentState, UserConsentState
consent.interface.ts # IConsent — isGranted, grant, withdraw, getCategories
with-consent.ts # withConsent wrapper attaching ConsentChecked brand
index.ts # Barrel export
```
## Design
`IConsent` exposes four methods:
- `isGranted(category)` — synchronous check whether consent is granted
- `grant(category)` — record consent grant for a category
- `withdraw(category)` — record consent withdrawal for a category
- `getCategories()` — list all known consent states
The interface is vendor-neutral: no storage implementation is bundled here. Concrete implementations (e.g. a Payload-backed store) are wired at DI bind time in `bind-production` (Story 04).
`withConsent` wraps a use-case factory at bind time, attaches the `__consentChecked` brand, and is the innermost wrapper in the composition chain:
`withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`
See `docs/architecture/agent-first-workflow-and-conformance.md` for the dependency-injection conventions.

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View File

@@ -0,0 +1,26 @@
{
"name": "@repo/core-consent",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/core-shared": "workspace:*"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,22 @@
/**
* Known consent categories. The `(string & {})` escape hatch keeps the union
* open for custom categories while still providing autocomplete for the
* standard values.
*/
export type ConsentCategory =
| "necessary"
| "functional"
| "analytics"
| "marketing"
| (string & {});
/** Whether a subject has granted or denied consent for a category. */
export type ConsentState = "granted" | "denied" | "pending";
/** Per-category consent record for a single subject. */
export type UserConsentState = {
readonly category: ConsentCategory;
readonly state: ConsentState;
readonly grantedAt?: Date;
readonly withdrawnAt?: Date;
};

View File

@@ -0,0 +1,22 @@
import type { ConsentCategory, UserConsentState } from "./consent-types";
/**
* Vendor-neutral consent management interface.
*
* Feature binders that receive a consent instance operate through this
* interface. Concrete implementations (Payload-backed, in-memory, etc.) are
* wired at DI bind time and never imported by feature packages directly.
*/
export interface IConsent {
/** Synchronous check — true when the subject has granted the category. */
isGranted(category: ConsentCategory): boolean;
/** Record a consent grant for the given category. */
grant(category: ConsentCategory): Promise<void>;
/** Record a consent withdrawal for the given category. */
withdraw(category: ConsentCategory): Promise<void>;
/** Return the full list of per-category consent states. */
getCategories(): UserConsentState[];
}

View File

@@ -0,0 +1,8 @@
export type {
ConsentCategory,
ConsentState,
UserConsentState,
} from "./consent-types";
export type { IConsent } from "./consent.interface";
export type { ConsentChecked } from "./with-consent";
export { withConsent } from "./with-consent";

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import { withConsent, type ConsentChecked } from "@/with-consent";
import type { IConsent } from "@/consent.interface";
import { isConsentChecked } from "@repo/core-shared/conformance";
function makeConsent(): IConsent {
return {
isGranted: () => true,
grant: () => Promise.resolve(),
withdraw: () => Promise.resolve(),
getCategories: () => [],
};
}
describe("withConsent", () => {
it("returns a ConsentChecked<F>", () => {
const consent = makeConsent();
const fn = async (_input: { id: string }) => ({ ok: true });
const wrapped = withConsent(consent, fn);
expectTypeOf(wrapped).toMatchTypeOf<ConsentChecked<typeof fn>>();
});
it("attaches __consentChecked as a non-enumerable property on the wrapped function", () => {
const consent = makeConsent();
const fn = async () => ({ ok: true });
const wrapped = withConsent(consent, fn);
expect(isConsentChecked(wrapped)).toBe(true);
expect(Object.keys(wrapped)).not.toContain("__consentChecked");
});
it("does NOT pollute the original input function with the brand", () => {
const consent = makeConsent();
const fn = async () => ({ ok: true });
const wrapped = withConsent(consent, fn);
expect(isConsentChecked(fn)).toBe(false);
expect(wrapped).not.toBe(fn);
});
it("passes input and output through unchanged", async () => {
const consent = makeConsent();
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
const wrapped = withConsent(consent, fn);
const result = await wrapped({ id: "abc" });
expect(result).toEqual({ ok: true, id: "abc" });
});
it("propagates errors", async () => {
const consent = makeConsent();
const err = new Error("boom");
const wrapped = withConsent(consent, async () => {
throw err;
});
await expect(wrapped()).rejects.toBe(err);
});
});

View File

@@ -0,0 +1,30 @@
import type { IConsent } from "./consent.interface";
import type { ConsentChecked } from "@repo/core-shared/conformance";
import { attachBrand } from "@repo/core-shared/conformance";
export type { ConsentChecked };
/**
* Use-case wrapper applied at DI bind time. The wrapper is a thin closure
* that forwards to `fn` unchanged and carries the `__consentChecked` brand.
* The forward closure keeps the brand on a fresh function so the original
* `fn` reference is not mutated — important when the same factory output is
* used elsewhere unwrapped (dev-seed paths, tests).
*
* Composition order (innermost to outermost):
* withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)
*
* The wrapper exists to:
* (1) require callers to pass the consent instance at bind time (dep is available)
* (2) attach the `__consentChecked` brand so the boot-time assertion can verify
* consent-gated use cases were bound through the consent-aware path.
*/
export function withConsent<Args extends unknown[], R>(
consent: IConsent,
fn: (...args: Args) => Promise<R>,
): ConsentChecked<(...args: Args) => Promise<R>> {
void consent;
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
attachBrand(wrapped, "__consentChecked");
return wrapped as ConsentChecked<(...args: Args) => Promise<R>>;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["core"]
}

View File

@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(
nodeVitestConfig,
defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
}),
);

View File

@@ -285,6 +285,82 @@ describe("assertFeatureConformance", () => {
).toThrow(/Analyzed|__analyzed/); ).toThrow(/Analyzed|__analyzed/);
}); });
it("throws when feature requiresConsent but binding is missing __consentChecked brand", () => {
const container = new Container();
const sym = Symbol("test.processData");
const ctx = makeCtx();
const wrappedNoConsent = withSpan(
ctx.tracer,
{ name: "test.processData", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
container.bind(sym).toConstantValue(wrappedNoConsent);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
processData: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
requiresConsent: ["analytics"],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).toThrow(ConformanceError);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).toThrow(/__consentChecked/);
});
it("passes when feature requiresConsent and binding carries __consentChecked brand", () => {
const container = new Container();
const sym = Symbol("test.processData");
const ctx = makeCtx();
const base = withSpan(
ctx.tracer,
{ name: "test.processData", op: "use-case" },
withCapture(
ctx.logger,
{ feature: "test", layer: "use-case" },
async (x: number) => x,
),
);
attachBrand(base, "__consentChecked");
container.bind(sym).toConstantValue(base);
const manifest = defineFeature({
name: "test",
requiredCores: [],
useCases: {
processData: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
},
},
realtimeChannels: [],
jobs: [],
requiresConsent: ["analytics"],
} as const);
expect(() =>
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
).not.toThrow();
});
it("passes when analyticsEvents is empty and __analyzed brand is absent", () => { it("passes when analyticsEvents is empty and __analyzed brand is absent", () => {
const container = new Container(); const container = new Container();
const sym = Symbol("test.signIn"); const sym = Symbol("test.signIn");

View File

@@ -5,6 +5,7 @@ import {
isCaptured, isCaptured,
isAudited, isAudited,
isAnalyzed, isAnalyzed,
isConsentChecked,
} from "./brand-runtime"; } from "./brand-runtime";
import { ConformanceError } from "./conformance-error"; import { ConformanceError } from "./conformance-error";
import type { BindContext } from "../di/bind-context"; import type { BindContext } from "../di/bind-context";
@@ -70,5 +71,12 @@ export function assertFeatureConformance(
); );
} }
} }
if ((manifest.requiresConsent?.length ?? 0) > 0) {
if (!isConsentChecked(bound)) {
throw new ConformanceError(
`${manifest.name}.${name}: feature declares requiresConsent but binding is missing __consentChecked brand — was withConsent applied at bind time?`,
);
}
}
} }
} }

View File

@@ -13,9 +13,14 @@
* commitment, not a mutable flag. * commitment, not a mutable flag.
*/ */
import type { Analyzed } from "./brands"; import type { Analyzed, ConsentChecked } from "./brands";
type Brand = "__instrumented" | "__captured" | "__audited" | "__analyzed"; type Brand =
| "__instrumented"
| "__captured"
| "__audited"
| "__analyzed"
| "__consentChecked";
/** /**
* Attaches the brand as a non-enumerable property on the given function. * Attaches the brand as a non-enumerable property on the given function.
@@ -57,3 +62,9 @@ export function isAudited(fn: unknown): boolean {
export function isAnalyzed<F extends object>(fn: unknown): fn is Analyzed<F> { export function isAnalyzed<F extends object>(fn: unknown): fn is Analyzed<F> {
return hasBrand(fn, "__analyzed"); return hasBrand(fn, "__analyzed");
} }
export function isConsentChecked<F extends object>(
fn: unknown,
): fn is ConsentChecked<F> {
return hasBrand(fn, "__consentChecked");
}

View File

@@ -10,3 +10,4 @@
export type Instrumented<F> = F & { readonly __instrumented: true }; export type Instrumented<F> = F & { readonly __instrumented: true };
export type Captured<F> = F & { readonly __captured: true }; export type Captured<F> = F & { readonly __captured: true };
export type Analyzed<F> = F & { readonly __analyzed: true }; export type Analyzed<F> = F & { readonly __analyzed: true };
export type ConsentChecked<F> = F & { readonly __consentChecked: true };

View File

@@ -29,6 +29,14 @@ export type FeatureManifest = {
readonly realtimeChannels: readonly string[]; readonly realtimeChannels: readonly string[];
readonly jobs: readonly string[]; readonly jobs: readonly string[];
readonly coverage?: CoverageManifest; readonly coverage?: CoverageManifest;
/**
* Consent categories this feature's use cases require before processing
* personal data. When non-empty, `assertFeatureConformance` requires every
* bound use case to carry the `__consentChecked` brand from `withConsent`.
* Defaults to `[]` — existing features with no consent requirements omit
* or declare an empty array.
*/
readonly requiresConsent?: readonly string[];
}; };
/** /**

View File

@@ -1,4 +1,9 @@
export type { Instrumented, Captured, Analyzed } from "./brands"; export type {
Instrumented,
Captured,
Analyzed,
ConsentChecked,
} from "./brands";
export type { FeatureManifest, UseCaseManifest } from "./define-feature"; export type { FeatureManifest, UseCaseManifest } from "./define-feature";
export { defineFeature } from "./define-feature"; export { defineFeature } from "./define-feature";
export type { export type {
@@ -23,6 +28,7 @@ export {
isCaptured, isCaptured,
isAudited, isAudited,
isAnalyzed, isAnalyzed,
isConsentChecked,
} from "./brand-runtime"; } from "./brand-runtime";
export { ConformanceError } from "./conformance-error"; export { ConformanceError } from "./conformance-error";
export { assertFeatureConformance } from "./assert-bindings"; export { assertFeatureConformance } from "./assert-bindings";

View File

@@ -2,7 +2,12 @@ import type { ITracer, SpanOpts } from "./tracer.interface";
import type { Instrumented } from "../conformance/brands"; import type { Instrumented } from "../conformance/brands";
import { attachBrand } from "../conformance/brand-runtime"; import { attachBrand } from "../conformance/brand-runtime";
const PROPAGATED_BRANDS = ["__captured", "__audited", "__analyzed"] as const; const PROPAGATED_BRANDS = [
"__captured",
"__audited",
"__analyzed",
"__consentChecked",
] as const;
export function withSpan<Args extends unknown[], R, Extra extends object>( export function withSpan<Args extends unknown[], R, Extra extends object>(
tracer: ITracer, tracer: ITracer,

28
pnpm-lock.yaml generated
View File

@@ -25,6 +25,9 @@ importers:
"@types/node": "@types/node":
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.17 version: 22.19.17
"@typescript-eslint/parser":
specifier: ^8.25.0
version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
fallow: fallow:
specifier: ^2.73.0 specifier: ^2.73.0
version: 2.73.0 version: 2.73.0
@@ -584,6 +587,31 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
packages/core-consent:
dependencies:
"@repo/core-shared":
specifier: workspace:*
version: link:../core-shared
devDependencies:
"@repo/core-eslint":
specifier: workspace:*
version: link:../core-eslint
"@repo/core-testing":
specifier: workspace:*
version: link:../core-testing
"@repo/core-typescript":
specifier: workspace:*
version: link:../core-typescript
"@vitest/coverage-v8":
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
packages/core-eslint: packages/core-eslint:
dependencies: dependencies:
globals: globals: