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

@@ -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,