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

@@ -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/);
});
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", () => {
const container = new Container();
const sym = Symbol("test.signIn");

View File

@@ -5,6 +5,7 @@ import {
isCaptured,
isAudited,
isAnalyzed,
isConsentChecked,
} from "./brand-runtime";
import { ConformanceError } from "./conformance-error";
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.
*/
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.
@@ -57,3 +62,9 @@ export function isAudited(fn: unknown): boolean {
export function isAnalyzed<F extends object>(fn: unknown): fn is Analyzed<F> {
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 Captured<F> = F & { readonly __captured: 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 jobs: readonly string[];
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 { defineFeature } from "./define-feature";
export type {
@@ -23,6 +28,7 @@ export {
isCaptured,
isAudited,
isAnalyzed,
isConsentChecked,
} from "./brand-runtime";
export { ConformanceError } from "./conformance-error";
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 { 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>(
tracer: ITracer,