Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions packages/core/src/application/ViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ViewArtifact,
ViewArtifactFormat,
ViewContent,
ViewFormDescriptor,
ViewFormId,
ViewKind,
ViewKindDescriptor,
Expand Down Expand Up @@ -140,6 +141,29 @@ export class ViewService {
return generator === undefined ? undefined : formOfId(generator.forms, form)?.format
}

/**
* The skill a form runs to write its document.
*
* Exposed so a caller can ask who may run it before anything is projected,
* since writing a document is that skill run,
* and permission to write one is permission to run it.
* Refuses an unknown kind or form the way `generate` does,
* so a bad request is answered as a bad request rather than as a refusal.
*/
skillIdFor(kind: ViewKind, form: ViewFormId): SkillId {
const generator = this.deps.pluginRegistry.requireViewGenerator(kind)
return skillFor(generator, this.requireForm(generator, kind, form).id)
}

private requireForm(generator: ViewGeneratorPlugin, kind: ViewKind, form: ViewFormId): ViewFormDescriptor {
const match = formOfId(generator.forms, form)
if (match === undefined) {
const named = generator.forms.map(one => one.id).join(', ')
throw new ValidationError(`A "${kind}" view is written in one of ${named}`)
}
return match
}

/**
* Project the subject, then set a form writing it out.
*
Expand All @@ -148,11 +172,7 @@ export class ViewService {
*/
async generate(input: GenerateViewInput): Promise<GenerateViewResponse> {
const generator = this.deps.pluginRegistry.requireViewGenerator(input.kind)
const form = formOfId(generator.forms, input.form)
if (form === undefined) {
const named = generator.forms.map(one => one.id).join(', ')
throw new ValidationError(`A "${input.kind}" view is written in one of ${named}`)
}
const form = this.requireForm(generator, input.kind, input.form)

const environment = this.deps.environment ?? {}
const unset = unsetRequirements(form, environment)
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/domain/hitl/handoffVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Actor, ClarificationStatus, ProposalStatus, UserId, UserKind } from '@braidhq/schema'

/** The part of a handoff that decides who may see it. */
export interface HandoffVisibility {
readonly status: ProposalStatus | ClarificationStatus
readonly owner: Actor
readonly ownerKind?: UserKind | undefined
}

/**
* Whether one person may see a handoff that somebody else may have made.
*
* An applied handoff is public, because it is part of why the graph says
* what it says, and hiding it would hide the model's own provenance.
* Everything else stays with whoever handed it over,
* plus whatever a service handed over,
* which belongs to the workspace rather than to any one person.
*
* Callers holding `workspace.manage` skip this by passing no viewer at all,
* which is the one way to see another person's unsettled work.
*/
export function handoffVisibleTo(handoff: HandoffVisibility, viewerId: UserId): boolean {
return handoff.status === 'applied'
|| handoff.owner === viewerId
|| handoff.ownerKind === 'service'
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export * from './domain/history/WorkspaceHistory.js'
export * from './domain/hitl/Clarification.js'
export * from './domain/hitl/clarificationOutcome.js'
export * from './domain/hitl/ClarificationRepository.js'
export * from './domain/hitl/handoffVisibility.js'
export * from './domain/hitl/Proposal.js'
export * from './domain/hitl/ProposalRepository.js'
export * from './domain/hitl/runSubmission.js'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ClarificationFilter, ClarificationId } from '@braidhq/schema'
import type { Clarification } from '../../domain/hitl/Clarification.js'
import type { ClarificationRepository } from '../../domain/hitl/ClarificationRepository.js'
import { handoffVisibleTo } from '../../domain/hitl/handoffVisibility.js'
import { paginate } from '../../domain/paginate.js'
import { InMemoryKeyedStore } from './InMemoryKeyedStore.js'

Expand All @@ -19,10 +20,7 @@ export class InMemoryClarificationRepository implements ClarificationRepository
}
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
clarifications = clarifications.filter(clarification =>
clarification.status !== 'pending' || clarification.owner === viewerId || (includeServiceOwned && clarification.ownerKind === 'service'),
)
clarifications = clarifications.filter(clarification => handoffVisibleTo(clarification, viewerId))
}
return paginate(clarifications, filter?.limit, filter?.offset)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ProposalFilter, ProposalId } from '@braidhq/schema'
import type { Proposal } from '../../domain/hitl/Proposal.js'
import type { ProposalRepository } from '../../domain/hitl/ProposalRepository.js'
import { handoffVisibleTo } from '../../domain/hitl/handoffVisibility.js'
import { paginate } from '../../domain/paginate.js'
import { InMemoryKeyedStore } from './InMemoryKeyedStore.js'

Expand All @@ -23,10 +24,7 @@ export class InMemoryProposalRepository implements ProposalRepository {
}
if (filter?.viewerId !== undefined) {
const viewerId = filter.viewerId
const includeServiceOwned = filter.includeServiceOwned ?? false
proposals = proposals.filter(proposal =>
proposal.status !== 'pending' || proposal.owner === viewerId || (includeServiceOwned && proposal.ownerKind === 'service'),
)
proposals = proposals.filter(proposal => handoffVisibleTo(proposal, viewerId))
}
return paginate(proposals, filter?.limit, filter?.offset)
}
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/domain/hitl/handoffVisibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Actor, UserId } from '@braidhq/schema'
import { describe, expect, it } from 'vitest'
import { handoffVisibleTo } from '../../../src/domain/hitl/handoffVisibility.js'

const alice = 'usr-alice' as UserId
const bob = 'usr-bob' as UserId

describe('handoffVisibleTo', () => {
it('shows a viewer what they handed over themselves', () => {
expect(handoffVisibleTo({ status: 'pending', owner: alice as Actor }, alice)).toBe(true)
})

it('hides what somebody else handed over and has not settled', () => {
expect(handoffVisibleTo({ status: 'pending', owner: bob as Actor }, alice)).toBe(false)
})

it('shows what a service handed over, since it belongs to the workspace', () => {
const autonomous = { status: 'pending' as const, owner: 'system' as Actor, ownerKind: 'service' as const }
expect(handoffVisibleTo(autonomous, alice)).toBe(true)
})

it('shows an applied handoff to everybody, since it is now the graph\'s provenance', () => {
expect(handoffVisibleTo({ status: 'applied', owner: bob as Actor }, alice)).toBe(true)
})

it('keeps a rejected or skipped handoff with the person who raised it', () => {
expect(handoffVisibleTo({ status: 'rejected', owner: bob as Actor }, alice)).toBe(false)
expect(handoffVisibleTo({ status: 'skipped', owner: bob as Actor }, alice)).toBe(false)
expect(handoffVisibleTo({ status: 'answered', owner: bob as Actor }, alice)).toBe(false)
})
})
33 changes: 20 additions & 13 deletions packages/schema/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,33 @@ import { z } from 'zod'
/**
* The closed catalog of capability ids the platform gates on. The server is the
* authoritative check, Studio reads the same list to show locked affordances.
* Each resource has at most one read and one write, write means change anything
* mutable in that resource. Plugins register their own checks under custom string
* ids, this list is the first-party set.
*
* A capability is named for its resource, never for the surface that shows it,
* so a page that moves or merges costs no rename here.
* The verb is one of four, and it says only whether the call changes the
* resource, never what the call is called.
* `manage` is the part of a resource that reaches other people,
* which is a wider scope rather than a stronger write.
*
* A resource every member may read needs no id at all,
* since the membership gate upstream has already answered that.
* Plugins register their own checks under custom string ids,
* this list is the first-party set.
*/
export const Capability = z.enum([
// Server scope, no workspace member required.
'workspace.create', // scaffold a new workspace or register a path
'server.admin', // manage users and invites, requires the admin serverRole
'server.write', // scaffold a workspace
'server.manage', // manage users and invites, requires the admin serverRole

// Workspace scope.
'workspace.read', // open the workspace, see settings, sources, members, graph, history
'workspace.write', // edit PRODUCT.md, sources, plugins, ontology, members, or delete
'proposal.read', // list and read pending or decided proposals
'proposal.write', // submit, apply, reject
'clarification.read', // list and read clarifications
'clarification.write', // answer, skip
'handoff.read', // list and read what runs have handed over for a person to settle
'handoff.write', // apply, reject, answer, skip, defer
'workspace.write', // edit PRODUCT.md, sources, mcp servers, or delete the workspace
'workspace.manage', // members and roles, plus everyone's handoffs and conversations
'history.write', // restore a past commit, manage tags

// Skill run carries a per-member override matrix on top of the role default,
// so it stays its own verb rather than a read or write pair.
// Running carries a per-member override matrix on top of the role default,
// so it keeps its own verb rather than folding into a write.
'skill.run',
])
export type Capability = z.infer<typeof Capability>
Expand Down
21 changes: 3 additions & 18 deletions packages/schema/src/clarification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { z } from 'zod'
import {
Actor,
ClarificationCandidateId,
ClarificationId,
ExternalReference,
Expand All @@ -11,8 +10,8 @@ import {
UserId,
WorkspaceId,
} from './common.js'
import { HandoffFilter, HandoffOwner } from './handoff.js'
import { GraphOperation } from './proposal.js'
import { UserKind } from './user.js'

// Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer.
const clarificationQuestion = z.string().min(1).max(400).describe('The single question shown to the reviewer.')
Expand Down Expand Up @@ -48,7 +47,7 @@ export type ClarificationCandidate = z.infer<typeof ClarificationCandidate>
export const ClarificationAnswerMode = z.enum(['resumes', 'standing'])
export type ClarificationAnswerMode = z.infer<typeof ClarificationAnswerMode>

export const Clarification = z.object({
export const Clarification = HandoffOwner.extend({
id: ClarificationId,
workspaceId: WorkspaceId,
question: clarificationQuestion,
Expand All @@ -57,13 +56,6 @@ export const Clarification = z.object({
answeredBy: UserId.optional(),
selectedCandidateId: ClarificationCandidateId.optional(),
resolution: z.array(GraphOperation).optional(),
// The user who filed it, or 'system' for autonomous ones. Pending is owner-only.
owner: Actor,
// Display-name snapshot at submit time. Absent for the 'system' owner.
ownerDisplayName: z.string().min(1).optional(),
// Owner's kind snapshotted at submit time.
// Absent means a human's private clarification, 'service' is autonomous and owner-visible.
ownerKind: UserKind.optional(),
// Set when the resolution becomes a Proposal, so the UI can link the two.
proposalId: ProposalId.optional(),
externalReferences: z.array(ExternalReference).optional(),
Expand Down Expand Up @@ -118,14 +110,7 @@ export const ClarificationCreateBody = ClarificationCreate
.extend({ candidates: z.array(ClarificationCandidate.partial({ id: true })) })
export type ClarificationCreateBody = z.infer<typeof ClarificationCreateBody>

export const ClarificationFilter = z.object({
workspaceId: WorkspaceId.optional(),
export const ClarificationFilter = HandoffFilter.extend({
statuses: z.array(ClarificationStatus).optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
// When set, hides others' pending clarifications. Non-pending stay visible, absent shows all.
viewerId: UserId.optional(),
// Owner-only, also shows service-owned (autonomous) pending to this viewer.
includeServiceOwned: z.boolean().optional(),
})
export type ClarificationFilter = z.infer<typeof ClarificationFilter>
51 changes: 51 additions & 0 deletions packages/schema/src/handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { z } from 'zod'
import { Actor, UserId, WorkspaceId } from './common.js'
import { UserKind } from './user.js'

/**
* A run reached a point only a person can settle, and handed it over.
*
* Always from a run to a person, never the other way,
* and never between two runs, which is what the word means elsewhere.
* Answering a clarification carries its run on,
* but that is the answer travelling back, not a second handoff.
*
* A proposal and a clarification are the two shapes this takes.
* What makes both one kind is who must act next,
* rather than whether a run is parked waiting on it,
* since a proposal ends its run and a resuming clarification suspends one.
*/
export const HandoffKind = z.enum(['proposal', 'clarification'])
export type HandoffKind = z.infer<typeof HandoffKind>

/**
* Who handed this over, snapshotted at the moment they did.
*
* The display name survives a rename, because it records who it was then.
* A 'service' kind marks an autonomous handoff,
* which belongs to the workspace rather than to any one person,
* so everybody who may act on handoffs sees it.
*/
export const HandoffOwner = z.object({
owner: Actor,
ownerDisplayName: z.string().min(1).optional(),
ownerKind: UserKind.optional(),
})
export type HandoffOwner = z.infer<typeof HandoffOwner>

/**
* The query shape every handoff listing shares.
*
* A present `viewerId` narrows the result to what that person may see,
* which is their own plus anything a service handed over.
* Absent applies no narrowing, which is what `workspace.manage` grants.
* Each kind extends this with its own statuses,
* since the two do not share a status enum.
*/
export const HandoffFilter = z.object({
workspaceId: WorkspaceId.optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
viewerId: UserId.optional(),
})
export type HandoffFilter = z.infer<typeof HandoffFilter>
1 change: 1 addition & 0 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './embedding.js'
export * from './error.js'
export * from './event.js'
export * from './graph-validation.js'
export * from './handoff.js'
export * from './history.js'
export * from './locale.js'
export * from './mcp.js'
Expand Down
21 changes: 3 additions & 18 deletions packages/schema/src/proposal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { z } from 'zod'
import {
Actor,
ClarificationId,
EdgeId,
ExternalReference,
Expand All @@ -12,6 +11,7 @@ import {
UserId,
WorkspaceId,
} from './common.js'
import { HandoffFilter, HandoffOwner } from './handoff.js'
import {
GraphEdge,
GraphEdgeCreate,
Expand All @@ -21,7 +21,6 @@ import {
GraphNodeUpdate,
} from './model.js'
import { SourceUnit } from './source-unit.js'
import { UserKind } from './user.js'

// Only the hard contract here. Authoring rules (length, tone, language) live in the skill layer.
const proposalRationale = z.string().min(1).max(1500).describe('One-paragraph plain-text summary of what changed and why.')
Expand All @@ -45,7 +44,7 @@ export type GraphOperation = z.infer<typeof GraphOperation>
export const ProposalStatus = z.enum(['pending', 'applied', 'rejected'])
export type ProposalStatus = z.infer<typeof ProposalStatus>

export const Proposal = z.object({
export const Proposal = HandoffOwner.extend({
id: ProposalId,
workspaceId: WorkspaceId,
status: ProposalStatus,
Expand Down Expand Up @@ -82,13 +81,6 @@ export const Proposal = z.object({
* while the model reflected none of it.
*/
sourceUnits: z.array(SourceUnit).optional(),
// The user who created it, or 'system' for autonomous ones. Pending is owner-only.
owner: Actor,
// Name at submit time, survives renames. Absent for the 'system' owner.
ownerDisplayName: z.string().min(1).optional(),
// Owner's kind snapshotted at submit time.
// Absent means a human's private draft, 'service' is autonomous and owner-visible.
ownerKind: UserKind.optional(),
})
export type Proposal = z.infer<typeof Proposal>

Expand All @@ -104,15 +96,8 @@ export const ProposalCreate = z.object({
})
export type ProposalCreate = z.infer<typeof ProposalCreate>

export const ProposalFilter = z.object({
workspaceId: WorkspaceId.optional(),
export const ProposalFilter = HandoffFilter.extend({
statuses: z.array(ProposalStatus).optional(),
generatedBy: z.array(SkillId).optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
// When set, hides others' pending proposals. Non-pending stay visible, absent shows all.
viewerId: UserId.optional(),
// Owner-only, also shows service-owned (autonomous) pending to this viewer.
includeServiceOwned: z.boolean().optional(),
})
export type ProposalFilter = z.infer<typeof ProposalFilter>
Loading
Loading