Skip to content

Commit 4e484d3

Browse files
committed
feat: add compliance-first engagement layer
1 parent a3502d4 commit 4e484d3

25 files changed

Lines changed: 1840 additions & 51 deletions

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,40 @@ how to tune back.
248248

249249
---
250250

251+
## compliance-first engagement layer
252+
253+
The X engagement path is now guarded by a dedicated consent / energy / decision layer.
254+
255+
### What it does
256+
257+
- evaluates whether interaction is allowed at all
258+
- scores whether the current signal is strong enough to consider
259+
- blocks writes unless auth, approval, budget, and duplicate checks all pass
260+
261+
### Decision states
262+
263+
- `SKIP` - no generation, no write
264+
- `HOLD` - retain candidate, but do not write
265+
- `REVIEW` - candidate may be rechecked, but still does not write yet
266+
- `ENGAGE` - the only state that may reach generation and write preflight
267+
- `BLOCK` - hard fail-closed state for opt-out, auth, approval, or policy violations
268+
269+
### Guardrails
270+
271+
- no consent means no engagement
272+
- opt-out always wins
273+
- search-only and keyword-only candidates never create consent
274+
- AI-generated replies require explicit approval
275+
- duplicate interactions are interaction-bounded, not just tweet-id-bounded
276+
- writes are preflighted before publish
277+
278+
### Documentation
279+
280+
- [consent / energy / decision layer](docs/compliance/consent-energy-decision.md)
281+
- [compliance test spec](docs/compliance/compliance-test-spec.md)
282+
283+
---
284+
251285
🧬 tl;dr
252286

253287
this is:
@@ -257,4 +291,4 @@ evolving into a human-machine symbiont interface»
257291

258292
you’re early.
259293

260-
act accordingly.
294+
act accordingly.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Compliance Test Spec
2+
3+
This spec validates that the consent / energy / decision layer is fail-closed in all RED zones.
4+
5+
## Unit Coverage
6+
7+
- `tests/unit/policy/consentEvaluator.test.ts`
8+
- `tests/unit/policy/energyEvaluator.test.ts`
9+
- `tests/unit/policy/engagementDecision.test.ts`
10+
11+
## Integration Coverage
12+
13+
- `tests/integration/pipeline/proactiveEngagementPipeline.test.ts`
14+
- `tests/integration/pipeline/mentionPipelineConsentFlow.test.ts`
15+
- `tests/integration/pipeline/writePreflightCompliance.test.ts`
16+
17+
## Observability Coverage
18+
19+
- `tests/e2e/operator/compliance-surfaces.spec.ts`
20+
- `tests/e2e/operator/blocked-write-observability.spec.ts`
21+
22+
## Release Gates
23+
24+
The build is not release-ready unless all of the following are true:
25+
26+
- search-only candidates never reach `ENGAGE`
27+
- opt-out always wins
28+
- AI approval is required for AI-generated replies
29+
- invalid auth is blocked before write
30+
- duplicate interactions cannot be written twice
31+
- `HOLD`, `REVIEW`, and `SKIP` never create write side effects
32+
- reason codes are emitted consistently in logs and metrics
33+
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Consent / Energy / Decision Layer
2+
3+
This document describes the compliance-first engagement layer that sits in front of the canonical generation pipeline.
4+
5+
## Purpose
6+
7+
The layer is fail-closed by design.
8+
9+
It separates three concerns:
10+
11+
1. **Consent** - may the system interact at all?
12+
2. **Energy** - is the current signal strong and natural enough to consider?
13+
3. **Decision** - does the runtime allow a write attempt after budget, auth, and duplicate checks?
14+
15+
## Runtime States
16+
17+
### Consent
18+
19+
`NONE`
20+
: No usable consent signal.
21+
22+
`WEAK_CONTEXT`
23+
: Weak historical or contextual permission only. Never auto-write.
24+
25+
`INTERACTION_BASED`
26+
: Direct mention, reply, or quote-style interaction.
27+
28+
`EXPLICIT`
29+
: Explicit opt-in or durable permission state.
30+
31+
`OPTOUT`
32+
: Immediate hard stop. Overrides everything else.
33+
34+
`BLOCKED`
35+
: Policy disallowance. Hard stop.
36+
37+
### Energy Bands
38+
39+
`E0`
40+
: Dead or noisy signal.
41+
42+
`E1`
43+
: Low signal. May be retained for review, but not written.
44+
45+
`E2`
46+
: Moderate signal. Candidate may be reviewed if consent is valid.
47+
48+
`E3`
49+
: Strong signal. Still requires valid consent and runtime preflight.
50+
51+
### Decision Outputs
52+
53+
`SKIP`
54+
: No generation, no write.
55+
56+
`HOLD`
57+
: Candidate is retained, but no write-side effect occurs.
58+
59+
`REVIEW`
60+
: Candidate is still non-writing until final preflight passes.
61+
62+
`ENGAGE`
63+
: The only state that can progress into generation and write preflight.
64+
65+
`BLOCK`
66+
: Hard fail-closed state for auth, opt-out, approval, or policy violations.
67+
68+
## Hard Gates
69+
70+
The following states always stop the path:
71+
72+
- opt-out present
73+
- invalid auth
74+
- missing AI approval
75+
- target missing or deleted
76+
- duplicate interaction already handled
77+
- budget exhausted
78+
- policy-blocked candidate
79+
80+
## Architectural Rule
81+
82+
Consent is evaluated before energy.
83+
Energy is evaluated before engagement.
84+
Write is always the last step and must be preflighted.
85+
86+
No search hit, relevance score, or energy score may create consent by itself.
87+
88+
## Repository Linkage
89+
90+
- `src/engagement/consentEvaluator.ts`
91+
- `src/engagement/energyEvaluator.ts`
92+
- `src/engagement/engagementDecision.ts`
93+
- `src/engagement/writePreflight.ts`
94+
- `src/worker/pollMentions.ts`
95+
- `src/worker/pollTimelineEngagement.ts`
96+

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"test": "vitest run",
1717
"test:run": "vitest run",
1818
"test:coverage": "vitest run --coverage",
19+
"test:compliance": "vitest run tests/unit/policy tests/integration/pipeline tests/e2e/operator",
1920
"test:stress": "vitest run tests/stress/personaStress.test.ts",
2021
"test:watch": "vitest",
2122
"test:critical": "vitest run tests/critical",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface EngagementComplianceConfig {
2+
aiApproval: boolean;
3+
optInHandles: string[];
4+
optOutHandles: string[];
5+
}
6+
7+
function parseHandles(input: string | undefined): string[] {
8+
if (!input) return [];
9+
return input
10+
.split(",")
11+
.map((value) => value.trim().replace(/^@/, "").toLowerCase())
12+
.filter(Boolean);
13+
}
14+
15+
export function readEngagementComplianceConfig(): EngagementComplianceConfig {
16+
return {
17+
aiApproval: (process.env.ENGAGEMENT_AI_APPROVED ?? "false") === "true",
18+
optInHandles: parseHandles(process.env.ENGAGEMENT_OPT_IN_HANDLES),
19+
optOutHandles: parseHandles(process.env.ENGAGEMENT_OPT_OUT_HANDLES),
20+
};
21+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { incrementCounter } from "../observability/metrics.js";
2+
import { COUNTER_NAMES } from "../observability/metricTypes.js";
3+
import type { DecisionResult } from "./engagementDecision.js";
4+
import type { ConsentResult } from "./consentEvaluator.js";
5+
import type { EnergyScore } from "./energyEvaluator.js";
6+
7+
export function recordConsentDecision(consent: ConsentResult): void {
8+
if (consent.state === "OPTOUT") {
9+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_OPTOUT_TOTAL);
10+
return;
11+
}
12+
13+
if (consent.state === "BLOCKED") {
14+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_POLICY_TOTAL);
15+
}
16+
}
17+
18+
export function recordEngagementDecision(
19+
decision: DecisionResult,
20+
consent: ConsentResult,
21+
energy?: EnergyScore,
22+
): void {
23+
switch (decision.reason) {
24+
case "AUTH_INVALID":
25+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_AUTH_INVALID_TOTAL);
26+
return;
27+
case "AI_APPROVAL_MISSING":
28+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_AI_APPROVAL_MISSING_TOTAL);
29+
return;
30+
case "BLOCKED_BY_POLICY":
31+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_POLICY_TOTAL);
32+
return;
33+
case "OPTOUT_PRESENT":
34+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_BLOCK_OPTOUT_TOTAL);
35+
return;
36+
case "ALREADY_REPLIED":
37+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_ALREADY_REPLIED_TOTAL);
38+
return;
39+
case "NO_CONSENT":
40+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_NO_CONSENT_TOTAL);
41+
return;
42+
case "NO_WRITE_BUDGET":
43+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_HOLD_NO_BUDGET_TOTAL);
44+
return;
45+
case "TARGET_MISSING":
46+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_TARGET_MISSING_TOTAL);
47+
return;
48+
case "DEAD_SIGNAL":
49+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_NO_CONSENT_TOTAL);
50+
return;
51+
case "LOW_ENERGY":
52+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_HOLD_LOW_ENERGY_TOTAL);
53+
return;
54+
case "REVIEW_REQUIRED":
55+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_REVIEW_TOTAL);
56+
return;
57+
case "ENGAGE_VALID_CONSENT":
58+
case "ENGAGE_STRONG_CONSENT":
59+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_ENGAGE_TOTAL);
60+
return;
61+
default:
62+
if (decision.decision === "ENGAGE") {
63+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_ENGAGE_TOTAL);
64+
} else if (decision.decision === "REVIEW") {
65+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_REVIEW_TOTAL);
66+
} else if (decision.decision === "HOLD") {
67+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_HOLD_LOW_ENERGY_TOTAL);
68+
} else if (decision.decision === "SKIP") {
69+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_NO_CONSENT_TOTAL);
70+
}
71+
return;
72+
}
73+
}
74+
75+
export function recordEnergyBand(energy: EnergyScore): void {
76+
if (energy.band === "E1") {
77+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_HOLD_LOW_ENERGY_TOTAL);
78+
} else if (energy.band === "E0") {
79+
incrementCounter(COUNTER_NAMES.ENGAGEMENT_DECISION_SKIP_NO_CONSENT_TOTAL);
80+
}
81+
}

src/engagement/consentEvaluator.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
export type ConsentState =
2+
| "NONE"
3+
| "WEAK_CONTEXT"
4+
| "INTERACTION_BASED"
5+
| "EXPLICIT"
6+
| "OPTOUT"
7+
| "BLOCKED";
8+
9+
export type ConsentReason =
10+
| "NO_SIGNAL"
11+
| "WEAK_CONTEXT_ONLY"
12+
| "DIRECT_INTERACTION"
13+
| "EXPLICIT_OPT_IN"
14+
| "OPTOUT_PRESENT"
15+
| "BLOCKED_BY_POLICY";
16+
17+
export type ConsentInput = {
18+
isDirectMention: boolean;
19+
isReplyToBot: boolean;
20+
isQuoteOfBot: boolean;
21+
hasExplicitOptIn: boolean;
22+
hasOptOut: boolean;
23+
priorInteraction: boolean;
24+
isSearchDerived: boolean;
25+
blockedByPolicy?: boolean;
26+
};
27+
28+
export type ConsentResult = {
29+
state: ConsentState;
30+
allowCandidate: boolean;
31+
reason: ConsentReason;
32+
};
33+
34+
export function evaluateConsent(input: ConsentInput): ConsentResult {
35+
if (input.hasOptOut) {
36+
return {
37+
state: "OPTOUT",
38+
allowCandidate: false,
39+
reason: "OPTOUT_PRESENT",
40+
};
41+
}
42+
43+
if (input.blockedByPolicy) {
44+
return {
45+
state: "BLOCKED",
46+
allowCandidate: false,
47+
reason: "BLOCKED_BY_POLICY",
48+
};
49+
}
50+
51+
if (input.hasExplicitOptIn) {
52+
return {
53+
state: "EXPLICIT",
54+
allowCandidate: true,
55+
reason: "EXPLICIT_OPT_IN",
56+
};
57+
}
58+
59+
if (input.isDirectMention || input.isReplyToBot || input.isQuoteOfBot) {
60+
return {
61+
state: "INTERACTION_BASED",
62+
allowCandidate: true,
63+
reason: "DIRECT_INTERACTION",
64+
};
65+
}
66+
67+
if (input.priorInteraction && !input.isSearchDerived) {
68+
return {
69+
state: "WEAK_CONTEXT",
70+
allowCandidate: false,
71+
reason: "WEAK_CONTEXT_ONLY",
72+
};
73+
}
74+
75+
return {
76+
state: "NONE",
77+
allowCandidate: false,
78+
reason: "NO_SIGNAL",
79+
};
80+
}

0 commit comments

Comments
 (0)