Add ceremony enforcement gates (#750)

This commit is contained in:
Brad Groux 2026-06-26 13:35:45 -05:00 committed by GitHub
parent 13fc8ac083
commit b9a648afe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1379 additions and 32 deletions

View file

@ -49,13 +49,14 @@
36. [Tool Policies](#tool-policies)
37. [Watcher Continuation Policies](#watcher-continuation-policies)
38. [Traces](#traces)
39. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
40. [Audit](#audit)
41. [Maintenance Center](#maintenance-center-apiv1maintenance)
42. [Common Workflows](#common-workflows)
43. [Versioning & Deprecation](#versioning--deprecation)
44. [Rate Limits](#rate-limits)
45. [Additional Endpoint Groups](#additional-endpoint-groups)
39. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
40. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
41. [Audit](#audit)
42. [Maintenance Center](#maintenance-center-apiv1maintenance)
43. [Common Workflows](#common-workflows)
44. [Versioning & Deprecation](#versioning--deprecation)
45. [Rate Limits](#rate-limits)
46. [Additional Endpoint Groups](#additional-endpoint-groups)
---
@ -545,10 +546,18 @@ PATCH /api/settings/features # Toggle feature flags
{
"darkMode": true,
"squadChat": true,
"analyticsEnabled": true
"analyticsEnabled": true,
"enforcement": {
"ceremonyDesignReview": "block",
"ceremonyFailureRetrospective": "warn"
}
}
```
Ceremony enforcement modes are `off`, `warn`, or `block`. `block` prevents task
completion until the matching ceremony is completed; `warn` records a pending
ceremony and governance trace without blocking completion.
---
## Lifecycle Hooks
@ -3003,6 +3012,7 @@ These endpoints follow the same auth/error patterns documented above:
| `/api/lessons` | Lessons learned |
| `/api/delegation` | Task delegation |
| `/api/workflows` | Workflow engine ([details](API-WORKFLOWS.md)) |
| `/api/ceremonies` | Design-review and failure-retrospective requirements |
| `/api/tool-policies` | Tool access policies |
| `/api/sandbox-policies` | Agent sandbox policy presets |
| `/api/integrations` | External integrations, outbound delivery audit, and human reply adapters |
@ -3217,9 +3227,71 @@ Task-launched review sessions capture independent participant responses, ordered
---
### Ceremony Requirements (`/api/ceremonies`)
Ceremony requirements are durable review records created by enforcement gates or
operators. They link back to tasks, runs, workflows, pull requests, or CI runs.
| Method | Path | Description | Permissions |
| ------ | ------------------------------ | --------------------------------------- | ---------------- |
| `GET` | `/api/ceremonies` | List ceremony requirements | `workflow:read` |
| `POST` | `/api/ceremonies` | Create a ceremony requirement | `workflow:write` |
| `POST` | `/api/ceremonies/:id/complete` | Complete a pending ceremony requirement | `workflow:write` |
#### List Ceremony Requirements
```
GET /api/ceremonies?status=pending&kind=design_review&taskId=task_123&limit=20
```
Query params: `status`, `kind`, `taskId`, `limit`.
#### Create Ceremony Requirement
```json
{
"kind": "design_review",
"enforcementMode": "block",
"reason": "Task coordinates multiple agents.",
"target": { "taskId": "task_20260626_review" },
"trigger": "manual",
"requiredArtifacts": ["decision-packet", "risk-list", "action-items"]
}
```
#### Complete Ceremony Requirement
```json
{
"completedBy": "brad",
"artifacts": [
{
"kind": "decision-packet",
"title": "Design review notes",
"body": "Reviewed scope, risks, rollback, and follow-up actions."
}
],
"actionItems": [
{
"title": "Track hardening follow-up",
"priority": "high",
"issueUrl": "https://github.com/BradGroux/veritas-kanban/issues/123"
}
]
}
```
`completedBy` defaults to the authenticated actor when omitted. Completion keeps
the record for audit and satisfies future blocking evaluations for the same task
and ceremony kind.
---
### Governance Decision Traces (`/api/governance/traces`)
Inspect policy, tool-policy, sandbox-policy, budget-policy, agent-permission, routing, and workflow-gate decisions with evaluated rules, matched rules, remediation, and redacted raw detail.
Inspect policy, tool-policy, sandbox-policy, budget-policy, agent-permission,
routing, workflow-gate, and ceremony decisions with evaluated rules, matched
rules, remediation, and redacted raw detail.
#### List Governance Traces
@ -3229,7 +3301,7 @@ GET /api/governance/traces
Query params: `kind`, `outcome`, `agent`, `taskId`, `actionType`, `startTime`, `endTime`, `limit`.
`kind` values: `policy`, `tool-policy`, `sandbox-policy`, `budget-policy`, `agent-permission`, `routing`, `workflow-gate`.
`kind` values: `policy`, `tool-policy`, `sandbox-policy`, `budget-policy`, `agent-permission`, `routing`, `workflow-gate`, `ceremony`.
`outcome` values: `allowed`, `warned`, `blocked`, `approval-required`, `routed`, `fallback`, `skipped`.

View file

@ -1073,18 +1073,31 @@ Real-time monitoring for workflow execution.
## Enforcement Gates
Structural quality gates that prevent workflow violations. Six gates shipped in v3.1, all disabled by default.
Structural quality gates that prevent workflow violations. The original six gates shipped in v3.1, all disabled by default. Ceremony gates add off/warn/block controls for review and retrospective records.
### Available Gates
| Gate | What It Enforces |
| ------------------------ | ----------------------------------------------------------------------- |
| `squadChat` | Agents must post to squad chat at every major step |
| `reviewGate` | Code tasks must pass 4×10 review scoring before completion |
| `closingComments` | Tasks require a deliverable summary (≥20 chars) before moving to Done |
| `autoTelemetry` | Automatic telemetry event emission on task transitions |
| `autoTimeTracking` | Automatic time tracking start/stop on status changes |
| `orchestratorDelegation` | Orchestrator agent must delegate work to sub-agents, not do it directly |
| Gate | What It Enforces |
| ------------------------------ | ----------------------------------------------------------------------- |
| `squadChat` | Agents must post to squad chat at every major step |
| `reviewGate` | Code tasks must pass 4×10 review scoring before completion |
| `closingComments` | Tasks require a deliverable summary (≥20 chars) before moving to Done |
| `autoTelemetry` | Automatic telemetry event emission on task transitions |
| `autoTimeTracking` | Automatic time tracking start/stop on status changes |
| `orchestratorDelegation` | Orchestrator agent must delegate work to sub-agents, not do it directly |
| `ceremonyDesignReview` | Design-review ceremony for critical, review-mode, or multi-agent tasks |
| `ceremonyFailureRetrospective` | Retrospective ceremony after blocked work or failed attempts |
### Ceremony Enforcement
Ceremony gates create durable review records instead of letting risky or failed work move to Done without a trace.
- **Off/warn/block modes** — Each ceremony gate can be disabled, advisory, or blocking
- **Design-review targeting** — Applies to multi-agent tasks, critical tasks, and `strategy`, `eng-review`, or `paranoid-review` run modes
- **Failure retrospective targeting** — Applies to blocked tasks, blocked reasons, and failed attempts
- **Durable queue** — Pending and completed ceremonies live at `/api/ceremonies` with target links, required artifacts, participants, and action items
- **Governance traces** — Warned and blocked evaluations record `ceremony` traces under `/api/governance/traces`
- **Settings visibility** — Settings -> Enforcement exposes both ceremony modes and the latest pending ceremony queue
### Orchestrator Delegation Enforcement

View file

@ -6,14 +6,16 @@ gates are **disabled by default** and must be explicitly enabled via the Setting
## Available Gates
| Gate | Behavior | Default |
| ------------------------ | ----------------------------------------------------------------------------- | ------- |
| `squadChat` | Auto-post task lifecycle events to squad chat | `false` |
| `reviewGate` | Blocks completion unless all four `reviewScores` are `10` (4x10 review gate). | `false` |
| `closingComments` | Blocks completion unless at least one review comment has ≥20 characters. | `false` |
| `autoTelemetry` | Auto-emits `run.started`/`run.completed` on status changes. | `false` |
| `autoTimeTracking` | Auto-starts/stops task timers when status changes. | `false` |
| `orchestratorDelegation` | Warn when orchestrator does implementation work instead of delegating | `false` |
| Gate | Behavior | Default |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------- |
| `squadChat` | Auto-post task lifecycle events to squad chat | `false` |
| `reviewGate` | Blocks completion unless all four `reviewScores` are `10` (4x10 review gate). | `false` |
| `closingComments` | Blocks completion unless at least one review comment has ≥20 characters. | `false` |
| `autoTelemetry` | Auto-emits `run.started`/`run.completed` on status changes. | `false` |
| `autoTimeTracking` | Auto-starts/stops task timers when status changes. | `false` |
| `orchestratorDelegation` | Warn when orchestrator does implementation work instead of delegating | `false` |
| `ceremonyDesignReview` | Requires a design-review ceremony for high-risk or multi-agent task completion. Values: `off`, `warn`, `block`. | `off` |
| `ceremonyFailureRetrospective` | Requires a retrospective ceremony after blocked work or failed attempts. Values: `off`, `warn`, `block`. | `off` |
---
@ -67,6 +69,19 @@ curl -X PATCH http://localhost:3001/api/settings/features \
}'
```
### Enable ceremony gates
```bash
curl -X PATCH http://localhost:3001/api/settings/features \
-H 'Content-Type: application/json' \
-d '{
"enforcement": {
"ceremonyDesignReview": "block",
"ceremonyFailureRetrospective": "warn"
}
}'
```
### Disable all enforcement gates
```bash
@ -79,7 +94,9 @@ curl -X PATCH http://localhost:3001/api/settings/features \
"closingComments": false,
"autoTelemetry": false,
"autoTimeTracking": false,
"orchestratorDelegation": false
"orchestratorDelegation": false,
"ceremonyDesignReview": "off",
"ceremonyFailureRetrospective": "off"
}
}'
```
@ -266,6 +283,50 @@ Task US-42 started by VERITAS
---
### 7. ceremonyDesignReview and ceremonyFailureRetrospective
**What they do:** Create auditable ceremony requirements before task completion when the task shape says human or cross-agent review is needed.
**Triggered on:**
- `ceremonyDesignReview`: completing a task with multiple agents, `critical` priority, or review-heavy run modes (`strategy`, `eng-review`, `paranoid-review`)
- `ceremonyFailureRetrospective`: completing work that was blocked or has failed attempts
**Behavior:**
- `off`: no requirement is created
- `warn`: a pending ceremony is created, a governance trace is recorded, and completion continues
- `block`: a pending ceremony is created, a governance trace is recorded, and completion is blocked until the matching ceremony is completed
**Ceremony records:** Stored under `/api/ceremonies` with required artifacts, participants, action items, and target links back to the task/run/workflow.
**Complete a ceremony:**
```bash
curl -X POST http://localhost:3001/api/ceremonies/ceremony_123/complete \
-H 'Content-Type: application/json' \
-d '{
"completedBy": "brad",
"artifacts": [
{
"kind": "decision-packet",
"title": "Design review notes",
"body": "Reviewed scope, risks, rollback, and follow-up actions."
}
],
"actionItems": [
{
"title": "Track follow-up hardening issue",
"priority": "high"
}
]
}'
```
**Use case:** Multi-agent and failed-run work should not silently disappear into Done. Ceremony gates leave a durable review or retrospective record, with governance traces explaining whether the gate warned or blocked.
---
## For AI Agents: How to Interact with Enforcement Gates
If you're an autonomous agent interacting with the Veritas Kanban API, here's how to handle enforcement gates gracefully.
@ -287,7 +348,9 @@ curl http://localhost:3001/api/settings/features | jq '.data.enforcement'
"closingComments": true,
"autoTelemetry": false,
"autoTimeTracking": false,
"orchestratorDelegation": false
"orchestratorDelegation": false,
"ceremonyDesignReview": "block",
"ceremonyFailureRetrospective": "warn"
}
```
@ -297,6 +360,8 @@ curl http://localhost:3001/api/settings/features | jq '.data.enforcement'
- `closingComments: true` → You must have at least one comment ≥20 characters
- `autoTelemetry: false` → You are responsible for emitting `run.*` events yourself
- `autoTimeTracking: false` → You must manually start/stop timers
- `ceremonyDesignReview: block` → Complete any pending design-review ceremony before marking risky work done
- `ceremonyFailureRetrospective: warn` → Expect a pending retrospective record after blocked or failed work
### 2. Pre-Flight Checks

View file

@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';
import type { Task } from '@veritas-kanban/shared';
import { CeremonyService } from '../services/ceremony-service.js';
function task(overrides: Partial<Task> = {}): Task {
return {
id: 'task_20260626_ceremony',
title: 'Ceremony task',
description: '',
type: 'code',
status: 'in-progress',
priority: 'medium',
created: '2026-06-26T12:00:00.000Z',
updated: '2026-06-26T12:00:00.000Z',
...overrides,
} as Task;
}
function service() {
const audit = vi.fn().mockResolvedValue(undefined);
const record = vi.fn().mockResolvedValue({ id: 'govtrace_1' });
return {
audit,
record,
service: new CeremonyService({
persist: false,
audit,
governanceTraceService: { record } as never,
}),
};
}
describe('CeremonyService', () => {
it('creates, lists, and completes ceremony requirements with artifacts', async () => {
const { audit, service: ceremonyService } = service();
const requirement = await ceremonyService.create({
kind: 'design_review',
enforcementMode: 'block',
reason: 'Task coordinates multiple agents.',
target: { taskId: 'task_20260626_design' },
trigger: 'task.completion',
});
expect(requirement).toMatchObject({
kind: 'design_review',
status: 'pending',
enforcementMode: 'block',
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
});
expect(await ceremonyService.list({ status: 'pending' })).toHaveLength(1);
const completed = await ceremonyService.complete(requirement.id, {
completedBy: 'brad',
artifacts: [
{
kind: 'decision-packet',
title: 'Review notes',
body: 'Reviewed scope, risks, and rollback path.',
},
],
actionItems: [{ title: 'Add follow-up hardening task', priority: 'high' }],
});
expect(completed.status).toBe('completed');
expect(completed.artifacts[0]).toMatchObject({ title: 'Review notes' });
expect(completed.artifacts[0].createdAt).toBeDefined();
expect(completed.actionItems[0]).toMatchObject({ priority: 'high' });
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'ceremony.completed' }));
});
it('blocks risky task completion until the matching design review is completed', async () => {
const { record, service: ceremonyService } = service();
const riskyTask = task({ agents: ['codex', 'claude-code'] });
const blocked = await ceremonyService.evaluateTaskCompletion(riskyTask, {
ceremonyDesignReview: 'block',
});
expect(blocked.allowed).toBe(false);
expect(blocked.mode).toBe('block');
expect(blocked.pending).toHaveLength(1);
expect(blocked.blockedReasons[0]).toMatch(/Design review required/);
expect(record).toHaveBeenCalledWith(expect.objectContaining({ kind: 'ceremony' }));
const secondEvaluation = await ceremonyService.evaluateTaskCompletion(riskyTask, {
ceremonyDesignReview: 'block',
});
expect(secondEvaluation.pending).toHaveLength(1);
expect(await ceremonyService.list({ status: 'pending' })).toHaveLength(1);
await ceremonyService.complete(blocked.pending[0].id, { completedBy: 'brad' });
const allowed = await ceremonyService.evaluateTaskCompletion(riskyTask, {
ceremonyDesignReview: 'block',
});
expect(allowed.allowed).toBe(true);
expect(allowed.pending).toHaveLength(0);
});
it('warns instead of blocking for failure retrospectives in warn mode', async () => {
const { service: ceremonyService } = service();
const blockedTask = task({
status: 'blocked',
blockedReason: { category: 'technical-snag', note: 'CI failed repeatedly.' },
});
const evaluation = await ceremonyService.evaluateTaskCompletion(blockedTask, {
ceremonyFailureRetrospective: 'warn',
});
expect(evaluation.allowed).toBe(true);
expect(evaluation.mode).toBe('warn');
expect(evaluation.warnings[0]).toMatch(/Failure retrospective required/);
});
});

View file

@ -115,6 +115,75 @@ describe('Enforcement gates', () => {
);
});
it('blocks completion when a required ceremony is pending', async () => {
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue(
buildSettings({ enforcement: { ceremonyDesignReview: 'block' } }) as any
);
const ceremonyService = {
evaluateTaskCompletion: vi.fn().mockResolvedValue({
allowed: false,
mode: 'block',
pending: [
{
id: 'ceremony_1',
kind: 'design_review',
title: 'Design review required before completion',
reason: 'Task is high-risk, multi-agent, or review-mode work.',
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
dueAt: '2026-06-27T12:00:00.000Z',
},
],
warnings: [],
blockedReasons: [
'Design review required before completion: Task is high-risk, multi-agent, or review-mode work.',
],
}),
};
service = new TaskService({ tasksDir, archiveDir, ceremonyService: ceremonyService as any });
const task = await service.createTask({ title: 'Ceremony blocked', type: 'code' });
await expect(service.updateTask(task.id, { status: 'done' })).rejects.toThrow(
/Ceremony Enforcement:/
);
expect(ceremonyService.evaluateTaskCompletion).toHaveBeenCalledWith(
expect.objectContaining({ id: task.id, status: 'done' }),
expect.objectContaining({ ceremonyDesignReview: 'block' })
);
});
it('allows completion when ceremony enforcement only warns', async () => {
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue(
buildSettings({ enforcement: { ceremonyDesignReview: 'warn' } }) as any
);
const ceremonyService = {
evaluateTaskCompletion: vi.fn().mockResolvedValue({
allowed: true,
mode: 'warn',
pending: [
{
id: 'ceremony_1',
kind: 'design_review',
title: 'Design review required before completion',
reason: 'Task is high-risk, multi-agent, or review-mode work.',
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
},
],
warnings: [
'Design review required before completion: Task is high-risk, multi-agent, or review-mode work.',
],
blockedReasons: [],
}),
};
service = new TaskService({ tasksDir, archiveDir, ceremonyService: ceremonyService as any });
const task = await service.createTask({ title: 'Ceremony warned', type: 'code' });
const updated = await service.updateTask(task.id, { status: 'done' });
expect(updated.status).toBe('done');
expect(ceremonyService.evaluateTaskCompletion).toHaveBeenCalledOnce();
});
it('skips enforcement when enforcement settings are missing', async () => {
vi.spyOn(ConfigService.prototype, 'getFeatureSettings').mockResolvedValue({
...DEFAULT_FEATURE_SETTINGS,

View file

@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import express, { type NextFunction, type Request, type Response } from 'express';
import request from 'supertest';
const { mockCeremonyService } = vi.hoisted(() => ({
mockCeremonyService: {
list: vi.fn(),
create: vi.fn(),
complete: vi.fn(),
},
}));
vi.mock('../../services/ceremony-service.js', () => ({
getCeremonyService: () => mockCeremonyService,
}));
import { ceremonyRoutes } from '../../routes/ceremonies.js';
interface TestAuthRequest extends Request {
auth?: { role: string; userId?: string; permissions: string[] };
}
interface TestError extends Error {
statusCode?: number;
code?: string;
}
const requirement = {
id: 'ceremony_1',
kind: 'design_review',
status: 'pending',
enforcementMode: 'block',
title: 'Design review required before completion',
reason: 'Task is high-risk, multi-agent, or review-mode work.',
target: { taskId: 'task_20260626_ceremony' },
trigger: 'task.completion',
participants: [{ role: 'coordinator' }],
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
artifacts: [],
actionItems: [],
createdAt: '2026-06-26T12:00:00.000Z',
updatedAt: '2026-06-26T12:00:00.000Z',
};
function createApp() {
const app = express();
app.use(express.json());
app.use((req: TestAuthRequest, _res: Response, next: NextFunction) => {
req.auth = { role: 'agent', userId: 'brad', permissions: ['workflow:write'] };
next();
});
app.use('/api/ceremonies', ceremonyRoutes);
app.use((err: TestError, _req: Request, res: Response, _next: NextFunction) => {
res.status(err.statusCode || 500).json({ code: err.code || 'ERROR', message: err.message });
});
return app;
}
describe('ceremony routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCeremonyService.list.mockResolvedValue([requirement]);
mockCeremonyService.create.mockResolvedValue(requirement);
mockCeremonyService.complete.mockResolvedValue({ ...requirement, status: 'completed' });
});
it('lists ceremony requirements with validated filters', async () => {
const res = await request(createApp()).get('/api/ceremonies?status=pending&limit=5');
expect(res.status).toBe(200);
expect(res.body[0].id).toBe('ceremony_1');
expect(mockCeremonyService.list).toHaveBeenCalledWith({ status: 'pending', limit: 5 });
});
it('creates ceremony requirements', async () => {
const res = await request(createApp())
.post('/api/ceremonies')
.send({
kind: 'design_review',
enforcementMode: 'block',
reason: 'Task coordinates multiple agents.',
target: { taskId: 'task_20260626_ceremony' },
trigger: 'manual',
});
expect(res.status).toBe(201);
expect(res.body.id).toBe('ceremony_1');
expect(mockCeremonyService.create).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'design_review', enforcementMode: 'block' })
);
});
it('completes ceremony requirements using the authenticated actor by default', async () => {
const res = await request(createApp())
.post('/api/ceremonies/ceremony_1/complete')
.send({
artifacts: [
{
kind: 'decision-packet',
title: 'Decision packet',
body: 'Reviewed the risk list and rollback plan.',
},
],
});
expect(res.status).toBe(200);
expect(res.body.status).toBe('completed');
expect(mockCeremonyService.complete).toHaveBeenCalledWith(
'ceremony_1',
expect.objectContaining({ completedBy: 'brad' })
);
});
it('rejects invalid ceremony targets', async () => {
const res = await request(createApp()).post('/api/ceremonies').send({
kind: 'design_review',
reason: 'Missing target.',
target: {},
trigger: 'manual',
});
expect(res.status).toBe(400);
expect(mockCeremonyService.create).not.toHaveBeenCalled();
});
});

View file

@ -18,6 +18,14 @@ describe('shared API permission metadata', () => {
).toEqual(['workflow:execute']);
});
it('keeps ceremony API under workflow read and write permissions', () => {
expect(getApiPermissionRequirement('/api/ceremonies').permissions).toEqual(['workflow:read']);
expect(
getApiPermissionRequirement('/api/ceremonies/ceremony_1/complete', { method: 'POST' })
.permissions
).toEqual(['workflow:write']);
});
it('keeps diff reads task-read scoped', () => {
expect(getApiPermissionRequirement('/api/diff/task_1/full').permissions).toEqual(['task:read']);
});

View file

@ -0,0 +1,149 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/async-handler.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { ValidationError } from '../middleware/error-handler.js';
import { getCeremonyService } from '../services/ceremony-service.js';
const router: RouterType = Router();
const ceremonyKindSchema = z.enum(['design_review', 'failure_retrospective']);
const ceremonyStatusSchema = z.enum(['pending', 'completed', 'cancelled']);
const ceremonyModeSchema = z.enum(['off', 'warn', 'block']);
const participantRoleSchema = z.enum([
'coordinator',
'implementer',
'reviewer',
'security-owner',
'qa-owner',
'human-approver',
]);
const artifactKindSchema = z.enum([
'decision-packet',
'risk-list',
'retrospective',
'action-items',
'github-issues',
]);
const targetSchema = z
.object({
taskId: z.string().min(1).optional(),
runId: z.string().min(1).optional(),
workflowId: z.string().min(1).optional(),
prUrl: z.string().url().optional(),
ciUrl: z.string().url().optional(),
})
.refine((target) => Object.values(target).some(Boolean), {
message: 'At least one ceremony target identifier is required',
});
const participantSchema = z.object({
role: participantRoleSchema,
name: z.string().min(1).optional(),
agent: z.string().min(1).optional(),
});
const createCeremonySchema = z.object({
kind: ceremonyKindSchema,
enforcementMode: ceremonyModeSchema.optional(),
title: z.string().min(1).max(200).optional(),
reason: z.string().min(1).max(2000),
target: targetSchema,
trigger: z.string().min(1).max(120),
dueAt: z.string().datetime().optional(),
participants: z.array(participantSchema).max(20).optional(),
requiredArtifacts: z.array(artifactKindSchema).max(12).optional(),
});
const completeCeremonySchema = z.object({
completedBy: z.string().min(1).optional(),
artifacts: z
.array(
z.object({
kind: artifactKindSchema,
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
url: z.string().url().optional(),
createdAt: z.string().datetime().optional(),
})
)
.max(20)
.optional(),
actionItems: z
.array(
z.object({
title: z.string().min(1).max(200),
assignee: z.string().min(1).optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
dueAt: z.string().datetime().optional(),
taskId: z.string().min(1).optional(),
issueUrl: z.string().url().optional(),
createdAt: z.string().datetime().optional(),
})
)
.max(50)
.optional(),
});
const listQuerySchema = z.object({
status: ceremonyStatusSchema.optional(),
kind: ceremonyKindSchema.optional(),
taskId: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
try {
return schema.parse(value);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Validation failed', error.issues);
}
throw error;
}
}
function actorFromRequest(req: AuthenticatedRequest): string {
return (
req.auth?.userId ||
req.auth?.tokenName ||
req.auth?.keyName ||
req.auth?.clientId ||
req.auth?.deviceId ||
req.auth?.role ||
'operator'
);
}
router.get(
'/',
asyncHandler(async (req, res) => {
const query = parseOrThrow(listQuerySchema, req.query);
res.json(await getCeremonyService().list(query));
})
);
router.post(
'/',
asyncHandler(async (req, res) => {
const body = parseOrThrow(createCeremonySchema, req.body);
const requirement = await getCeremonyService().create(body);
res.status(201).json(requirement);
})
);
router.post(
'/:id/complete',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const id = String(req.params.id);
const body = parseOrThrow(completeCeremonySchema, req.body);
const requirement = await getCeremonyService().complete(id, {
...body,
completedBy: body.completedBy ?? actorFromRequest(req),
});
res.json(requirement);
})
);
export { router as ceremonyRoutes };

View file

@ -16,6 +16,7 @@ const traceListQuerySchema = z.object({
'agent-permission',
'routing',
'workflow-gate',
'ceremony',
])
.optional(),
outcome: z

View file

@ -118,6 +118,7 @@ import transitionHooksRoutes from '../transition-hooks.js';
import lessonsRoutes from '../lessons.js';
import delegationRoutes from '../delegation.js';
import { workflowRoutes } from '../workflows.js';
import { ceremonyRoutes } from '../ceremonies.js';
import toolPolicyRoutes from '../tool-policies.js';
import policyRoutes from '../policies.js';
import { integrationsRoutes } from '../integrations.js';
@ -232,6 +233,7 @@ v1Router.use('/audit', adminAccess, auditRoutes);
v1Router.use('/lessons', taskReadAccess, lessonsRoutes);
v1Router.use('/delegation', delegationAccess, delegationRoutes);
v1Router.use('/workflows', workflowAccess, workflowRoutes);
v1Router.use('/ceremonies', workflowAccess, ceremonyRoutes);
v1Router.use('/scheduler', schedulerAccess, schedulerRoutes);
v1Router.use('/queue-monitors', queueMonitorAccess, queueMonitorRoutes);
v1Router.use('/watcher-policies', watcherPolicyAccess, watcherPolicyRoutes);

View file

@ -220,6 +220,8 @@ const EnforcementSettingsSchema = z
autoTimeTracking: z.boolean().optional(),
orchestratorDelegation: z.boolean().optional(),
orchestratorAgent: z.string().max(50).optional(),
ceremonyDesignReview: z.enum(['off', 'warn', 'block']).optional(),
ceremonyFailureRetrospective: z.enum(['off', 'warn', 'block']).optional(),
})
.strict()
.optional();

View file

@ -0,0 +1,416 @@
import fs from 'fs/promises';
import path from 'path';
import { nanoid } from 'nanoid';
import type {
CeremonyActionItem,
CeremonyArtifact,
CeremonyArtifactKind,
CeremonyEnforcementMode,
CeremonyEvaluationResult,
CeremonyKind,
CeremonyParticipant,
CeremonyRequirement,
CeremonyStatus,
CompleteCeremonyRequirementInput,
CreateCeremonyRequirementInput,
EnforcementSettings,
Task,
} from '@veritas-kanban/shared';
import { auditLog, type AuditEvent } from './audit-service.js';
import {
getGovernanceTraceService,
type GovernanceTraceService,
} from './governance-trace-service.js';
import { withFileLock } from './file-lock.js';
import { ConflictError, NotFoundError } from '../middleware/error-handler.js';
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
import { getRuntimeDir } from '../utils/paths.js';
const MAX_REQUIREMENTS = 1000;
interface CeremonyState {
version: 1;
requirements: CeremonyRequirement[];
updatedAt: string;
}
export interface CeremonyServiceOptions {
storageDir?: string;
persist?: boolean;
audit?: (event: AuditEvent) => Promise<void>;
governanceTraceService?: GovernanceTraceService;
}
export interface CeremonyListFilters {
status?: CeremonyStatus;
kind?: CeremonyKind;
taskId?: string;
limit?: number;
}
function nowIso(): string {
return new Date().toISOString();
}
function defaultParticipants(kind: CeremonyKind): CeremonyParticipant[] {
if (kind === 'design_review') {
return [
{ role: 'coordinator' },
{ role: 'implementer' },
{ role: 'reviewer' },
{ role: 'qa-owner' },
];
}
return [{ role: 'coordinator' }, { role: 'implementer' }, { role: 'reviewer' }];
}
function defaultArtifacts(kind: CeremonyKind): CeremonyArtifactKind[] {
return kind === 'design_review'
? ['decision-packet', 'risk-list', 'action-items']
: ['retrospective', 'action-items'];
}
function defaultTitle(kind: CeremonyKind): string {
return kind === 'design_review' ? 'Design review required' : 'Failure retrospective required';
}
function normalizeMode(mode?: CeremonyEnforcementMode): CeremonyEnforcementMode {
return mode ?? 'warn';
}
function dueInHours(hours: number): string {
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
export class CeremonyService {
private readonly storageDir: string;
private readonly persist: boolean;
private readonly audit: (event: AuditEvent) => Promise<void>;
private readonly governanceTraceService: GovernanceTraceService;
private loaded = false;
private state: CeremonyState = this.emptyState();
constructor(options: CeremonyServiceOptions = {}) {
this.storageDir = options.storageDir ?? path.join(getRuntimeDir(), 'ceremonies');
this.persist = options.persist ?? process.env.VITEST !== 'true';
this.audit = options.audit ?? auditLog;
this.governanceTraceService = options.governanceTraceService ?? getGovernanceTraceService();
}
async list(filters: CeremonyListFilters = {}): Promise<CeremonyRequirement[]> {
await this.ensureLoaded();
const limit = Math.max(1, Math.min(Math.floor(filters.limit ?? 100), MAX_REQUIREMENTS));
return this.state.requirements
.filter((requirement) => !filters.status || requirement.status === filters.status)
.filter((requirement) => !filters.kind || requirement.kind === filters.kind)
.filter((requirement) => !filters.taskId || requirement.target.taskId === filters.taskId)
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
.slice(0, limit);
}
async create(input: CreateCeremonyRequirementInput): Promise<CeremonyRequirement> {
await this.ensureLoaded();
const existing = this.findOpenRequirement(input.kind, input.target.taskId, input.target.runId);
if (existing) return existing;
const timestamp = nowIso();
const requirement: CeremonyRequirement = {
id: `ceremony_${Date.now()}_${nanoid(6)}`,
kind: input.kind,
status: 'pending',
enforcementMode: normalizeMode(input.enforcementMode),
title: input.title ?? defaultTitle(input.kind),
reason: input.reason,
target: input.target,
trigger: input.trigger,
dueAt: input.dueAt,
participants: input.participants ?? defaultParticipants(input.kind),
requiredArtifacts: input.requiredArtifacts ?? defaultArtifacts(input.kind),
artifacts: [],
actionItems: [],
createdAt: timestamp,
updatedAt: timestamp,
};
this.state.requirements.push(requirement);
if (this.state.requirements.length > MAX_REQUIREMENTS) {
this.state.requirements = this.state.requirements.slice(-MAX_REQUIREMENTS);
}
await this.saveState();
await this.auditChange('ceremony.created', requirement);
return requirement;
}
async complete(
id: string,
input: CompleteCeremonyRequirementInput
): Promise<CeremonyRequirement> {
validatePathSegment(id);
await this.ensureLoaded();
const requirement = this.findById(id);
if (!requirement) throw new NotFoundError('Ceremony requirement not found');
if (requirement.status !== 'pending') {
throw new ConflictError('Ceremony requirement is not pending');
}
const timestamp = nowIso();
requirement.status = 'completed';
requirement.completedAt = timestamp;
requirement.completedBy = input.completedBy;
requirement.updatedAt = timestamp;
requirement.artifacts = [
...requirement.artifacts,
...(input.artifacts ?? []).map<CeremonyArtifact>((artifact) => ({
...artifact,
createdAt: artifact.createdAt ?? timestamp,
})),
];
requirement.actionItems = [
...requirement.actionItems,
...(input.actionItems ?? []).map<CeremonyActionItem>((item) => ({
...item,
createdAt: item.createdAt ?? timestamp,
})),
];
await this.saveState();
await this.auditChange('ceremony.completed', requirement);
return requirement;
}
async evaluateTaskCompletion(
task: Task,
enforcement?: Partial<EnforcementSettings>
): Promise<CeremonyEvaluationResult> {
await this.ensureLoaded();
const required: CeremonyRequirement[] = [];
const designMode = enforcement?.ceremonyDesignReview ?? 'off';
if (
designMode !== 'off' &&
this.taskNeedsDesignReview(task) &&
!this.hasCompletedRequirement('design_review', task.id)
) {
required.push(
await this.create({
kind: 'design_review',
enforcementMode: designMode,
title: 'Design review required before completion',
reason: 'Task is high-risk, multi-agent, or review-mode work.',
target: { taskId: task.id },
trigger: 'task.completion',
dueAt: dueInHours(24),
})
);
}
const retroMode = enforcement?.ceremonyFailureRetrospective ?? 'off';
if (
retroMode !== 'off' &&
this.taskNeedsFailureRetrospective(task) &&
!this.hasCompletedRequirement('failure_retrospective', task.id)
) {
required.push(
await this.create({
kind: 'failure_retrospective',
enforcementMode: retroMode,
title: 'Failure retrospective required before completion',
reason: 'Task has blocked status or failed run attempts.',
target: { taskId: task.id },
trigger: 'task.completion',
dueAt: dueInHours(24),
})
);
}
const pending = required.filter((requirement) => requirement.status === 'pending');
const blocking = pending.filter((requirement) => requirement.enforcementMode === 'block');
const warnings = pending
.filter((requirement) => requirement.enforcementMode === 'warn')
.map((requirement) => `${requirement.title}: ${requirement.reason}`);
const blockedReasons = blocking.map(
(requirement) => `${requirement.title}: ${requirement.reason}`
);
const mode: CeremonyEnforcementMode =
blocking.length > 0 ? 'block' : warnings.length > 0 ? 'warn' : 'off';
if (pending.length > 0) {
await this.recordEvaluationTrace(task, pending, mode);
}
return {
allowed: blocking.length === 0,
mode,
pending,
warnings,
blockedReasons,
};
}
private taskNeedsDesignReview(task: Task): boolean {
return (
(task.agents?.length ?? 0) > 1 ||
task.priority === 'critical' ||
task.runMode === 'strategy' ||
task.runMode === 'eng-review' ||
task.runMode === 'paranoid-review'
);
}
private taskNeedsFailureRetrospective(task: Task): boolean {
return (
task.status === 'blocked' ||
Boolean(task.blockedReason?.note) ||
task.attempt?.status === 'failed' ||
(task.attempts ?? []).some((attempt) => attempt.status === 'failed')
);
}
private async recordEvaluationTrace(
task: Task,
pending: CeremonyRequirement[],
mode: CeremonyEnforcementMode
): Promise<void> {
await this.governanceTraceService.record({
kind: 'ceremony',
outcome: mode === 'block' ? 'blocked' : 'warned',
title: mode === 'block' ? 'Ceremony gate blocked completion' : 'Ceremony gate warned',
summary: `${pending.length} ceremony requirement(s) pending for ${task.id}.`,
remediation: 'Complete the required ceremony artifacts or change enforcement mode.',
subject: { taskId: task.id, actionType: 'task.complete' },
evaluatedRules: pending.map((requirement) => ({
id: requirement.id,
label: requirement.title,
type: requirement.kind,
status: 'matched',
outcome: requirement.enforcementMode === 'block' ? 'blocked' : 'warned',
message: requirement.reason,
})),
matchedRules: pending.map((requirement) => ({
id: requirement.id,
label: requirement.title,
type: requirement.kind,
status: 'matched',
outcome: requirement.enforcementMode === 'block' ? 'blocked' : 'warned',
message: requirement.reason,
})),
steps: pending.map((requirement) => ({
id: requirement.id,
label: requirement.title,
status: 'matched',
message: requirement.reason,
details: {
kind: requirement.kind,
enforcementMode: requirement.enforcementMode,
requiredArtifacts: requirement.requiredArtifacts,
},
})),
});
}
private findOpenRequirement(
kind: CeremonyKind,
taskId?: string,
runId?: string
): CeremonyRequirement | undefined {
return this.state.requirements.find(
(requirement) =>
requirement.kind === kind &&
requirement.status === 'pending' &&
requirement.target.taskId === taskId &&
requirement.target.runId === runId
);
}
private hasCompletedRequirement(kind: CeremonyKind, taskId?: string, runId?: string): boolean {
return this.state.requirements.some(
(requirement) =>
requirement.kind === kind &&
requirement.status === 'completed' &&
requirement.target.taskId === taskId &&
requirement.target.runId === runId
);
}
private findById(id: string): CeremonyRequirement | undefined {
return this.state.requirements.find((requirement) => requirement.id === id);
}
private async ensureLoaded(): Promise<void> {
if (this.loaded) return;
if (!this.persist) {
this.loaded = true;
return;
}
await fs.mkdir(this.storageDir, { recursive: true });
try {
const raw = await fs.readFile(this.statePath, 'utf-8');
const parsed = JSON.parse(raw) as Partial<CeremonyState>;
this.state = {
version: 1,
requirements: Array.isArray(parsed.requirements)
? (parsed.requirements as CeremonyRequirement[])
: [],
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(),
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
this.state = this.emptyState();
}
this.loaded = true;
}
private async saveState(): Promise<void> {
this.state.updatedAt = nowIso();
if (!this.persist) return;
await fs.mkdir(this.storageDir, { recursive: true });
await withFileLock(this.statePath, async () => {
await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
});
}
private get statePath(): string {
const filePath = path.join(this.storageDir, 'requirements.json');
ensureWithinBase(this.storageDir, filePath);
return filePath;
}
private emptyState(): CeremonyState {
return { version: 1, requirements: [], updatedAt: nowIso() };
}
private async auditChange(action: string, requirement: CeremonyRequirement): Promise<void> {
await this.audit({
action,
actor: requirement.completedBy ?? 'system',
resource: requirement.id,
details: {
kind: requirement.kind,
status: requirement.status,
enforcementMode: requirement.enforcementMode,
target: requirement.target,
requiredArtifacts: requirement.requiredArtifacts,
actionItems: requirement.actionItems.map((item) => ({
title: item.title,
taskId: item.taskId,
issueUrl: item.issueUrl,
assignee: item.assignee,
priority: item.priority,
dueAt: item.dueAt,
})),
},
});
}
}
let ceremonyService: CeremonyService | null = null;
export function getCeremonyService(): CeremonyService {
ceremonyService ??= new CeremonyService();
return ceremonyService;
}
export function resetCeremonyServiceForTests(): void {
ceremonyService = null;
}

View file

@ -45,6 +45,7 @@ import {
type TaskIdentityLocation,
type TaskIdentityScanSource,
} from './task-identity-diagnostics.js';
import { getCeremonyService, type CeremonyService } from './ceremony-service.js';
const log = createLogger('task-cache');
const TASK_SYNC_CONTEXT: TaskSyncContext = createTaskSyncToken('task-service');
@ -96,6 +97,7 @@ export interface TaskServiceOptions {
sqliteDatabase?: SqliteDatabase;
sqliteConnectionOptions?: SqliteConnectionOptions;
configService?: Pick<ConfigService, 'getFeatureSettings'>;
ceremonyService?: Pick<CeremonyService, 'evaluateTaskCompletion'>;
}
/** Ignore file-watcher events within this window after our own writes */
@ -110,6 +112,7 @@ export class TaskService {
private sqliteTasks: SqliteTaskRepository | null = null;
private sqliteMutationQueue: Promise<unknown> = Promise.resolve();
private configService: Pick<ConfigService, 'getFeatureSettings'>;
private ceremonyService: Pick<CeremonyService, 'evaluateTaskCompletion'>;
// ============ In-Memory Cache ============
private cache: Map<string, Task> = new Map();
@ -128,6 +131,7 @@ export class TaskService {
options.backlogDir ?? (options.tasksDir || options.archiveDir ? null : getTasksBacklogDir());
this.telemetry = options.telemetryService || getTelemetryService();
this.configService = options.configService ?? new ConfigService();
this.ceremonyService = options.ceremonyService ?? getCeremonyService();
const storageType =
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
@ -967,6 +971,34 @@ export class TaskService {
? undefined
: (blockedReasonUpdate ?? freshTask.blockedReason),
};
if (input.status === 'done' && settings.enforcement) {
const ceremonyEvaluation = await this.ceremonyService.evaluateTaskCompletion(
previewTask,
settings.enforcement
);
if (!ceremonyEvaluation.allowed) {
const detailMessage = `Ceremony Enforcement: ${ceremonyEvaluation.blockedReasons.join(
' '
)}`;
throw new ValidationError(detailMessage, [
{
code: 'CEREMONY_REQUIRED',
message: detailMessage,
path: ['status'],
details: ceremonyEvaluation.pending.map((requirement) => ({
id: requirement.id,
kind: requirement.kind,
title: requirement.title,
reason: requirement.reason,
requiredArtifacts: requirement.requiredArtifacts,
dueAt: requirement.dueAt,
})),
},
]);
}
}
const validation = await validateTransition(previewTask, previousStatus, input.status);
if (!validation.allowed) {
throw new ValidationError(

View file

@ -0,0 +1,96 @@
import type { TaskPriority } from './task.types.js';
export type CeremonyKind = 'design_review' | 'failure_retrospective';
export type CeremonyStatus = 'pending' | 'completed' | 'cancelled';
export type CeremonyEnforcementMode = 'off' | 'warn' | 'block';
export type CeremonyParticipantRole =
| 'coordinator'
| 'implementer'
| 'reviewer'
| 'security-owner'
| 'qa-owner'
| 'human-approver';
export type CeremonyArtifactKind =
| 'decision-packet'
| 'risk-list'
| 'retrospective'
| 'action-items'
| 'github-issues';
export interface CeremonyTarget {
taskId?: string;
runId?: string;
workflowId?: string;
prUrl?: string;
ciUrl?: string;
}
export interface CeremonyParticipant {
role: CeremonyParticipantRole;
name?: string;
agent?: string;
}
export interface CeremonyArtifact {
kind: CeremonyArtifactKind;
title: string;
body: string;
url?: string;
createdAt: string;
}
export interface CeremonyActionItem {
title: string;
assignee?: string;
priority?: TaskPriority;
dueAt?: string;
taskId?: string;
issueUrl?: string;
createdAt: string;
}
export interface CeremonyRequirement {
id: string;
kind: CeremonyKind;
status: CeremonyStatus;
enforcementMode: CeremonyEnforcementMode;
title: string;
reason: string;
target: CeremonyTarget;
trigger: string;
dueAt?: string;
participants: CeremonyParticipant[];
requiredArtifacts: CeremonyArtifactKind[];
artifacts: CeremonyArtifact[];
actionItems: CeremonyActionItem[];
createdAt: string;
updatedAt: string;
completedAt?: string;
completedBy?: string;
}
export interface CreateCeremonyRequirementInput {
kind: CeremonyKind;
enforcementMode?: CeremonyEnforcementMode;
title?: string;
reason: string;
target: CeremonyTarget;
trigger: string;
dueAt?: string;
participants?: CeremonyParticipant[];
requiredArtifacts?: CeremonyArtifactKind[];
}
export interface CompleteCeremonyRequirementInput {
completedBy: string;
artifacts?: Array<Omit<CeremonyArtifact, 'createdAt'> & { createdAt?: string }>;
actionItems?: Array<Omit<CeremonyActionItem, 'createdAt'> & { createdAt?: string }>;
}
export interface CeremonyEvaluationResult {
allowed: boolean;
mode: CeremonyEnforcementMode;
pending: CeremonyRequirement[];
warnings: string[];
blockedReasons: string[];
}

View file

@ -329,6 +329,8 @@ export interface EnforcementSettings {
autoTimeTracking: boolean; // Auto-start/stop timers on status changes
orchestratorDelegation: boolean; // Warn when orchestrator does implementation work instead of delegating
orchestratorAgent?: string; // The designated orchestrator agent name (e.g. "veritas")
ceremonyDesignReview: 'off' | 'warn' | 'block'; // Require design review ceremonies for risky/multi-agent tasks
ceremonyFailureRetrospective: 'off' | 'warn' | 'block'; // Require retrospectives after blocked or failed runs
}
/** Individual hook configuration */
@ -534,6 +536,8 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
autoTimeTracking: false,
orchestratorDelegation: false,
orchestratorAgent: '',
ceremonyDesignReview: 'off',
ceremonyFailureRetrospective: 'off',
},
hooks: {
enabled: false, // Disabled by default

View file

@ -5,7 +5,8 @@ export type GovernanceTraceKind =
| 'budget-policy'
| 'agent-permission'
| 'routing'
| 'workflow-gate';
| 'workflow-gate'
| 'ceremony';
export type GovernanceTraceOutcome =
| 'allowed'

View file

@ -8,6 +8,7 @@ export * from './websocket.types.js';
export * from './managed-list.types.js';
export * from './chat.types.js';
export * from './communication-adapter.types.js';
export * from './ceremony.types.js';
export * from './transition-hooks.types.js';
export * from './delegation.types.js';
export * from './changes.types.js';

View file

@ -257,6 +257,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
},
],
},
{ prefix: '/api/ceremonies', read: 'workflow:read', write: 'workflow:write' },
{
prefix: '/api/scheduler',
read: 'workflow:read',

View file

@ -154,6 +154,24 @@ const mocks = vi.hoisted(() => ({
createdAt: '2026-06-04T08:00:00.000Z',
updatedAt: '2026-06-04T08:00:00.000Z',
})),
ceremonies: vi.fn(async () => [
{
id: 'ceremony_1',
kind: 'design_review',
status: 'pending',
enforcementMode: 'warn',
title: 'Design review required before completion',
reason: 'Task is high-risk, multi-agent, or review-mode work.',
target: { taskId: 'task_20260626_demo' },
trigger: 'task.completion',
participants: [{ role: 'coordinator' }],
requiredArtifacts: ['decision-packet', 'risk-list', 'action-items'],
artifacts: [],
actionItems: [],
createdAt: '2026-06-04T08:00:00.000Z',
updatedAt: '2026-06-04T08:00:00.000Z',
},
]),
settings: {
board: {},
tasks: {},
@ -182,6 +200,8 @@ const mocks = vi.hoisted(() => ({
closingComments: false,
autoTelemetry: false,
autoTimeTracking: false,
ceremonyDesignReview: 'warn',
ceremonyFailureRetrospective: 'block',
},
},
}));
@ -208,6 +228,9 @@ vi.mock('@/lib/api', () => ({
testCommunicationAdapter: mocks.testCommunicationAdapter,
disconnectCommunicationAdapter: mocks.disconnectCommunicationAdapter,
},
ceremonies: {
list: mocks.ceremonies,
},
},
}));
@ -295,10 +318,17 @@ describe('Settings tab Mantine controls', () => {
});
});
it('renders Enforcement agent selection through direct Mantine Select', () => {
it('renders Enforcement ceremony and agent selection through direct Mantine Select', async () => {
const { container } = renderWithProviders(<EnforcementTab />);
expect(
screen.getByRole('combobox', { name: 'Design Review Ceremony Enforcement' })
).toBeDefined();
expect(
screen.getByRole('combobox', { name: 'Failure Retrospective Ceremony Enforcement' })
).toBeDefined();
expect(screen.getByRole('combobox', { name: 'Orchestrator Agent' })).toBeDefined();
expect(await screen.findByText('Design review required before completion')).toBeDefined();
expect(container.querySelector('.mantine-Select-root')).toBeDefined();
});
});

View file

@ -1,15 +1,48 @@
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { useConfig } from '@/hooks/useConfig';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { api } from '@/lib/api';
import {
DEFAULT_FEATURE_SETTINGS,
type CeremonyEnforcementMode,
type CeremonyRequirement,
} from '@veritas-kanban/shared';
import { Select } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { ToggleRow, SettingRow, SectionHeader, SaveIndicator } from '../shared';
import { Shield, ShieldCheck, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
const ceremonyModeOptions: Array<{ value: CeremonyEnforcementMode; label: string }> = [
{ value: 'off', label: 'Off' },
{ value: 'warn', label: 'Warn' },
{ value: 'block', label: 'Block' },
];
function formatCeremonyKind(kind: CeremonyRequirement['kind']): string {
return kind === 'design_review' ? 'Design review' : 'Failure retrospective';
}
function formatCeremonyTarget(requirement: CeremonyRequirement): string {
const { target } = requirement;
return (
target.taskId ||
target.runId ||
target.workflowId ||
target.prUrl ||
target.ciUrl ||
'workspace'
);
}
export function EnforcementTab() {
const { settings } = useFeatureSettings();
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
const { data: config } = useConfig();
const { data: pendingCeremonies = [] } = useQuery({
queryKey: ['ceremonies', 'pending', 'settings'],
queryFn: () => api.ceremonies.list({ status: 'pending', limit: 5 }),
staleTime: 30_000,
});
const updateEnforcement = (key: string, value: boolean | string) => {
debouncedUpdate({ enforcement: { [key]: value } });
@ -61,6 +94,66 @@ export function EnforcementTab() {
onCheckedChange={(v) => updateEnforcement('closingComments', v)}
/>
</div>
<div className="border-t pt-3 space-y-3">
<SettingRow
label="Design Review Ceremony"
description="Require review artifacts before completing high-risk or multi-agent tasks"
>
<Select
value={enforcement.ceremonyDesignReview ?? 'off'}
onChange={(value) =>
updateEnforcement(
'ceremonyDesignReview',
(value ?? 'off') as CeremonyEnforcementMode
)
}
data={ceremonyModeOptions}
aria-label="Design Review Ceremony Enforcement"
allowDeselect={false}
size="xs"
w={140}
/>
</SettingRow>
<SettingRow
label="Failure Retrospective Ceremony"
description="Require retrospective artifacts after blocked work or failed attempts"
>
<Select
value={enforcement.ceremonyFailureRetrospective ?? 'off'}
onChange={(value) =>
updateEnforcement(
'ceremonyFailureRetrospective',
(value ?? 'off') as CeremonyEnforcementMode
)
}
data={ceremonyModeOptions}
aria-label="Failure Retrospective Ceremony Enforcement"
allowDeselect={false}
size="xs"
w={140}
/>
</SettingRow>
<div className="rounded-md bg-muted/50 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-foreground">Pending ceremonies</span>
<span className="text-xs text-muted-foreground">{pendingCeremonies.length}</span>
</div>
{pendingCeremonies.length > 0 ? (
<div className="mt-2 space-y-2">
{pendingCeremonies.map((requirement) => (
<div key={requirement.id} className="text-xs">
<div className="font-medium text-foreground">{requirement.title}</div>
<div className="text-muted-foreground">
{formatCeremonyKind(requirement.kind)} - {formatCeremonyTarget(requirement)}
</div>
</div>
))}
</div>
) : (
<div className="mt-1 text-xs text-muted-foreground">No pending ceremonies</div>
)}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,48 @@
import type {
CeremonyKind,
CeremonyRequirement,
CeremonyStatus,
CompleteCeremonyRequirementInput,
CreateCeremonyRequirementInput,
} from '@veritas-kanban/shared';
import { apiFetch } from './helpers';
export interface CeremonyListFilters {
status?: CeremonyStatus;
kind?: CeremonyKind;
taskId?: string;
limit?: number;
}
function toQuery(filters: CeremonyListFilters = {}): string {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.kind) params.set('kind', filters.kind);
if (filters.taskId) params.set('taskId', filters.taskId);
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
const query = params.toString();
return query ? `?${query}` : '';
}
export const ceremoniesApi = {
list: async (filters: CeremonyListFilters = {}): Promise<CeremonyRequirement[]> =>
apiFetch<CeremonyRequirement[]>(`/api/ceremonies${toQuery(filters)}`),
create: async (input: CreateCeremonyRequirementInput): Promise<CeremonyRequirement> =>
apiFetch<CeremonyRequirement>('/api/ceremonies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
complete: async (
id: string,
input: CompleteCeremonyRequirementInput
): Promise<CeremonyRequirement> =>
apiFetch<CeremonyRequirement>(`/api/ceremonies/${encodeURIComponent(id)}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}),
};

View file

@ -32,6 +32,7 @@ import { runSessionsApi } from './run-sessions';
import { workspaceCapabilitiesApi } from './workspace-capabilities';
import { schedulerApi } from './scheduler';
import { queueMonitorsApi } from './queue-monitors';
import { ceremoniesApi } from './ceremonies';
// Assemble the full API object (matches original structure exactly)
export const api = {
@ -75,6 +76,7 @@ export const api = {
workspaceCapabilities: workspaceCapabilitiesApi,
scheduler: schedulerApi,
queueMonitors: queueMonitorsApi,
ceremonies: ceremoniesApi,
};
export type {
@ -88,6 +90,7 @@ export type {
} from './search';
export type { WorkProductExportFormat, WorkProductExportOptions } from './work-products';
export type { CeremonyListFilters } from './ceremonies';
export type { TraceStatus } from './traces';
export type { SqlitePortabilityReport } from './maintenance';
export type {